1use std::fmt;
4use std::path::PathBuf;
5
6use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};
7use clap::{Parser, Subcommand};
8
9#[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 #[arg(long, global = true)]
19 pub json: bool,
20
21 #[arg(long, global = true, value_name = "7d|2026-01-01")]
23 pub since: Option<String>,
24
25 #[arg(long, global = true, value_name = "NAME")]
27 pub project: Option<String>,
28
29 #[arg(long, global = true, value_name = "PATH")]
31 pub data_dir: Option<PathBuf>,
32
33 #[arg(long, global = true)]
35 pub no_ingest: bool,
36
37 #[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 Ingest,
50 Report {
52 name: String,
54 },
55 Query {
57 #[arg(long, value_name = "DIMS")]
59 group_by: Option<String>,
60 },
61 Watch {
63 #[arg(long)]
65 oneline: bool,
66 },
67 Suggest {
69 #[arg(long, value_name = "ID", conflicts_with = "json")]
71 draft: Option<String>,
72 },
73 Doctor,
75 Purge {
77 #[arg(long)]
79 prompts: bool,
80 #[arg(long, visible_alias = "force")]
82 yes: bool,
83 },
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct TimeWindow {
92 pub from_ms: i64,
94 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 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 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#[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}