Skip to main content

warden/
cli.rs

1//! Command-line surface: argument parsing and the shared `--since` time window.
2
3use std::fmt;
4use std::path::PathBuf;
5
6use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};
7use clap::{Parser, Subcommand};
8
9/// Top-level CLI. Global flags apply to every subcommand.
10#[derive(Debug, Parser)]
11#[command(
12    name = "warden",
13    version,
14    about = "Local, read-only reporting over coding-agent session logs"
15)]
16pub struct Cli {
17    /// Emit the versioned JSON envelope instead of a table.
18    #[arg(long, global = true)]
19    pub json: bool,
20
21    /// Time window: relative (`7d`, `24h`, `90m`) or absolute (`2026-01-01`).
22    #[arg(long, global = true, value_name = "7d|2026-01-01")]
23    pub since: Option<String>,
24
25    /// Restrict output to a single project.
26    #[arg(long, global = true, value_name = "NAME")]
27    pub project: Option<String>,
28
29    /// Override the store location (default: config, else `~/.warden`).
30    #[arg(long, global = true, value_name = "PATH")]
31    pub data_dir: Option<PathBuf>,
32
33    /// Skip the implicit ingest that otherwise runs before a report.
34    #[arg(long, global = true)]
35    pub no_ingest: bool,
36
37    /// Exclude subagent (sidechain) events. They are real spend and are
38    /// included by default; excluding them understates totals.
39    #[arg(long, global = true)]
40    pub no_sidechain: bool,
41
42    #[command(subcommand)]
43    pub command: Command,
44}
45
46#[derive(Debug, Subcommand)]
47pub enum Command {
48    /// Scan sources and append new events to the store.
49    Ingest,
50    /// Render a named report.
51    Report {
52        /// Report name (`summary`, `projects`, `models`, ...).
53        name: String,
54    },
55    /// Filtered event rollup over named dimensions.
56    Query {
57        /// Dimensions to group by, comma-separated.
58        #[arg(long, value_name = "DIMS")]
59        group_by: Option<String>,
60    },
61    /// Live burn rate.
62    Watch {
63        /// Print a single status-bar-friendly line and exit.
64        #[arg(long)]
65        oneline: bool,
66    },
67    /// Detected improvements (exact-duplicate prompts).
68    Suggest {
69        /// Print the SKILL.md draft for a suggestion to stdout.
70        #[arg(long, value_name = "ID", conflicts_with = "json")]
71        draft: Option<String>,
72    },
73    /// Report what warden can see and why a number might be empty.
74    Doctor,
75    /// Remove stored data. Rewrites files, so it must be explicit.
76    Purge {
77        /// Delete stored prompt text.
78        #[arg(long)]
79        prompts: bool,
80        /// Skip the confirmation prompt. Required when stdin is not a terminal.
81        #[arg(long, visible_alias = "force")]
82        yes: bool,
83    },
84}
85
86/// A half-open `[from, to)` window in epoch milliseconds.
87///
88/// Every read path takes one of these; it is what lets the scanner open only
89/// the partitions that overlap the requested period.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct TimeWindow {
92    /// Inclusive lower bound, epoch ms.
93    pub from_ms: i64,
94    /// Exclusive upper bound, epoch ms.
95    pub to_ms: i64,
96}
97
98impl TimeWindow {
99    pub fn new(from_ms: i64, to_ms: i64) -> Self {
100        Self { from_ms, to_ms }
101    }
102
103    /// The widest window representable; used when `--since` is absent.
104    pub fn all() -> Self {
105        Self::new(i64::MIN, i64::MAX)
106    }
107
108    pub fn contains(&self, ts_ms: i64) -> bool {
109        ts_ms >= self.from_ms && ts_ms < self.to_ms
110    }
111
112    pub fn from(&self) -> Option<DateTime<Utc>> {
113        Utc.timestamp_millis_opt(self.from_ms).single()
114    }
115
116    pub fn to(&self) -> Option<DateTime<Utc>> {
117        Utc.timestamp_millis_opt(self.to_ms).single()
118    }
119
120    /// Parse a `--since` value relative to `now`, ending at `now`.
121    pub fn parse_since(spec: &str, now: DateTime<Utc>) -> Result<Self, SinceParseError> {
122        let spec = spec.trim();
123        if spec.is_empty() {
124            return Err(SinceParseError::new(spec));
125        }
126
127        if let Some(date) = parse_absolute(spec) {
128            return Ok(Self::new(date.timestamp_millis(), now.timestamp_millis()));
129        }
130
131        let duration = parse_relative(spec).ok_or_else(|| SinceParseError::new(spec))?;
132        let from = now
133            .checked_sub_signed(duration)
134            .ok_or_else(|| SinceParseError::new(spec))?;
135        Ok(Self::new(from.timestamp_millis(), now.timestamp_millis()))
136    }
137}
138
139fn parse_absolute(spec: &str) -> Option<DateTime<Utc>> {
140    let date = NaiveDate::parse_from_str(spec, "%Y-%m-%d").ok()?;
141    Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?).into()
142}
143
144fn parse_relative(spec: &str) -> Option<Duration> {
145    let (digits, unit) = spec.split_at(spec.len().checked_sub(1)?);
146    let n: i64 = digits.parse().ok()?;
147    if n < 0 {
148        return None;
149    }
150    match unit {
151        "m" => Duration::try_minutes(n),
152        "h" => Duration::try_hours(n),
153        "d" => Duration::try_days(n),
154        "w" => Duration::try_weeks(n),
155        _ => None,
156    }
157}
158
159/// `--since` could not be interpreted.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct SinceParseError {
162    spec: String,
163}
164
165impl SinceParseError {
166    fn new(spec: &str) -> Self {
167        Self {
168            spec: spec.to_string(),
169        }
170    }
171}
172
173impl fmt::Display for SinceParseError {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        write!(
176            f,
177            "invalid --since value {:?}: expected a relative window like 7d, 24h, 90m, 2w, \
178             or an absolute date like 2026-01-01",
179            self.spec
180        )
181    }
182}
183
184impl std::error::Error for SinceParseError {}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn now() -> DateTime<Utc> {
191        Utc.with_ymd_and_hms(2026, 8, 4, 12, 0, 0).unwrap()
192    }
193
194    #[test]
195    fn parses_relative_days() {
196        let w = TimeWindow::parse_since("7d", now()).unwrap();
197        assert_eq!(w.to_ms, now().timestamp_millis());
198        assert_eq!(
199            w.from_ms,
200            (now() - Duration::try_days(7).unwrap()).timestamp_millis()
201        );
202    }
203
204    #[test]
205    fn parses_relative_hours_minutes_weeks() {
206        for (spec, dur) in [
207            ("24h", Duration::try_hours(24).unwrap()),
208            ("90m", Duration::try_minutes(90).unwrap()),
209            ("2w", Duration::try_weeks(2).unwrap()),
210            ("30d", Duration::try_days(30).unwrap()),
211        ] {
212            let w = TimeWindow::parse_since(spec, now()).unwrap();
213            assert_eq!(w.from_ms, (now() - dur).timestamp_millis(), "spec {spec}");
214        }
215    }
216
217    #[test]
218    fn parses_absolute_date_as_utc_midnight() {
219        let w = TimeWindow::parse_since("2026-01-01", now()).unwrap();
220        let expected = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
221        assert_eq!(w.from_ms, expected.timestamp_millis());
222        assert_eq!(w.to_ms, now().timestamp_millis());
223    }
224
225    #[test]
226    fn rejects_nonsense() {
227        for spec in ["", "d", "7y", "-3d", "7 d", "2026-13-01", "seven days"] {
228            assert!(
229                TimeWindow::parse_since(spec, now()).is_err(),
230                "expected {spec:?} to be rejected"
231            );
232        }
233    }
234
235    #[test]
236    fn draft_and_json_conflict_at_the_parser() {
237        let err = Cli::try_parse_from(["warden", "suggest", "--draft", "abc", "--json"])
238            .expect_err("--draft and --json should conflict");
239        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
240
241        Cli::try_parse_from(["warden", "suggest", "--draft", "abc"])
242            .expect("--draft alone should parse fine");
243    }
244
245    #[test]
246    fn window_containment_is_half_open() {
247        let w = TimeWindow::new(100, 200);
248        assert!(w.contains(100));
249        assert!(w.contains(199));
250        assert!(!w.contains(200));
251        assert!(!w.contains(99));
252        assert!(TimeWindow::all().contains(0));
253    }
254}