Skip to main content

rbnx/cmd/
logs.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx logs` — read Scribe JSON-lines log files with tag / level
3// filtering and optional follow mode.
4
5use anyhow::Result;
6use robonix_scribe::LogRecord;
7use std::path::PathBuf;
8
9/// Minimum level string → numeric floor for filtering.
10fn level_floor(s: &str) -> u8 {
11    match s.to_lowercase().as_str() {
12        "debug" => 0,
13        "info" => 1,
14        "warn" => 2,
15        "error" => 3,
16        _ => 0, // unknown → show everything
17    }
18}
19
20fn level_value(level: &robonix_scribe::Level) -> u8 {
21    match level {
22        robonix_scribe::Level::Debug => 0,
23        robonix_scribe::Level::Info => 1,
24        robonix_scribe::Level::Warn => 2,
25        robonix_scribe::Level::Error => 3,
26    }
27}
28
29/// Render one record as a logcat-style line (reuse Scribe's own formatter).
30fn render_console(rec: &LogRecord) -> String {
31    robonix_scribe::format::format_console(rec)
32}
33
34/// Read all `*.log` files in `dir`, parse every line as a LogRecord,
35/// apply tag / level filters, sort by timestamp, and print.
36fn read_all(dir: &PathBuf, tags: &[String], min_level: u8, raw_json: bool) -> Result<()> {
37    let mut records: Vec<LogRecord> = Vec::new();
38
39    let entries = std::fs::read_dir(dir)?;
40    for entry in entries.flatten() {
41        let p = entry.path();
42        if p.extension().and_then(|s| s.to_str()) != Some("log") {
43            continue;
44        }
45        let content = std::fs::read_to_string(&p)?;
46        for line in content.lines() {
47            let line = line.trim();
48            if line.is_empty() {
49                continue;
50            }
51            if let Ok(rec) = serde_json::from_str::<LogRecord>(line) {
52                records.push(rec);
53            }
54        }
55    }
56
57    // Sort by timestamp.
58    records.sort_by_key(|r| r.ts);
59
60    // Filter and print.
61    for rec in &records {
62        // Tag filter (OR).
63        if !tags.is_empty() && !tags.iter().any(|t| t == &rec.tag) {
64            continue;
65        }
66        // Level filter.
67        if level_value(&rec.level) < min_level {
68            continue;
69        }
70        if raw_json {
71            println!("{}", serde_json::to_string(rec)?);
72        } else {
73            print!("{}", render_console(rec));
74        }
75    }
76    Ok(())
77}
78
79/// Scan every `*.log` in `dir` and print the distinct tags found, each with a
80/// record count, sorted by tag. Helps discover which `-t <tag>` values exist.
81/// Ignores the tag / level filters. When no Scribe records parse, says so
82/// (the files may be empty or in a pre-Scribe text format) rather than
83/// printing nothing.
84fn list_tags(dir: &PathBuf) -> Result<()> {
85    use std::collections::BTreeMap;
86    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
87    let mut files = 0u64;
88    let mut parsed = 0u64;
89    for entry in std::fs::read_dir(dir)?.flatten() {
90        let p = entry.path();
91        if p.extension().and_then(|s| s.to_str()) != Some("log") {
92            continue;
93        }
94        files += 1;
95        let content = std::fs::read_to_string(&p)?;
96        for line in content.lines() {
97            let line = line.trim();
98            if line.is_empty() {
99                continue;
100            }
101            if let Ok(rec) = serde_json::from_str::<LogRecord>(line) {
102                parsed += 1;
103                *counts.entry(rec.tag).or_default() += 1;
104            }
105        }
106    }
107    if counts.is_empty() {
108        eprintln!(
109            "rbnx logs: no Scribe records in {} ({files} .log file(s) scanned). \
110             The files may be empty or in a pre-Scribe text format.",
111            dir.display()
112        );
113        return Ok(());
114    }
115    let width = counts.keys().map(String::len).max().unwrap_or(0);
116    for (tag, n) in &counts {
117        println!("{tag:<width$}  {n} records");
118    }
119    eprintln!(
120        "{} tag(s), {parsed} records across {files} file(s)",
121        counts.len()
122    );
123    Ok(())
124}
125
126/// Follow mode: open all `*.log` files, seek to end, and tail new lines.
127fn follow(dir: &PathBuf, tags: &[String], min_level: u8, raw_json: bool) -> Result<()> {
128    use std::io::{BufRead, Seek, SeekFrom};
129
130    // Collect initial file positions.
131    let mut handles: Vec<(PathBuf, std::fs::File, u64)> = Vec::new();
132    let entries = std::fs::read_dir(dir)?;
133    for entry in entries.flatten() {
134        let p = entry.path();
135        if p.extension().and_then(|s| s.to_str()) != Some("log") {
136            continue;
137        }
138        let mut f = std::fs::File::open(&p)?;
139        let pos = f.seek(SeekFrom::End(0))?;
140        handles.push((p, f, pos));
141    }
142
143    // Poll loop.
144    loop {
145        for (path, f, pos) in &mut handles {
146            let current_len = f.metadata()?.len();
147            if current_len > *pos {
148                f.seek(SeekFrom::Start(*pos))?;
149                let reader = std::io::BufReader::new(&*f);
150                for line in reader.lines() {
151                    let line = line?;
152                    let line = line.trim();
153                    if line.is_empty() {
154                        continue;
155                    }
156                    if let Ok(rec) = serde_json::from_str::<LogRecord>(line) {
157                        if !tags.is_empty() && !tags.iter().any(|t| t == &rec.tag) {
158                            continue;
159                        }
160                        if level_value(&rec.level) < min_level {
161                            continue;
162                        }
163                        if raw_json {
164                            println!("{}", serde_json::to_string(&rec)?);
165                        } else {
166                            print!("{}", render_console(&rec));
167                        }
168                    }
169                }
170                // Update position (re-read metadata to avoid TOCTOU).
171                *pos = std::fs::metadata(path)?.len();
172            }
173        }
174        std::thread::sleep(std::time::Duration::from_millis(250));
175    }
176}
177
178pub async fn execute(
179    log_dir: Option<PathBuf>,
180    tags: Vec<String>,
181    level: Option<String>,
182    follow_mode: bool,
183    raw_json: bool,
184    list_tags_mode: bool,
185) -> Result<()> {
186    let dir = log_dir.unwrap_or_else(|| {
187        std::env::var("SCRIBE_LOG_DIR")
188            .map(PathBuf::from)
189            .unwrap_or_else(|_| PathBuf::from("./rbnx-boot/logs"))
190    });
191
192    if !dir.is_dir() {
193        eprintln!(
194            "rbnx logs: {} does not exist or is not a directory.",
195            dir.display()
196        );
197        eprintln!("Set SCRIBE_LOG_DIR or run from a deploy directory.");
198        std::process::exit(1);
199    }
200
201    if list_tags_mode {
202        return list_tags(&dir);
203    }
204
205    let min_level = level.as_deref().map(level_floor).unwrap_or(0);
206
207    if follow_mode {
208        follow(&dir, &tags, min_level, raw_json)
209    } else {
210        read_all(&dir, &tags, min_level, raw_json)
211    }
212}