1use std::path::{Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use serde::{Deserialize, Serialize};
28
29use crate::errors::TokenFoldError;
30use crate::report::CompressionReport;
31use crate::status::Status;
32
33pub const SCHEMA_VERSION: &str = "1.0";
34
35#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
36pub struct RetrievalStats {
37 pub markers: usize,
38 pub hits: usize,
39 pub misses: usize,
40 pub expired: usize,
41}
42
43#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
44pub struct CacheStats {
45 pub hits: usize,
46 pub misses: usize,
47}
48
49#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
50pub struct LatencyStats {
51 pub p50_ms: f64,
52 pub p95_ms: f64,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct StatsSummary {
58 pub schema_version: String,
59 pub scope: String,
60 pub window: String,
61 pub project: Option<String>,
62 pub requests: usize,
63 pub commands: usize,
64 pub wrapped_commands: usize,
65 pub raw_commands: usize,
66 pub bypass_count: usize,
67 pub raw_tokens: usize,
68 pub compressed_tokens: usize,
69 pub saved_tokens: usize,
70 pub savings_pct: f64,
71 pub estimated_lost_tokens: usize,
72 pub coverage_pct: f64,
73 pub untrusted_filter_count: usize,
74 pub retrieval: RetrievalStats,
75 pub cache: CacheStats,
76 pub latency: LatencyStats,
77 pub recent_requests: Vec<LedgerRecord>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct LedgerRecord {
86 pub request_id: String,
87 pub timestamp: String,
88 pub surface: String,
89 pub format: String,
90 pub mode: String,
91 pub status: String,
92 pub original_tokens: usize,
93 pub compressed_tokens: usize,
94 pub saved_tokens: usize,
95 pub savings_pct: f64,
96 pub bypass_reason: Option<String>,
97 pub project_hash: Option<String>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum SavingsProvenance {
106 Measured,
107 Heuristic,
108 Estimated,
109}
110
111impl SavingsProvenance {
112 pub fn as_str(self) -> &'static str {
113 match self {
114 SavingsProvenance::Measured => "measured",
115 SavingsProvenance::Heuristic => "heuristic",
116 SavingsProvenance::Estimated => "estimated",
117 }
118 }
119}
120
121pub fn savings_provenance(is_exact: bool) -> SavingsProvenance {
125 if is_exact {
126 SavingsProvenance::Measured
127 } else {
128 SavingsProvenance::Heuristic
129 }
130}
131
132pub fn record_from_report(
138 report: &CompressionReport,
139 request_id: String,
140 timestamp: String,
141 project_hash: Option<String>,
142) -> LedgerRecord {
143 let surface = if report.command.is_some() {
144 "wrap"
145 } else {
146 "cli"
147 }
148 .to_string();
149
150 let bypass_reason = report
155 .command
156 .as_ref()
157 .filter(|c| c.never_worse_applied)
158 .map(|_| "would_increase_tokens".to_string())
159 .or_else(|| report.bypass.as_ref().map(|b| b.reason.clone()));
160
161 LedgerRecord {
162 request_id,
163 timestamp,
164 surface,
165 format: report.format.clone(),
166 mode: report.mode.clone(),
167 status: status_label(&report.status),
168 original_tokens: report.original_tokens,
169 compressed_tokens: report.compressed_tokens,
170 saved_tokens: report.saved_tokens,
171 savings_pct: report.savings_pct,
172 bypass_reason,
173 project_hash,
174 }
175}
176
177fn status_label(status: &Status) -> String {
178 serde_json::to_value(status)
179 .ok()
180 .and_then(|v| v.as_str().map(str::to_string))
181 .unwrap_or_else(|| "unknown".to_string())
182}
183
184pub fn aggregate(records: &[LedgerRecord]) -> StatsSummary {
189 let requests = records.len();
190
191 let is_wrap = |r: &&LedgerRecord| r.surface == "wrap";
192 let commands: usize = records.iter().filter(is_wrap).count();
193 let wrapped_commands = records
194 .iter()
195 .filter(is_wrap)
196 .filter(|r| r.bypass_reason.is_none())
197 .count();
198 let raw_commands = commands - wrapped_commands;
199 let bypass_count = records.iter().filter(|r| r.bypass_reason.is_some()).count();
200
201 let raw_tokens: usize = records.iter().map(|r| r.original_tokens).sum();
202 let compressed_tokens: usize = records.iter().map(|r| r.compressed_tokens).sum();
203 let saved_tokens = raw_tokens.saturating_sub(compressed_tokens);
204 let savings_pct = if raw_tokens == 0 {
205 0.0
206 } else {
207 saved_tokens as f64 / raw_tokens as f64 * 100.0
208 };
209
210 let wrapped_raw_tokens: usize = records
216 .iter()
217 .filter(is_wrap)
218 .filter(|r| r.bypass_reason.is_none())
219 .map(|r| r.original_tokens)
220 .sum();
221 let wrapped_saved_tokens: usize = records
222 .iter()
223 .filter(is_wrap)
224 .filter(|r| r.bypass_reason.is_none())
225 .map(|r| r.saved_tokens)
226 .sum();
227 let raw_command_tokens: usize = records
228 .iter()
229 .filter(is_wrap)
230 .filter(|r| r.bypass_reason.is_some())
231 .map(|r| r.original_tokens)
232 .sum();
233 let estimated_lost_tokens = if wrapped_raw_tokens == 0 {
234 0
235 } else {
236 let ratio = wrapped_saved_tokens as f64 / wrapped_raw_tokens as f64;
237 (raw_command_tokens as f64 * ratio).round() as usize
238 };
239 let coverage_pct = if commands == 0 {
240 0.0
241 } else {
242 wrapped_commands as f64 / commands as f64 * 100.0
243 };
244
245 let mut recent_requests = records.to_vec();
248 recent_requests.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
249
250 StatsSummary {
251 schema_version: SCHEMA_VERSION.to_string(),
252 scope: "aggregate".to_string(),
253 window: "all".to_string(),
254 project: None,
255 requests,
256 commands,
257 wrapped_commands,
258 raw_commands,
259 bypass_count,
260 raw_tokens,
261 compressed_tokens,
262 saved_tokens,
263 savings_pct,
264 estimated_lost_tokens,
265 coverage_pct,
266 untrusted_filter_count: 0,
268 retrieval: RetrievalStats::default(),
274 cache: CacheStats::default(),
277 latency: LatencyStats::default(),
281 recent_requests,
282 }
283}
284
285pub fn parse_duration_secs(input: &str) -> Result<u64, TokenFoldError> {
288 let trimmed = input.trim();
289 let invalid = || {
290 TokenFoldError::InvalidInput(format!(
291 "invalid duration {input:?}; expected e.g. \"30d\", \"24h\", \"90m\", \"120s\", or a bare integer of seconds"
292 ))
293 };
294 if trimmed.is_empty() {
295 return Err(invalid());
296 }
297 let (digits, unit_secs) = match trimmed.chars().last().unwrap() {
298 'd' => (&trimmed[..trimmed.len() - 1], 86_400u64),
299 'h' => (&trimmed[..trimmed.len() - 1], 3_600u64),
300 'm' => (&trimmed[..trimmed.len() - 1], 60u64),
301 's' => (&trimmed[..trimmed.len() - 1], 1u64),
302 _ => (trimmed, 1u64),
303 };
304 let count: u64 = digits.trim().parse().map_err(|_| invalid())?;
305 Ok(count.saturating_mul(unit_secs))
306}
307
308pub fn filter_since(records: &[LedgerRecord], now: u64, window_secs: u64) -> Vec<LedgerRecord> {
313 records
314 .iter()
315 .filter(|r| match parse_timestamp_to_unix(&r.timestamp) {
316 Some(ts) => now.saturating_sub(ts) <= window_secs,
317 None => false,
318 })
319 .cloned()
320 .collect()
321}
322
323pub fn to_csv(summary: &StatsSummary) -> String {
328 let mut out = String::new();
329 out.push_str(
330 "schema_version,scope,window,project,requests,commands,wrapped_commands,raw_commands,\
331 bypass_count,raw_tokens,compressed_tokens,saved_tokens,savings_pct,\
332 estimated_lost_tokens,coverage_pct,untrusted_filter_count,retrieval_markers,\
333 retrieval_hits,retrieval_misses,retrieval_expired,cache_hits,cache_misses,\
334 latency_p50_ms,latency_p95_ms\n",
335 );
336 out.push_str(&format!(
337 "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
338 csv_field(&summary.schema_version),
339 csv_field(&summary.scope),
340 csv_field(&summary.window),
341 csv_field(summary.project.as_deref().unwrap_or("")),
342 summary.requests,
343 summary.commands,
344 summary.wrapped_commands,
345 summary.raw_commands,
346 summary.bypass_count,
347 summary.raw_tokens,
348 summary.compressed_tokens,
349 summary.saved_tokens,
350 summary.savings_pct,
351 summary.estimated_lost_tokens,
352 summary.coverage_pct,
353 summary.untrusted_filter_count,
354 summary.retrieval.markers,
355 summary.retrieval.hits,
356 summary.retrieval.misses,
357 summary.retrieval.expired,
358 summary.cache.hits,
359 summary.cache.misses,
360 summary.latency.p50_ms,
361 summary.latency.p95_ms,
362 ));
363 out.push('\n');
364 out.push_str(
365 "request_id,timestamp,surface,format,mode,status,original_tokens,compressed_tokens,\
366 saved_tokens,savings_pct,bypass_reason,project_hash\n",
367 );
368 for r in &summary.recent_requests {
369 out.push_str(&format!(
370 "{},{},{},{},{},{},{},{},{},{},{},{}\n",
371 csv_field(&r.request_id),
372 csv_field(&r.timestamp),
373 csv_field(&r.surface),
374 csv_field(&r.format),
375 csv_field(&r.mode),
376 csv_field(&r.status),
377 r.original_tokens,
378 r.compressed_tokens,
379 r.saved_tokens,
380 r.savings_pct,
381 csv_field(r.bypass_reason.as_deref().unwrap_or("")),
382 csv_field(r.project_hash.as_deref().unwrap_or("")),
383 ));
384 }
385 out
386}
387
388fn csv_field(value: &str) -> String {
389 if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
390 format!("\"{}\"", value.replace('"', "\"\""))
391 } else {
392 value.to_string()
393 }
394}
395
396pub fn generate_request_id() -> String {
401 use std::hash::{Hash, Hasher};
402 let mut hasher = std::collections::hash_map::DefaultHasher::new();
403 let nanos = SystemTime::now()
404 .duration_since(UNIX_EPOCH)
405 .unwrap_or_default()
406 .as_nanos();
407 nanos.hash(&mut hasher);
408 std::process::id().hash(&mut hasher);
409 format!("tc-{:08x}", hasher.finish() as u32)
410}
411
412pub fn now_unix() -> u64 {
413 SystemTime::now()
414 .duration_since(UNIX_EPOCH)
415 .map(|d| d.as_secs())
416 .unwrap_or(0)
417}
418
419pub fn format_unix_timestamp(unix_secs: u64) -> String {
425 let days = (unix_secs / 86_400) as i64;
426 let rem = unix_secs % 86_400;
427 let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
428 let (y, m, d) = civil_from_days(days);
429 format!("{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z")
430}
431
432fn civil_from_days(z: i64) -> (i64, u32, u32) {
433 let z = z + 719_468;
434 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
435 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
438 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; (if m <= 2 { y + 1 } else { y }, m, d)
443}
444
445fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
446 let y = if m <= 2 { y - 1 } else { y };
447 let era = if y >= 0 { y } else { y - 399 } / 400;
448 let yoe = y - era * 400; let mp = if m > 2 { m as i64 - 3 } else { m as i64 + 9 }; let doy = (153 * mp + 2) / 5 + d as i64 - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
453}
454
455fn parse_timestamp_to_unix(ts: &str) -> Option<u64> {
456 let ts = ts.strip_suffix('Z')?;
457 let (date, time) = ts.split_once('T')?;
458 let mut date_parts = date.splitn(3, '-');
459 let year: i64 = date_parts.next()?.parse().ok()?;
460 let month: u32 = date_parts.next()?.parse().ok()?;
461 let day: u32 = date_parts.next()?.parse().ok()?;
462 let mut time_parts = time.splitn(3, ':');
463 let hour: u64 = time_parts.next()?.parse().ok()?;
464 let minute: u64 = time_parts.next()?.parse().ok()?;
465 let second: u64 = time_parts.next()?.parse().ok()?;
466 let days = days_from_civil(year, month, day);
467 if days < 0 {
468 return None;
469 }
470 Some(days as u64 * 86_400 + hour * 3_600 + minute * 60 + second)
471}
472
473pub struct LedgerStore {
476 path: PathBuf,
477}
478
479#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
480pub struct LedgerGcOutcome {
481 pub kept: usize,
482 pub removed: usize,
483}
484
485impl LedgerStore {
486 pub fn new(path: impl Into<PathBuf>) -> Self {
487 LedgerStore { path: path.into() }
488 }
489
490 pub fn default_path() -> PathBuf {
494 if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
495 return PathBuf::from(dir).join("tokenfold").join("ledger.db");
496 }
497 let home = home_dir().unwrap_or_else(|| PathBuf::from("."));
498 home.join(".local")
499 .join("share")
500 .join("tokenfold")
501 .join("ledger.db")
502 }
503
504 pub fn path(&self) -> &Path {
505 &self.path
506 }
507
508 pub fn append(&self, record: &LedgerRecord) -> Result<(), TokenFoldError> {
509 if let Some(parent) = self.path.parent() {
510 std::fs::create_dir_all(parent)?;
511 }
512 let mut line = serde_json::to_string(record).map_err(|e| {
513 TokenFoldError::InternalError(format!("failed to encode ledger record: {e}"))
514 })?;
515 line.push('\n');
516 use std::io::Write;
517 let mut file = std::fs::OpenOptions::new()
518 .create(true)
519 .append(true)
520 .open(&self.path)?;
521 file.write_all(line.as_bytes())?;
522 Ok(())
523 }
524
525 pub fn read_all(&self) -> Result<Vec<LedgerRecord>, TokenFoldError> {
530 let Ok(text) = std::fs::read_to_string(&self.path) else {
531 return Ok(Vec::new());
532 };
533 Ok(text
534 .lines()
535 .filter(|line| !line.trim().is_empty())
536 .filter_map(|line| serde_json::from_str::<LedgerRecord>(line).ok())
537 .collect())
538 }
539
540 pub fn gc(&self, retention_days: u64) -> Result<LedgerGcOutcome, TokenFoldError> {
545 let records = self.read_all()?;
546 let cutoff_secs = retention_days.saturating_mul(86_400);
547 let now = now_unix();
548
549 let mut kept = Vec::with_capacity(records.len());
550 let mut removed = 0usize;
551 for record in records {
552 let within_retention = match parse_timestamp_to_unix(&record.timestamp) {
553 Some(ts) => now.saturating_sub(ts) <= cutoff_secs,
554 None => true,
555 };
556 if within_retention {
557 kept.push(record);
558 } else {
559 removed += 1;
560 }
561 }
562
563 let mut out = String::new();
564 for record in &kept {
565 let line = serde_json::to_string(record).map_err(|e| {
566 TokenFoldError::InternalError(format!("failed to encode ledger record: {e}"))
567 })?;
568 out.push_str(&line);
569 out.push('\n');
570 }
571 if let Some(parent) = self.path.parent() {
572 std::fs::create_dir_all(parent)?;
573 }
574 std::fs::write(&self.path, out)?;
575
576 Ok(LedgerGcOutcome {
577 kept: kept.len(),
578 removed,
579 })
580 }
581}
582
583fn home_dir() -> Option<PathBuf> {
584 std::env::var_os("HOME")
585 .or_else(|| std::env::var_os("USERPROFILE"))
586 .map(PathBuf::from)
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::report::{BypassReport, CommandReport, EstimatorInfo, RetrievalReport};
593 use std::sync::atomic::{AtomicU64, Ordering};
594
595 fn temp_ledger_path(tag: &str) -> PathBuf {
596 static COUNTER: AtomicU64 = AtomicU64::new(0);
597 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
598 std::env::temp_dir().join(format!(
599 "tokenfold_stats_test_{tag}_{}_{n}.db",
600 std::process::id()
601 ))
602 }
603
604 fn exact_estimator() -> EstimatorInfo {
605 EstimatorInfo {
606 backend: "tiktoken".to_string(),
607 model: Some("o200k_base".to_string()),
608 is_exact: true,
609 }
610 }
611
612 fn heuristic_estimator() -> EstimatorInfo {
613 EstimatorInfo {
614 backend: "heuristic".to_string(),
615 model: None,
616 is_exact: false,
617 }
618 }
619
620 fn cli_report(
621 original: usize,
622 compressed: usize,
623 format: &str,
624 status: Status,
625 estimator: EstimatorInfo,
626 ) -> CompressionReport {
627 CompressionReport::new(
628 original,
629 compressed,
630 estimator,
631 status,
632 "balanced".to_string(),
633 format.to_string(),
634 "general".to_string(),
635 vec![],
636 vec![],
637 )
638 }
639
640 #[test]
643 fn format_unix_timestamp_matches_known_epoch_values() {
644 assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
645 assert_eq!(format_unix_timestamp(86_400), "1970-01-02T00:00:00Z");
646 }
647
648 #[test]
649 fn timestamp_round_trips_through_parse_and_format() {
650 for secs in [0u64, 1, 86_399, 86_400, 1_000_000_000, 1_752_000_000] {
651 let formatted = format_unix_timestamp(secs);
652 assert_eq!(
653 parse_timestamp_to_unix(&formatted),
654 Some(secs),
655 "{formatted}"
656 );
657 }
658 }
659
660 #[test]
661 fn parse_duration_secs_supports_documented_suffixes() {
662 assert_eq!(parse_duration_secs("30d").unwrap(), 30 * 86_400);
663 assert_eq!(parse_duration_secs("24h").unwrap(), 24 * 3_600);
664 assert_eq!(parse_duration_secs("90m").unwrap(), 90 * 60);
665 assert_eq!(parse_duration_secs("120s").unwrap(), 120);
666 assert_eq!(parse_duration_secs("45").unwrap(), 45);
667 assert!(parse_duration_secs("nonsense").is_err());
668 }
669
670 #[test]
673 fn record_from_report_uses_cli_surface_when_no_command_report() {
674 let report = cli_report(
675 1000,
676 600,
677 "openai_json",
678 Status::Compressed,
679 exact_estimator(),
680 );
681 let record = record_from_report(
682 &report,
683 "tc-00000001".to_string(),
684 "2026-01-01T00:00:00Z".to_string(),
685 Some("sha256:abc".to_string()),
686 );
687 assert_eq!(record.surface, "cli");
688 assert_eq!(record.status, "compressed");
689 assert_eq!(record.original_tokens, 1000);
690 assert_eq!(record.compressed_tokens, 600);
691 assert_eq!(record.saved_tokens, 400);
692 assert!(record.bypass_reason.is_none());
693 assert_eq!(record.project_hash, Some("sha256:abc".to_string()));
694 }
695
696 #[test]
697 fn record_from_report_uses_wrap_surface_and_flags_never_worse_as_bypass() {
698 let mut report = cli_report(
699 100,
700 120,
701 "command_output",
702 Status::BestEffort,
703 heuristic_estimator(),
704 );
705 report.command = Some(CommandReport {
706 command_family: None,
707 child_exit_code: Some(0),
708 duration_ms: 5,
709 raw_output_bytes: 100,
710 stdout_bytes: 100,
711 stderr_bytes: 0,
712 stderr_mode: "captured".to_string(),
713 stderr_truncated: false,
714 compressed_output_bytes: 100,
715 filter_pack_id: None,
716 filter_version: None,
717 never_worse_applied: true,
718 bypass_reason: None,
719 });
720 let record = record_from_report(
721 &report,
722 "tc-00000002".to_string(),
723 "2026-01-01T00:00:00Z".to_string(),
724 None,
725 );
726 assert_eq!(record.surface, "wrap");
727 assert_eq!(
728 record.bypass_reason,
729 Some("would_increase_tokens".to_string())
730 );
731 }
732
733 #[test]
734 fn record_from_report_falls_back_to_bypass_report_reason() {
735 let mut report = cli_report(50, 50, "plain_text", Status::Passthrough, exact_estimator());
736 report.bypass = Some(BypassReport {
737 reason: "env".to_string(),
738 source: "cli".to_string(),
739 });
740 let record = record_from_report(
741 &report,
742 "tc-00000003".to_string(),
743 "2026-01-01T00:00:00Z".to_string(),
744 None,
745 );
746 assert_eq!(record.bypass_reason, Some("env".to_string()));
747 }
748
749 #[test]
752 fn aggregates_fixture_reports_by_transform_format_estimator_status_and_project() {
753 let openai = cli_report(
756 2000,
757 1000,
758 "openai_json",
759 Status::Compressed,
760 exact_estimator(),
761 );
762 let text = cli_report(
763 500,
764 500,
765 "plain_text",
766 Status::Passthrough,
767 heuristic_estimator(),
768 );
769 let mut diff = cli_report(300, 200, "git_diff", Status::BestEffort, exact_estimator());
770 diff.transforms.push(crate::report::TransformReport {
771 id: "diff_compaction".to_string(),
772 version: "1.0.0".to_string(),
773 tokens_before: 300,
774 tokens_after: 200,
775 saved_tokens: 100,
776 savings_ratio: 0.333,
777 elapsed_micros: None,
778 status: crate::report::TransformStatus::Applied,
779 skipped_reason: None,
780 warnings: vec![],
781 });
782
783 let records = vec![
784 record_from_report(
785 &openai,
786 "tc-a".to_string(),
787 "2026-01-01T00:00:00Z".to_string(),
788 Some("sha256:proj-a".to_string()),
789 ),
790 record_from_report(
791 &text,
792 "tc-b".to_string(),
793 "2026-01-02T00:00:00Z".to_string(),
794 Some("sha256:proj-b".to_string()),
795 ),
796 record_from_report(
797 &diff,
798 "tc-c".to_string(),
799 "2026-01-03T00:00:00Z".to_string(),
800 Some("sha256:proj-c".to_string()),
801 ),
802 ];
803
804 let summary = aggregate(&records);
805 assert_eq!(summary.requests, 3);
806 assert_eq!(summary.raw_tokens, 2000 + 500 + 300);
807 assert_eq!(summary.compressed_tokens, 1000 + 500 + 200);
808 assert_eq!(summary.saved_tokens, 1000 + 100);
809 assert_eq!(summary.recent_requests.len(), 3);
810 let project_hashes: Vec<_> = summary
811 .recent_requests
812 .iter()
813 .filter_map(|r| r.project_hash.clone())
814 .collect();
815 assert!(project_hashes.contains(&"sha256:proj-a".to_string()));
816 assert!(project_hashes.contains(&"sha256:proj-b".to_string()));
817 assert!(project_hashes.contains(&"sha256:proj-c".to_string()));
818 assert_eq!(summary.recent_requests[0].request_id, "tc-c");
820 }
821
822 #[test]
823 fn aggregate_splits_wrapped_vs_raw_commands_and_computes_coverage() {
824 let wrapped = {
825 let mut r = cli_report(
826 1000,
827 400,
828 "command_output",
829 Status::BestEffort,
830 exact_estimator(),
831 );
832 r.command = Some(CommandReport {
833 command_family: None,
834 child_exit_code: Some(0),
835 duration_ms: 1,
836 raw_output_bytes: 1000,
837 stdout_bytes: 1000,
838 stderr_bytes: 0,
839 stderr_mode: "captured".to_string(),
840 stderr_truncated: false,
841 compressed_output_bytes: 400,
842 filter_pack_id: None,
843 filter_version: None,
844 never_worse_applied: false,
845 bypass_reason: None,
846 });
847 r
848 };
849 let raw = {
850 let mut r = cli_report(
851 1000,
852 1000,
853 "command_output",
854 Status::BestEffort,
855 exact_estimator(),
856 );
857 r.command = Some(CommandReport {
858 command_family: None,
859 child_exit_code: Some(0),
860 duration_ms: 1,
861 raw_output_bytes: 1000,
862 stdout_bytes: 1000,
863 stderr_bytes: 0,
864 stderr_mode: "captured".to_string(),
865 stderr_truncated: false,
866 compressed_output_bytes: 1000,
867 filter_pack_id: None,
868 filter_version: None,
869 never_worse_applied: true,
870 bypass_reason: None,
871 });
872 r
873 };
874
875 let records = vec![
876 record_from_report(
877 &wrapped,
878 "tc-w".to_string(),
879 "2026-01-01T00:00:00Z".to_string(),
880 None,
881 ),
882 record_from_report(
883 &raw,
884 "tc-r".to_string(),
885 "2026-01-01T00:00:01Z".to_string(),
886 None,
887 ),
888 ];
889 let summary = aggregate(&records);
890 assert_eq!(summary.commands, 2);
891 assert_eq!(summary.wrapped_commands, 1);
892 assert_eq!(summary.raw_commands, 1);
893 assert_eq!(summary.bypass_count, 1);
894 assert_eq!(summary.coverage_pct, 50.0);
895 assert_eq!(summary.estimated_lost_tokens, 600);
897 }
898
899 #[test]
900 fn aggregate_on_empty_records_never_divides_by_zero() {
901 let summary = aggregate(&[]);
902 assert_eq!(summary.requests, 0);
903 assert_eq!(summary.savings_pct, 0.0);
904 assert_eq!(summary.coverage_pct, 0.0);
905 assert_eq!(summary.estimated_lost_tokens, 0);
906 }
907
908 #[test]
909 fn json_and_csv_outputs_are_schema_stable_and_carry_no_raw_payload_bytes() {
910 let report = cli_report(
911 18_400,
912 11_900,
913 "openai_json",
914 Status::Compressed,
915 exact_estimator(),
916 );
917 let record = record_from_report(
918 &report,
919 "tc-7f3a2b1c".to_string(),
920 "2026-07-08T12:00:00Z".to_string(),
921 Some("sha256:deadbeef".to_string()),
922 );
923 let summary = aggregate(&[record]);
924
925 let json = serde_json::to_value(&summary).unwrap();
926 for key in [
927 "schema_version",
928 "scope",
929 "window",
930 "project",
931 "requests",
932 "commands",
933 "wrapped_commands",
934 "raw_commands",
935 "bypass_count",
936 "raw_tokens",
937 "compressed_tokens",
938 "saved_tokens",
939 "savings_pct",
940 "estimated_lost_tokens",
941 "coverage_pct",
942 "untrusted_filter_count",
943 "retrieval",
944 "cache",
945 "latency",
946 "recent_requests",
947 ] {
948 assert!(json.get(key).is_some(), "missing key {key}");
949 }
950 assert_eq!(json["retrieval"]["markers"], 0);
951 assert_eq!(json["cache"]["hits"], 0);
952 assert_eq!(json["recent_requests"][0]["request_id"], "tc-7f3a2b1c");
953
954 let serialized = serde_json::to_string(&summary).unwrap();
957 assert!(!serialized.contains("hello world"));
958
959 let csv = to_csv(&summary);
960 assert!(csv.contains("schema_version,scope,window"));
961 assert!(csv.contains("request_id,timestamp,surface"));
962 assert!(csv.contains("tc-7f3a2b1c"));
963 assert!(csv.contains("sha256:deadbeef"));
964 }
965
966 #[test]
967 fn csv_escapes_fields_containing_commas_or_quotes() {
968 assert_eq!(csv_field("plain"), "plain");
969 assert_eq!(csv_field("a,b"), "\"a,b\"");
970 assert_eq!(csv_field("a\"b"), "\"a\"\"b\"");
971 }
972
973 #[test]
974 fn savings_provenance_maps_estimator_exactness() {
975 assert_eq!(savings_provenance(true), SavingsProvenance::Measured);
976 assert_eq!(savings_provenance(false), SavingsProvenance::Heuristic);
977 assert_eq!(SavingsProvenance::Measured.as_str(), "measured");
978 assert_eq!(SavingsProvenance::Heuristic.as_str(), "heuristic");
979 assert_eq!(SavingsProvenance::Estimated.as_str(), "estimated");
980 }
981
982 fn sample_record(id: &str, timestamp: &str) -> LedgerRecord {
985 LedgerRecord {
986 request_id: id.to_string(),
987 timestamp: timestamp.to_string(),
988 surface: "cli".to_string(),
989 format: "plain_text".to_string(),
990 mode: "balanced".to_string(),
991 status: "compressed".to_string(),
992 original_tokens: 100,
993 compressed_tokens: 60,
994 saved_tokens: 40,
995 savings_pct: 40.0,
996 bypass_reason: None,
997 project_hash: None,
998 }
999 }
1000
1001 #[test]
1002 fn append_then_read_all_round_trips_records_in_order() {
1003 let path = temp_ledger_path("append_read");
1004 let store = LedgerStore::new(&path);
1005 store
1006 .append(&sample_record("tc-1", "2026-01-01T00:00:00Z"))
1007 .unwrap();
1008 store
1009 .append(&sample_record("tc-2", "2026-01-02T00:00:00Z"))
1010 .unwrap();
1011
1012 let records = store.read_all().unwrap();
1013 assert_eq!(records.len(), 2);
1014 assert_eq!(records[0].request_id, "tc-1");
1015 assert_eq!(records[1].request_id, "tc-2");
1016
1017 std::fs::remove_file(&path).ok();
1018 }
1019
1020 #[test]
1021 fn read_all_on_missing_file_is_an_empty_ledger_not_an_error() {
1022 let path = temp_ledger_path("missing");
1023 let store = LedgerStore::new(&path);
1024 assert_eq!(store.read_all().unwrap(), Vec::new());
1025 }
1026
1027 #[test]
1028 fn read_all_skips_malformed_lines_without_failing() {
1029 let path = temp_ledger_path("malformed");
1030 std::fs::write(
1031 &path,
1032 format!(
1033 "{}\nnot json at all\n{}\n",
1034 serde_json::to_string(&sample_record("tc-1", "2026-01-01T00:00:00Z")).unwrap(),
1035 serde_json::to_string(&sample_record("tc-2", "2026-01-02T00:00:00Z")).unwrap(),
1036 ),
1037 )
1038 .unwrap();
1039 let store = LedgerStore::new(&path);
1040 let records = store.read_all().unwrap();
1041 assert_eq!(records.len(), 2);
1042
1043 std::fs::remove_file(&path).ok();
1044 }
1045
1046 #[test]
1047 fn gc_deletes_only_records_older_than_retention_and_leaves_active_records_untouched() {
1048 let path = temp_ledger_path("gc");
1049 let store = LedgerStore::new(&path);
1050
1051 let now = now_unix();
1052 let old_ts = format_unix_timestamp(now.saturating_sub(200 * 86_400));
1053 let recent_ts = format_unix_timestamp(now.saturating_sub(86_400));
1054 store.append(&sample_record("tc-old", &old_ts)).unwrap();
1055 store
1056 .append(&sample_record("tc-recent", &recent_ts))
1057 .unwrap();
1058
1059 let outcome = store.gc(90).unwrap();
1060 assert_eq!(outcome.removed, 1);
1061 assert_eq!(outcome.kept, 1);
1062
1063 let remaining = store.read_all().unwrap();
1064 assert_eq!(remaining.len(), 1);
1065 assert_eq!(remaining[0].request_id, "tc-recent");
1066 assert_eq!(remaining[0], sample_record("tc-recent", &recent_ts));
1068
1069 std::fs::remove_file(&path).ok();
1070 }
1071
1072 #[test]
1073 fn gc_keeps_records_with_unparsable_timestamps_fail_safe() {
1074 let path = temp_ledger_path("gc_unparsable");
1075 let store = LedgerStore::new(&path);
1076 store
1077 .append(&sample_record("tc-weird", "not-a-timestamp"))
1078 .unwrap();
1079
1080 let outcome = store.gc(1).unwrap();
1081 assert_eq!(outcome.removed, 0);
1082 assert_eq!(outcome.kept, 1);
1083
1084 std::fs::remove_file(&path).ok();
1085 }
1086
1087 #[test]
1088 fn filter_since_drops_older_and_unparsable_records() {
1089 let now = now_unix();
1090 let recent_ts = format_unix_timestamp(now.saturating_sub(60));
1091 let old_ts = format_unix_timestamp(now.saturating_sub(90 * 86_400));
1092 let records = vec![
1093 sample_record("tc-recent", &recent_ts),
1094 sample_record("tc-old", &old_ts),
1095 sample_record("tc-weird", "garbage"),
1096 ];
1097 let filtered = filter_since(&records, now, 3_600);
1098 assert_eq!(filtered.len(), 1);
1099 assert_eq!(filtered[0].request_id, "tc-recent");
1100 }
1101
1102 #[test]
1103 fn retrieval_report_used_by_glob_aggregation_carries_marker_count() {
1104 let mut report = cli_report(100, 80, "plain_text", Status::Compressed, exact_estimator());
1108 report.retrieval = Some(RetrievalReport {
1109 store_namespace: "default".to_string(),
1110 hash_algorithm: "sha256".to_string(),
1111 marker_count: 1,
1112 ttl_seconds: Some(3600),
1113 persisted_original_bytes: 100,
1114 skipped_original_bytes: 0,
1115 });
1116 assert_eq!(report.retrieval.as_ref().unwrap().marker_count, 1);
1117 }
1118}