Skip to main content

tokenfold_core/
stats.rs

1//! Savings ledger, stats aggregation, and JSON/CSV export backing the CLI's
2//! `stats`/`gain`/`session` subcommands.
3//!
4//! `StatsSummary`/`LedgerRecord` are the crate's stable machine-readable JSON shapes.
5//! `aggregate()` is the one pure aggregation path shared by the CLI's `stats`/`gain`/`session`
6//! subcommands (each just calls it and then tweaks framing fields like `scope`/`window`).
7//!
8//! Ledger storage format: `tokenfold.toml`'s `[analytics].ledger_db` path is documented as
9//! ending in `.db`, but this pass stores newline-delimited JSON (JSONL) inside it rather than
10//! embedding a sqlite dependency — there is no sqlite crate anywhere in this workspace's
11//! dependency graph. The `.db` extension is only what the config schema names the path; the
12//! format inside is plain JSONL. Upgrade path is a real embedded database (sqlite or similar)
13//! if this ever needs concurrent-writer safety; a single local CLI process appending its own
14//! ledger doesn't need that yet.
15//!
16//! Fields with no honest data source yet are zero-filled rather than invented, each with a
17//! comment at its computation site: `cache` (no cache subsystem exists anywhere in this
18//! codebase), `retrieval.hits`/`misses`/`expired` (only store-time marker counts are tracked,
19//! not later retrieval-attempt outcomes), `latency` (no per-request timing is threaded through
20//! `CompressionReport`/`LedgerRecord` yet — every `TransformReport.elapsed_micros` in this
21//! codebase is already always `None`), and `untrusted_filter_count` (no count of skipped
22//! untrusted filter packs is threaded through to the ledger yet).
23
24use 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/// The stable `StatsSummary` JSON shape shared by `tokenfold stats`, `gain`, and `session`.
56#[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/// The redacted `recent_requests[]` item shape of [`StatsSummary`]. This is
81/// also the exact shape persisted (one per line, as JSON) by [`LedgerStore`] — there is no
82/// separate "storage" representation, so no raw prompt/response/command-arg/path/header bytes
83/// can ever end up on disk through this type.
84#[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/// Whether a savings/tokens figure came from an exact estimator (`"measured"`), the byte
101/// heuristic (`"heuristic"`), or is a derived extrapolation like `estimated_lost_tokens`
102/// (`"estimated"`) rather than a directly observed value. There is no per-provider pricing data
103/// anywhere in this codebase, so this labels token-count provenance, not dollar cost.
104#[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
121/// Labels a directly-observed token count by estimator exactness (see `EstimatorInfo.is_exact`).
122/// Derived/extrapolated figures (e.g. `estimated_lost_tokens`) are always
123/// `SavingsProvenance::Estimated` by construction, independent of this function.
124pub fn savings_provenance(is_exact: bool) -> SavingsProvenance {
125    if is_exact {
126        SavingsProvenance::Measured
127    } else {
128        SavingsProvenance::Heuristic
129    }
130}
131
132/// Builds the `LedgerRecord` for one `CompressionReport`, used both by the CLI's post-run
133/// ledger-recording hook and by ad-hoc `tokenfold stats <report-glob>` aggregation (turning a
134/// standalone report file into the same shape). `surface` is derived from the report itself
135/// (`"wrap"` when `CommandReport` is present, `"cli"` otherwise) rather than passed in, so both
136/// callers classify it identically.
137pub 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    // A wrap invocation whose `never_worse` guard fell back to raw output is a genuine,
151    // already-recorded "why compression didn't apply here" reason; `report.bypass` is the future
152    // general-purpose source once something records filter-registry bypasses, but nothing sets
153    // it yet, so this `or_else` currently never fires.
154    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
184/// The one shared aggregation path: pure, total-order-independent, takes only ledger-shaped
185/// records. `scope`/`window`/`project` are generic defaults here — callers (the CLI's
186/// `stats`/`gain`/`session` entry points) overwrite those framing fields on the returned
187/// `StatsSummary` to suit each command's emphasis; the underlying counts never change.
188pub 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    // estimated_lost_tokens/coverage_pct: extrapolated from the measured average savings ratio
211    // of wrapped (actually-compressed) commands, applied to raw (bypassed / never-worse
212    // fallback) commands' own token counts. This is the honest "straightforward" computation
213    // available today: nothing records *why* coverage is incomplete for a given command, only
214    // that it is.
215    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    // Lexicographic order matches chronological order for the fixed-width, zero-padded
246    // `format_unix_timestamp` shape, so no timestamp parsing is needed just to sort.
247    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        // ponytail: nothing reports skipped untrusted filter packs here yet; real counts land
267        // when the filter registry's trust outcomes are threaded into the ledger.
268        untrusted_filter_count: 0,
269        // ponytail: no historical record of individual `tokenfold retrieve` outcomes exists —
270        // `RetrievalReport` only carries store-time marker counts, not later hit/miss/expiry —
271        // so those three stay zero; a real `markers` count is filled in by ad-hoc report-glob
272        // aggregation (see `crates/tokenfold-cli/src/main.rs::cmd_stats`), which has direct
273        // access to each `CompressionReport.retrieval.marker_count`.
274        retrieval: RetrievalStats::default(),
275        // ponytail: no cache subsystem exists anywhere in this codebase yet; zero is honest,
276        // not a placeholder for a feature this pass should build.
277        cache: CacheStats::default(),
278        // ponytail: no per-request latency is threaded through `CompressionReport`/
279        // `LedgerRecord` yet (every `TransformReport.elapsed_micros` in this codebase is
280        // already always `None`); zero is honest, not measured.
281        latency: LatencyStats::default(),
282        recent_requests,
283    }
284}
285
286/// Parses a duration shorthand like `"30d"`, `"24h"`, `"90m"`, `"120s"`, or a bare integer
287/// (seconds) — used by `tokenfold gain --since`.
288pub 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
309/// Keeps only records whose `timestamp` is within `window_secs` of `now` (both in Unix
310/// seconds). Records with an unparsable timestamp are dropped — unlike [`LedgerStore::gc`],
311/// which fails safe by keeping what it can't confidently date, a `--since` view is explicitly
312/// asking for "recent", so an undatable record can't honestly satisfy that.
313pub 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
324/// Renders `summary` as two CSV sections separated by a blank line: a one-row summary table,
325/// then the `recent_requests` table. A manual writer (no new dependency) is enough for this
326/// shape — every field is a plain number or a short known-alphabet string, so the only escaping
327/// that can ever matter is on `project`/`project_hash`/`bypass_reason`, handled by [`csv_field`].
328pub 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
397/// A short synthetic ID in the documented `tc-XXXXXXXX` shape (8 lowercase hex chars), derived
398/// from the current time and process ID. Good enough for a human-scannable, practically-unique
399/// local identifier; not a security-sensitive value, so `DefaultHasher` (not a cryptographic
400/// hash) is an appropriate, dependency-free choice.
401pub 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
420/// Formats Unix seconds as an RFC3339 UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`) without pulling in
421/// a date/time crate: this is the one place in the codebase that needs calendar math, so a
422/// compact civil-from-days conversion (Howard Hinnant's well-known `civil_from_days` algorithm,
423/// see http://howardhinnant.github.io/date_algorithms.html) is a fair ponytail trade against
424/// adding `chrono`/`time` as a dependency for a couple of call sites.
425pub 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; // [0, 146096]
437    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
438    let y = yoe + era * 400;
439    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
440    let mp = (5 * doy + 2) / 153; // [0, 11]
441    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
442    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
443    (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; // [0, 399]
450    let mp = if m > 2 { m as i64 - 3 } else { m as i64 + 9 }; // [0, 11]
451    let doy = (153 * mp + 2) / 5 + d as i64 - 1; // [0, 365]
452    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
453    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
474/// The optional local savings ledger: appends/reads/garbage-collects redacted `LedgerRecord`
475/// metadata at a JSONL file path (see the module doc for the `.db`-named-but-JSONL decision).
476pub 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    /// `$XDG_DATA_HOME/tokenfold/ledger.db`, falling back to
492    /// `<home>/.local/share/tokenfold/ledger.db` — mirrors
493    /// `retrieval_store::default_store_path`'s HOME/USERPROFILE fallback.
494    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    /// Reads every well-formed record. A line that fails to parse (e.g. a partial write left by
527    /// a crash mid-append) is skipped rather than failing the whole read. A missing file reads
528    /// as an empty ledger, not an error (an analytics-enabled run before any record exists yet
529    /// is the common case, not a failure).
530    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    /// Rewrites the ledger file keeping only records whose `timestamp` is within
542    /// `retention_days` of now; records with an unparsable timestamp are kept (fail safe rather
543    /// than silently discarding data this can't confidently date). Survivors keep their original
544    /// relative order — nothing about a kept record is touched or reordered.
545    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    // --- timestamp round trip -------------------------------------------------------------
642
643    #[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    // --- record_from_report ----------------------------------------------------------------
672
673    #[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    // --- aggregate: the v0.2 stats parity surface -------------------------------------------
751
752    #[test]
753    fn aggregates_fixture_reports_by_transform_format_estimator_status_and_project() {
754        // Three heterogeneous fixture CompressionReports: different format, estimator
755        // exactness, status, and project attribution.
756        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        // Sorted newest-timestamp-first.
820        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        // wrapped ratio is 60% (400/1000 saved); extrapolated onto raw's 1000 original tokens.
897        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        // No arbitrary payload text ever enters the summary: every string in it is drawn from
956        // a fixed short vocabulary (ids, ISO timestamps, format/mode/status labels, hashes).
957        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    // --- LedgerStore -------------------------------------------------------------------------
984
985    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        // The surviving record must be byte-identical metadata, not just present.
1068        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        // Sanity check that CompressionReport.retrieval is the honest source for retrieval
1106        // marker totals during ad-hoc report-glob aggregation (see module doc); aggregate()
1107        // itself never touches it since LedgerRecord doesn't carry retrieval fields.
1108        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}