omni_dev/cli/log.rs
1//! `omni-dev log` — search and pretty-print the local invocation + HTTP log.
2//!
3//! Read-only and synchronous. Streams [`request_log::log_file_path`] line by
4//! line, applies the filter matrix, and renders each match as `oneline`,
5//! `json` (byte-identical to the on-disk NDJSON), or `full`.
6
7mod count;
8mod format;
9mod prune;
10mod query;
11mod stream;
12
13use anyhow::{bail, Context, Result};
14use clap::{Parser, Subcommand, ValueEnum};
15
16use crate::request_log;
17use crate::utils::env::{EnvSource, SystemEnv};
18use query::Filter;
19
20/// Shared relative-duration parser, also reused by `drive lease prune`'s own
21/// `--older-than` (#1678) so both prune commands accept the identical
22/// `30m`/`2h`/`1d`/`1w` syntax.
23pub(crate) use query::parse_since;
24/// Shared `--since`/`--until` parser (relative durations, dates, or RFC3339),
25/// reused by the `count` subcommand so its time bounds match `omni-dev log`.
26pub(crate) use query::parse_time_bound;
27
28/// Output rendering for `omni-dev log`.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
30#[value(rename_all = "lowercase")]
31pub enum Format {
32 /// One compact line per record (default).
33 Oneline,
34 /// The on-disk NDJSON line, verbatim (composes with `jq`).
35 Json,
36 /// A labelled, multi-line block per record.
37 Full,
38}
39
40/// Searches and pretty-prints the local invocation + HTTP request log.
41///
42/// With no subcommand, the flags below search the log; the `count` subcommand
43/// aggregates records by kind and source (`--kind gh` gives the GitHub API-call
44/// breakdown), and the `prune` subcommand trims the log to bound its on-disk
45/// growth.
46#[derive(Parser)]
47pub struct LogCommand {
48 /// Subcommand; when absent, the flags below search the log.
49 #[command(subcommand)]
50 action: Option<LogAction>,
51 /// Lower time bound: a relative window (`30m`, `2h`, `1d`), a date
52 /// (`2026-07-01`), or an RFC3339 timestamp.
53 #[arg(long, value_name = "DUR_OR_TS")]
54 since: Option<String>,
55 /// Upper time bound: same forms as `--since` (a relative value means that
56 /// long ago). Pair with `--since` for a bounded window.
57 #[arg(long, value_name = "DUR_OR_TS")]
58 until: Option<String>,
59 /// Match the HTTP method (case-insensitive), e.g. `GET`.
60 #[arg(long, value_name = "METHOD")]
61 method: Option<String>,
62 /// Match the status: exact (`200`), class (`5xx`), or list (`4xx,5xx`).
63 #[arg(long, value_name = "STATUS")]
64 status: Option<String>,
65 /// Match the service tag, e.g. `jira`, `datadog`, `browser-bridge`.
66 #[arg(long, value_name = "NAME")]
67 service: Option<String>,
68 /// Match the resolved command path prefix, e.g. `"jira read"`.
69 #[arg(long, value_name = "PATH")]
70 command: Option<String>,
71 /// Match a substring of the request URL.
72 #[arg(long, value_name = "SUBSTR")]
73 url: Option<String>,
74 /// Match a regular expression against the raw JSON line.
75 #[arg(long, value_name = "REGEX")]
76 grep: Option<String>,
77 /// Require a fuzzy token (substring of the raw line); repeatable, AND-ed.
78 #[arg(long, value_name = "TOKEN")]
79 fuzzy: Vec<String>,
80 /// A query expression (AND/OR/NOT, `field:value`, bare tokens); repeatable,
81 /// AND-ed together.
82 #[arg(long, value_name = "EXPR")]
83 query: Vec<String>,
84 /// Match this record `id` or `invocation_id` (pulls a run and its requests).
85 #[arg(long, value_name = "ID")]
86 id: Option<String>,
87 /// Output format.
88 #[arg(short = 'o', long, value_enum, default_value_t = Format::Oneline)]
89 output: Format,
90 /// Deprecated: use `-o`/`--output` instead.
91 #[arg(long = "format", hide = true)]
92 format: Option<Format>,
93 /// Show at most N (most recent) matching records.
94 #[arg(short = 'n', long, value_name = "N")]
95 limit: Option<usize>,
96 /// Follow the log, printing new matching records as they are appended.
97 #[arg(short = 'f', long)]
98 follow: bool,
99 /// Read the fail-closed audit log (`audit.jsonl`) instead of the
100 /// best-effort request log — the leased-write lifecycle and refusal
101 /// trail ([ADR-0080](../../docs/adrs/adr-0080.md) §11), not subject to
102 /// `OMNI_DEV_LOG_DISABLE`, rotation or `prune`. Every filter, the
103 /// `--query` mini-language and all three output formats apply
104 /// unchanged; only the file being read differs.
105 #[arg(long)]
106 audit: bool,
107}
108
109/// A `log` subcommand. Absent = search (the flags on [`LogCommand`]).
110#[derive(Subcommand)]
111enum LogAction {
112 /// Count records by kind and source (`--kind gh` for the GitHub breakdown).
113 Count(count::CountCommand),
114 /// Prune old records to bound the log's on-disk growth.
115 Prune(prune::PruneCommand),
116}
117
118impl LogCommand {
119 /// Executes the `omni-dev log` command.
120 pub fn execute(mut self) -> Result<()> {
121 if let Some(action) = self.action {
122 // `self.audit` only affects the bare search below — a subcommand
123 // never sees it, so silently proceeding here would make `--audit`
124 // placed before the subcommand name a silent no-op instead of the
125 // explicit refusal ADR-0080 §11 calls for. `prune` has its own
126 // `--audit` (placed *after* the subcommand) that refuses loudly;
127 // `count` has none yet.
128 if self.audit {
129 let sub = match &action {
130 LogAction::Count(_) => "count",
131 LogAction::Prune(_) => "prune",
132 };
133 bail!(
134 "--audit has no effect here: it must follow the subcommand, e.g. \
135 `omni-dev log {sub} --audit`, not `omni-dev log --audit {sub}`"
136 );
137 }
138 return match action {
139 LogAction::Count(cmd) => cmd.execute(),
140 LogAction::Prune(cmd) => cmd.execute(),
141 };
142 }
143 if let Some(format) = self.format.take() {
144 eprintln!("warning: --format is deprecated; use -o/--output instead");
145 self.output = format;
146 }
147 let path = self.resolve_path()?;
148 let filter = Filter::build(query::FilterInput {
149 since: self.since.as_deref(),
150 until: self.until.as_deref(),
151 method: self.method.as_deref(),
152 status: self.status.as_deref(),
153 service: self.service.as_deref(),
154 command: self.command.as_deref(),
155 url: self.url.as_deref(),
156 grep: self.grep.as_deref(),
157 fuzzy: &self.fuzzy,
158 query: &self.query,
159 id: self.id.as_deref(),
160 })?;
161 stream::run(&path, &filter, self.output, self.limit, self.follow)
162 }
163
164 /// Resolves which file this invocation reads: `audit.jsonl` when
165 /// `--audit` is set, else the ordinary `log.jsonl` — the entire effect
166 /// of the flag ([ADR-0080](../../docs/adrs/adr-0080.md) §11).
167 fn resolve_path(&self) -> Result<std::path::PathBuf> {
168 self.resolve_path_with(&SystemEnv)
169 }
170
171 /// [`Self::resolve_path`], reading through an injected [`EnvSource`]
172 /// (STYLE-0028).
173 fn resolve_path_with(&self, env: &impl EnvSource) -> Result<std::path::PathBuf> {
174 if self.audit {
175 request_log::audit_file_path_with(env)
176 .context("could not resolve the audit log file path")
177 } else {
178 request_log::log_file_path_with(env).context("could not resolve the log file path")
179 }
180 }
181}
182
183/// Filter inputs for a one-shot log search that captures its output, used by the
184/// MCP `log_search` tool. Mirrors the search flags on [`LogCommand`] minus
185/// `--follow` (a capturing search never tails).
186///
187/// Only compiled with the `mcp` feature — the MCP `log_search` tool is its sole
188/// consumer.
189#[cfg(feature = "mcp")]
190pub(crate) struct SearchRequest<'a> {
191 /// Lower time bound: a relative window (`30m`, `2h`, `1d`), a date, or an
192 /// RFC3339 timestamp.
193 pub since: Option<&'a str>,
194 /// Upper time bound: same forms as `since`.
195 pub until: Option<&'a str>,
196 /// Match the HTTP method (case-insensitive).
197 pub method: Option<&'a str>,
198 /// Match the status: exact, class (`5xx`), or list (`4xx,5xx`).
199 pub status: Option<&'a str>,
200 /// Match the service tag.
201 pub service: Option<&'a str>,
202 /// Match the resolved command-path prefix.
203 pub command: Option<&'a str>,
204 /// Match a substring of the request URL.
205 pub url: Option<&'a str>,
206 /// Match a regular expression against the raw JSON line.
207 pub grep: Option<&'a str>,
208 /// Fuzzy tokens (substrings of the raw line); AND-ed.
209 pub fuzzy: &'a [String],
210 /// Query expressions (AND/OR/NOT, `field:value`, bare tokens); AND-ed.
211 pub query: &'a [String],
212 /// Match this record `id` or `invocation_id`.
213 pub id: Option<&'a str>,
214 /// Output rendering.
215 pub format: Format,
216 /// Show at most N (most recent) matching records.
217 pub limit: Option<usize>,
218}
219
220/// Runs a one-shot (non-follow) log search and returns the rendered matches as a
221/// single string. Shared with the MCP `log_search` tool so it reuses the CLI's
222/// filter matrix and renderers verbatim; a missing log file yields an empty
223/// string.
224///
225/// Only compiled with the `mcp` feature — the MCP `log_search` tool is its sole
226/// consumer.
227#[cfg(feature = "mcp")]
228pub(crate) fn run_search_capture(req: SearchRequest<'_>) -> Result<String> {
229 let path = request_log::log_file_path().context("could not resolve the log file path")?;
230 let filter = Filter::build(query::FilterInput {
231 since: req.since,
232 until: req.until,
233 method: req.method,
234 status: req.status,
235 service: req.service,
236 command: req.command,
237 url: req.url,
238 grep: req.grep,
239 fuzzy: req.fuzzy,
240 query: req.query,
241 id: req.id,
242 })?;
243 stream::run_capture(&path, &filter, req.format, req.limit)
244}
245
246#[cfg(test)]
247#[allow(clippy::unwrap_used, clippy::expect_used)]
248mod tests {
249 use super::*;
250 use crate::test_support::env::MapEnv;
251
252 #[derive(Parser)]
253 struct Wrapper {
254 #[command(subcommand)]
255 cmd: Wrapped,
256 }
257
258 #[derive(clap::Subcommand)]
259 enum Wrapped {
260 Log(LogCommand),
261 }
262
263 fn parse(args: &[&str]) -> LogCommand {
264 let mut full = vec!["omni-dev", "log"];
265 full.extend_from_slice(args);
266 match Wrapper::try_parse_from(full).unwrap().cmd {
267 Wrapped::Log(cmd) => cmd,
268 }
269 }
270
271 #[test]
272 fn defaults_are_sane() {
273 let cmd = parse(&[]);
274 assert_eq!(cmd.output, Format::Oneline);
275 assert!(cmd.format.is_none());
276 assert!(cmd.limit.is_none());
277 assert!(!cmd.follow);
278 assert!(!cmd.audit);
279 }
280
281 #[test]
282 fn audit_flag_parses() {
283 assert!(parse(&["--audit"]).audit);
284 }
285
286 #[test]
287 fn resolve_path_reads_audit_jsonl_only_when_the_flag_is_set() {
288 let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", "/tmp/omni-dev-test-audit.jsonl");
289
290 // Without `--audit`, this resolves through `log_file_path` — some
291 // path other than the audit override (`OMNI_DEV_LOG_FILE` is unset
292 // in `env`, so it falls to the state/data-dir default). With
293 // `--audit`, it must be exactly the override above.
294 assert_ne!(
295 parse(&[]).resolve_path_with(&env).unwrap(),
296 std::path::PathBuf::from("/tmp/omni-dev-test-audit.jsonl")
297 );
298 assert_eq!(
299 parse(&["--audit"]).resolve_path_with(&env).unwrap(),
300 std::path::PathBuf::from("/tmp/omni-dev-test-audit.jsonl")
301 );
302 }
303
304 #[test]
305 fn top_level_audit_before_a_subcommand_is_refused_not_silently_dropped() {
306 // `--audit` only wires into the bare-search path; placed before a
307 // subcommand it must never be a silent no-op (it used to be, since
308 // `execute` returned out of the subcommand match before checking
309 // `self.audit` at all).
310 let err = parse(&["--audit", "prune", "--older-than", "7d"])
311 .execute()
312 .unwrap_err();
313 assert!(
314 format!("{err}").contains("omni-dev log prune --audit"),
315 "{err}"
316 );
317
318 let err = parse(&["--audit", "count"]).execute().unwrap_err();
319 assert!(
320 format!("{err}").contains("omni-dev log count --audit"),
321 "{err}"
322 );
323 }
324
325 #[test]
326 fn deprecated_format_alias_still_parses() {
327 let cmd = parse(&["--format", "json"]);
328 // Captured separately; `execute` folds it into `output` with a warning.
329 assert_eq!(cmd.format, Some(Format::Json));
330 assert_eq!(cmd.output, Format::Oneline);
331 }
332
333 #[test]
334 fn parses_full_flag_matrix() {
335 let cmd = parse(&[
336 "--since",
337 "2h",
338 "--method",
339 "GET",
340 "--status",
341 "5xx",
342 "--service",
343 "jira",
344 "--command",
345 "jira read",
346 "--url",
347 "issue",
348 "--grep",
349 "X-\\d+",
350 "--fuzzy",
351 "a",
352 "--fuzzy",
353 "b",
354 "--query",
355 "status:5xx OR method:POST",
356 "--id",
357 "abc",
358 "-o",
359 "json",
360 "-n",
361 "10",
362 "-f",
363 ]);
364 assert_eq!(cmd.since.as_deref(), Some("2h"));
365 assert_eq!(cmd.status.as_deref(), Some("5xx"));
366 assert_eq!(cmd.fuzzy, vec!["a", "b"]);
367 assert_eq!(cmd.query.len(), 1);
368 assert_eq!(cmd.output, Format::Json);
369 assert_eq!(cmd.limit, Some(10));
370 assert!(cmd.follow);
371 }
372}