1use crate::config::ProxyConfig;
6use firstpass_core::{Mode, Trace};
7
8#[derive(Debug, PartialEq, Eq)]
10pub enum CheckStatus {
11 Ok,
13 Warn,
15 Fail,
17}
18
19#[derive(Debug)]
21pub struct Check {
22 pub name: String,
24 pub status: CheckStatus,
26 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#[derive(Debug)]
42pub struct DoctorReport {
43 pub checks: Vec<Check>,
45}
46
47impl DoctorReport {
48 #[must_use]
50 pub fn healthy(&self) -> bool {
51 self.checks.iter().all(|c| c.status != CheckStatus::Fail)
52 }
53
54 #[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#[must_use]
79pub fn doctor(config: &ProxyConfig, env: impl Fn(&str) -> Option<String>) -> DoctorReport {
80 let mut checks = Vec::new();
81
82 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 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 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 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 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#[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
171fn 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#[derive(Debug, serde::Serialize)]
194pub struct EvalsSummary {
195 pub enforce_traces: usize,
197 pub escalations: u64,
199 pub gates: std::collections::BTreeMap<String, (u64, u64, u64)>,
201 pub served_by_rung: std::collections::BTreeMap<u32, u64>,
203}
204
205#[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#[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#[derive(Debug, serde::Serialize)]
261pub struct SavingsSummary {
262 pub traces: usize,
264 pub enforce_traces: usize,
266 pub spent_usd: f64,
268 pub gate_usd: f64,
270 pub baseline_usd: f64,
272 pub savings_usd: f64,
274 pub savings_pct: f64,
276}
277
278#[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#[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#[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 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}