Skip to main content

firstpass_proxy/
cli.rs

1//! CLI surfaces for the `firstpass` binary (SPEC §7.3/§7.4): `doctor` validates a setup before
2//! you route real traffic through it, and `trace` reads recent audit records from the store. Both
3//! are kept here (not in the binary) so the judgment is unit-tested.
4
5use crate::config::ProxyConfig;
6use firstpass_core::{Mode, Trace};
7
8/// A single doctor check outcome.
9#[derive(Debug, PartialEq, Eq)]
10pub enum CheckStatus {
11    /// Healthy.
12    Ok,
13    /// Works, but worth knowing (e.g. no key in env — observe still runs).
14    Warn,
15    /// Broken; `firstpass doctor` exits non-zero.
16    Fail,
17}
18
19/// One line of the doctor report.
20#[derive(Debug)]
21pub struct Check {
22    /// Short check name.
23    pub name: String,
24    /// Outcome.
25    pub status: CheckStatus,
26    /// Human-readable detail.
27    pub detail: String,
28}
29
30impl Check {
31    fn new(name: impl Into<String>, status: CheckStatus, detail: impl Into<String>) -> Self {
32        Self {
33            name: name.into(),
34            status,
35            detail: detail.into(),
36        }
37    }
38}
39
40/// The result of `firstpass doctor`.
41#[derive(Debug)]
42pub struct DoctorReport {
43    /// One entry per check, in report order.
44    pub checks: Vec<Check>,
45}
46
47impl DoctorReport {
48    /// Healthy iff no check failed (warnings are fine).
49    #[must_use]
50    pub fn healthy(&self) -> bool {
51        self.checks.iter().all(|c| c.status != CheckStatus::Fail)
52    }
53
54    /// Render the report as human-readable lines (a rendering of the structured checks).
55    #[must_use]
56    pub fn render(&self) -> String {
57        let mut out = String::new();
58        for c in &self.checks {
59            let mark = match c.status {
60                CheckStatus::Ok => "✓",
61                CheckStatus::Warn => "!",
62                CheckStatus::Fail => "✗",
63            };
64            out.push_str(&format!("{mark} {}: {}\n", c.name, c.detail));
65        }
66        out.push_str(if self.healthy() {
67            "\nhealthy — ready to route.\n"
68        } else {
69            "\nnot healthy — fix the ✗ items above.\n"
70        });
71        out
72    }
73}
74
75/// Validate a loaded config against the environment: routing sanity, provider key presence, and
76/// that every configured gate's command actually exists. `env` looks up environment variables
77/// (injected so this is testable).
78#[must_use]
79pub fn doctor(config: &ProxyConfig, env: impl Fn(&str) -> Option<String>) -> DoctorReport {
80    let mut checks = Vec::new();
81
82    // Config parsed (we hold a ProxyConfig), report the shape.
83    let route_count = config.routing.as_ref().map_or(0, |c| c.routes.len());
84    checks.push(Check::new(
85        "config",
86        CheckStatus::Ok,
87        format!("default mode {:?}, {route_count} route(s)", config.mode),
88    ));
89
90    // Enforce is only meaningful with an enforce route to activate the engine.
91    let enforce_routes = config.routing.as_ref().map_or(0, |c| {
92        c.routes.iter().filter(|r| r.mode == Mode::Enforce).count()
93    });
94    if config.mode == Mode::Enforce && enforce_routes == 0 {
95        checks.push(Check::new(
96            "routing",
97            CheckStatus::Warn,
98            "mode is enforce but no enforce route is defined — all traffic will observe",
99        ));
100    } else {
101        checks.push(Check::new(
102            "routing",
103            CheckStatus::Ok,
104            format!("{enforce_routes} enforce route(s)"),
105        ));
106    }
107
108    // A provider key in the environment. Observe uses the caller's key, so absence is a warning.
109    if env("ANTHROPIC_API_KEY").is_some_and(|k| !k.is_empty()) {
110        checks.push(Check::new("anthropic-key", CheckStatus::Ok, "present"));
111    } else {
112        checks.push(Check::new(
113            "anthropic-key",
114            CheckStatus::Warn,
115            "ANTHROPIC_API_KEY not set — observe uses the caller's key; enforce needs one reachable",
116        ));
117    }
118
119    // Every configured gate command must resolve, or that gate silently abstains at runtime.
120    let path = env("PATH");
121    let gate_defs = config.routing.as_ref().map_or(&[][..], |c| &c.gate_defs);
122    for def in gate_defs {
123        match def.cmd.first() {
124            Some(program) if command_on_path(program, path.as_deref()) => checks.push(Check::new(
125                format!("gate:{}", def.id),
126                CheckStatus::Ok,
127                format!("`{program}` found"),
128            )),
129            Some(program) => checks.push(Check::new(
130                format!("gate:{}", def.id),
131                CheckStatus::Fail,
132                format!("`{program}` not found on PATH"),
133            )),
134            None => checks.push(Check::new(
135                format!("gate:{}", def.id),
136                CheckStatus::Fail,
137                "empty command",
138            )),
139        }
140    }
141
142    // The trace store must be writable, or we'd trade the audit trail (or availability) for it.
143    if can_write_db(&config.db_path) {
144        checks.push(Check::new(
145            "trace-store",
146            CheckStatus::Ok,
147            format!("{} is writable", config.db_path),
148        ));
149    } else {
150        checks.push(Check::new(
151            "trace-store",
152            CheckStatus::Fail,
153            format!("cannot write near {}", config.db_path),
154        ));
155    }
156
157    DoctorReport { checks }
158}
159
160/// Whether `program` is runnable: an explicit path (contains a separator) that exists, or a bare
161/// name found in one of `PATH`'s directories.
162#[must_use]
163pub fn command_on_path(program: &str, path_var: Option<&str>) -> bool {
164    if program.contains('/') || program.contains('\\') {
165        return std::path::Path::new(program).is_file();
166    }
167    let Some(path) = path_var else { return false };
168    std::env::split_paths(path).any(|dir| dir.join(program).is_file())
169}
170
171/// How this binary got onto the machine. Upgrading is channel-specific — running the wrong
172/// updater either does nothing or fights the package manager that owns the file — so
173/// `firstpass upgrade` detects the channel instead of guessing one.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Channel {
176    /// The shell/powershell installer, which ships its own `firstpass-update` (dist-workspace.toml
177    /// sets `install-updater = true`). This is the only channel we can upgrade in place.
178    Dist,
179    /// `brew install dshakes/tap/firstpass-proxy`.
180    Homebrew,
181    /// `cargo install --git`.
182    Cargo,
183    /// pip / uv / uvx / pipx wheel.
184    Python,
185    /// The npm installer package.
186    Npm,
187    /// Running inside the container image.
188    Docker,
189    /// Built from source, vendored, or something we do not recognise.
190    Unknown,
191}
192
193impl Channel {
194    /// Human label used in the report.
195    #[must_use]
196    pub const fn label(self) -> &'static str {
197        match self {
198            Self::Dist => "installer script (self-updating)",
199            Self::Homebrew => "Homebrew",
200            Self::Cargo => "cargo install",
201            Self::Python => "Python wheel (pip / uv / uvx)",
202            Self::Npm => "npm",
203            Self::Docker => "Docker image",
204            Self::Unknown => "unrecognised",
205        }
206    }
207
208    /// The command that upgrades this channel. `None` when we cannot tell.
209    #[must_use]
210    pub const fn upgrade_command(self) -> Option<&'static str> {
211        match self {
212            Self::Dist => Some("firstpass-update"),
213            Self::Homebrew => Some("brew upgrade firstpass-proxy"),
214            Self::Cargo => Some(
215                "cargo install --git https://github.com/dshakes/firstpass firstpass-proxy --force",
216            ),
217            Self::Python => Some("uv tool upgrade firstpass    # or: pip install -U firstpass"),
218            Self::Npm => Some("npm install -g @dshakesnotbot/firstpass-proxy@latest"),
219            Self::Docker => Some("docker pull ghcr.io/dshakes/firstpass:latest"),
220            Self::Unknown => None,
221        }
222    }
223}
224
225/// Work out how this binary was installed from the path it runs as. Pure: the executable path,
226/// the updater probe and the container check are all injected, so every branch is unit-tested
227/// without installing anything.
228///
229/// Package-manager path markers are checked BEFORE the updater probe: a Homebrew or wheel install
230/// can still carry the dist updater alongside it, and in that case the package manager owns the
231/// file — running the self-updater would leave the manager's metadata pointing at a binary it no
232/// longer controls.
233#[must_use]
234pub fn detect_channel(exe: &std::path::Path, updater_present: bool, in_container: bool) -> Channel {
235    let p = exe.to_string_lossy().to_lowercase().replace('\\', "/");
236    let has = |needle: &str| p.contains(needle);
237    if in_container {
238        return Channel::Docker;
239    }
240    if has("/.cargo/bin/") || has("/.cargo/registry/") {
241        return Channel::Cargo;
242    }
243    if has("/cellar/") || has("/homebrew/") || has("/linuxbrew/") {
244        return Channel::Homebrew;
245    }
246    if has("/site-packages/")
247        || has("/dist-packages/")
248        || has("/.venv/")
249        || has("/pipx/")
250        || has("/uv/")
251        || has("/uv-cache/")
252    {
253        return Channel::Python;
254    }
255    if has("/node_modules/") {
256        return Channel::Npm;
257    }
258    if updater_present {
259        return Channel::Dist;
260    }
261    Channel::Unknown
262}
263
264/// The text `firstpass upgrade` prints. Separated from the doing so the wording is testable.
265#[must_use]
266pub fn upgrade_report(channel: Channel, current_version: &str) -> String {
267    let mut out = format!(
268        "firstpass {current_version} · installed via {}\n",
269        channel.label()
270    );
271    match channel.upgrade_command() {
272        Some(cmd) if channel == Channel::Dist => {
273            out.push_str(&format!("\nupgrading in place — running `{cmd}`\n"));
274        }
275        Some(cmd) => {
276            out.push_str(&format!(
277                "\nthis channel is owned by its package manager, so upgrade with:\n    {cmd}\n"
278            ));
279        }
280        None => {
281            out.push_str(
282                "\ncould not tell how this binary was installed. Pick the line that matches:\n\
283                 \x20   firstpass-update                              # installer script\n\
284                 \x20   brew upgrade firstpass-proxy                  # Homebrew\n\
285                 \x20   uv tool upgrade firstpass                     # uv / pip: pip install -U firstpass\n\
286                 \x20   npm install -g @dshakesnotbot/firstpass-proxy@latest\n\
287                 \x20   docker pull ghcr.io/dshakes/firstpass:latest\n\
288                 \x20   cargo install --git https://github.com/dshakes/firstpass firstpass-proxy --force\n",
289            );
290        }
291    }
292    out
293}
294
295/// Probe whether the trace DB's directory is writable, without creating the DB itself.
296fn can_write_db(db_path: &str) -> bool {
297    let path = std::path::Path::new(db_path);
298    let dir = path
299        .parent()
300        .filter(|d| !d.as_os_str().is_empty())
301        .map_or_else(
302            || std::path::PathBuf::from("."),
303            std::path::Path::to_path_buf,
304        );
305    let probe = dir.join(format!(".firstpass-doctor-probe-{}", std::process::id()));
306    match std::fs::File::create(&probe) {
307        Ok(_) => {
308            let _ = std::fs::remove_file(&probe);
309            true
310        }
311        Err(_) => false,
312    }
313}
314
315/// Per-gate and per-rung evaluation summary computed from receipts — the operator's live
316/// eval suite: how each gate is verdict-ing, how often routing escalates, where serves land.
317#[derive(Debug, serde::Serialize)]
318pub struct EvalsSummary {
319    /// Traces aggregated (enforce mode only — observe records no gate decisions on-path).
320    pub enforce_traces: usize,
321    /// Total escalations across those traces.
322    pub escalations: u64,
323    /// gate_id → (pass, fail, abstain) counts across every attempt.
324    pub gates: std::collections::BTreeMap<String, (u64, u64, u64)>,
325    /// rung index → times an attempt at that rung was the served one.
326    pub served_by_rung: std::collections::BTreeMap<u32, u64>,
327}
328
329/// Aggregate gate verdicts + routing behavior over `traces` (enforce only).
330#[must_use]
331/// Prequential evaluation of the per-query gate-pass predictor (ADR 0008 Phase 2) — the honest
332/// "is it any good yet" check before the predictor is ever allowed to *act*. Replays the receipts
333/// in order through a fresh predictor: for each attempt with a clear Pass/Fail verdict, **predict
334/// before update** (so every prediction is on data the model has not trained on), then update.
335/// Reports AUC (predicted -> passed) and Brier score over those pairs.
336#[derive(Debug, serde::Serialize)]
337pub struct PredictorEval {
338    /// Labeled attempts evaluated.
339    pub n: usize,
340    /// AUC of predicted P(pass) ranking actual passes above fails. 0.5 = no signal; 1.0 = perfect.
341    /// `None` when one class is absent (all pass or all fail — AUC undefined).
342    pub auc: Option<f64>,
343    /// Mean squared error of the probabilistic prediction, in `[0, 1]`. Lower is better; 0.25 is
344    /// the always-0.5 baseline.
345    pub brier: f64,
346}
347
348/// AUC via the Mann-Whitney rank-sum identity (ties = 0.5). `None` if either class is empty.
349fn predictor_auc(pairs: &[(f64, bool)]) -> Option<f64> {
350    let pos: Vec<f64> = pairs.iter().filter(|(_, y)| *y).map(|(s, _)| *s).collect();
351    let neg: Vec<f64> = pairs.iter().filter(|(_, y)| !*y).map(|(s, _)| *s).collect();
352    if pos.is_empty() || neg.is_empty() {
353        return None;
354    }
355    let mut wins = 0.0_f64;
356    for &p in &pos {
357        for &n in &neg {
358            wins += if p > n {
359                1.0
360            } else if (p - n).abs() < f64::EPSILON {
361                0.5
362            } else {
363                0.0
364            };
365        }
366    }
367    Some(wins / (pos.len() as f64 * neg.len() as f64))
368}
369
370/// Run the prequential predictor evaluation over `traces` with the given `lr`/`l2`.
371pub fn evaluate_predictor(traces: &[Trace], lr: f64, l2: f64) -> PredictorEval {
372    use firstpass_core::{PassPredictor, Verdict};
373    let mut model = PassPredictor::new(lr, l2);
374    let mut pairs: Vec<(f64, bool)> = Vec::new();
375    let mut se = 0.0_f64;
376    for t in traces {
377        for a in &t.attempts {
378            let y = match a.verdict {
379                Verdict::Pass => true,
380                Verdict::Fail => false,
381                Verdict::Abstain => continue,
382            };
383            let p = model.predict(&t.request.features, a.rung);
384            let target = f64::from(u8::from(y));
385            se += (p - target) * (p - target);
386            pairs.push((p, y));
387            model.update(&t.request.features, a.rung, y);
388        }
389    }
390    let n = pairs.len();
391    PredictorEval {
392        n,
393        auc: predictor_auc(&pairs),
394        brier: if n == 0 { 0.0 } else { se / n as f64 },
395    }
396}
397
398pub fn summarize_evals(traces: &[Trace]) -> EvalsSummary {
399    let mut s = EvalsSummary {
400        enforce_traces: 0,
401        escalations: 0,
402        gates: std::collections::BTreeMap::new(),
403        served_by_rung: std::collections::BTreeMap::new(),
404    };
405    for t in traces {
406        if t.mode != Mode::Enforce {
407            continue;
408        }
409        s.enforce_traces += 1;
410        s.escalations += u64::from(t.final_.escalations);
411        if let Some(rung) = t.final_.served_rung {
412            *s.served_by_rung.entry(rung).or_insert(0) += 1;
413        }
414        for attempt in &t.attempts {
415            for g in &attempt.gates {
416                let e = s.gates.entry(g.gate_id.clone()).or_insert((0, 0, 0));
417                match g.verdict {
418                    firstpass_core::Verdict::Pass => e.0 += 1,
419                    firstpass_core::Verdict::Fail => e.1 += 1,
420                    firstpass_core::Verdict::Abstain => e.2 += 1,
421                }
422            }
423        }
424    }
425    s
426}
427
428/// Render an [`EvalsSummary`] for humans (`--json` callers serialize the struct instead).
429#[must_use]
430pub fn format_evals(s: &EvalsSummary) -> String {
431    if s.enforce_traces == 0 {
432        return "no enforce traces yet — route some traffic in enforce mode first".to_owned();
433    }
434    let mut out = format!(
435        "enforce traces: {} · escalations: {}\n",
436        s.enforce_traces, s.escalations
437    );
438    out.push_str("gates (pass / fail / abstain):\n");
439    for (id, (p, f, a)) in &s.gates {
440        out.push_str(&format!("  {id:<24} {p:>6} / {f:>6} / {a:>6}\n"));
441    }
442    out.push_str("served by rung:\n");
443    for (rung, n) in &s.served_by_rung {
444        out.push_str(&format!("  rung {rung:<2} {n:>6}\n"));
445    }
446    out.trim_end().to_owned()
447}
448
449/// A structured, agent-readable explanation of a single routing decision — why the ladder
450/// went where it did, built purely from a sealed [`Trace`].
451#[derive(Debug, serde::Serialize)]
452pub struct RouteExplanation {
453    pub trace_id: String,
454    pub mode: String,
455    pub policy: String,
456    /// The model actually served, if any.
457    pub served_model: Option<String>,
458    pub served_rung: Option<u32>,
459    pub escalations: u32,
460    pub total_cost_usd: f64,
461    pub baseline_usd: f64,
462    pub savings_usd: f64,
463    /// Per attempt, in ladder order: what was tried and how it was judged.
464    pub attempts: Vec<AttemptExplanation>,
465    /// One-line human summary.
466    pub summary: String,
467}
468
469/// Per-attempt slice of a [`RouteExplanation`].
470#[derive(Debug, serde::Serialize)]
471pub struct AttemptExplanation {
472    pub rung: u32,
473    pub model: String,
474    pub verdict: String,
475    /// gate_id → verdict, for every gate that ran on this attempt.
476    pub gates: Vec<(String, String)>,
477}
478
479fn verdict_str(v: firstpass_core::Verdict) -> &'static str {
480    match v {
481        firstpass_core::Verdict::Pass => "pass",
482        firstpass_core::Verdict::Fail => "fail",
483        firstpass_core::Verdict::Abstain => "abstain",
484    }
485}
486
487/// Explain one routing decision from its sealed receipt — the "why this model" answer, as
488/// structured data an agent can act on (and the backing for the MCP `explain_route` tool).
489#[must_use]
490pub fn explain_trace(t: &Trace) -> RouteExplanation {
491    let attempts: Vec<AttemptExplanation> = t
492        .attempts
493        .iter()
494        .map(|a| AttemptExplanation {
495            rung: a.rung,
496            model: a.model.clone(),
497            verdict: verdict_str(a.verdict).to_owned(),
498            gates: a
499                .gates
500                .iter()
501                .map(|g| (g.gate_id.clone(), verdict_str(g.verdict).to_owned()))
502                .collect(),
503        })
504        .collect();
505    let served_model = t
506        .final_
507        .served_rung
508        .and_then(|r| t.attempts.iter().find(|a| a.rung == r))
509        .map(|a| a.model.clone());
510    let summary = match &served_model {
511        Some(m) => format!(
512            "served {m} at rung {} after {} escalation(s); spent ${:.4} vs ${:.4} always-top (saved ${:.4})",
513            t.final_.served_rung.unwrap_or(0),
514            t.final_.escalations,
515            t.final_.total_cost_usd,
516            t.final_.counterfactual_baseline_usd,
517            t.final_.savings_usd
518        ),
519        None => "no rung served (all attempts failed the gate or errored)".to_owned(),
520    };
521    RouteExplanation {
522        trace_id: t.trace_id.to_string(),
523        mode: match t.mode {
524            Mode::Enforce => "enforce",
525            Mode::Observe => "observe",
526        }
527        .to_owned(),
528        policy: t.policy.id.clone(),
529        served_model,
530        served_rung: t.final_.served_rung,
531        escalations: t.final_.escalations,
532        total_cost_usd: t.final_.total_cost_usd,
533        baseline_usd: t.final_.counterfactual_baseline_usd,
534        savings_usd: t.final_.savings_usd,
535        attempts,
536        summary,
537    }
538}
539
540/// Outcome of an independent receipt-chain verification — the compliance artifact.
541#[derive(Debug, serde::Serialize)]
542pub struct VerifyReport {
543    /// Receipts checked.
544    pub receipts: usize,
545    /// `true` iff the hash chain re-derives cleanly from genesis (tamper-evident proof).
546    pub valid: bool,
547    /// On failure, the 0-based index of the first broken link and why.
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub broken_at: Option<usize>,
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub detail: Option<String>,
552}
553
554/// Independently re-derive the hash chain over `traces` from genesis — the same computation an
555/// external auditor runs, with no trust in the proxy or the database. A single altered or
556/// reordered receipt breaks the chain at its index.
557#[must_use]
558pub fn verify_receipts(traces: &[Trace]) -> VerifyReport {
559    match firstpass_core::verify_chain(traces, firstpass_core::GENESIS_HASH) {
560        Ok(()) => VerifyReport {
561            receipts: traces.len(),
562            valid: true,
563            broken_at: None,
564            detail: None,
565        },
566        Err(firstpass_core::Error::ChainBroken { index, detail }) => VerifyReport {
567            receipts: traces.len(),
568            valid: false,
569            broken_at: Some(index),
570            detail: Some(detail),
571        },
572        Err(e) => VerifyReport {
573            receipts: traces.len(),
574            valid: false,
575            broken_at: None,
576            detail: Some(e.to_string()),
577        },
578    }
579}
580
581/// Parse a JSONL receipt export (one [`Trace`] per line) back into traces, for offline
582/// verification. Blank lines are skipped; a malformed line fails loudly with its line number.
583///
584/// # Errors
585/// Returns the 1-based line number and parse error of the first unparseable line.
586pub fn parse_receipt_jsonl(text: &str) -> Result<Vec<Trace>, String> {
587    let mut traces = Vec::new();
588    for (i, line) in text.lines().enumerate() {
589        if line.trim().is_empty() {
590            continue;
591        }
592        let t: Trace = serde_json::from_str(line).map_err(|e| format!("line {}: {e}", i + 1))?;
593        traces.push(t);
594    }
595    Ok(traces)
596}
597
598/// Render `traces` as a JSONL receipt export — one sealed receipt per line, in chain order.
599/// This is the artifact an operator hands an auditor; the deferred-verdict side table is never
600/// included (it is not part of the hashed body).
601#[must_use]
602pub fn export_receipts_jsonl(traces: &[Trace]) -> String {
603    let mut out = String::new();
604    for t in traces {
605        if let Ok(line) = serde_json::to_string(t) {
606            out.push_str(&line);
607            out.push('\n');
608        }
609    }
610    out
611}
612
613/// Aggregated spend/savings over a set of traces — the number the operator screenshots.
614/// Pure so it's unit-testable; `firstpass savings` feeds it the trace store.
615#[derive(Debug, serde::Serialize)]
616pub struct SavingsSummary {
617    /// Traces aggregated.
618    pub traces: usize,
619    /// Enforce-mode traces (the ones where routing actually decided).
620    pub enforce_traces: usize,
621    /// USD actually spent (model + gate calls), summed over all traces.
622    pub spent_usd: f64,
623    /// USD spent on gates alone (the price of proof).
624    pub gate_usd: f64,
625    /// What always calling the top rung would have cost (§9.1 counterfactual), summed.
626    pub baseline_usd: f64,
627    /// `baseline - spent` — the savings the receipts prove.
628    pub savings_usd: f64,
629    /// Savings as a fraction of baseline, `0.0` when there is no baseline.
630    pub savings_pct: f64,
631}
632
633/// Aggregate spend vs the always-top counterfactual over `traces`.
634#[must_use]
635pub fn summarize_savings(traces: &[Trace]) -> SavingsSummary {
636    let mut s = SavingsSummary {
637        traces: traces.len(),
638        enforce_traces: 0,
639        spent_usd: 0.0,
640        gate_usd: 0.0,
641        baseline_usd: 0.0,
642        savings_usd: 0.0,
643        savings_pct: 0.0,
644    };
645    for t in traces {
646        if t.mode == Mode::Enforce {
647            s.enforce_traces += 1;
648        }
649        s.spent_usd += t.final_.total_cost_usd;
650        s.gate_usd += t.final_.gate_cost_usd;
651        s.baseline_usd += t.final_.counterfactual_baseline_usd;
652    }
653    s.savings_usd = s.baseline_usd - s.spent_usd;
654    if s.baseline_usd > 0.0 {
655        s.savings_pct = s.savings_usd / s.baseline_usd;
656    }
657    s
658}
659
660/// Render a [`SavingsSummary`] for humans (`--json` callers serialize the struct instead).
661#[must_use]
662pub fn format_savings(s: &SavingsSummary) -> String {
663    if s.traces == 0 {
664        return "no traces recorded yet — route some traffic through the proxy first".to_owned();
665    }
666    format!(
667        "traces: {} ({} enforce)\nspent:    ${:.4}  (gates ${:.4})\nbaseline: ${:.4}  (always top rung)\nsavings:  ${:.4}  ({:.1}%)",
668        s.traces,
669        s.enforce_traces,
670        s.spent_usd,
671        s.gate_usd,
672        s.baseline_usd,
673        s.savings_usd,
674        s.savings_pct * 100.0
675    )
676}
677
678/// Render the most recent `limit` traces as JSON lines — machine-first (SPEC §0.2): each line is a
679/// full [`Trace`], newest last.
680#[must_use]
681pub fn format_traces(traces: &[Trace], limit: usize) -> String {
682    let mut lines: Vec<String> = traces
683        .iter()
684        .rev()
685        .take(limit)
686        .filter_map(|t| serde_json::to_string(t).ok())
687        .collect();
688    lines.reverse();
689    if lines.is_empty() {
690        "no traces recorded yet".to_owned()
691    } else {
692        lines.join("\n")
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    fn config_with(toml: Option<&str>, db_path: &str) -> ProxyConfig {
701        ProxyConfig::from_lookup(|k| match k {
702            "FIRSTPASS_MODE" if toml.is_some() => Some("enforce".to_owned()),
703            "FIRSTPASS_CONFIG_TOML" => toml.map(str::to_owned),
704            "FIRSTPASS_DB" => Some(db_path.to_owned()),
705            _ => None,
706        })
707        .unwrap()
708    }
709
710    #[test]
711    fn command_on_path_finds_real_and_rejects_fake() {
712        let path = std::env::var("PATH").ok();
713        assert!(command_on_path("sh", path.as_deref()), "sh is on PATH");
714        assert!(!command_on_path(
715            "firstpass-definitely-not-a-real-binary",
716            path.as_deref()
717        ));
718        assert!(!command_on_path("/nonexistent/abs/path", None));
719    }
720
721    #[test]
722    fn doctor_flags_a_missing_gate_binary() {
723        let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\ngates = [\"good\", \"bad\"]\n\
724                    [[gate]]\nid = \"good\"\ncmd = [\"sh\"]\n\
725                    [[gate]]\nid = \"bad\"\ncmd = [\"firstpass-nope-not-real\"]\n";
726        let db = std::env::temp_dir().join("firstpass-doctor-test.db");
727        let config = config_with(Some(toml), db.to_str().unwrap());
728
729        // Real PATH so `sh` resolves; no ANTHROPIC_API_KEY -> a warning, not a failure.
730        let report = doctor(&config, |k| match k {
731            "PATH" => std::env::var("PATH").ok(),
732            _ => None,
733        });
734
735        assert!(
736            !report.healthy(),
737            "a missing gate binary must fail the report"
738        );
739        let bad = report.checks.iter().find(|c| c.name == "gate:bad").unwrap();
740        assert_eq!(bad.status, CheckStatus::Fail);
741        let good = report
742            .checks
743            .iter()
744            .find(|c| c.name == "gate:good")
745            .unwrap();
746        assert_eq!(good.status, CheckStatus::Ok);
747        let key = report
748            .checks
749            .iter()
750            .find(|c| c.name == "anthropic-key")
751            .unwrap();
752        assert_eq!(key.status, CheckStatus::Warn);
753    }
754
755    #[test]
756    fn doctor_is_healthy_for_a_plain_observe_setup() {
757        let db = std::env::temp_dir().join("firstpass-doctor-ok.db");
758        let config = config_with(None, db.to_str().unwrap());
759        let report = doctor(&config, |k| {
760            (k == "ANTHROPIC_API_KEY").then(|| "sk-test".to_owned())
761        });
762        assert!(report.healthy(), "{}", report.render());
763    }
764
765    #[test]
766    fn format_traces_handles_empty() {
767        assert_eq!(format_traces(&[], 10), "no traces recorded yet");
768    }
769
770    #[test]
771    fn savings_summary_empty_and_nonempty() {
772        let empty = summarize_savings(&[]);
773        assert_eq!(empty.traces, 0);
774        assert!(format_savings(&empty).contains("no traces"));
775    }
776
777    #[test]
778    fn evals_summary_empty_zero_state() {
779        let s = summarize_evals(&[]);
780        assert_eq!(s.enforce_traces, 0);
781        assert!(format_evals(&s).contains("no enforce traces"));
782    }
783
784    #[test]
785    fn verify_and_export_round_trip_detects_tampering() {
786        use firstpass_core::{
787            Attempt, Features, FinalOutcome, GENESIS_HASH, PolicyRef, RequestInfo, ServedFrom,
788            TaskKind, Verdict,
789        };
790
791        // Build a valid 3-link chain: each prev_hash = the previous record's hash.
792        let mk = |prev: &str, session: &str| Trace {
793            trace_id: uuid::Uuid::now_v7(),
794            prev_hash: prev.to_owned(),
795            tenant_id: "default".to_owned(),
796            session_id: session.to_owned(),
797            ts: jiff::Timestamp::UNIX_EPOCH,
798            mode: Mode::Observe,
799            policy: PolicyRef {
800                id: "observe-passthrough@v0".to_owned(),
801                explore: false,
802                propensity: None,
803                mode_profile: None,
804            },
805            request: RequestInfo {
806                api: "anthropic.messages".to_owned(),
807                prompt_hash: "deadbeef".to_owned(),
808                features: Features::new(TaskKind::Other),
809            },
810            attempts: vec![Attempt {
811                rung: 0,
812                model: "anthropic/claude-haiku-4-5".to_owned(),
813                provider: "anthropic".to_owned(),
814                in_tokens: 10,
815                out_tokens: 5,
816                cost_usd: 0.001,
817                latency_ms: 12,
818                gates: vec![],
819                verdict: Verdict::Pass,
820            }],
821            deferred: Vec::new(),
822            final_: FinalOutcome {
823                served_rung: Some(0),
824                served_from: ServedFrom::Attempt,
825                total_cost_usd: 0.001,
826                gate_cost_usd: 0.0,
827                total_latency_ms: 12,
828                escalations: 0,
829                counterfactual_baseline_usd: 0.001,
830                savings_usd: 0.0,
831            },
832            probe: None,
833            predicted_pass: None,
834            elastic: None,
835        };
836        let t0 = mk(GENESIS_HASH, "s0");
837        let t1 = mk(&t0.hash().unwrap(), "s1");
838        let t2 = mk(&t1.hash().unwrap(), "s2");
839        let chain = vec![t0, t1, t2];
840
841        // Clean chain verifies.
842        let report = verify_receipts(&chain);
843        assert!(report.valid, "intact chain must verify: {report:?}");
844        assert_eq!(report.receipts, 3);
845
846        // Export → parse round-trips and still verifies (the auditor's offline path).
847        let jsonl = export_receipts_jsonl(&chain);
848        assert_eq!(jsonl.lines().count(), 3);
849        let reparsed = parse_receipt_jsonl(&jsonl).expect("export must re-parse");
850        assert!(
851            verify_receipts(&reparsed).valid,
852            "round-tripped chain must verify"
853        );
854
855        // Tamper with a middle receipt's body → verification catches it at that index.
856        let mut tampered = reparsed;
857        tampered[1].final_.total_cost_usd = 999.0; // alter a sealed field
858        let bad = verify_receipts(&tampered);
859        assert!(!bad.valid, "a mutated receipt must break the chain");
860        assert_eq!(
861            bad.broken_at,
862            Some(2),
863            "the break surfaces at the next link"
864        );
865
866        // Reordering is also caught.
867        let mut reordered = parse_receipt_jsonl(&jsonl).unwrap();
868        reordered.swap(0, 1);
869        assert!(
870            !verify_receipts(&reordered).valid,
871            "reordering breaks the chain"
872        );
873    }
874
875    #[test]
876    fn parse_receipt_jsonl_reports_bad_line() {
877        let err = parse_receipt_jsonl("not json\n").unwrap_err();
878        assert!(err.starts_with("line 1:"), "must name the bad line: {err}");
879        assert!(
880            parse_receipt_jsonl("\n\n").unwrap().is_empty(),
881            "blank lines skipped"
882        );
883    }
884
885    #[test]
886    fn explain_trace_summarizes_served_and_escalation() {
887        use firstpass_core::{
888            Attempt, Features, FinalOutcome, GENESIS_HASH, GateResult, PolicyRef, RequestInfo,
889            ServedFrom, TaskKind, Verdict,
890        };
891        let t = Trace {
892            trace_id: uuid::Uuid::now_v7(),
893            prev_hash: GENESIS_HASH.to_owned(),
894            tenant_id: "default".to_owned(),
895            session_id: "s".to_owned(),
896            ts: jiff::Timestamp::UNIX_EPOCH,
897            mode: Mode::Enforce,
898            policy: PolicyRef {
899                id: "static-ladder@v0".to_owned(),
900                explore: false,
901                propensity: None,
902                mode_profile: None,
903            },
904            request: RequestInfo {
905                api: "anthropic.messages".to_owned(),
906                prompt_hash: "x".to_owned(),
907                features: Features::new(TaskKind::Other),
908            },
909            attempts: vec![
910                Attempt {
911                    rung: 0,
912                    model: "anthropic/claude-haiku-4-5".to_owned(),
913                    provider: "anthropic".to_owned(),
914                    in_tokens: 1,
915                    out_tokens: 1,
916                    cost_usd: 0.001,
917                    latency_ms: 5,
918                    gates: vec![GateResult::deterministic("non-empty", Verdict::Fail, 0)],
919                    verdict: Verdict::Fail,
920                },
921                Attempt {
922                    rung: 1,
923                    model: "anthropic/claude-sonnet-5".to_owned(),
924                    provider: "anthropic".to_owned(),
925                    in_tokens: 1,
926                    out_tokens: 1,
927                    cost_usd: 0.01,
928                    latency_ms: 9,
929                    gates: vec![GateResult::deterministic("non-empty", Verdict::Pass, 0)],
930                    verdict: Verdict::Pass,
931                },
932            ],
933            deferred: Vec::new(),
934            final_: FinalOutcome {
935                served_rung: Some(1),
936                served_from: ServedFrom::Attempt,
937                total_cost_usd: 0.011,
938                gate_cost_usd: 0.0,
939                total_latency_ms: 14,
940                escalations: 1,
941                counterfactual_baseline_usd: 0.02,
942                savings_usd: 0.009,
943            },
944            probe: None,
945            predicted_pass: None,
946            elastic: None,
947        };
948        let ex = explain_trace(&t);
949        assert_eq!(
950            ex.served_model.as_deref(),
951            Some("anthropic/claude-sonnet-5")
952        );
953        assert_eq!(ex.escalations, 1);
954        assert_eq!(ex.attempts.len(), 2);
955        assert_eq!(ex.attempts[0].verdict, "fail");
956        assert_eq!(
957            ex.attempts[1].gates[0],
958            ("non-empty".to_owned(), "pass".to_owned())
959        );
960        assert!(ex.summary.contains("served anthropic/claude-sonnet-5"));
961    }
962
963    #[test]
964    fn evaluate_predictor_empty_and_signal() {
965        // empty store => n=0, no crash
966        let e = evaluate_predictor(&[], 0.05, 1e-4);
967        assert_eq!(e.n, 0);
968        assert!(e.auc.is_none());
969    }
970
971    /// Each channel must be recognised from a realistic install path. Getting this wrong is not
972    /// cosmetic: it would print an upgrade command that silently does nothing.
973    #[test]
974    fn detect_channel_reads_real_install_paths() {
975        use std::path::Path;
976        let cases = [
977            ("/Users/u/.cargo/bin/firstpass", Channel::Cargo),
978            (
979                "/opt/homebrew/Cellar/firstpass-proxy/0.2.2/bin/firstpass",
980                Channel::Homebrew,
981            ),
982            (
983                "/home/linuxbrew/.linuxbrew/bin/firstpass",
984                Channel::Homebrew,
985            ),
986            (
987                "/Users/u/.venv/lib/python3.12/site-packages/firstpass/firstpass",
988                Channel::Python,
989            ),
990            // Both observed on a real machine rather than invented: uvx runs from an ephemeral
991            // env under archive-v0, and `uv tool install` lands under the uv tools dir.
992            (
993                "/Users/u/.cache/uv/archive-v0/DQ4usH_XuqNyywgHKB8qB/bin/firstpass",
994                Channel::Python,
995            ),
996            (
997                "/Users/u/.local/share/uv/tools/firstpass/bin/firstpass",
998                Channel::Python,
999            ),
1000            (
1001                "/usr/local/lib/node_modules/@dshakesnotbot/firstpass-proxy/bin/firstpass",
1002                Channel::Npm,
1003            ),
1004            (r"C:\Users\u\.cargo\bin\firstpass.exe", Channel::Cargo),
1005        ];
1006        for (path, want) in cases {
1007            assert_eq!(
1008                detect_channel(Path::new(path), false, false),
1009                want,
1010                "misread {path}"
1011            );
1012        }
1013        // The installer channel is identified by its updater, not by its path.
1014        assert_eq!(
1015            detect_channel(Path::new("/Users/u/.local/bin/firstpass"), true, false),
1016            Channel::Dist
1017        );
1018        assert_eq!(
1019            detect_channel(Path::new("/Users/u/.local/bin/firstpass"), false, false),
1020            Channel::Unknown
1021        );
1022        // A container wins outright — nothing on the host owns that file.
1023        assert_eq!(
1024            detect_channel(Path::new("/usr/local/bin/firstpass"), true, true),
1025            Channel::Docker
1026        );
1027    }
1028
1029    /// A package manager owning the file must beat the updater probe: a brew or wheel install can
1030    /// carry the updater too, and self-updating underneath the manager desynchronises its metadata.
1031    #[test]
1032    fn package_manager_paths_win_over_the_self_updater() {
1033        use std::path::Path;
1034        for (path, want) in [
1035            (
1036                "/opt/homebrew/Cellar/firstpass-proxy/0.2.2/bin/firstpass",
1037                Channel::Homebrew,
1038            ),
1039            ("/Users/u/.venv/bin/firstpass", Channel::Python),
1040            ("/Users/u/.cargo/bin/firstpass", Channel::Cargo),
1041        ] {
1042            assert_eq!(detect_channel(Path::new(path), true, false), want, "{path}");
1043        }
1044    }
1045
1046    /// Only the channel we own may be upgraded in place; the rest must be advice, and the
1047    /// unknown case must still leave the user with something runnable.
1048    #[test]
1049    fn upgrade_report_runs_only_our_own_channel() {
1050        assert!(upgrade_report(Channel::Dist, "0.2.2").contains("upgrading in place"));
1051        for c in [
1052            Channel::Homebrew,
1053            Channel::Python,
1054            Channel::Npm,
1055            Channel::Docker,
1056            Channel::Cargo,
1057        ] {
1058            let r = upgrade_report(c, "0.2.2");
1059            assert!(
1060                !r.contains("upgrading in place"),
1061                "{c:?} must not self-update"
1062            );
1063            assert!(
1064                r.contains("owned by its package manager"),
1065                "{c:?} must explain why"
1066            );
1067            assert!(
1068                r.contains(c.upgrade_command().expect("has a command")),
1069                "{c:?} must print its exact command"
1070            );
1071        }
1072        let unknown = upgrade_report(Channel::Unknown, "0.2.2");
1073        assert!(unknown.contains("brew upgrade") && unknown.contains("docker pull"));
1074        assert!(
1075            unknown.contains("0.2.2"),
1076            "always states the running version"
1077        );
1078    }
1079}