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