use std::path::Path; use tracing::{info, warn}; use super::{AnalyzeJob, AnalyzeSender}; /// Walk `data_path/*/` and enqueue every pending analysis for every user. /// Intended to run once at startup; the same primitive backs the per-user /// self-heal triggered by web handlers. pub async fn scan_and_enqueue(data_path: &Path, tx: &AnalyzeSender) { let Ok(mut users) = tokio::fs::read_dir(data_path).await else { return; }; let mut total = 0; while let Ok(Some(user_entry)) = users.next_entry().await { total += enqueue_pending_for_user(&user_entry.path(), tx).await; } info!(count = total, "Analyze recovery scan finished"); } /// Scan a single `//analysis_input_v*.json` set and enqueue /// every input whose `document_v{N}.md` is missing. Returns the number sent. /// Channel send is synchronous on the unbounded channel — only fails if the /// worker is gone, which is a programming error. pub async fn enqueue_pending_for_user(user_root: &Path, tx: &AnalyzeSender) -> usize { let Ok(mut cases) = tokio::fs::read_dir(user_root).await else { return 0; }; let mut sent = 0; while let Ok(Some(case_entry)) = cases.next_entry().await { let case_dir = case_entry.path(); if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await { continue; } let Ok(mut files) = tokio::fs::read_dir(&case_dir).await else { continue; }; while let Ok(Some(file)) = files.next_entry().await { let path = file.path(); let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue; }; let Some(version) = parse_input_version(name) else { continue; }; if case_dir.join(format!("document_v{version}.md")).exists() { continue; } if tx .send(AnalyzeJob { case_dir: case_dir.clone(), version, }) .is_err() { warn!(case = %case_dir.display(), "recovery send failed (worker gone)"); return sent; } sent += 1; } } sent } /// Parse `analysis_input_v{N}.json` and return `N` (as u32) if the pattern /// matches exactly. Rejects malformed names, leading zeros, and overflow. fn parse_input_version(filename: &str) -> Option { let rest = filename.strip_prefix("analysis_input_v")?; let num = rest.strip_suffix(".json")?; if num.is_empty() || (num.starts_with('0') && num.len() > 1) { return None; } num.parse::().ok() } #[cfg(test)] mod tests { use super::*; #[test] fn parses_valid_names() { assert_eq!(parse_input_version("analysis_input_v1.json"), Some(1)); assert_eq!(parse_input_version("analysis_input_v42.json"), Some(42)); } #[test] fn rejects_invalid_names() { assert_eq!(parse_input_version("analysis_input_v.json"), None); assert_eq!(parse_input_version("analysis_input_v01.json"), None); assert_eq!(parse_input_version("analysis_input_vx.json"), None); assert_eq!(parse_input_version("document_v1.md"), None); assert_eq!(parse_input_version("analysis_input_v1.txt"), None); assert_eq!(parse_input_version(""), None); } }