1use crate::SyslogFacility;
7use serde::{Deserialize, Serialize};
8use std::fs::{File, OpenOptions};
9use std::io::{self, BufWriter, Write};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub enum OutputConfig {
15 #[default]
17 Stdout,
18 Stderr,
20 File {
22 path: PathBuf,
23 rotation: Option<FileRotation>,
24 },
25 Syslog { facility: SyslogFacility },
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FileRotation {
32 pub max_size: u64,
34 pub max_files: u32,
36 pub compress: bool,
38}
39
40impl Default for FileRotation {
41 fn default() -> Self {
42 Self {
43 max_size: 10 * 1024 * 1024, max_files: 5,
45 compress: false,
46 }
47 }
48}
49
50impl FileRotation {
51 pub fn with_max_size_mb(mb: u64) -> Self {
53 Self {
54 max_size: mb * 1024 * 1024,
55 ..Default::default()
56 }
57 }
58
59 pub fn max_files(mut self, count: u32) -> Self {
61 self.max_files = count;
62 self
63 }
64
65 pub fn compressed(mut self) -> Self {
67 self.compress = true;
68 self
69 }
70}
71
72pub trait LogOutput: Send {
74 fn write(&mut self, line: &str) -> io::Result<()>;
76
77 fn flush(&mut self) -> io::Result<()>;
79}
80
81pub 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
110pub 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
139pub struct FileOutput {
141 path: PathBuf,
142 writer: BufWriter<File>,
143 rotation: Option<FileRotation>,
144 current_size: u64,
145}
146
147impl FileOutput {
148 pub fn open(path: impl AsRef<Path>, rotation: Option<FileRotation>) -> io::Result<Self> {
150 let path = path.as_ref().to_path_buf();
151
152 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 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 self.writer.flush()?;
179
180 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 let rotated = rotated_path(&self.path, 1);
195 std::fs::rename(&self.path, &rotated)?;
196
197 if rotation.compress {
199 }
202
203 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
233fn 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#[cfg(unix)]
252pub struct SyslogOutput {
253 socket: std::os::unix::net::UnixDatagram,
254}
255
256#[cfg(unix)]
257impl SyslogOutput {
258 pub fn connect() -> io::Result<Self> {
260 let socket = std::os::unix::net::UnixDatagram::unbound()?;
261
262 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#[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
315pub 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 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, max_files: 3,
363 compress: false,
364 };
365
366 let mut output = FileOutput::open(&log_path, Some(rotation)).unwrap();
367
368 for i in 0..10 {
370 output.write(&format!("This is line number {}", i)).unwrap();
371 }
372 output.flush().unwrap();
373
374 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}