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