zeph_core/agent/
log_commands.rs1use 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 pub async fn handle_log_command(&mut self) -> Result<(), AgentError> {
19 let logging = self.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
65pub(crate) fn resolve_current_log_file(base: &std::path::Path) -> Option<std::path::PathBuf> {
70 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 #[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 #[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 assert!(line.chars().count() <= MAX_LINE_CHARS + 1);
221 assert!(line.ends_with('…'));
222 }
223
224 #[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 let rotated = dir.path().join("zeph.2026-03-09.log");
240 std::fs::write(&rotated, b"rotated\n").unwrap();
241 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 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}