diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 68aac95a452..e0a1e73c460 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -438,7 +438,32 @@ impl CostScanner { /// Scan Claude local logs, stopping early when the caller cancels the scan. pub fn scan_claude_with_cancel(&self, cancel: Option<&AtomicBool>) -> CostSummary { - let projects_dir = self.get_claude_projects_dir(); + let mut summary = self.scan_claude_projects(&self.get_claude_projects_dir(), cancel); + + // OMP / pi-compatible anthropic rows, deduped across shared files. + let mut seen_pi = HashSet::new(); + crate::pi_session_cost::scan_pi_compatible_into( + &mut summary, + crate::pi_session_cost::PiMappedProvider::Claude, + self.days, + cancel, + &mut seen_pi, + ); + summary.known_zero = summary.known_zero && claude_summary_is_empty(&summary); + + summary + } + + /// Scan one Claude projects directory. A finished walk over an existing + /// directory that was not cancelled and read every folder and file is the + /// whole history of the window, so it establishes coverage (and a known + /// zero when it found nothing). A missing directory, a cancelled walk or + /// an unreadable folder/file leaves coverage unestablished. + fn scan_claude_projects( + &self, + projects_dir: &Path, + cancel: Option<&AtomicBool>, + ) -> CostSummary { let mut summary = CostSummary::default(); let today = Utc::now().date_naive(); let start_date = today - Duration::days(self.days as i64); @@ -449,11 +474,13 @@ impl CostScanner { // Walk through projects directory, de-duplicating usage records // that appear across multiple files. + let mut read_complete = false; if projects_dir.exists() { + read_complete = true; let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); let mut handle_file = |path: &Path| { - let counted = for_each_claude_usage_record_with_pricing( + let Some(counted) = for_each_claude_usage_record_with_pricing( path, &cutoff, &mut seen, @@ -462,24 +489,22 @@ impl CostScanner { |record| { add_claude_record_to_summary(&mut summary, record); }, - ); + ) else { + read_complete = false; + return; + }; if counted > 0 { summary.sessions_count += 1; } }; - self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut handle_file); + if !self.walk_claude_files(projects_dir, &cutoff, cancel, &mut handle_file) { + read_complete = false; + } } - // OMP / pi-compatible anthropic rows, deduped across shared files. - let mut seen_pi = HashSet::new(); - crate::pi_session_cost::scan_pi_compatible_into( - &mut summary, - crate::pi_session_cost::PiMappedProvider::Claude, - self.days, - cancel, - &mut seen_pi, - ); - + summary.history_coverage_established = read_complete && !is_cancelled(cancel); + summary.known_zero = + summary.history_coverage_established && claude_summary_is_empty(&summary); summary } @@ -526,45 +551,60 @@ impl CostScanner { home.join(".config").join("claude").join("projects") } + /// Calls `on_file` for every in-window `.jsonl` file under `dir`. + /// Returns false when a folder could not be listed or a file's + /// modification time could not be read, so the walk missed files. fn walk_claude_files( &self, dir: &Path, cutoff: &DateTime, cancel: Option<&AtomicBool>, on_file: &mut F, - ) where + ) -> bool + where F: FnMut(&Path), { if is_cancelled(cancel) { - return; + return true; } let entries = match fs::read_dir(dir) { Ok(e) => e, - Err(_) => return, + Err(_) => return false, }; + let mut complete = true; for entry in entries.flatten() { if is_cancelled(cancel) { break; } let path = entry.path(); if path.is_dir() { - self.walk_claude_files(&path, cutoff, cancel, on_file); + complete &= self.walk_claude_files(&path, cutoff, cancel, on_file); } else if path.extension().is_some_and(|e| e == "jsonl") { // Check file modification time - if let Ok(metadata) = fs::metadata(&path) - && let Ok(modified) = metadata.modified() - { - let modified_dt: DateTime = modified.into(); - if modified_dt >= *cutoff { - on_file(&path); - } + let Ok(modified) = fs::metadata(&path).and_then(|metadata| metadata.modified()) + else { + complete = false; + continue; + }; + let modified_dt: DateTime = modified.into(); + if modified_dt >= *cutoff { + on_file(&path); } } } + complete } } +fn claude_summary_is_empty(summary: &CostSummary) -> bool { + summary.sessions_count == 0 + && summary.input_tokens == 0 + && summary.output_tokens == 0 + && summary.cached_tokens == 0 + && summary.total_cost_usd == 0.0 +} + /// Stream the de-duplicated, in-window usage records from one transcript /// file into `on_record`. Both the summary scan and the daily-history scan /// consume this single reader, so Claude log semantics live in one place. @@ -583,8 +623,11 @@ where { let mut pricing = ClaudeScanPricingResolver::default(); for_each_claude_usage_record_with_pricing(path, cutoff, seen, cancel, &mut pricing, on_record) + .unwrap_or(0) } +/// Returns the number of records consumed, or None when the file could not +/// be opened or read to its end. fn for_each_claude_usage_record_with_pricing( path: &Path, cutoff: &DateTime, @@ -592,19 +635,17 @@ fn for_each_claude_usage_record_with_pricing( cancel: Option<&AtomicBool>, pricing: &mut ClaudeScanPricingResolver, mut on_record: F, -) -> usize +) -> Option where F: FnMut(&ClaudeUsageRecord), { - let Ok(file) = File::open(path) else { - return 0; - }; + let file = File::open(path).ok()?; let mut counted = 0; // Use read_until so a final incomplete line (no trailing newline) is still // processed when it is valid UTF-8 JSON, and so a single bad line does not // stop the walk the way `lines().map_while(Result::ok)` would. - for_each_jsonl_text_line(BufReader::new(file), |line| { + let read_ok = for_each_jsonl_text_line(BufReader::new(file), |line| { if is_cancelled(cancel) { return false; } @@ -618,12 +659,13 @@ where } true }); - counted + read_ok.then_some(counted) } /// Walk JSONL text lines from `reader`, including a final incomplete line at EOF. /// Continues past invalid UTF-8 segments. `on_line` returns `false` to stop early. -fn for_each_jsonl_text_line(mut reader: R, mut on_line: F) +/// Returns false when reading failed before the end of the input. +fn for_each_jsonl_text_line(mut reader: R, mut on_line: F) -> bool where R: BufRead, F: FnMut(&str) -> bool, @@ -634,7 +676,7 @@ where match reader.read_until(b'\n', &mut buf) { Ok(0) => break, Ok(_) => {} - Err(_) => break, + Err(_) => return false, } while matches!(buf.last(), Some(b'\n' | b'\r')) { buf.pop(); @@ -646,6 +688,7 @@ where break; } } + true } #[cfg(test)] diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index c233d29835b..7a08e11a3cc 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -2038,6 +2038,70 @@ fn known_zero_is_not_set_when_scan_has_results() { assert!(!summary.known_zero, "scan with results is not known-zero"); } +// ── Claude history coverage (VibeTV #820) ─────────────────────────────── + +#[test] +fn claude_scan_with_usage_establishes_coverage() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("projects").join("demo"); + std::fs::create_dir_all(&project).unwrap(); + let ts = (Utc::now() - Duration::hours(1)) + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + let line = claude_transcript_line(&ts, "requestId", "req_cov", "msg_cov"); + std::fs::write(project.join("a.jsonl"), format!("{line}\n")).unwrap(); + + let summary = CostScanner::new(7).scan_claude_projects(&root.path().join("projects"), None); + assert_eq!(summary.sessions_count, 1); + assert!(summary.history_coverage_established, "completed scan"); + assert!(!summary.known_zero, "scan with usage is not a known zero"); +} + +#[test] +fn claude_scan_without_usage_is_a_known_zero() { + let root = tempfile::tempdir().unwrap(); + let projects = root.path().join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + + let summary = CostScanner::new(7).scan_claude_projects(&projects, None); + assert!(summary.history_coverage_established); + assert!(summary.known_zero); +} + +#[test] +fn claude_scan_without_projects_dir_does_not_establish_coverage() { + let root = tempfile::tempdir().unwrap(); + + let summary = CostScanner::new(7).scan_claude_projects(&root.path().join("missing"), None); + assert!(!summary.history_coverage_established); + assert!(!summary.known_zero, "a missing log folder is not a zero"); +} + +#[test] +fn cancelled_claude_scan_does_not_establish_coverage() { + let root = tempfile::tempdir().unwrap(); + let projects = root.path().join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + let cancel = AtomicBool::new(true); + + let summary = CostScanner::new(7).scan_claude_projects(&projects, Some(&cancel)); + assert!(!summary.history_coverage_established); + assert!(!summary.known_zero); +} + +#[test] +fn jsonl_read_error_is_reported() { + struct FailingReader; + impl std::io::Read for FailingReader { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("disk")) + } + } + let read_ok = for_each_jsonl_text_line(BufReader::new(FailingReader), |_| true); + assert!(!read_ok, "a read error must not count as a complete file"); + assert!(for_each_jsonl_text_line(&b"{}\n"[..], |_| true)); +} + #[test] fn tiny_candidate_limit_prefers_newest_dirty_file_and_persists_older_pending() { let root = tempfile::tempdir().unwrap();