Skip to main content

warden/adapters/
mod.rs

1//! Source adapters and the KPIs they can populate.
2//!
3//! An adapter turns one line of a vendor log into a normalized [`Event`]. It
4//! also *declares* which KPIs it is able to fill in, so reports can grey out a
5//! column instead of printing a misleading `0` for something the source simply
6//! never recorded.
7//!
8//! Adapters are strictly read-only against their source tree: they open files
9//! for reading and never write, rename, or truncate anything under it.
10
11pub mod claude_code;
12
13use std::io;
14use std::path::{Path, PathBuf};
15
16use crate::config::Config;
17use crate::store::Event;
18
19/// A measurable a report might want to show. An adapter that does not list a
20/// KPI cannot fill it in from its logs — the column is blank because the data
21/// does not exist, not because the number is zero.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Kpi {
24    /// `input_tok` / `output_tok`.
25    Tokens,
26    /// `cache_read_tok` / `cache_write_tok`.
27    CacheTokens,
28    /// `cost_est`, derived from configured pricing.
29    Cost,
30    /// Prompt text and `text_hash`.
31    Prompts,
32    /// `tool_calls`.
33    ToolCalls,
34    /// `stop_reason`.
35    StopReason,
36    /// Per-turn wall-clock time.
37    DurationMs,
38    /// Whether an event came from a subagent transcript.
39    Sidechain,
40}
41
42impl Kpi {
43    /// Every KPI warden knows about, in display order.
44    pub const ALL: [Kpi; 8] = [
45        Kpi::Tokens,
46        Kpi::CacheTokens,
47        Kpi::Cost,
48        Kpi::Prompts,
49        Kpi::ToolCalls,
50        Kpi::StopReason,
51        Kpi::DurationMs,
52        Kpi::Sidechain,
53    ];
54
55    /// Short label used by `doctor` and report headers.
56    pub fn label(self) -> &'static str {
57        match self {
58            Kpi::Tokens => "tokens",
59            Kpi::CacheTokens => "cache",
60            Kpi::Cost => "cost",
61            Kpi::Prompts => "prompts",
62            Kpi::ToolCalls => "tools",
63            Kpi::StopReason => "stop_reason",
64            Kpi::DurationMs => "duration_ms",
65            Kpi::Sidechain => "sidechain",
66        }
67    }
68}
69
70/// The set of KPIs an adapter declares it can populate.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct Capabilities {
73    supported: &'static [Kpi],
74}
75
76impl Capabilities {
77    pub const fn new(supported: &'static [Kpi]) -> Self {
78        Self { supported }
79    }
80
81    /// Nothing is supported — used by adapters that are not implemented yet.
82    pub const fn none() -> Self {
83        Self::new(&[])
84    }
85
86    pub fn supports(&self, kpi: Kpi) -> bool {
87        self.supported.contains(&kpi)
88    }
89
90    pub fn supported(&self) -> &'static [Kpi] {
91        self.supported
92    }
93
94    /// KPIs this adapter cannot fill in. These are the columns a report must
95    /// grey out, and the answer `doctor` gives to "why is this empty?".
96    pub fn unsupported(&self) -> Vec<Kpi> {
97        Kpi::ALL
98            .into_iter()
99            .filter(|kpi| !self.supports(*kpi))
100            .collect()
101    }
102}
103
104/// One parsed source record.
105#[derive(Debug, Clone, PartialEq)]
106pub struct ParsedRecord {
107    pub event: Event,
108    /// Prompt text, when the record carries one. Whether it is *stored* is the
109    /// ingester's decision (`general.index_prompt_text`).
110    pub prompt_text: Option<String>,
111    /// Usage on this record is reported per-request and may be repeated
112    /// verbatim on sibling records; it must be counted once per key. `None`
113    /// means the counts on this record stand on their own.
114    pub usage_key: Option<String>,
115}
116
117/// What one source line yielded.
118#[derive(Debug, Clone, PartialEq)]
119pub enum Parsed {
120    /// A normalized record.
121    Record(Box<ParsedRecord>),
122    /// A line the adapter understands and deliberately ignores (a record type
123    /// that carries no usage). Not an error, and never counted as unparseable.
124    Skipped,
125    /// A line the adapter could not make sense of. Counted and reported; it
126    /// never fails the run, because the source schema is undocumented and
127    /// drifts between versions.
128    Unparseable,
129}
130
131/// A source of events.
132pub trait Adapter {
133    /// Stable adapter name, used as the `agent` field and the config key.
134    fn name(&self) -> &'static str;
135
136    /// Whether this adapter is implemented at all.
137    fn is_implemented(&self) -> bool;
138
139    /// KPIs this adapter can populate.
140    fn capabilities(&self) -> Capabilities;
141
142    /// Log root for this adapter: the configured path, else its built-in
143    /// default. `None` when no default can be determined.
144    fn root(&self, config: &Config) -> Option<PathBuf>;
145
146    /// Transcript files under `root`, in a deterministic order. Read-only.
147    fn discover(&self, root: &Path) -> io::Result<Vec<PathBuf>>;
148
149    /// Number of distinct sessions visible under `root`, for `doctor`.
150    fn session_count(&self, root: &Path) -> io::Result<usize>;
151
152    /// Parse one line of `source`.
153    fn parse_line(&self, source: &Path, line: &str) -> Parsed;
154}
155
156/// Key under which a per-request usage figure is counted exactly once.
157///
158/// Both the adapter (when parsing) and the ingester (when rebuilding state from
159/// an existing store) must derive the same string, which is what makes usage
160/// attribution survive an interrupted run.
161pub fn usage_key(agent: &str, session_id: Option<&str>, turn_id: &str) -> String {
162    format!("{agent}\u{1}{}\u{1}{turn_id}", session_id.unwrap_or(""))
163}
164
165/// Every adapter warden knows about, implemented or not, so `doctor` can list
166/// the ones a user might expect to see.
167pub fn registry() -> Vec<Box<dyn Adapter>> {
168    vec![
169        Box::new(claude_code::ClaudeCodeAdapter),
170        Box::new(NotImplementedAdapter {
171            name: "codex",
172            default_root: "~/.codex/sessions",
173        }),
174        Box::new(NotImplementedAdapter {
175            name: "cursor",
176            default_root: "",
177        }),
178    ]
179}
180
181/// Adapters that are enabled in config and can actually ingest.
182pub fn enabled(config: &Config) -> Vec<Box<dyn Adapter>> {
183    registry()
184        .into_iter()
185        .filter(|adapter| adapter.is_implemented() && config.source(adapter.name()).enabled)
186        .collect()
187}
188
189/// A placeholder for a source warden does not read yet. It declares no KPIs, so
190/// nothing downstream can mistake it for a source of zeroes.
191struct NotImplementedAdapter {
192    name: &'static str,
193    /// Where its logs are known to live, for `doctor`'s benefit. Empty when
194    /// even that is not settled.
195    default_root: &'static str,
196}
197
198impl Adapter for NotImplementedAdapter {
199    fn name(&self) -> &'static str {
200        self.name
201    }
202
203    fn is_implemented(&self) -> bool {
204        false
205    }
206
207    fn capabilities(&self) -> Capabilities {
208        Capabilities::none()
209    }
210
211    fn root(&self, config: &Config) -> Option<PathBuf> {
212        config
213            .source(self.name)
214            .path
215            .or_else(|| (!self.default_root.is_empty()).then(|| PathBuf::from(self.default_root)))
216    }
217
218    fn discover(&self, _root: &Path) -> io::Result<Vec<PathBuf>> {
219        Ok(Vec::new())
220    }
221
222    fn session_count(&self, _root: &Path) -> io::Result<usize> {
223        Ok(0)
224    }
225
226    fn parse_line(&self, _source: &Path, _line: &str) -> Parsed {
227        Parsed::Skipped
228    }
229}
230
231/// Collect `*.jsonl` files under `root`, recursively, sorted. Read-only:
232/// unreadable subdirectories are skipped rather than failing the walk.
233pub(crate) fn jsonl_files(root: &Path) -> io::Result<Vec<PathBuf>> {
234    let mut found = Vec::new();
235    let mut stack = vec![root.to_path_buf()];
236    while let Some(dir) = stack.pop() {
237        let entries = match std::fs::read_dir(&dir) {
238            Ok(entries) => entries,
239            Err(_) => continue,
240        };
241        for entry in entries.flatten() {
242            let path = entry.path();
243            match entry.file_type() {
244                Ok(kind) if kind.is_dir() => stack.push(path),
245                Ok(_) if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") => {
246                    found.push(path)
247                }
248                _ => {}
249            }
250        }
251    }
252    found.sort();
253    Ok(found)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn registry_lists_every_adapter_a_user_might_expect() {
262        let names: Vec<_> = registry().iter().map(|a| a.name()).collect();
263        assert_eq!(names, ["claude-code", "codex", "cursor"]);
264    }
265
266    #[test]
267    fn unimplemented_adapters_declare_no_kpis() {
268        for adapter in registry().iter().filter(|a| !a.is_implemented()) {
269            assert!(adapter.capabilities().supported().is_empty());
270            assert_eq!(adapter.capabilities().unsupported().len(), Kpi::ALL.len());
271        }
272    }
273
274    #[test]
275    fn usage_key_boundaries_are_unambiguous() {
276        assert_ne!(
277            usage_key("a", Some("b"), "c"),
278            usage_key("a", Some("bc"), "")
279        );
280        assert_eq!(usage_key("a", None, "c"), usage_key("a", Some(""), "c"));
281    }
282
283    #[test]
284    fn jsonl_walk_is_recursive_sorted_and_extension_filtered() {
285        let dir = tempfile::tempdir().unwrap();
286        let nested = dir.path().join("proj/deeper");
287        std::fs::create_dir_all(&nested).unwrap();
288        std::fs::write(dir.path().join("b.jsonl"), "").unwrap();
289        std::fs::write(dir.path().join("a.txt"), "").unwrap();
290        std::fs::write(nested.join("a.jsonl"), "").unwrap();
291
292        let found = jsonl_files(dir.path()).unwrap();
293        assert_eq!(
294            found,
295            vec![dir.path().join("b.jsonl"), nested.join("a.jsonl")]
296        );
297    }
298}