1use anyhow::Result;
6use robonix_scribe::LogRecord;
7use std::path::PathBuf;
8
9fn 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, }
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
29fn render_console(rec: &LogRecord) -> String {
31 robonix_scribe::format::format_console(rec)
32}
33
34fn 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 records.sort_by_key(|r| r.ts);
59
60 for rec in &records {
62 if !tags.is_empty() && !tags.iter().any(|t| t == &rec.tag) {
64 continue;
65 }
66 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
79fn 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
126fn follow(dir: &PathBuf, tags: &[String], min_level: u8, raw_json: bool) -> Result<()> {
128 use std::io::{BufRead, Seek, SeekFrom};
129
130 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 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 *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}