Skip to main content

dig_logging/
logs.rs

1//! The reusable `logs` CLI verb set (SPEC §8.1).
2//!
3//! Every DIG binary mounts this verbatim so `<bin> logs path|tail|level|bundle` behaves identically
4//! everywhere: `command()` returns the `logs` clap subcommand to add, and `run()` dispatches it. The
5//! rendering/selection logic is factored into pure helpers so it is unit-testable without a terminal.
6
7use 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
18/// The `logs` subcommand a consumer adds to its clap app (SPEC §8.1).
19pub 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
78/// Dispatch a matched `logs` subcommand for `service` (SPEC §8.1).
79pub 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(()); // no logs yet
109    };
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        // `--all`: every service under the shared root (SPEC §8.1) — nested `<service>/<file>`.
152        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
164/// Read every `<service>/` subdir under the shared log `root`, prefixing archive names with the
165/// service so an `--all` bundle keeps services separated. `since` narrows each service's files to the
166/// `--since` window (SPEC §8.1).
167fn 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
183/// Resolve a `--since` duration (`24h`, `7d`) to the OLDEST rotation date to include, relative to
184/// `today`. Sub-day units round UP to whole days (files rotate daily, §4.3). An unparseable value
185/// yields `None` (no filtering) so a typo never silently drops logs from a bug report.
186fn 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
199/// The newest rotated log file for `service` in `dir`, by name (dates sort lexically), if any.
200fn 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
214/// The last `n` lines of `contents`, optionally filtered to records at or above `min_level`.
215fn 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
229/// Does a JSONL `line`'s level rank at or above `min`? (Unparseable/level-less lines pass.)
230fn 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
245/// Severity rank (ERROR highest) so `--level warn` keeps WARN + ERROR.
246fn 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
257/// Render one JSONL line as compact human text: `TS  LEVEL target: message  {extra fields}`.
258fn render_human(line: &str) -> String {
259    let Ok(Value::Object(mut record)) = serde_json::from_str::<Value>(line) else {
260        return line.to_string(); // not our JSON — show it raw
261    };
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
288/// Follow a growing log file, rendering new lines as they arrive (SPEC §8.1 `-f`).
289fn 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        // warn+ drops the INFO line.
336        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        // 24h → 1 day; 25h rounds up to 2 days.
368        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        // Garbage → no filtering, never silently drops logs.
377        assert_eq!(since_cutoff("nonsense", today), None);
378        assert_eq!(since_cutoff("7w", today), None);
379    }
380}