1use std::collections::BTreeMap;
16use std::io::{self, BufRead, BufReader};
17use std::path::Path;
18use std::process::{Command, Output};
19use std::time::Instant;
20
21use chrono::{DateTime, Utc};
22
23use crate::request_log::{self, GhOutcome, LogRecord, RecordKind, Source};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum Category {
28 Api,
30 Subcommand,
33 Local,
36}
37
38impl Category {
39 #[must_use]
41 pub fn from_command(command: &[String]) -> Self {
42 match command.first().map(String::as_str) {
43 Some("api") => Self::Api,
44 Some(first) if first.starts_with('-') => Self::Local,
46 _ => Self::Subcommand,
47 }
48 }
49
50 #[must_use]
52 pub fn as_str(self) -> &'static str {
53 match self {
54 Self::Api => "api",
55 Self::Subcommand => "subcommand",
56 Self::Local => "local",
57 }
58 }
59}
60
61#[must_use]
63pub fn source_str(source: Source) -> &'static str {
64 match source {
65 Source::Cli => "cli",
66 Source::Mcp => "mcp",
67 Source::Daemon => "daemon",
68 Source::Unknown => "unknown",
69 }
70}
71
72pub fn run_gh<I, S>(bin: &Path, args: I, label: &str, cwd: Option<&Path>) -> io::Result<Output>
86where
87 I: IntoIterator<Item = S>,
88 S: AsRef<str>,
89{
90 let argv: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
91
92 let mut cmd = Command::new(bin);
93 cmd.args(&argv);
94 if let Some(dir) = cwd {
95 cmd.current_dir(dir);
96 }
97
98 let started = Instant::now();
99 let result = cmd.output();
100 let duration = started.elapsed();
101
102 let (exit_code, error) = match &result {
103 Ok(output) => (output.status.code(), None),
104 Err(e) => (None, Some(e.to_string())),
105 };
106 request_log::record_gh(GhOutcome {
107 label: label.to_string(),
108 argv,
109 exit_code,
110 duration,
111 error,
112 });
113
114 result
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
119pub struct GhCounts {
120 pub by_category: BTreeMap<Category, u64>,
122 pub by_subcommand: BTreeMap<String, u64>,
124 pub by_source: BTreeMap<Source, u64>,
126}
127
128impl GhCounts {
129 #[must_use]
131 pub fn total(&self) -> u64 {
132 self.by_category.values().copied().sum()
133 }
134
135 #[must_use]
138 pub fn api_total(&self) -> u64 {
139 self.by_category
140 .iter()
141 .filter(|(cat, _)| **cat != Category::Local)
142 .map(|(_, n)| *n)
143 .sum()
144 }
145
146 #[must_use]
148 pub fn summary_line(&self) -> String {
149 let sources: Vec<String> = self
150 .by_source
151 .iter()
152 .map(|(s, n)| format!("{}={n}", source_str(*s)))
153 .collect();
154 let subs: Vec<String> = self
155 .by_subcommand
156 .iter()
157 .map(|(name, n)| format!("{name}={n}"))
158 .collect();
159 let local = self.by_category.get(&Category::Local).copied().unwrap_or(0);
160 format!(
161 "{} api call(s) (total {}, local {local}); by source: [{}]; by subcommand: [{}]",
162 self.api_total(),
163 self.total(),
164 sources.join(" "),
165 subs.join(" "),
166 )
167 }
168
169 #[must_use]
172 pub fn to_json(&self) -> serde_json::Value {
173 let by_category: BTreeMap<&str, u64> = self
174 .by_category
175 .iter()
176 .map(|(c, n)| (c.as_str(), *n))
177 .collect();
178 let by_source: BTreeMap<&str, u64> = self
179 .by_source
180 .iter()
181 .map(|(s, n)| (source_str(*s), *n))
182 .collect();
183 serde_json::json!({
184 "api_total": self.api_total(),
185 "total": self.total(),
186 "by_category": by_category,
187 "by_subcommand": self.by_subcommand,
188 "by_source": by_source,
189 })
190 }
191
192 fn tally(&mut self, command: &[String], source: Source) {
193 *self
194 .by_category
195 .entry(Category::from_command(command))
196 .or_default() += 1;
197 *self.by_subcommand.entry(command.join(" ")).or_default() += 1;
198 *self.by_source.entry(source).or_default() += 1;
199 }
200}
201
202#[must_use]
209pub fn aggregate(
210 path: &Path,
211 since: Option<DateTime<Utc>>,
212 until: Option<DateTime<Utc>>,
213 source: Option<Source>,
214) -> GhCounts {
215 let mut counts = GhCounts::default();
216 let Ok(file) = std::fs::File::open(path) else {
217 return counts; };
219 for line in BufReader::new(file).lines() {
220 let Ok(line) = line else { break };
221 if line.is_empty() {
222 continue;
223 }
224 let Ok(rec) = serde_json::from_str::<LogRecord>(&line) else {
225 continue; };
227 if rec.kind != RecordKind::Gh {
228 continue;
229 }
230 let rec_source = rec.source.unwrap_or(Source::Unknown);
231 if let Some(want) = source {
232 if rec_source != want {
233 continue;
234 }
235 }
236 if since.is_some() || until.is_some() {
237 let Some(ts) = parse_timestamp(&rec.timestamp) else {
238 continue; };
240 if since.is_some_and(|s| ts < s) || until.is_some_and(|u| ts > u) {
241 continue;
242 }
243 }
244 counts.tally(&rec.command, rec_source);
245 }
246 counts
247}
248
249pub(crate) fn parse_timestamp(ts: &str) -> Option<DateTime<Utc>> {
253 DateTime::parse_from_rfc3339(ts)
254 .ok()
255 .map(|dt| dt.with_timezone(&Utc))
256}
257
258#[cfg(test)]
259#[allow(clippy::unwrap_used, clippy::expect_used)]
260mod tests {
261 use super::*;
262 use std::io::Write;
263
264 fn cmd(parts: &[&str]) -> Vec<String> {
265 parts.iter().copied().map(String::from).collect()
266 }
267
268 #[test]
269 fn category_from_command_classifies_api_subcommand_and_local() {
270 assert_eq!(
271 Category::from_command(&cmd(&["api", "graphql"])),
272 Category::Api
273 );
274 assert_eq!(
275 Category::from_command(&cmd(&["api", "rate_limit"])),
276 Category::Api
277 );
278 assert_eq!(
279 Category::from_command(&cmd(&["pr", "list"])),
280 Category::Subcommand
281 );
282 assert_eq!(
283 Category::from_command(&cmd(&["repo", "view"])),
284 Category::Subcommand
285 );
286 assert_eq!(
287 Category::from_command(&cmd(&["--version"])),
288 Category::Local
289 );
290 assert_eq!(Category::from_command(&[]), Category::Subcommand);
291 }
292
293 const SAMPLE: &[&str] = &[
296 r#"{"kind":"gh","timestamp":"2026-07-21T10:00:00.000Z","command":["api","graphql"],"source":"daemon"}"#,
297 r#"{"kind":"gh","timestamp":"2026-07-21T10:01:00.000Z","command":["api","rate_limit"],"source":"daemon"}"#,
298 r#"{"kind":"gh","timestamp":"2026-07-21T10:02:00.000Z","command":["pr","list"],"source":"cli"}"#,
299 r#"{"kind":"gh","timestamp":"2026-07-21T10:03:00.000Z","command":["pr","list"],"source":"cli"}"#,
300 r#"{"kind":"gh","timestamp":"2026-07-21T10:04:00.000Z","command":["--version"],"source":"cli"}"#,
301 r#"{"kind":"http","timestamp":"2026-07-21T10:05:00.000Z","service":"jira"}"#,
302 "not json",
303 "",
304 ];
305
306 fn write_log(lines: &[&str]) -> tempfile::NamedTempFile {
307 let mut f = tempfile::NamedTempFile::new().unwrap();
308 for line in lines {
309 writeln!(f, "{line}").unwrap();
310 }
311 f.flush().unwrap();
312 f
313 }
314
315 fn utc(ts: &str) -> DateTime<Utc> {
316 DateTime::parse_from_rfc3339(ts)
317 .unwrap()
318 .with_timezone(&Utc)
319 }
320
321 #[test]
322 fn aggregate_tallies_by_category_subcommand_and_source_ignoring_non_gh() {
323 let f = write_log(SAMPLE);
324 let counts = aggregate(f.path(), None, None, None);
325 assert_eq!(counts.total(), 5); assert_eq!(counts.api_total(), 4); assert_eq!(counts.by_category[&Category::Api], 2);
328 assert_eq!(counts.by_category[&Category::Subcommand], 2);
329 assert_eq!(counts.by_category[&Category::Local], 1);
330 assert_eq!(counts.by_subcommand["pr list"], 2);
331 assert_eq!(counts.by_subcommand["api graphql"], 1);
332 assert_eq!(counts.by_source[&Source::Daemon], 2);
333 assert_eq!(counts.by_source[&Source::Cli], 3);
334 }
335
336 #[test]
337 fn aggregate_filters_by_source() {
338 let f = write_log(SAMPLE);
339 let counts = aggregate(f.path(), None, None, Some(Source::Daemon));
340 assert_eq!(counts.total(), 2);
341 assert_eq!(counts.api_total(), 2);
342 assert!(!counts.by_source.contains_key(&Source::Cli));
343 }
344
345 #[test]
346 fn aggregate_filters_by_time_window() {
347 let f = write_log(SAMPLE);
348 let counts = aggregate(
350 f.path(),
351 Some(utc("2026-07-21T10:02:00.000Z")),
352 Some(utc("2026-07-21T10:03:30.000Z")),
353 None,
354 );
355 assert_eq!(counts.total(), 2);
356 assert_eq!(counts.by_subcommand["pr list"], 2);
357 }
358
359 #[test]
360 fn aggregate_missing_file_is_empty() {
361 let counts = aggregate(
362 std::path::Path::new("/nonexistent/omni-dev/log.jsonl"),
363 None,
364 None,
365 None,
366 );
367 assert_eq!(counts, GhCounts::default());
368 assert_eq!(counts.total(), 0);
369 }
370
371 #[test]
372 fn source_str_covers_every_source() {
373 assert_eq!(source_str(Source::Cli), "cli");
374 assert_eq!(source_str(Source::Mcp), "mcp");
375 assert_eq!(source_str(Source::Daemon), "daemon");
376 assert_eq!(source_str(Source::Unknown), "unknown");
377 }
378
379 #[test]
380 fn aggregate_drops_undateable_record_when_windowed() {
381 let lines = &[
385 r#"{"kind":"gh","timestamp":"not-a-timestamp","command":["pr","list"],"source":"cli"}"#,
386 r#"{"kind":"gh","timestamp":"2026-07-21T10:02:00.000Z","command":["pr","list"],"source":"cli"}"#,
387 ];
388 let f = write_log(lines);
389 let windowed = aggregate(
390 f.path(),
391 Some(utc("2026-07-21T00:00:00.000Z")),
392 Some(utc("2026-07-22T00:00:00.000Z")),
393 None,
394 );
395 assert_eq!(windowed.total(), 1); assert_eq!(aggregate(f.path(), None, None, None).total(), 2);
397 }
398
399 #[test]
400 fn to_json_has_string_keys_and_totals() {
401 let f = write_log(SAMPLE);
402 let v = aggregate(f.path(), None, None, None).to_json();
403 assert_eq!(v["api_total"], 4);
404 assert_eq!(v["total"], 5);
405 assert_eq!(v["by_category"]["api"], 2);
406 assert_eq!(v["by_category"]["local"], 1);
407 assert_eq!(v["by_subcommand"]["pr list"], 2);
408 assert_eq!(v["by_source"]["daemon"], 2);
409 }
410
411 #[test]
412 fn summary_line_mentions_api_total() {
413 let f = write_log(SAMPLE);
414 let s = aggregate(f.path(), None, None, None).summary_line();
415 assert!(s.contains("4 api call"), "summary was: {s}");
416 }
417}