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