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