Skip to main content

hdds_logger/
output.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025-2026 naskel.com
3
4//! Log output destinations: file (with rotation), stdout, syslog.
5
6use crate::SyslogFacility;
7use serde::{Deserialize, Serialize};
8use std::fs::{File, OpenOptions};
9use std::io::{self, BufWriter, Write};
10use std::path::{Path, PathBuf};
11
12/// Output configuration.
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub enum OutputConfig {
15    /// Write to stdout.
16    #[default]
17    Stdout,
18    /// Write to stderr.
19    Stderr,
20    /// Write to file with optional rotation.
21    File {
22        path: PathBuf,
23        rotation: Option<FileRotation>,
24    },
25    /// Write to syslog daemon.
26    Syslog { facility: SyslogFacility },
27}
28
29/// File rotation configuration.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FileRotation {
32    /// Maximum file size in bytes before rotation.
33    pub max_size: u64,
34    /// Maximum number of rotated files to keep.
35    pub max_files: u32,
36    /// Compress rotated files (gzip).
37    pub compress: bool,
38}
39
40impl Default for FileRotation {
41    fn default() -> Self {
42        Self {
43            max_size: 10 * 1024 * 1024, // 10 MB
44            max_files: 5,
45            compress: false,
46        }
47    }
48}
49
50impl FileRotation {
51    /// Create rotation config with size in megabytes.
52    pub fn with_max_size_mb(mb: u64) -> Self {
53        Self {
54            max_size: mb * 1024 * 1024,
55            ..Default::default()
56        }
57    }
58
59    /// Set maximum number of backup files.
60    pub fn max_files(mut self, count: u32) -> Self {
61        self.max_files = count;
62        self
63    }
64
65    /// Enable compression of rotated files.
66    pub fn compressed(mut self) -> Self {
67        self.compress = true;
68        self
69    }
70}
71
72/// Log output trait.
73pub trait LogOutput: Send {
74    /// Write a formatted log line.
75    fn write(&mut self, line: &str) -> io::Result<()>;
76
77    /// Flush output.
78    fn flush(&mut self) -> io::Result<()>;
79}
80
81/// Stdout output.
82pub struct StdoutOutput {
83    handle: io::Stdout,
84}
85
86impl StdoutOutput {
87    pub fn new() -> Self {
88        Self {
89            handle: io::stdout(),
90        }
91    }
92}
93
94impl Default for StdoutOutput {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl LogOutput for StdoutOutput {
101    fn write(&mut self, line: &str) -> io::Result<()> {
102        writeln!(self.handle, "{}", line)
103    }
104
105    fn flush(&mut self) -> io::Result<()> {
106        self.handle.flush()
107    }
108}
109
110/// Stderr output.
111pub struct StderrOutput {
112    handle: io::Stderr,
113}
114
115impl StderrOutput {
116    pub fn new() -> Self {
117        Self {
118            handle: io::stderr(),
119        }
120    }
121}
122
123impl Default for StderrOutput {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl LogOutput for StderrOutput {
130    fn write(&mut self, line: &str) -> io::Result<()> {
131        writeln!(self.handle, "{}", line)
132    }
133
134    fn flush(&mut self) -> io::Result<()> {
135        self.handle.flush()
136    }
137}
138
139/// File output with optional rotation.
140pub struct FileOutput {
141    path: PathBuf,
142    writer: BufWriter<File>,
143    rotation: Option<FileRotation>,
144    current_size: u64,
145}
146
147impl FileOutput {
148    /// Open file for logging.
149    pub fn open(path: impl AsRef<Path>, rotation: Option<FileRotation>) -> io::Result<Self> {
150        let path = path.as_ref().to_path_buf();
151
152        // Create parent directories if needed
153        if let Some(parent) = path.parent() {
154            std::fs::create_dir_all(parent)?;
155        }
156
157        let file = OpenOptions::new().create(true).append(true).open(&path)?;
158
159        let current_size = file.metadata()?.len();
160        let writer = BufWriter::new(file);
161
162        Ok(Self {
163            path,
164            writer,
165            rotation,
166            current_size,
167        })
168    }
169
170    /// Check if rotation is needed and perform it.
171    fn maybe_rotate(&mut self) -> io::Result<()> {
172        let rotation = match &self.rotation {
173            Some(r) if self.current_size >= r.max_size => r.clone(),
174            _ => return Ok(()),
175        };
176
177        // Flush and close current file
178        self.writer.flush()?;
179
180        // Rotate files: .log.4 -> .log.5, .log.3 -> .log.4, etc.
181        for i in (1..rotation.max_files).rev() {
182            let old_path = rotated_path(&self.path, i);
183            let new_path = rotated_path(&self.path, i + 1);
184            if old_path.exists() {
185                if i + 1 >= rotation.max_files {
186                    std::fs::remove_file(&old_path)?;
187                } else {
188                    std::fs::rename(&old_path, &new_path)?;
189                }
190            }
191        }
192
193        // Rename current to .1
194        let rotated = rotated_path(&self.path, 1);
195        std::fs::rename(&self.path, &rotated)?;
196
197        // Compress if enabled
198        if rotation.compress {
199            // Compression would require flate2 or similar - skip for now
200            // compress_file(&rotated)?;
201        }
202
203        // Open new file
204        let file = OpenOptions::new()
205            .create(true)
206            .append(true)
207            .open(&self.path)?;
208
209        self.writer = BufWriter::new(file);
210        self.current_size = 0;
211
212        Ok(())
213    }
214}
215
216impl LogOutput for FileOutput {
217    fn write(&mut self, line: &str) -> io::Result<()> {
218        self.maybe_rotate()?;
219
220        let bytes = line.as_bytes();
221        self.writer.write_all(bytes)?;
222        self.writer.write_all(b"\n")?;
223        self.current_size += bytes.len() as u64 + 1;
224
225        Ok(())
226    }
227
228    fn flush(&mut self) -> io::Result<()> {
229        self.writer.flush()
230    }
231}
232
233/// Generate rotated file path.
234fn rotated_path(base: &Path, index: u32) -> PathBuf {
235    let stem = base.file_stem().unwrap_or_default().to_string_lossy();
236    let ext = base
237        .extension()
238        .map(|e| e.to_string_lossy())
239        .unwrap_or_default();
240
241    let new_name = if ext.is_empty() {
242        format!("{}.{}", stem, index)
243    } else {
244        format!("{}.{}.{}", stem, index, ext)
245    };
246
247    base.with_file_name(new_name)
248}
249
250/// Syslog output (Unix domain socket or UDP).
251#[cfg(unix)]
252pub struct SyslogOutput {
253    socket: std::os::unix::net::UnixDatagram,
254}
255
256#[cfg(unix)]
257impl SyslogOutput {
258    /// Connect to local syslog daemon.
259    pub fn connect() -> io::Result<Self> {
260        let socket = std::os::unix::net::UnixDatagram::unbound()?;
261
262        // Try common syslog socket paths
263        let paths = ["/dev/log", "/var/run/syslog", "/var/run/log"];
264        for path in &paths {
265            if std::path::Path::new(path).exists() {
266                socket.connect(path)?;
267                return Ok(Self { socket });
268            }
269        }
270
271        Err(io::Error::new(
272            io::ErrorKind::NotFound,
273            "No syslog socket found",
274        ))
275    }
276}
277
278#[cfg(unix)]
279impl LogOutput for SyslogOutput {
280    fn write(&mut self, line: &str) -> io::Result<()> {
281        self.socket.send(line.as_bytes())?;
282        Ok(())
283    }
284
285    fn flush(&mut self) -> io::Result<()> {
286        Ok(())
287    }
288}
289
290/// Stub syslog output for non-Unix systems.
291#[cfg(not(unix))]
292pub struct SyslogOutput;
293
294#[cfg(not(unix))]
295impl SyslogOutput {
296    pub fn connect() -> io::Result<Self> {
297        Err(io::Error::new(
298            io::ErrorKind::Unsupported,
299            "Syslog not supported on this platform",
300        ))
301    }
302}
303
304#[cfg(not(unix))]
305impl LogOutput for SyslogOutput {
306    fn write(&mut self, _line: &str) -> io::Result<()> {
307        Ok(())
308    }
309
310    fn flush(&mut self) -> io::Result<()> {
311        Ok(())
312    }
313}
314
315/// Create output from configuration.
316pub fn create_output(config: &OutputConfig) -> io::Result<Box<dyn LogOutput>> {
317    match config {
318        OutputConfig::Stdout => Ok(Box::new(StdoutOutput::new())),
319        OutputConfig::Stderr => Ok(Box::new(StderrOutput::new())),
320        OutputConfig::File { path, rotation } => {
321            Ok(Box::new(FileOutput::open(path, rotation.clone())?))
322        }
323        OutputConfig::Syslog { .. } => Ok(Box::new(SyslogOutput::connect()?)),
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use tempfile::TempDir;
331
332    #[test]
333    fn test_stdout_output() {
334        let mut output = StdoutOutput::new();
335        // Just verify it doesn't panic
336        output.write("test log line").unwrap();
337        output.flush().unwrap();
338    }
339
340    #[test]
341    fn test_file_output() {
342        let temp_dir = TempDir::new().unwrap();
343        let log_path = temp_dir.path().join("test.log");
344
345        let mut output = FileOutput::open(&log_path, None).unwrap();
346        output.write("line 1").unwrap();
347        output.write("line 2").unwrap();
348        output.flush().unwrap();
349
350        let content = std::fs::read_to_string(&log_path).unwrap();
351        assert!(content.contains("line 1"));
352        assert!(content.contains("line 2"));
353    }
354
355    #[test]
356    fn test_file_rotation() {
357        let temp_dir = TempDir::new().unwrap();
358        let log_path = temp_dir.path().join("test.log");
359
360        let rotation = FileRotation {
361            max_size: 50, // Very small for testing
362            max_files: 3,
363            compress: false,
364        };
365
366        let mut output = FileOutput::open(&log_path, Some(rotation)).unwrap();
367
368        // Write enough to trigger rotation
369        for i in 0..10 {
370            output.write(&format!("This is line number {}", i)).unwrap();
371        }
372        output.flush().unwrap();
373
374        // Should have rotated files
375        assert!(log_path.exists());
376        let rotated_1 = temp_dir.path().join("test.1.log");
377        assert!(rotated_1.exists());
378    }
379
380    #[test]
381    fn test_rotated_path() {
382        let base = Path::new("/var/log/hdds.log");
383        assert_eq!(rotated_path(base, 1), PathBuf::from("/var/log/hdds.1.log"));
384        assert_eq!(rotated_path(base, 5), PathBuf::from("/var/log/hdds.5.log"));
385
386        let no_ext = Path::new("/var/log/hdds");
387        assert_eq!(rotated_path(no_ext, 1), PathBuf::from("/var/log/hdds.1"));
388    }
389}