1use std::io::Write;
8use std::path::Path;
9
10use clap::{Arg, ArgAction, ArgMatches, Command};
11use serde_json::Value;
12use time::format_description::well_known::Rfc3339;
13use time::{Date, Duration, OffsetDateTime};
14
15use crate::error::Result;
16use crate::{bundle, dirs, filter, Service};
17
18pub fn command() -> Command {
20 Command::new("logs")
21 .about("Inspect, tune, and bundle this service's logs")
22 .subcommand_required(true)
23 .arg_required_else_help(true)
24 .subcommand(
25 Command::new("path")
26 .about("Print the resolved log directory")
27 .arg(json_flag()),
28 )
29 .subcommand(
30 Command::new("tail")
31 .about("Print the most recent log lines (human by default)")
32 .arg(
33 Arg::new("follow")
34 .short('f')
35 .long("follow")
36 .action(ArgAction::SetTrue),
37 )
38 .arg(
39 Arg::new("lines")
40 .short('n')
41 .long("lines")
42 .default_value("50"),
43 )
44 .arg(Arg::new("level").long("level"))
45 .arg(json_flag()),
46 )
47 .subcommand(
48 Command::new("level")
49 .about("Show or persist the log level filter")
50 .arg(Arg::new("filter").index(1))
51 .arg(json_flag()),
52 )
53 .subcommand(
54 Command::new("bundle")
55 .about("Write a redacted zip of the logs for a bug report")
56 .arg(
57 Arg::new("out")
58 .short('o')
59 .long("out")
60 .default_value("dig-logs.zip"),
61 )
62 .arg(Arg::new("all").long("all").action(ArgAction::SetTrue))
63 .arg(
64 Arg::new("since")
65 .long("since")
66 .help("Limit to files no older than a duration, e.g. 24h or 7d"),
67 ),
68 )
69}
70
71fn json_flag() -> Arg {
72 Arg::new("json")
73 .long("json")
74 .action(ArgAction::SetTrue)
75 .help("Emit machine-readable JSON")
76}
77
78pub fn run(service: &Service, matches: &ArgMatches) -> Result<()> {
80 let dir = dirs::log_dir(service.name);
81 match matches.subcommand() {
82 Some(("path", m)) => run_path(&dir, m.get_flag("json")),
83 Some(("tail", m)) => run_tail(&dir, service.name, m),
84 Some(("level", m)) => run_level(&dir, m),
85 Some(("bundle", m)) => run_bundle(service, &dir, m),
86 _ => Ok(()),
87 }
88}
89
90fn run_path(dir: &Path, json: bool) -> Result<()> {
91 if json {
92 println!("{}", serde_json::json!({ "dir": dir.to_string_lossy() }));
93 } else {
94 println!("{}", dir.display());
95 }
96 Ok(())
97}
98
99fn run_tail(dir: &Path, service: &str, m: &ArgMatches) -> Result<()> {
100 let n: usize = m
101 .get_one::<String>("lines")
102 .and_then(|v| v.parse().ok())
103 .unwrap_or(50);
104 let min_level = m.get_one::<String>("level").map(String::as_str);
105 let json = m.get_flag("json");
106
107 let Some(file) = newest_log_file(dir, service) else {
108 return Ok(()); };
110 let contents = std::fs::read_to_string(&file).unwrap_or_default();
111 for line in select_tail(&contents, n, min_level) {
112 let out = if json {
113 line.to_string()
114 } else {
115 render_human(line)
116 };
117 println!("{out}");
118 }
119 if m.get_flag("follow") {
120 follow(&file, min_level, json)?;
121 }
122 Ok(())
123}
124
125fn run_level(dir: &Path, m: &ArgMatches) -> Result<()> {
126 if let Some(directive) = m.get_one::<String>("filter") {
127 filter::write_persisted_level(dir, directive)?;
128 }
129 let effective = filter::resolve_filter_from_env(filter::read_persisted_level(dir).as_deref());
130 if m.get_flag("json") {
131 println!("{}", serde_json::json!({ "filter": effective }));
132 } else {
133 println!("{effective}");
134 }
135 Ok(())
136}
137
138fn run_bundle(service: &Service, dir: &Path, m: &ArgMatches) -> Result<()> {
139 let out = m
140 .get_one::<String>("out")
141 .map(String::as_str)
142 .unwrap_or("dig-logs.zip");
143 let all = m.get_flag("all");
144 let now = OffsetDateTime::now_utc();
145 let created_at = now.format(&Rfc3339).unwrap_or_default();
146 let since = m
147 .get_one::<String>("since")
148 .and_then(|s| since_cutoff(s, now.date()));
149
150 let sources = if all {
151 let root = dir.parent().unwrap_or(dir).to_path_buf();
153 collect_all_services(&root, since)
154 } else {
155 bundle::read_service_dir(dir, service.name, since)
156 };
157 let service_label = if all { "all" } else { service.name };
158 let bytes = bundle::build(service_label, service.version, &created_at, &sources)?;
159 std::fs::File::create(out)?.write_all(&bytes)?;
160 println!("Wrote redacted bundle: {out} ({} file(s))", sources.len());
161 Ok(())
162}
163
164fn collect_all_services(root: &Path, since: Option<Date>) -> Vec<bundle::SourceFile> {
168 let mut sources = Vec::new();
169 let dirents = std::fs::read_dir(root).into_iter().flatten().flatten();
170 for dirent in dirents {
171 if !dirent.path().is_dir() {
172 continue;
173 }
174 let service = dirent.file_name().to_string_lossy().into_owned();
175 for mut file in bundle::read_service_dir(&dirent.path(), &service, since) {
176 file.name = format!("{service}/{}", file.name);
177 sources.push(file);
178 }
179 }
180 sources
181}
182
183fn since_cutoff(spec: &str, today: Date) -> Option<Date> {
187 let spec = spec.trim();
188 let split = spec.find(|c: char| !c.is_ascii_digit())?;
189 let (num, unit) = spec.split_at(split);
190 let n: i64 = num.parse().ok()?;
191 let days = match unit {
192 "d" => n,
193 "h" => (n + 23) / 24,
194 _ => return None,
195 };
196 Some(today.saturating_sub(Duration::days(days)))
197}
198
199fn newest_log_file(dir: &Path, service: &str) -> Option<std::path::PathBuf> {
201 let prefix = format!("{service}.jsonl");
202 std::fs::read_dir(dir)
203 .ok()?
204 .flatten()
205 .map(|d| d.path())
206 .filter(|p| {
207 p.file_name()
208 .map(|n| n.to_string_lossy().starts_with(&prefix))
209 .unwrap_or(false)
210 })
211 .max()
212}
213
214fn select_tail<'a>(contents: &'a str, n: usize, min_level: Option<&str>) -> Vec<&'a str> {
216 let filtered: Vec<&str> = contents
217 .lines()
218 .filter(|line| !line.trim().is_empty())
219 .filter(|line| {
220 min_level
221 .map(|min| level_at_least(line, min))
222 .unwrap_or(true)
223 })
224 .collect();
225 let start = filtered.len().saturating_sub(n);
226 filtered[start..].to_vec()
227}
228
229fn level_at_least(line: &str, min: &str) -> bool {
231 let Some(level) = parse_level(line) else {
232 return true;
233 };
234 level_rank(&level) >= level_rank(&min.to_ascii_uppercase())
235}
236
237fn parse_level(line: &str) -> Option<String> {
238 serde_json::from_str::<Value>(line)
239 .ok()?
240 .get("level")?
241 .as_str()
242 .map(str::to_string)
243}
244
245fn level_rank(level: &str) -> u8 {
247 match level {
248 "ERROR" => 5,
249 "WARN" => 4,
250 "INFO" => 3,
251 "DEBUG" => 2,
252 "TRACE" => 1,
253 _ => 0,
254 }
255}
256
257fn render_human(line: &str) -> String {
259 let Ok(Value::Object(mut record)) = serde_json::from_str::<Value>(line) else {
260 return line.to_string(); };
262 let take = |m: &mut serde_json::Map<String, Value>, k: &str| {
263 m.remove(k)
264 .and_then(|v| v.as_str().map(str::to_string))
265 .unwrap_or_default()
266 };
267 let ts = take(&mut record, "ts");
268 let level = take(&mut record, "level");
269 let target = take(&mut record, "target");
270 let message = take(&mut record, "message");
271 for reserved in [
272 "schema",
273 "service",
274 "service_version",
275 "run_context",
276 "run_id",
277 ] {
278 record.remove(reserved);
279 }
280 let extra = if record.is_empty() {
281 String::new()
282 } else {
283 format!(" {}", Value::Object(record))
284 };
285 format!("{ts} {level:5} {target}: {message}{extra}")
286}
287
288fn follow(file: &Path, min_level: Option<&str>, json: bool) -> Result<()> {
290 use std::io::{BufRead, BufReader, Seek, SeekFrom};
291 let mut reader = BufReader::new(std::fs::File::open(file)?);
292 reader.seek(SeekFrom::End(0))?;
293 loop {
294 let mut line = String::new();
295 if reader.read_line(&mut line)? == 0 {
296 std::thread::sleep(std::time::Duration::from_millis(250));
297 continue;
298 }
299 let trimmed = line.trim_end();
300 if trimmed.is_empty()
301 || !min_level
302 .map(|m| level_at_least(trimmed, m))
303 .unwrap_or(true)
304 {
305 continue;
306 }
307 println!(
308 "{}",
309 if json {
310 trimmed.to_string()
311 } else {
312 render_human(trimmed)
313 }
314 );
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 const A: &str = r#"{"schema":1,"ts":"2026-07-16T00:00:01Z","level":"INFO","target":"a","message":"one","run_id":"r"}"#;
323 const B: &str = r#"{"schema":1,"ts":"2026-07-16T00:00:02Z","level":"WARN","target":"b","message":"two","peer":"1.2.3.4"}"#;
324 const C: &str = r#"{"schema":1,"ts":"2026-07-16T00:00:03Z","level":"ERROR","target":"c","message":"three"}"#;
325
326 #[test]
327 fn tail_takes_last_n() {
328 let contents = [A, B, C].join("\n");
329 assert_eq!(select_tail(&contents, 2, None), vec![B, C]);
330 }
331
332 #[test]
333 fn tail_filters_by_level() {
334 let contents = [A, B, C].join("\n");
335 assert_eq!(select_tail(&contents, 10, Some("warn")), vec![B, C]);
337 }
338
339 #[test]
340 fn human_render_shows_core_fields_and_extras() {
341 let rendered = render_human(B);
342 assert!(rendered.contains("WARN"));
343 assert!(rendered.contains("b: two"));
344 assert!(rendered.contains("peer"), "extra fields shown: {rendered}");
345 assert!(
346 !rendered.contains("schema"),
347 "reserved fields hidden: {rendered}"
348 );
349 }
350
351 #[test]
352 fn command_builds_with_all_verbs() {
353 let cmd = command();
354 let names: Vec<_> = cmd.get_subcommands().map(|c| c.get_name()).collect();
355 assert!(names.contains(&"path") && names.contains(&"tail"));
356 assert!(names.contains(&"level") && names.contains(&"bundle"));
357 }
358
359 #[test]
360 fn since_cutoff_parses_days_and_hours() {
361 use time::Month;
362 let today = Date::from_calendar_date(2026, Month::July, 16).unwrap();
363 assert_eq!(
364 since_cutoff("7d", today),
365 Date::from_calendar_date(2026, Month::July, 9).ok()
366 );
367 assert_eq!(
369 since_cutoff("24h", today),
370 Date::from_calendar_date(2026, Month::July, 15).ok()
371 );
372 assert_eq!(
373 since_cutoff("25h", today),
374 Date::from_calendar_date(2026, Month::July, 14).ok()
375 );
376 assert_eq!(since_cutoff("nonsense", today), None);
378 assert_eq!(since_cutoff("7w", today), None);
379 }
380}