Skip to main content

rlg_cli/
lib.rs

1// lib.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! Shared parsing, filtering, and re-emission for the `rlg` CLI.
7//!
8//! Splitting these helpers into a library lets `rlg-mcp` (and any
9//! other downstream tool) reuse the same record pipeline without
10//! depending on `clap` or the binary entry point.
11//!
12//! # Example
13//!
14//! ```
15//! use rlg_cli::{parse_record, Filter};
16//! use rlg::log_level::LogLevel;
17//!
18//! let line = r#"{"session_id":1,"time":"2026-05-30T00:00:00.000000000Z","level":"INFO","component":"svc","description":"hello","format":"JSON","attributes":{}}"#;
19//! let record = parse_record(line).unwrap();
20//! let filter = Filter::new().min_level(LogLevel::WARN);
21//! assert!(!filter.matches(&record));
22//! ```
23
24use rlg::log::Log;
25use rlg::log_format::LogFormat;
26use rlg::log_level::LogLevel;
27
28/// Parse a single line as an [`rlg::log::Log`] record.
29///
30/// Accepts the canonical `LogFormat::JSON` shape — the same one that
31/// `rlg`'s `Display` impl emits for `LogFormat::JSON` and `NDJSON`.
32/// Other rlg formats (MCP, OTLP, ECS, …) wrap the underlying record
33/// inside a transport envelope and are not yet parsed back to a
34/// `Log` (see [crate-level docs](crate) for the roadmap).
35///
36/// # Errors
37///
38/// Returns `serde_json::Error` if the input is not valid JSON in the
39/// canonical shape.
40pub fn parse_record(line: &str) -> Result<Log, serde_json::Error> {
41    serde_json::from_str(line.trim())
42}
43
44/// Filter criteria applied to each record as it streams past.
45#[derive(Debug, Default, Clone)]
46pub struct Filter {
47    /// Minimum log level (inclusive). Records below this level are
48    /// dropped.
49    pub min_level: Option<LogLevel>,
50    /// Optional component name. Only records with this exact
51    /// `component` value pass.
52    pub component: Option<String>,
53    /// Optional `(key, value)` attribute match. Only records whose
54    /// attributes map contains this exact pairing pass.
55    pub attribute: Option<(String, serde_json::Value)>,
56}
57
58impl Filter {
59    /// Construct a filter that lets every record through.
60    #[must_use]
61    pub const fn new() -> Self {
62        Self {
63            min_level: None,
64            component: None,
65            attribute: None,
66        }
67    }
68
69    /// Set the minimum severity.
70    #[must_use]
71    pub const fn min_level(mut self, level: LogLevel) -> Self {
72        self.min_level = Some(level);
73        self
74    }
75
76    /// Restrict to a single component.
77    #[must_use]
78    pub fn component(mut self, component: impl Into<String>) -> Self {
79        self.component = Some(component.into());
80        self
81    }
82
83    /// Restrict to records carrying a specific attribute key/value.
84    #[must_use]
85    pub fn attribute(
86        mut self,
87        key: impl Into<String>,
88        value: serde_json::Value,
89    ) -> Self {
90        self.attribute = Some((key.into(), value));
91        self
92    }
93
94    /// Does this record satisfy every active criterion?
95    #[must_use]
96    pub fn matches(&self, record: &Log) -> bool {
97        if let Some(min) = self.min_level
98            && record.level.to_numeric() < min.to_numeric()
99        {
100            return false;
101        }
102        if let Some(comp) = &self.component
103            && record.component.as_ref() != comp.as_str()
104        {
105            return false;
106        }
107        if let Some((key, val)) = &self.attribute
108            && record.attributes.get(key) != Some(val)
109        {
110            return false;
111        }
112        true
113    }
114}
115
116/// Re-emit a record using the chosen [`LogFormat`].
117///
118/// The record's `format` field is overwritten so the wire shape
119/// follows `format`, not whatever was in the input.
120#[must_use]
121pub fn render(mut record: Log, format: LogFormat) -> String {
122    record.format = format;
123    format!("{record}")
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn sample_json() -> &'static str {
131        r#"{
132            "session_id": 7,
133            "time": "2026-05-30T00:00:00.000000000Z",
134            "level": "INFO",
135            "component": "svc",
136            "description": "hi",
137            "format": "JSON",
138            "attributes": {"user_id": 42, "region": "eu-west-1"}
139        }"#
140    }
141
142    #[test]
143    fn parse_returns_log_struct() {
144        let log = parse_record(sample_json()).unwrap();
145        assert_eq!(log.session_id, 7);
146        assert_eq!(log.component.as_ref(), "svc");
147        assert_eq!(log.level, LogLevel::INFO);
148    }
149
150    #[test]
151    fn filter_default_passes_everything() {
152        let log = parse_record(sample_json()).unwrap();
153        assert!(Filter::new().matches(&log));
154    }
155
156    #[test]
157    fn filter_min_level_drops_below() {
158        let log = parse_record(sample_json()).unwrap();
159        assert!(!Filter::new().min_level(LogLevel::WARN).matches(&log));
160        assert!(Filter::new().min_level(LogLevel::INFO).matches(&log));
161        assert!(Filter::new().min_level(LogLevel::DEBUG).matches(&log));
162    }
163
164    #[test]
165    fn filter_component_exact_match() {
166        let log = parse_record(sample_json()).unwrap();
167        assert!(Filter::new().component("svc").matches(&log));
168        assert!(!Filter::new().component("other").matches(&log));
169    }
170
171    #[test]
172    fn filter_attribute_exact_match() {
173        let log = parse_record(sample_json()).unwrap();
174        assert!(
175            Filter::new()
176                .attribute("user_id", serde_json::json!(42))
177                .matches(&log)
178        );
179        assert!(
180            !Filter::new()
181                .attribute("user_id", serde_json::json!(99))
182                .matches(&log)
183        );
184        assert!(
185            !Filter::new()
186                .attribute("missing", serde_json::json!(true))
187                .matches(&log)
188        );
189    }
190
191    #[test]
192    fn render_overrides_format() {
193        let log = parse_record(sample_json()).unwrap();
194        let out = render(log, LogFormat::Logfmt);
195        assert!(out.contains("level=info"));
196        assert!(out.contains("session_id=7"));
197    }
198
199    #[test]
200    fn render_to_mcp_wraps_in_jsonrpc() {
201        let log = parse_record(sample_json()).unwrap();
202        let out = render(log, LogFormat::MCP);
203        assert!(out.contains("\"jsonrpc\":\"2.0\""));
204        assert!(out.contains("notifications/log"));
205    }
206
207    #[test]
208    fn parse_rejects_garbage() {
209        assert!(parse_record("not json at all").is_err());
210    }
211}