Skip to main content

zeph_core/agent/
log_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt::Write as _;
5use std::io::{BufRead as _, BufReader, Seek, SeekFrom};
6
7use super::{Agent, error::AgentError};
8use crate::channel::Channel;
9use crate::config::{LogRotation, LoggingConfig};
10use crate::redact::scrub_content;
11
12impl<C: Channel> Agent<C> {
13    /// Handle the `/log` slash command: show current log file path and recent entries.
14    ///
15    /// # Errors
16    ///
17    /// Returns an error if the channel send fails.
18    pub async fn handle_log_command(&mut self) -> Result<(), AgentError> {
19        let logging = self.debug_state.logging_config.clone();
20
21        let mut out = String::new();
22        format_logging_status(&logging, &mut out);
23
24        if !logging.file.is_empty() {
25            let base_path = std::path::PathBuf::from(&logging.file);
26            let tail = tokio::task::spawn_blocking(move || {
27                let actual = resolve_current_log_file(&base_path);
28                actual.and_then(|p| read_log_tail(&p, 20))
29            })
30            .await
31            .unwrap_or(None);
32
33            if let Some(lines) = tail {
34                let _ = writeln!(out);
35                let _ = writeln!(out, "Recent entries:");
36                out.push_str(&scrub_content(&lines));
37            }
38        }
39
40        self.channel.send(out.trim_end()).await?;
41        Ok(())
42    }
43}
44
45pub(crate) fn format_logging_status(logging: &LoggingConfig, out: &mut String) {
46    let _ = writeln!(
47        out,
48        "Log file:  {}",
49        if logging.file.is_empty() {
50            "<disabled>"
51        } else {
52            &logging.file
53        }
54    );
55    let _ = writeln!(out, "Level:     {}", logging.level);
56    let rotation_str = match logging.rotation {
57        LogRotation::Daily => "daily",
58        LogRotation::Hourly => "hourly",
59        LogRotation::Never => "never",
60    };
61    let _ = writeln!(out, "Rotation:  {rotation_str}");
62    let _ = writeln!(out, "Max files: {}", logging.max_files);
63}
64
65/// Resolve the most recently modified log file in the log directory whose name starts with
66/// the configured file's stem. `tracing-appender` appends a date suffix (e.g.
67/// `zeph.2026-03-09.log`) for daily/hourly rotation, so opening the base path directly
68/// would fail.
69pub(crate) fn resolve_current_log_file(base: &std::path::Path) -> Option<std::path::PathBuf> {
70    // Fast path: base path exists as-is (Never rotation).
71    if base.exists() {
72        return Some(base.to_path_buf());
73    }
74
75    let dir = base.parent()?;
76    let stem = base.file_stem()?.to_string_lossy();
77
78    let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
79    for entry in std::fs::read_dir(dir).ok()?.flatten() {
80        let name = entry.file_name();
81        let name_str = name.to_string_lossy();
82        if !name_str.starts_with(stem.as_ref()) {
83            continue;
84        }
85        if let Ok(meta) = entry.metadata()
86            && let Ok(modified) = meta.modified()
87            && best.as_ref().is_none_or(|(t, _)| modified > *t)
88        {
89            best = Some((modified, entry.path()));
90        }
91    }
92    best.map(|(_, p)| p)
93}
94
95pub(crate) const MAX_LINE_CHARS: usize = 512;
96pub(crate) const MAX_TAIL_BYTES: usize = 4 * 1024;
97
98pub(crate) fn read_log_tail(path: &std::path::Path, n: usize) -> Option<String> {
99    let file = std::fs::File::open(path).ok()?;
100    let mut reader = BufReader::new(file);
101    let size = reader.seek(SeekFrom::End(0)).ok()?;
102    if size == 0 {
103        return None;
104    }
105
106    let chunk = size.min(64 * 1024);
107    reader.seek(SeekFrom::End(-chunk.cast_signed())).ok()?;
108    let mut lines: Vec<String> = reader
109        .lines()
110        .map_while(Result::ok)
111        .map(|l| {
112            if l.chars().count() > MAX_LINE_CHARS {
113                let mut s: String = l.chars().take(MAX_LINE_CHARS).collect();
114                s.push('…');
115                s
116            } else {
117                l
118            }
119        })
120        .collect();
121    lines.reverse();
122    lines.truncate(n);
123    lines.reverse();
124
125    let mut out = String::new();
126    for line in &lines {
127        if out.len() + line.len() + 1 > MAX_TAIL_BYTES {
128            break;
129        }
130        out.push_str(line);
131        out.push('\n');
132    }
133    if out.is_empty() { None } else { Some(out) }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    // --- format_logging_status ---
141
142    #[test]
143    fn format_logging_status_disabled() {
144        let logging = LoggingConfig {
145            file: String::new(),
146            level: "info".into(),
147            rotation: LogRotation::Daily,
148            max_files: 7,
149        };
150        let mut out = String::new();
151        format_logging_status(&logging, &mut out);
152        assert!(
153            out.contains("<disabled>"),
154            "expected <disabled>, got: {out}"
155        );
156        assert!(out.contains("info"));
157        assert!(out.contains("daily"));
158        assert!(out.contains('7'));
159    }
160
161    #[test]
162    fn format_logging_status_enabled() {
163        let logging = LoggingConfig {
164            file: "/var/log/zeph.log".into(),
165            level: "debug".into(),
166            rotation: LogRotation::Hourly,
167            max_files: 3,
168        };
169        let mut out = String::new();
170        format_logging_status(&logging, &mut out);
171        assert!(out.contains("/var/log/zeph.log"), "path missing: {out}");
172        assert!(out.contains("debug"));
173        assert!(out.contains("hourly"));
174        assert!(out.contains('3'));
175    }
176
177    // --- read_log_tail ---
178
179    #[test]
180    fn read_log_tail_missing_file_returns_none() {
181        let result = read_log_tail(std::path::Path::new("/nonexistent/path/zeph.log"), 20);
182        assert!(result.is_none());
183    }
184
185    #[test]
186    fn read_log_tail_empty_file_returns_none() {
187        let dir = tempfile::tempdir().unwrap();
188        let path = dir.path().join("empty.log");
189        std::fs::write(&path, b"").unwrap();
190        let result = read_log_tail(&path, 20);
191        assert!(result.is_none());
192    }
193
194    #[test]
195    fn read_log_tail_returns_last_n_lines() {
196        let dir = tempfile::tempdir().unwrap();
197        let path = dir.path().join("zeph.log");
198        let content = (1u32..=30)
199            .map(|i| format!("line {i}"))
200            .collect::<Vec<_>>()
201            .join("\n")
202            + "\n";
203        std::fs::write(&path, content).unwrap();
204        let result = read_log_tail(&path, 5).unwrap();
205        let lines: Vec<&str> = result.trim_end().split('\n').collect();
206        assert_eq!(lines.len(), 5);
207        assert_eq!(lines[0], "line 26");
208        assert_eq!(lines[4], "line 30");
209    }
210
211    #[test]
212    fn read_log_tail_long_line_truncated() {
213        let dir = tempfile::tempdir().unwrap();
214        let path = dir.path().join("zeph.log");
215        let long_line = "x".repeat(MAX_LINE_CHARS + 100);
216        std::fs::write(&path, format!("{long_line}\n")).unwrap();
217        let result = read_log_tail(&path, 5).unwrap();
218        let line = result.trim_end();
219        // char count: MAX_LINE_CHARS chars + 1 ellipsis char
220        assert!(line.chars().count() <= MAX_LINE_CHARS + 1);
221        assert!(line.ends_with('…'));
222    }
223
224    // --- resolve_current_log_file ---
225
226    #[test]
227    fn resolve_current_log_file_base_path_exists() {
228        let dir = tempfile::tempdir().unwrap();
229        let path = dir.path().join("zeph.log");
230        std::fs::write(&path, b"hello\n").unwrap();
231        let result = resolve_current_log_file(&path);
232        assert_eq!(result.as_deref(), Some(path.as_path()));
233    }
234
235    #[test]
236    fn resolve_current_log_file_date_suffixed_file_found() {
237        let dir = tempfile::tempdir().unwrap();
238        // tracing-appender creates files like `zeph.2026-03-09.log`
239        let rotated = dir.path().join("zeph.2026-03-09.log");
240        std::fs::write(&rotated, b"rotated\n").unwrap();
241        // base path does not exist
242        let base = dir.path().join("zeph.log");
243        let result = resolve_current_log_file(&base);
244        assert_eq!(result.as_deref(), Some(rotated.as_path()));
245    }
246
247    #[test]
248    fn resolve_current_log_file_no_matching_files_returns_none() {
249        let dir = tempfile::tempdir().unwrap();
250        // write a file with a completely different stem
251        std::fs::write(dir.path().join("other.log"), b"x\n").unwrap();
252        let base = dir.path().join("zeph.log");
253        let result = resolve_current_log_file(&base);
254        assert!(result.is_none());
255    }
256}