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::{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    /// Input file. Reads from stdin if omitted.
29    input: Option<PathBuf>,
30
31    /// Output `LogFormat`. Defaults to `Logfmt` for terminal-friendly
32    /// reading.
33    #[arg(short, long, value_enum, default_value_t = OutputFormat::Logfmt)]
34    format: OutputFormat,
35
36    /// Drop records below this level.
37    #[arg(short = 'l', long)]
38    min_level: Option<LevelArg>,
39
40    /// Restrict to a single component.
41    #[arg(short = 'c', long)]
42    component: Option<String>,
43
44    /// Filter by a single attribute, formatted as `key=value`. Value
45    /// is parsed as JSON (so `key=42` is a number, `key="x"` is a
46    /// string, etc.).
47    #[arg(short = 'a', long)]
48    attr: Option<String>,
49}
50
51/// CLI-friendly mirror of [`LogFormat`].
52#[derive(Copy, Clone, Debug, ValueEnum)]
53#[allow(missing_docs)]
54enum OutputFormat {
55    Clf,
56    Cef,
57    Elf,
58    W3c,
59    Apache,
60    Log4jXml,
61    Json,
62    Gelf,
63    Logstash,
64    Ndjson,
65    Mcp,
66    Otlp,
67    Logfmt,
68    Ecs,
69}
70
71impl From<OutputFormat> for LogFormat {
72    fn from(o: OutputFormat) -> Self {
73        match o {
74            OutputFormat::Clf => Self::CLF,
75            OutputFormat::Cef => Self::CEF,
76            OutputFormat::Elf => Self::ELF,
77            OutputFormat::W3c => Self::W3C,
78            OutputFormat::Apache => Self::ApacheAccessLog,
79            OutputFormat::Log4jXml => Self::Log4jXML,
80            OutputFormat::Json => Self::JSON,
81            OutputFormat::Gelf => Self::GELF,
82            OutputFormat::Logstash => Self::Logstash,
83            OutputFormat::Ndjson => Self::NDJSON,
84            OutputFormat::Mcp => Self::MCP,
85            OutputFormat::Otlp => Self::OTLP,
86            OutputFormat::Logfmt => Self::Logfmt,
87            OutputFormat::Ecs => Self::ECS,
88        }
89    }
90}
91
92/// CLI-friendly mirror of [`LogLevel`].
93#[derive(Copy, Clone, Debug, ValueEnum)]
94#[allow(missing_docs)]
95enum LevelArg {
96    Trace,
97    Debug,
98    Verbose,
99    Info,
100    Warn,
101    Error,
102    Fatal,
103    Critical,
104}
105
106impl From<LevelArg> for LogLevel {
107    fn from(l: LevelArg) -> Self {
108        match l {
109            LevelArg::Trace => Self::TRACE,
110            LevelArg::Debug => Self::DEBUG,
111            LevelArg::Verbose => Self::VERBOSE,
112            LevelArg::Info => Self::INFO,
113            LevelArg::Warn => Self::WARN,
114            LevelArg::Error => Self::ERROR,
115            LevelArg::Fatal => Self::FATAL,
116            LevelArg::Critical => Self::CRITICAL,
117        }
118    }
119}
120
121fn build_filter(cli: &Cli) -> anyhow::Result<Filter> {
122    let mut filter = Filter::new();
123    if let Some(level) = cli.min_level {
124        filter = filter.min_level(level.into());
125    }
126    if let Some(comp) = &cli.component {
127        filter = filter.component(comp);
128    }
129    if let Some(spec) = &cli.attr {
130        let (k, raw) = spec.split_once('=').ok_or_else(|| {
131            anyhow::anyhow!(
132                "--attr must be `key=value` (got: `{spec}`)"
133            )
134        })?;
135        let v: serde_json::Value = serde_json::from_str(raw)
136            .unwrap_or_else(|_| {
137                serde_json::Value::String(raw.to_string())
138            });
139        filter = filter.attribute(k, v);
140    }
141    Ok(filter)
142}
143
144fn run<R: BufRead, W: Write>(
145    reader: R,
146    mut writer: W,
147    filter: &Filter,
148    format: LogFormat,
149) -> anyhow::Result<()> {
150    for line in reader.lines() {
151        let line = line?;
152        if line.trim().is_empty() {
153            continue;
154        }
155        let Ok(record) = parse_record(&line) else {
156            // Pass through unparseable lines verbatim so the caller
157            // can still see them — `tail -f` of a non-rlg log
158            // shouldn't silently drop entries.
159            writeln!(writer, "{line}")?;
160            continue;
161        };
162        if !filter.matches(&record) {
163            continue;
164        }
165        writeln!(writer, "{}", render(record, format))?;
166    }
167    Ok(())
168}
169
170fn main() -> anyhow::Result<()> {
171    let cli = Cli::parse();
172    let filter = build_filter(&cli)?;
173    let format: LogFormat = cli.format.into();
174
175    let stdout = io::stdout();
176    let writer = stdout.lock();
177
178    match cli.input.as_ref() {
179        Some(path) => {
180            let file = File::open(path)?;
181            run(BufReader::new(file), writer, &filter, format)
182        }
183        None => {
184            let stdin = io::stdin();
185            run(stdin.lock(), writer, &filter, format)
186        }
187    }
188}