Skip to main content

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::{Context, Result};
14use clap::{Parser, Subcommand, ValueEnum};
15
16use crate::request_log;
17use query::Filter;
18
19/// Shared `--since`/`--until` parser (relative durations, dates, or RFC3339),
20/// reused by the `count` subcommand so its time bounds match `omni-dev log`.
21pub(crate) use query::parse_time_bound;
22
23/// Output rendering for `omni-dev log`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
25#[value(rename_all = "lowercase")]
26pub enum Format {
27    /// One compact line per record (default).
28    Oneline,
29    /// The on-disk NDJSON line, verbatim (composes with `jq`).
30    Json,
31    /// A labelled, multi-line block per record.
32    Full,
33}
34
35/// Searches and pretty-prints the local invocation + HTTP request log.
36///
37/// With no subcommand, the flags below search the log; the `count` subcommand
38/// aggregates records by kind and source (`--kind gh` gives the GitHub API-call
39/// breakdown), and the `prune` subcommand trims the log to bound its on-disk
40/// growth.
41#[derive(Parser)]
42pub struct LogCommand {
43    /// Subcommand; when absent, the flags below search the log.
44    #[command(subcommand)]
45    action: Option<LogAction>,
46    /// Lower time bound: a relative window (`30m`, `2h`, `1d`), a date
47    /// (`2026-07-01`), or an RFC3339 timestamp.
48    #[arg(long, value_name = "DUR_OR_TS")]
49    since: Option<String>,
50    /// Upper time bound: same forms as `--since` (a relative value means that
51    /// long ago). Pair with `--since` for a bounded window.
52    #[arg(long, value_name = "DUR_OR_TS")]
53    until: Option<String>,
54    /// Match the HTTP method (case-insensitive), e.g. `GET`.
55    #[arg(long, value_name = "METHOD")]
56    method: Option<String>,
57    /// Match the status: exact (`200`), class (`5xx`), or list (`4xx,5xx`).
58    #[arg(long, value_name = "STATUS")]
59    status: Option<String>,
60    /// Match the service tag, e.g. `jira`, `datadog`, `browser-bridge`.
61    #[arg(long, value_name = "NAME")]
62    service: Option<String>,
63    /// Match the resolved command path prefix, e.g. `"jira read"`.
64    #[arg(long, value_name = "PATH")]
65    command: Option<String>,
66    /// Match a substring of the request URL.
67    #[arg(long, value_name = "SUBSTR")]
68    url: Option<String>,
69    /// Match a regular expression against the raw JSON line.
70    #[arg(long, value_name = "REGEX")]
71    grep: Option<String>,
72    /// Require a fuzzy token (substring of the raw line); repeatable, AND-ed.
73    #[arg(long, value_name = "TOKEN")]
74    fuzzy: Vec<String>,
75    /// A query expression (AND/OR/NOT, `field:value`, bare tokens); repeatable,
76    /// AND-ed together.
77    #[arg(long, value_name = "EXPR")]
78    query: Vec<String>,
79    /// Match this record `id` or `invocation_id` (pulls a run and its requests).
80    #[arg(long, value_name = "ID")]
81    id: Option<String>,
82    /// Output format.
83    #[arg(short = 'o', long, value_enum, default_value_t = Format::Oneline)]
84    output: Format,
85    /// Deprecated: use `-o`/`--output` instead.
86    #[arg(long = "format", hide = true)]
87    format: Option<Format>,
88    /// Show at most N (most recent) matching records.
89    #[arg(short = 'n', long, value_name = "N")]
90    limit: Option<usize>,
91    /// Follow the log, printing new matching records as they are appended.
92    #[arg(short = 'f', long)]
93    follow: bool,
94}
95
96/// A `log` subcommand. Absent = search (the flags on [`LogCommand`]).
97#[derive(Subcommand)]
98enum LogAction {
99    /// Count records by kind and source (`--kind gh` for the GitHub breakdown).
100    Count(count::CountCommand),
101    /// Prune old records to bound the log's on-disk growth.
102    Prune(prune::PruneCommand),
103}
104
105impl LogCommand {
106    /// Executes the `omni-dev log` command.
107    pub fn execute(mut self) -> Result<()> {
108        if let Some(action) = self.action {
109            return match action {
110                LogAction::Count(cmd) => cmd.execute(),
111                LogAction::Prune(cmd) => cmd.execute(),
112            };
113        }
114        if let Some(format) = self.format.take() {
115            eprintln!("warning: --format is deprecated; use -o/--output instead");
116            self.output = format;
117        }
118        let path = request_log::log_file_path().context("could not resolve the log file path")?;
119        let filter = Filter::build(query::FilterInput {
120            since: self.since.as_deref(),
121            until: self.until.as_deref(),
122            method: self.method.as_deref(),
123            status: self.status.as_deref(),
124            service: self.service.as_deref(),
125            command: self.command.as_deref(),
126            url: self.url.as_deref(),
127            grep: self.grep.as_deref(),
128            fuzzy: &self.fuzzy,
129            query: &self.query,
130            id: self.id.as_deref(),
131        })?;
132        stream::run(&path, &filter, self.output, self.limit, self.follow)
133    }
134}
135
136/// Filter inputs for a one-shot log search that captures its output, used by the
137/// MCP `log_search` tool. Mirrors the search flags on [`LogCommand`] minus
138/// `--follow` (a capturing search never tails).
139///
140/// Only compiled with the `mcp` feature — the MCP `log_search` tool is its sole
141/// consumer.
142#[cfg(feature = "mcp")]
143pub(crate) struct SearchRequest<'a> {
144    /// Lower time bound: a relative window (`30m`, `2h`, `1d`), a date, or an
145    /// RFC3339 timestamp.
146    pub since: Option<&'a str>,
147    /// Upper time bound: same forms as `since`.
148    pub until: Option<&'a str>,
149    /// Match the HTTP method (case-insensitive).
150    pub method: Option<&'a str>,
151    /// Match the status: exact, class (`5xx`), or list (`4xx,5xx`).
152    pub status: Option<&'a str>,
153    /// Match the service tag.
154    pub service: Option<&'a str>,
155    /// Match the resolved command-path prefix.
156    pub command: Option<&'a str>,
157    /// Match a substring of the request URL.
158    pub url: Option<&'a str>,
159    /// Match a regular expression against the raw JSON line.
160    pub grep: Option<&'a str>,
161    /// Fuzzy tokens (substrings of the raw line); AND-ed.
162    pub fuzzy: &'a [String],
163    /// Query expressions (AND/OR/NOT, `field:value`, bare tokens); AND-ed.
164    pub query: &'a [String],
165    /// Match this record `id` or `invocation_id`.
166    pub id: Option<&'a str>,
167    /// Output rendering.
168    pub format: Format,
169    /// Show at most N (most recent) matching records.
170    pub limit: Option<usize>,
171}
172
173/// Runs a one-shot (non-follow) log search and returns the rendered matches as a
174/// single string. Shared with the MCP `log_search` tool so it reuses the CLI's
175/// filter matrix and renderers verbatim; a missing log file yields an empty
176/// string.
177///
178/// Only compiled with the `mcp` feature — the MCP `log_search` tool is its sole
179/// consumer.
180#[cfg(feature = "mcp")]
181pub(crate) fn run_search_capture(req: SearchRequest<'_>) -> Result<String> {
182    let path = request_log::log_file_path().context("could not resolve the log file path")?;
183    let filter = Filter::build(query::FilterInput {
184        since: req.since,
185        until: req.until,
186        method: req.method,
187        status: req.status,
188        service: req.service,
189        command: req.command,
190        url: req.url,
191        grep: req.grep,
192        fuzzy: req.fuzzy,
193        query: req.query,
194        id: req.id,
195    })?;
196    stream::run_capture(&path, &filter, req.format, req.limit)
197}
198
199#[cfg(test)]
200#[allow(clippy::unwrap_used, clippy::expect_used)]
201mod tests {
202    use super::*;
203
204    #[derive(Parser)]
205    struct Wrapper {
206        #[command(subcommand)]
207        cmd: Wrapped,
208    }
209
210    #[derive(clap::Subcommand)]
211    enum Wrapped {
212        Log(LogCommand),
213    }
214
215    fn parse(args: &[&str]) -> LogCommand {
216        let mut full = vec!["omni-dev", "log"];
217        full.extend_from_slice(args);
218        match Wrapper::try_parse_from(full).unwrap().cmd {
219            Wrapped::Log(cmd) => cmd,
220        }
221    }
222
223    #[test]
224    fn defaults_are_sane() {
225        let cmd = parse(&[]);
226        assert_eq!(cmd.output, Format::Oneline);
227        assert!(cmd.format.is_none());
228        assert!(cmd.limit.is_none());
229        assert!(!cmd.follow);
230    }
231
232    #[test]
233    fn deprecated_format_alias_still_parses() {
234        let cmd = parse(&["--format", "json"]);
235        // Captured separately; `execute` folds it into `output` with a warning.
236        assert_eq!(cmd.format, Some(Format::Json));
237        assert_eq!(cmd.output, Format::Oneline);
238    }
239
240    #[test]
241    fn parses_full_flag_matrix() {
242        let cmd = parse(&[
243            "--since",
244            "2h",
245            "--method",
246            "GET",
247            "--status",
248            "5xx",
249            "--service",
250            "jira",
251            "--command",
252            "jira read",
253            "--url",
254            "issue",
255            "--grep",
256            "X-\\d+",
257            "--fuzzy",
258            "a",
259            "--fuzzy",
260            "b",
261            "--query",
262            "status:5xx OR method:POST",
263            "--id",
264            "abc",
265            "-o",
266            "json",
267            "-n",
268            "10",
269            "-f",
270        ]);
271        assert_eq!(cmd.since.as_deref(), Some("2h"));
272        assert_eq!(cmd.status.as_deref(), Some("5xx"));
273        assert_eq!(cmd.fuzzy, vec!["a", "b"]);
274        assert_eq!(cmd.query.len(), 1);
275        assert_eq!(cmd.output, Format::Json);
276        assert_eq!(cmd.limit, Some(10));
277        assert!(cmd.follow);
278    }
279}