use std::path::{Path, PathBuf}; use tracing::{info, warn}; use super::{AnalyzeJob, AnalyzeSender}; /// Walk `data_path/*/*/analysis_input_v*.json` and enqueue every input /// that does not yet have a matching `document_v*.md` sibling. /// /// Intended to run once at startup. Send is synchronous on the unbounded /// channel — only fails if the worker is gone, which is a programming error. pub async fn scan_and_enqueue(data_path: &Path, tx: &AnalyzeSender) { let pending = collect_pending(data_path).await; let total = pending.len(); info!(count = total, "Analyze recovery scan found pending inputs"); for (case_dir, version) in pending { if tx.send(AnalyzeJob { case_dir: case_dir.clone(), version }).is_err() { warn!(case = %case_dir.display(), "Recovery send failed (worker gone)"); break; } } } /// Returns `(case_dir, version)` tuples for every `analysis_input_v{N}.json` /// whose `document_v{N}.md` is missing. async fn collect_pending(data_path: &Path) -> Vec<(PathBuf, u32)> { let mut out: Vec<(PathBuf, u32)> = Vec::new(); let mut users = match tokio::fs::read_dir(data_path).await { Ok(r) => r, Err(_) => return out, }; while let Ok(Some(user_entry)) = users.next_entry().await { let mut cases = match tokio::fs::read_dir(user_entry.path()).await { Ok(r) => r, Err(_) => continue, }; while let Ok(Some(case_entry)) = cases.next_entry().await { let case_dir = case_entry.path(); if !case_dir.is_dir() { continue; } if crate::paths::is_deleted(&case_dir).await { continue; } let mut files = match tokio::fs::read_dir(&case_dir).await { Ok(r) => r, Err(_) => 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; }; let document = case_dir.join(format!("document_v{version}.md")); if !document.exists() { out.push((case_dir.clone(), version)); } } } } // Oldest case directories first — not strictly required for correctness, // but gives deterministic recovery order. Cases are UUIDs so sorting is // purely lexicographic, which is fine. out.sort(); out } /// 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); } }