Skip to main content

rpi_cli/
events.rs

1//! `rpi events …` — inspect the extension event journal (P3).
2//!
3//! The journal is written only when `RPI_EVENT_LOG=1` is set (see
4//! [`rpi_extensions::event_log`]). This subcommand reads the same file:
5//!
6//! - `rpi events path` — print the resolved log path.
7//! - `rpi events tail` — print the log and follow new entries (Ctrl+C to stop).
8
9use std::io::{Read, Seek, SeekFrom, Write};
10use std::time::Duration;
11
12use crate::app::{EXIT_RUNTIME, EXIT_USAGE};
13
14/// Dispatch `rpi events <subcommand>`.
15pub async fn run(args: &[String]) -> i32 {
16    match args.first().map(String::as_str) {
17        Some("tail") => tail().await,
18        Some("path") => match rpi_extensions::event_log_path() {
19            Some(path) => {
20                println!("{}", path.display());
21                0
22            }
23            None => {
24                eprintln!("error: could not resolve the event log path (set RPI_EVENT_LOG_PATH)");
25                EXIT_RUNTIME
26            }
27        },
28        Some("--help" | "-h") | None => {
29            print_help();
30            0
31        }
32        Some(other) => {
33            eprintln!("error: unknown `events` subcommand `{other}`");
34            print_help();
35            EXIT_USAGE
36        }
37    }
38}
39
40fn print_help() {
41    println!("Usage: rpi events <command>");
42    println!();
43    println!("Commands:");
44    println!("  path    Print the event log path (JSONL)");
45    println!("  tail    Print the log and follow new entries (Ctrl+C to stop)");
46    println!();
47    println!("The journal is written only when RPI_EVENT_LOG=1 is set at run time.");
48}
49
50/// Follow the event log, printing complete lines as they appear. Never returns
51/// (Ctrl+C stops the process). A partial trailing line is re-read on the next
52/// tick rather than emitted split.
53async fn tail() -> i32 {
54    let Some(path) = rpi_extensions::event_log_path() else {
55        eprintln!("error: could not resolve the event log path (set RPI_EVENT_LOG_PATH)");
56        return EXIT_RUNTIME;
57    };
58    eprintln!("tailing {} — Ctrl+C to stop", path.display());
59    let mut offset = 0u64;
60    loop {
61        if let Ok(mut file) = std::fs::File::open(&path) {
62            let len = file.metadata().map(|m| m.len()).unwrap_or(0);
63            // Truncation / rotation: restart from the top.
64            if len < offset {
65                offset = 0;
66            }
67            if len > offset && file.seek(SeekFrom::Start(offset)).is_ok() {
68                let mut chunk = String::new();
69                if file.read_to_string(&mut chunk).is_ok() {
70                    if let Some(newline) = chunk.rfind('\n') {
71                        print!("{}", &chunk[..=newline]);
72                        let _ = std::io::stdout().flush();
73                        offset += (newline + 1) as u64;
74                    }
75                }
76            }
77        }
78        tokio::time::sleep(Duration::from_millis(400)).await;
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[tokio::test]
87    async fn help_and_unknown_are_handled() {
88        assert_eq!(run(&[]).await, 0);
89        assert_eq!(run(&["--help".to_string()]).await, 0);
90        assert_eq!(run(&["bogus".to_string()]).await, EXIT_USAGE);
91    }
92
93    #[tokio::test]
94    async fn path_command_returns_a_valid_code() {
95        // No env mutation (process-global, parallel-test unsafe): just assert the
96        // command completes with a valid code rather than panicking.
97        let code = run(&["path".to_string()]).await;
98        assert!(code == 0 || code == EXIT_RUNTIME);
99    }
100}