1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SourceStatus {
19 Found,
20 NotFound,
21 NotImplemented,
22}
23
24#[derive(Debug, Clone)]
26pub struct AdapterHealth {
27 pub name: String,
28 pub status: SourceStatus,
29 pub root: Option<PathBuf>,
31 pub sessions: usize,
32 pub capabilities: Capabilities,
33}
34
35#[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 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
53pub 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 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 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 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}