Skip to main content

syspulse_core/
logs.rs

1use std::fs;
2use std::io::{BufRead, BufReader, Seek, SeekFrom};
3use std::path::{Path, PathBuf};
4
5use chrono::Utc;
6
7use crate::daemon::LogConfig;
8use crate::error::Result;
9use crate::paths;
10
11pub struct LogManager {
12    data_dir: PathBuf,
13}
14
15impl LogManager {
16    pub fn new(data_dir: &Path) -> Self {
17        Self {
18            data_dir: data_dir.to_path_buf(),
19        }
20    }
21
22    pub fn with_defaults() -> Self {
23        Self {
24            data_dir: paths::data_dir(),
25        }
26    }
27
28    fn log_dir(&self, daemon_name: &str) -> PathBuf {
29        self.data_dir.join("logs").join(daemon_name)
30    }
31
32    /// Create log directory and return (stdout_path, stderr_path).
33    pub fn setup_log_files(&self, daemon_name: &str) -> Result<(PathBuf, PathBuf)> {
34        let dir = self.log_dir(daemon_name);
35        fs::create_dir_all(&dir)?;
36
37        let stdout_path = dir.join("stdout.log");
38        let stderr_path = dir.join("stderr.log");
39
40        Ok((stdout_path, stderr_path))
41    }
42
43    /// Rotate log files if they exceed the configured max size.
44    /// Rotated files are named with a timestamp suffix. Files beyond retain_count are pruned.
45    pub fn rotate_logs(&self, daemon_name: &str, config: &LogConfig) -> Result<()> {
46        let dir = self.log_dir(daemon_name);
47
48        for log_name in &["stdout.log", "stderr.log"] {
49            let log_path = dir.join(log_name);
50            if !log_path.exists() {
51                continue;
52            }
53
54            let metadata = fs::metadata(&log_path)?;
55            if metadata.len() < config.max_size_bytes {
56                continue;
57            }
58
59            // Rotate: rename current log with timestamp suffix
60            let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
61            let base = log_name.trim_end_matches(".log");
62            let rotated_name = format!("{}_{}.log", base, timestamp);
63            let rotated_path = dir.join(&rotated_name);
64
65            fs::rename(&log_path, &rotated_path)?;
66            tracing::info!(
67                daemon = daemon_name,
68                file = %log_name,
69                rotated_to = %rotated_name,
70                "Rotated log file"
71            );
72
73            // Prune old rotated files beyond retain_count
74            self.prune_rotated(&dir, base, config.retain_count)?;
75        }
76
77        Ok(())
78    }
79
80    /// Remove oldest rotated log files beyond retain_count.
81    fn prune_rotated(&self, dir: &Path, base_name: &str, retain_count: u32) -> Result<()> {
82        let prefix = format!("{}_", base_name);
83
84        let mut rotated: Vec<PathBuf> = fs::read_dir(dir)?
85            .filter_map(|entry| entry.ok())
86            .filter(|entry| {
87                let name = entry.file_name().to_string_lossy().to_string();
88                name.starts_with(&prefix) && name.ends_with(".log")
89            })
90            .map(|entry| entry.path())
91            .collect();
92
93        // Sort by name descending (newest first, since names contain timestamps)
94        rotated.sort();
95        rotated.reverse();
96
97        for path in rotated.iter().skip(retain_count as usize) {
98            tracing::debug!(path = %path.display(), "Pruning old rotated log");
99            if let Err(e) = fs::remove_file(path) {
100                tracing::warn!(path = %path.display(), error = %e, "Failed to prune log file");
101            }
102        }
103
104        Ok(())
105    }
106
107    /// Read the last N lines from a daemon's log file.
108    /// If `stderr` is true, reads from stderr.log; otherwise stdout.log.
109    pub fn read_logs(&self, daemon_name: &str, lines: usize, stderr: bool) -> Result<Vec<String>> {
110        let dir = self.log_dir(daemon_name);
111        let filename = if stderr { "stderr.log" } else { "stdout.log" };
112        let log_path = dir.join(filename);
113
114        if !log_path.exists() {
115            return Ok(Vec::new());
116        }
117
118        tail_file(&log_path, lines)
119    }
120}
121
122/// Read the last `n` lines from a file efficiently.
123fn tail_file(path: &Path, n: usize) -> Result<Vec<String>> {
124    if n == 0 {
125        return Ok(Vec::new());
126    }
127
128    let file = fs::File::open(path)?;
129    let metadata = file.metadata()?;
130    let file_size = metadata.len();
131
132    if file_size == 0 {
133        return Ok(Vec::new());
134    }
135
136    // For small files (< 64KB), just read the whole thing
137    if file_size < 64 * 1024 {
138        let reader = BufReader::new(file);
139        let all_lines: Vec<String> = reader.lines().filter_map(|l| l.ok()).collect();
140        let start = all_lines.len().saturating_sub(n);
141        return Ok(all_lines[start..].to_vec());
142    }
143
144    // For larger files, read backwards in chunks
145    let mut file = file;
146    let chunk_size: u64 = 8192;
147    let mut remaining = file_size;
148    let mut trailing_data = Vec::new();
149
150    loop {
151        let read_size = chunk_size.min(remaining);
152        remaining -= read_size;
153
154        file.seek(SeekFrom::Start(remaining))?;
155        let mut buf = vec![0u8; read_size as usize];
156        std::io::Read::read_exact(&mut file, &mut buf)?;
157
158        // Prepend new chunk to trailing data
159        buf.extend_from_slice(&trailing_data);
160        trailing_data = buf;
161
162        let text = String::from_utf8_lossy(&trailing_data);
163        let lines_in_buf: Vec<&str> = text.lines().collect();
164
165        if lines_in_buf.len() > n || remaining == 0 {
166            let start = lines_in_buf.len().saturating_sub(n);
167            return Ok(lines_in_buf[start..]
168                .iter()
169                .map(|s| s.to_string())
170                .collect());
171        }
172    }
173}