Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 76 additions & 33 deletions rust/src/cost_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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
}

Expand Down Expand Up @@ -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<F>(
&self,
dir: &Path,
cutoff: &DateTime<Utc>,
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<Utc> = 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<Utc> = 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.
Expand All @@ -583,28 +623,29 @@ 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<F>(
path: &Path,
cutoff: &DateTime<Utc>,
seen: &mut HashSet<String>,
cancel: Option<&AtomicBool>,
pricing: &mut ClaudeScanPricingResolver,
mut on_record: F,
) -> usize
) -> Option<usize>
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;
}
Expand All @@ -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<R, F>(mut reader: R, mut on_line: F)
/// Returns false when reading failed before the end of the input.
fn for_each_jsonl_text_line<R, F>(mut reader: R, mut on_line: F) -> bool
where
R: BufRead,
F: FnMut(&str) -> bool,
Expand All @@ -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();
Expand All @@ -646,6 +688,7 @@ where
break;
}
}
true
}

#[cfg(test)]
Expand Down
64 changes: 64 additions & 0 deletions rust/src/cost_scanner/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
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();
Expand Down