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/// Probe whether the trace DB's directory is writable, without creating the DB itself.
172fn can_write_db(db_path: &str) -> bool {
173    let path = std::path::Path::new(db_path);
174    let dir = path
175        .parent()
176        .filter(|d| !d.as_os_str().is_empty())
177        .map_or_else(
178            || std::path::PathBuf::from("."),
179            std::path::Path::to_path_buf,
180        );
181    let probe = dir.join(format!(".firstpass-doctor-probe-{}", std::process::id()));
182    match std::fs::File::create(&probe) {
183        Ok(_) => {
184            let _ = std::fs::remove_file(&probe);
185            true
186        }
187        Err(_) => false,
188    }
189}
190
191/// Per-gate and per-rung evaluation summary computed from receipts — the operator's live
192/// eval suite: how each gate is verdict-ing, how often routing escalates, where serves land.
193#[derive(Debug, serde::Serialize)]
194pub struct EvalsSummary {
195    /// Traces aggregated (enforce mode only — observe records no gate decisions on-path).
196    pub enforce_traces: usize,
197    /// Total escalations across those traces.
198    pub escalations: u64,
199    /// gate_id → (pass, fail, abstain) counts across every attempt.
200    pub gates: std::collections::BTreeMap<String, (u64, u64, u64)>,
201    /// rung index → times an attempt at that rung was the served one.
202    pub served_by_rung: std::collections::BTreeMap<u32, u64>,
203}
204
205/// Aggregate gate verdicts + routing behavior over `traces` (enforce only).
206#[must_use]
207pub fn summarize_evals(traces: &[Trace]) -> EvalsSummary {
208    let mut s = EvalsSummary {
209        enforce_traces: 0,
210        escalations: 0,
211        gates: std::collections::BTreeMap::new(),
212        served_by_rung: std::collections::BTreeMap::new(),
213    };
214    for t in traces {
215        if t.mode != Mode::Enforce {
216            continue;
217        }
218        s.enforce_traces += 1;
219        s.escalations += u64::from(t.final_.escalations);
220        if let Some(rung) = t.final_.served_rung {
221            *s.served_by_rung.entry(rung).or_insert(0) += 1;
222        }
223        for attempt in &t.attempts {
224            for g in &attempt.gates {
225                let e = s.gates.entry(g.gate_id.clone()).or_insert((0, 0, 0));
226                match g.verdict {
227                    firstpass_core::Verdict::Pass => e.0 += 1,
228                    firstpass_core::Verdict::Fail => e.1 += 1,
229                    firstpass_core::Verdict::Abstain => e.2 += 1,
230                }
231            }
232        }
233    }
234    s
235}
236
237/// Render an [`EvalsSummary`] for humans (`--json` callers serialize the struct instead).
238#[must_use]
239pub fn format_evals(s: &EvalsSummary) -> String {
240    if s.enforce_traces == 0 {
241        return "no enforce traces yet — route some traffic in enforce mode first".to_owned();
242    }
243    let mut out = format!(
244        "enforce traces: {} · escalations: {}\n",
245        s.enforce_traces, s.escalations
246    );
247    out.push_str("gates (pass / fail / abstain):\n");
248    for (id, (p, f, a)) in &s.gates {
249        out.push_str(&format!("  {id:<24} {p:>6} / {f:>6} / {a:>6}\n"));
250    }
251    out.push_str("served by rung:\n");
252    for (rung, n) in &s.served_by_rung {
253        out.push_str(&format!("  rung {rung:<2} {n:>6}\n"));
254    }
255    out.trim_end().to_owned()
256}
257
258/// Aggregated spend/savings over a set of traces — the number the operator screenshots.
259/// Pure so it's unit-testable; `firstpass savings` feeds it the trace store.
260#[derive(Debug, serde::Serialize)]
261pub struct SavingsSummary {
262    /// Traces aggregated.
263    pub traces: usize,
264    /// Enforce-mode traces (the ones where routing actually decided).
265    pub enforce_traces: usize,
266    /// USD actually spent (model + gate calls), summed over all traces.
267    pub spent_usd: f64,
268    /// USD spent on gates alone (the price of proof).
269    pub gate_usd: f64,
270    /// What always calling the top rung would have cost (§9.1 counterfactual), summed.
271    pub baseline_usd: f64,
272    /// `baseline - spent` — the savings the receipts prove.
273    pub savings_usd: f64,
274    /// Savings as a fraction of baseline, `0.0` when there is no baseline.
275    pub savings_pct: f64,
276}
277
278/// Aggregate spend vs the always-top counterfactual over `traces`.
279#[must_use]
280pub fn summarize_savings(traces: &[Trace]) -> SavingsSummary {
281    let mut s = SavingsSummary {
282        traces: traces.len(),
283        enforce_traces: 0,
284        spent_usd: 0.0,
285        gate_usd: 0.0,
286        baseline_usd: 0.0,
287        savings_usd: 0.0,
288        savings_pct: 0.0,
289    };
290    for t in traces {
291        if t.mode == Mode::Enforce {
292            s.enforce_traces += 1;
293        }
294        s.spent_usd += t.final_.total_cost_usd;
295        s.gate_usd += t.final_.gate_cost_usd;
296        s.baseline_usd += t.final_.counterfactual_baseline_usd;
297    }
298    s.savings_usd = s.baseline_usd - s.spent_usd;
299    if s.baseline_usd > 0.0 {
300        s.savings_pct = s.savings_usd / s.baseline_usd;
301    }
302    s
303}
304
305/// Render a [`SavingsSummary`] for humans (`--json` callers serialize the struct instead).
306#[must_use]
307pub fn format_savings(s: &SavingsSummary) -> String {
308    if s.traces == 0 {
309        return "no traces recorded yet — route some traffic through the proxy first".to_owned();
310    }
311    format!(
312        "traces: {} ({} enforce)\nspent:    ${:.4}  (gates ${:.4})\nbaseline: ${:.4}  (always top rung)\nsavings:  ${:.4}  ({:.1}%)",
313        s.traces,
314        s.enforce_traces,
315        s.spent_usd,
316        s.gate_usd,
317        s.baseline_usd,
318        s.savings_usd,
319        s.savings_pct * 100.0
320    )
321}
322
323/// Render the most recent `limit` traces as JSON lines — machine-first (SPEC §0.2): each line is a
324/// full [`Trace`], newest last.
325#[must_use]
326pub fn format_traces(traces: &[Trace], limit: usize) -> String {
327    let mut lines: Vec<String> = traces
328        .iter()
329        .rev()
330        .take(limit)
331        .filter_map(|t| serde_json::to_string(t).ok())
332        .collect();
333    lines.reverse();
334    if lines.is_empty() {
335        "no traces recorded yet".to_owned()
336    } else {
337        lines.join("\n")
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn config_with(toml: Option<&str>, db_path: &str) -> ProxyConfig {
346        ProxyConfig::from_lookup(|k| match k {
347            "FIRSTPASS_MODE" if toml.is_some() => Some("enforce".to_owned()),
348            "FIRSTPASS_CONFIG_TOML" => toml.map(str::to_owned),
349            "FIRSTPASS_DB" => Some(db_path.to_owned()),
350            _ => None,
351        })
352        .unwrap()
353    }
354
355    #[test]
356    fn command_on_path_finds_real_and_rejects_fake() {
357        let path = std::env::var("PATH").ok();
358        assert!(command_on_path("sh", path.as_deref()), "sh is on PATH");
359        assert!(!command_on_path(
360            "firstpass-definitely-not-a-real-binary",
361            path.as_deref()
362        ));
363        assert!(!command_on_path("/nonexistent/abs/path", None));
364    }
365
366    #[test]
367    fn doctor_flags_a_missing_gate_binary() {
368        let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\ngates = [\"good\", \"bad\"]\n\
369                    [[gate]]\nid = \"good\"\ncmd = [\"sh\"]\n\
370                    [[gate]]\nid = \"bad\"\ncmd = [\"firstpass-nope-not-real\"]\n";
371        let db = std::env::temp_dir().join("firstpass-doctor-test.db");
372        let config = config_with(Some(toml), db.to_str().unwrap());
373
374        // Real PATH so `sh` resolves; no ANTHROPIC_API_KEY -> a warning, not a failure.
375        let report = doctor(&config, |k| match k {
376            "PATH" => std::env::var("PATH").ok(),
377            _ => None,
378        });
379
380        assert!(
381            !report.healthy(),
382            "a missing gate binary must fail the report"
383        );
384        let bad = report.checks.iter().find(|c| c.name == "gate:bad").unwrap();
385        assert_eq!(bad.status, CheckStatus::Fail);
386        let good = report
387            .checks
388            .iter()
389            .find(|c| c.name == "gate:good")
390            .unwrap();
391        assert_eq!(good.status, CheckStatus::Ok);
392        let key = report
393            .checks
394            .iter()
395            .find(|c| c.name == "anthropic-key")
396            .unwrap();
397        assert_eq!(key.status, CheckStatus::Warn);
398    }
399
400    #[test]
401    fn doctor_is_healthy_for_a_plain_observe_setup() {
402        let db = std::env::temp_dir().join("firstpass-doctor-ok.db");
403        let config = config_with(None, db.to_str().unwrap());
404        let report = doctor(&config, |k| {
405            (k == "ANTHROPIC_API_KEY").then(|| "sk-test".to_owned())
406        });
407        assert!(report.healthy(), "{}", report.render());
408    }
409
410    #[test]
411    fn format_traces_handles_empty() {
412        assert_eq!(format_traces(&[], 10), "no traces recorded yet");
413    }
414
415    #[test]
416    fn savings_summary_empty_and_nonempty() {
417        let empty = summarize_savings(&[]);
418        assert_eq!(empty.traces, 0);
419        assert!(format_savings(&empty).contains("no traces"));
420    }
421
422    #[test]
423    fn evals_summary_empty_zero_state() {
424        let s = summarize_evals(&[]);
425        assert_eq!(s.enforce_traces, 0);
426        assert!(format_evals(&s).contains("no enforce traces"));
427    }
428}