Skip to main content

rlg/
main.rs

1// main.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! `rlg` — tail, filter, and convert structured log streams.
7//!
8//! The binary reads canonical JSON-shaped `rlg` records (one per
9//! line) from stdin or a file, applies the `--min-level`,
10//! `--component`, and `--attr` filters, and re-emits each surviving
11//! record in the format selected with `--format`.
12
13#![forbid(unsafe_code)]
14#![deny(missing_docs)]
15
16use clap::{CommandFactory, Parser, ValueEnum};
17use rlg::log_format::LogFormat;
18use rlg::log_level::LogLevel;
19use rlg_cli::{Filter, parse_record, render};
20use std::fs::File;
21use std::io::{self, BufRead, BufReader, Write};
22use std::path::PathBuf;
23
24/// `rlg` — `jq` for structured logs.
25#[derive(Parser, Debug)]
26#[command(name = "rlg", version, about, long_about = None)]
27struct Cli {
28    /// Print the manual page (roff) and exit.
29    #[arg(long, exclusive = true)]
30    manpage: bool,
31
32    /// Print the completion script for SHELL and exit.
33    #[arg(long, value_name = "SHELL", exclusive = true)]
34    completions: Option<clap_complete::Shell>,
35
36    /// Input file. Reads from stdin if omitted.
37    input: Option<PathBuf>,
38
39    /// Output `LogFormat`. Defaults to `Logfmt` for terminal-friendly
40    /// reading.
41    #[arg(short, long, value_enum, default_value_t = OutputFormat::Logfmt)]
42    format: OutputFormat,
43
44    /// Drop records below this level.
45    #[arg(short = 'l', long)]
46    min_level: Option<LevelArg>,
47
48    /// Restrict to a single component.
49    #[arg(short = 'c', long)]
50    component: Option<String>,
51
52    /// Filter by a single attribute, formatted as `key=value`. Value
53    /// is parsed as JSON (so `key=42` is a number, `key="x"` is a
54    /// string, etc.).
55    #[arg(short = 'a', long)]
56    attr: Option<String>,
57}
58
59/// CLI-friendly mirror of [`LogFormat`].
60#[derive(Copy, Clone, Debug, ValueEnum)]
61enum OutputFormat {
62    Clf,
63    Cef,
64    Elf,
65    W3c,
66    Apache,
67    Log4jXml,
68    Json,
69    Gelf,
70    Logstash,
71    Ndjson,
72    Mcp,
73    Otlp,
74    Logfmt,
75    Ecs,
76}
77
78impl From<OutputFormat> for LogFormat {
79    fn from(o: OutputFormat) -> Self {
80        match o {
81            OutputFormat::Clf => Self::CLF,
82            OutputFormat::Cef => Self::CEF,
83            OutputFormat::Elf => Self::ELF,
84            OutputFormat::W3c => Self::W3C,
85            OutputFormat::Apache => Self::ApacheAccessLog,
86            OutputFormat::Log4jXml => Self::Log4jXML,
87            OutputFormat::Json => Self::JSON,
88            OutputFormat::Gelf => Self::GELF,
89            OutputFormat::Logstash => Self::Logstash,
90            OutputFormat::Ndjson => Self::NDJSON,
91            OutputFormat::Mcp => Self::MCP,
92            OutputFormat::Otlp => Self::OTLP,
93            OutputFormat::Logfmt => Self::Logfmt,
94            OutputFormat::Ecs => Self::ECS,
95        }
96    }
97}
98
99/// CLI-friendly mirror of [`LogLevel`].
100#[derive(Copy, Clone, Debug, ValueEnum)]
101enum LevelArg {
102    Trace,
103    Debug,
104    Verbose,
105    Info,
106    Warn,
107    Error,
108    Fatal,
109    Critical,
110}
111
112impl From<LevelArg> for LogLevel {
113    fn from(l: LevelArg) -> Self {
114        match l {
115            LevelArg::Trace => Self::TRACE,
116            LevelArg::Debug => Self::DEBUG,
117            LevelArg::Verbose => Self::VERBOSE,
118            LevelArg::Info => Self::INFO,
119            LevelArg::Warn => Self::WARN,
120            LevelArg::Error => Self::ERROR,
121            LevelArg::Fatal => Self::FATAL,
122            LevelArg::Critical => Self::CRITICAL,
123        }
124    }
125}
126
127fn build_filter(cli: &Cli) -> anyhow::Result<Filter> {
128    let mut filter = Filter::new();
129    if let Some(level) = cli.min_level {
130        filter = filter.min_level(level.into());
131    }
132    if let Some(comp) = &cli.component {
133        filter = filter.component(comp);
134    }
135    if let Some(spec) = &cli.attr {
136        let (k, raw) = spec.split_once('=').ok_or_else(|| {
137            anyhow::anyhow!(
138                "--attr must be `key=value` (got: `{spec}`)"
139            )
140        })?;
141        let v: serde_json::Value = serde_json::from_str(raw)
142            .unwrap_or_else(|_| {
143                serde_json::Value::String(raw.to_string())
144            });
145        filter = filter.attribute(k, v);
146    }
147    Ok(filter)
148}
149
150fn run<R: BufRead, W: Write>(
151    reader: R,
152    mut writer: W,
153    filter: &Filter,
154    format: LogFormat,
155) -> anyhow::Result<()> {
156    for line in reader.lines() {
157        let line = line?;
158        if line.trim().is_empty() {
159            continue;
160        }
161        let Ok(record) = parse_record(&line) else {
162            // Pass through unparseable lines verbatim so the caller
163            // can still see them — `tail -f` of a non-rlg log
164            // shouldn't silently drop entries.
165            writeln!(writer, "{line}")?;
166            continue;
167        };
168        if !filter.matches(&record) {
169            continue;
170        }
171        writeln!(writer, "{}", render(record, format))?;
172    }
173    Ok(())
174}
175
176/// Handle `--manpage` and `--completions`: print what clap derives from
177/// the `Cli` definition. Returns whether one of them was asked for.
178fn print_generated(cli: &Cli) -> io::Result<bool> {
179    if cli.manpage {
180        clap_mangen::Man::new(Cli::command())
181            .render(&mut io::stdout())?;
182        return Ok(true);
183    }
184    if let Some(shell) = cli.completions {
185        clap_complete::generate(
186            shell,
187            &mut Cli::command(),
188            "rlg",
189            &mut io::stdout(),
190        );
191        return Ok(true);
192    }
193    Ok(false)
194}
195
196fn main() -> anyhow::Result<()> {
197    let cli = Cli::parse();
198    if print_generated(&cli)? {
199        return Ok(());
200    }
201    let filter = build_filter(&cli)?;
202    let format: LogFormat = cli.format.into();
203
204    let stdout = io::stdout();
205    let writer = stdout.lock();
206
207    match cli.input.as_ref() {
208        Some(path) => {
209            let file = File::open(path)?;
210            run(BufReader::new(file), writer, &filter, format)
211        }
212        None => {
213            let stdin = io::stdin();
214            run(stdin.lock(), writer, &filter, format)
215        }
216    }
217}