Skip to main content

warden/
doctor.rs

1//! `warden doctor` — "why is this number empty?".
2//!
3//! Every blank column has exactly one of three causes, and doctor names all
4//! three: the adapter is not implemented, the adapter cannot populate that KPI
5//! from its logs, or the model has no configured price.
6
7use std::collections::BTreeSet;
8use std::io;
9use std::path::PathBuf;
10
11use crate::adapters::{self, Capabilities};
12use crate::cli::TimeWindow;
13use crate::config::{Config, Pricing};
14use crate::store::{expand_tilde, Partition, ScanQuery, Scanner, StorePaths};
15
16/// Whether a source is there to read.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SourceStatus {
19    Found,
20    NotFound,
21    NotImplemented,
22}
23
24/// One adapter's health.
25#[derive(Debug, Clone)]
26pub struct AdapterHealth {
27    pub name: String,
28    pub status: SourceStatus,
29    /// Where warden looked, when it knows.
30    pub root: Option<PathBuf>,
31    pub sessions: usize,
32    pub capabilities: Capabilities,
33}
34
35/// Store-side stats, plus the reasons a cost column might be blank.
36#[derive(Debug, Clone, Default)]
37pub struct StoreHealth {
38    pub partitions: usize,
39    pub bytes: u64,
40    pub oldest: Option<Partition>,
41    pub events: u64,
42    /// Models seen with token counts but no configured price. Their cost is
43    /// blank because pricing is user-editable config, not a built-in table.
44    pub unpriced_models: Vec<String>,
45}
46
47#[derive(Debug, Clone)]
48pub struct DoctorReport {
49    pub adapters: Vec<AdapterHealth>,
50    pub store: StoreHealth,
51}
52
53/// Inspect every adapter and the store.
54pub fn run(
55    config: &Config,
56    paths: &StorePaths,
57    window: TimeWindow,
58    project: Option<&str>,
59) -> io::Result<DoctorReport> {
60    let mut health = Vec::new();
61    for adapter in adapters::registry() {
62        let root = adapter.root(config).map(|r| expand_tilde(&r)).transpose()?;
63        let present = root.as_deref().is_some_and(|root| root.is_dir());
64        let status = match (adapter.is_implemented(), present) {
65            (false, _) => SourceStatus::NotImplemented,
66            (true, true) => SourceStatus::Found,
67            (true, false) => SourceStatus::NotFound,
68        };
69        let sessions = match (status, root.as_deref()) {
70            (SourceStatus::Found, Some(root)) => adapter.session_count(root)?,
71            _ => 0,
72        };
73        health.push(AdapterHealth {
74            name: adapter.name().to_string(),
75            status,
76            root,
77            sessions,
78            capabilities: adapter.capabilities(),
79        });
80    }
81
82    Ok(DoctorReport {
83        adapters: health,
84        store: store_health(paths, window, project, &config.pricing())?,
85    })
86}
87
88fn store_health(
89    paths: &StorePaths,
90    window: TimeWindow,
91    project: Option<&str>,
92    pricing: &Pricing,
93) -> io::Result<StoreHealth> {
94    let scanner = Scanner::new(paths.clone());
95    let partitions = scanner.partitions_for(window)?;
96
97    let mut store = StoreHealth {
98        partitions: partitions.len(),
99        oldest: partitions.first().map(|(partition, _)| *partition),
100        ..StoreHealth::default()
101    };
102    for (_, path) in &partitions {
103        store.bytes += std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
104    }
105
106    let mut unpriced = BTreeSet::new();
107    let query = ScanQuery::new(window).with_project(project.map(str::to_string));
108    scanner.scan_with(&query, |event| {
109        store.events += 1;
110        // Priced from the config as it is now, exactly as a report would: a
111        // rate added since ingest must stop doctor from calling it unpriced.
112        // `has_usage` and not a narrower check, so a cache-only record counts
113        // here exactly as it counts in a report.
114        if event.has_usage() && crate::reports::event_cost(&event, pricing).is_none() {
115            unpriced.insert(event.model.unwrap_or_else(|| "(unknown model)".into()));
116        }
117    })?;
118    store.unpriced_models = unpriced.into_iter().collect();
119    Ok(store)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::adapters::Kpi;
126
127    #[test]
128    fn reports_found_missing_and_unimplemented_sources() {
129        let dir = tempfile::tempdir().unwrap();
130        let logs = dir.path().join("projects");
131        std::fs::create_dir_all(logs.join("proj")).unwrap();
132        std::fs::write(logs.join("proj/s1.jsonl"), "").unwrap();
133        std::fs::write(logs.join("proj/s2.jsonl"), "").unwrap();
134
135        let mut config = Config::default();
136        config.sources.insert(
137            "claude-code".into(),
138            crate::config::Source {
139                enabled: true,
140                path: Some(logs),
141            },
142        );
143        let paths = StorePaths::new(dir.path().join("store"));
144        let report = run(&config, &paths, TimeWindow::all(), None).unwrap();
145
146        let claude = &report.adapters[0];
147        assert_eq!(claude.status, SourceStatus::Found);
148        assert_eq!(claude.sessions, 2);
149        assert!(claude.capabilities.supports(Kpi::Tokens));
150        // The answer to "why is the duration column empty?".
151        assert!(claude.capabilities.unsupported().contains(&Kpi::DurationMs));
152
153        assert_eq!(report.adapters[1].status, SourceStatus::NotImplemented);
154        assert_eq!(report.store.partitions, 0);
155    }
156
157    #[test]
158    fn missing_source_directory_is_not_found_not_an_error() {
159        let dir = tempfile::tempdir().unwrap();
160        let mut config = Config::default();
161        config.sources.insert(
162            "claude-code".into(),
163            crate::config::Source {
164                enabled: true,
165                path: Some(dir.path().join("nope")),
166            },
167        );
168        let report = run(
169            &config,
170            &StorePaths::new(dir.path()),
171            TimeWindow::all(),
172            None,
173        )
174        .unwrap();
175        assert_eq!(report.adapters[0].status, SourceStatus::NotFound);
176        assert_eq!(report.adapters[0].sessions, 0);
177    }
178
179    #[test]
180    fn store_stats_name_the_unpriced_models() {
181        use crate::store::{Event, StoreWriter};
182        let dir = tempfile::tempdir().unwrap();
183        let paths = StorePaths::new(dir.path().join("store"));
184        let mut writer = StoreWriter::open(paths.clone()).unwrap();
185        let mut event = Event::new(
186            "a",
187            1_785_924_000_000,
188            "claude-code",
189            "anthropic",
190            "assistant",
191        );
192        event.model = Some("claude-sonnet-4-6".into());
193        event.input_tok = Some(10);
194        writer.append_event(&event).unwrap();
195
196        let report = run(&Config::default(), &paths, TimeWindow::all(), None).unwrap();
197        assert_eq!(report.store.partitions, 1);
198        assert_eq!(report.store.events, 1);
199        assert!(report.store.bytes > 0);
200        assert_eq!(report.store.oldest, Some(Partition::new(2026, 8)));
201        assert_eq!(report.store.unpriced_models, vec!["claude-sonnet-4-6"]);
202
203        // Adding the rate to config re-prices the *existing* store: doctor
204        // stops naming the model without anything being re-ingested.
205        let config: Config = toml::from_str(
206            r#"
207[pricing.anthropic]
208"claude-sonnet-4-6" = { input = 3.0, output = 15.0, cache_read = 0.3 }
209"#,
210        )
211        .unwrap();
212        let report = run(&config, &paths, TimeWindow::all(), None).unwrap();
213        assert!(report.store.unpriced_models.is_empty());
214        assert_eq!(report.store.events, 1, "nothing was re-ingested");
215    }
216}