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 query;
9mod stream;
10
11use anyhow::{Context, Result};
12use clap::{Parser, ValueEnum};
13
14use crate::request_log;
15use query::Filter;
16
17/// Output rendering for `omni-dev log`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
19#[value(rename_all = "lowercase")]
20pub enum Format {
21    /// One compact line per record (default).
22    Oneline,
23    /// The on-disk NDJSON line, verbatim (composes with `jq`).
24    Json,
25    /// A labelled, multi-line block per record.
26    Full,
27}
28
29/// Searches and pretty-prints the local invocation + HTTP request log.
30#[derive(Parser)]
31pub struct LogCommand {
32    /// Only records newer than this relative window (e.g. `30m`, `2h`, `1d`).
33    #[arg(long, value_name = "DUR")]
34    since: Option<String>,
35    /// Match the HTTP method (case-insensitive), e.g. `GET`.
36    #[arg(long, value_name = "METHOD")]
37    method: Option<String>,
38    /// Match the status: exact (`200`), class (`5xx`), or list (`4xx,5xx`).
39    #[arg(long, value_name = "STATUS")]
40    status: Option<String>,
41    /// Match the service tag, e.g. `jira`, `datadog`, `browser-bridge`.
42    #[arg(long, value_name = "NAME")]
43    service: Option<String>,
44    /// Match the resolved command path prefix, e.g. `"jira read"`.
45    #[arg(long, value_name = "PATH")]
46    command: Option<String>,
47    /// Match a substring of the request URL.
48    #[arg(long, value_name = "SUBSTR")]
49    url: Option<String>,
50    /// Match a regular expression against the raw JSON line.
51    #[arg(long, value_name = "REGEX")]
52    grep: Option<String>,
53    /// Require a fuzzy token (substring of the raw line); repeatable, AND-ed.
54    #[arg(long, value_name = "TOKEN")]
55    fuzzy: Vec<String>,
56    /// A query expression (AND/OR/NOT, `field:value`, bare tokens); repeatable,
57    /// AND-ed together.
58    #[arg(long, value_name = "EXPR")]
59    query: Vec<String>,
60    /// Match this record `id` or `invocation_id` (pulls a run and its requests).
61    #[arg(long, value_name = "ID")]
62    id: Option<String>,
63    /// Output format.
64    #[arg(long, value_enum, default_value_t = Format::Oneline)]
65    format: Format,
66    /// Show at most N (most recent) matching records.
67    #[arg(short = 'n', long, value_name = "N")]
68    limit: Option<usize>,
69    /// Follow the log, printing new matching records as they are appended.
70    #[arg(short = 'f', long)]
71    follow: bool,
72}
73
74impl LogCommand {
75    /// Executes the `omni-dev log` command.
76    pub fn execute(self) -> Result<()> {
77        let path = request_log::log_file_path().context("could not resolve the log file path")?;
78        let filter = Filter::build(query::FilterInput {
79            since: self.since.as_deref(),
80            method: self.method.as_deref(),
81            status: self.status.as_deref(),
82            service: self.service.as_deref(),
83            command: self.command.as_deref(),
84            url: self.url.as_deref(),
85            grep: self.grep.as_deref(),
86            fuzzy: &self.fuzzy,
87            query: &self.query,
88            id: self.id.as_deref(),
89        })?;
90        stream::run(&path, &filter, self.format, self.limit, self.follow)
91    }
92}
93
94#[cfg(test)]
95#[allow(clippy::unwrap_used, clippy::expect_used)]
96mod tests {
97    use super::*;
98
99    #[derive(Parser)]
100    struct Wrapper {
101        #[command(subcommand)]
102        cmd: Wrapped,
103    }
104
105    #[derive(clap::Subcommand)]
106    enum Wrapped {
107        Log(LogCommand),
108    }
109
110    fn parse(args: &[&str]) -> LogCommand {
111        let mut full = vec!["omni-dev", "log"];
112        full.extend_from_slice(args);
113        match Wrapper::try_parse_from(full).unwrap().cmd {
114            Wrapped::Log(cmd) => cmd,
115        }
116    }
117
118    #[test]
119    fn defaults_are_sane() {
120        let cmd = parse(&[]);
121        assert_eq!(cmd.format, Format::Oneline);
122        assert!(cmd.limit.is_none());
123        assert!(!cmd.follow);
124    }
125
126    #[test]
127    fn parses_full_flag_matrix() {
128        let cmd = parse(&[
129            "--since",
130            "2h",
131            "--method",
132            "GET",
133            "--status",
134            "5xx",
135            "--service",
136            "jira",
137            "--command",
138            "jira read",
139            "--url",
140            "issue",
141            "--grep",
142            "X-\\d+",
143            "--fuzzy",
144            "a",
145            "--fuzzy",
146            "b",
147            "--query",
148            "status:5xx OR method:POST",
149            "--id",
150            "abc",
151            "--format",
152            "json",
153            "-n",
154            "10",
155            "-f",
156        ]);
157        assert_eq!(cmd.since.as_deref(), Some("2h"));
158        assert_eq!(cmd.status.as_deref(), Some("5xx"));
159        assert_eq!(cmd.fuzzy, vec!["a", "b"]);
160        assert_eq!(cmd.query.len(), 1);
161        assert_eq!(cmd.format, Format::Json);
162        assert_eq!(cmd.limit, Some(10));
163        assert!(cmd.follow);
164    }
165}