1use crate::LogRecord;
12use std::collections::HashMap;
13use std::fs::{self, File, OpenOptions};
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16use std::sync::Mutex;
17
18pub struct FileSink {
25 dir: PathBuf,
26 writers: Mutex<HashMap<String, File>>,
27}
28
29impl FileSink {
30 pub fn new(log_dir: impl Into<PathBuf>) -> io::Result<Self> {
35 let dir = log_dir.into();
36 fs::create_dir_all(&dir)?;
37 Ok(Self {
38 dir,
39 writers: Mutex::new(HashMap::new()),
40 })
41 }
42
43 pub fn write(&self, record: &LogRecord) -> io::Result<()> {
55 let json_line = serde_json::to_string(record)?;
56 let mut guard = self.writers.lock().unwrap_or_else(|e| e.into_inner());
57 let writer: &mut File = if let Some(w) = guard.get_mut(&record.tag) {
59 w
60 } else {
61 let path = self.tag_path(&record.tag);
62 let f = OpenOptions::new().create(true).append(true).open(&path)?;
63 guard.entry(record.tag.clone()).or_insert(f)
64 };
65 writeln!(writer, "{json_line}")?;
66 writer.flush()?;
67 Ok(())
68 }
69
70 fn tag_path(&self, tag: &str) -> PathBuf {
75 let safe: String = tag
76 .chars()
77 .map(|c| {
78 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
79 c
80 } else {
81 '_'
82 }
83 })
84 .collect();
85 self.dir.join(format!("{safe}.log"))
86 }
87
88 #[allow(dead_code)]
90 pub fn dir(&self) -> &Path {
91 &self.dir
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use crate::Level;
99 use std::io::BufRead;
100
101 fn test_sink() -> (tempfile::TempDir, FileSink) {
102 let tmp = tempfile::tempdir().unwrap();
103 let sink = FileSink::new(tmp.path()).unwrap();
104 (tmp, sink)
105 }
106
107 fn read_lines(path: &Path) -> Vec<String> {
108 let f = File::open(path).unwrap();
109 io::BufReader::new(f).lines().map(|l| l.unwrap()).collect()
110 }
111
112 #[test]
113 fn first_write_creates_file() {
114 let (tmp, sink) = test_sink();
115 let rec = LogRecord {
116 ts: 1234567890,
117 level: Level::Info,
118 tag: "atlas".into(),
119 msg: "hello".into(),
120 };
121 sink.write(&rec).unwrap();
122
123 let log_path = tmp.path().join("atlas.log");
124 assert!(log_path.exists(), "expected {log_path:?} to exist");
125
126 let lines = read_lines(&log_path);
127 assert_eq!(lines.len(), 1);
128 let parsed: LogRecord = serde_json::from_str(&lines[0]).unwrap();
129 assert_eq!(parsed.tag, "atlas");
130 assert_eq!(parsed.msg, "hello");
131 }
132
133 #[test]
134 fn same_tag_appends() {
135 let (tmp, sink) = test_sink();
136 let rec1 = LogRecord {
137 ts: 1,
138 level: Level::Debug,
139 tag: "a".into(),
140 msg: "m1".into(),
141 };
142 let rec2 = LogRecord {
143 ts: 2,
144 level: Level::Info,
145 tag: "a".into(),
146 msg: "m2".into(),
147 };
148 sink.write(&rec1).unwrap();
149 sink.write(&rec2).unwrap();
150
151 let lines = read_lines(&tmp.path().join("a.log"));
152 assert_eq!(lines.len(), 2);
153 let r1: LogRecord = serde_json::from_str(&lines[0]).unwrap();
154 let r2: LogRecord = serde_json::from_str(&lines[1]).unwrap();
155 assert_eq!(r1.msg, "m1");
156 assert_eq!(r2.msg, "m2");
157 }
158
159 #[test]
160 fn different_tags_different_files() {
161 let (tmp, sink) = test_sink();
162 sink.write(&LogRecord {
163 ts: 0,
164 level: Level::Warn,
165 tag: "x".into(),
166 msg: "mx".into(),
167 })
168 .unwrap();
169 sink.write(&LogRecord {
170 ts: 0,
171 level: Level::Error,
172 tag: "y".into(),
173 msg: "my".into(),
174 })
175 .unwrap();
176
177 let x_path = tmp.path().join("x.log");
178 let y_path = tmp.path().join("y.log");
179 assert!(x_path.exists());
180 assert!(y_path.exists());
181
182 let x_lines = read_lines(&x_path);
183 let y_lines = read_lines(&y_path);
184 assert_eq!(x_lines.len(), 1);
185 assert_eq!(y_lines.len(), 1);
186
187 let rx: LogRecord = serde_json::from_str(&x_lines[0]).unwrap();
188 let ry: LogRecord = serde_json::from_str(&y_lines[0]).unwrap();
189 assert_eq!(rx.tag, "x");
190 assert_eq!(ry.tag, "y");
191 }
192
193 #[test]
194 fn concurrent_writes_no_data_loss() {
195 use std::sync::Arc;
196 use std::thread;
197
198 let tmp = tempfile::tempdir().unwrap();
199 let sink = Arc::new(FileSink::new(tmp.path()).unwrap());
200 const THREADS: usize = 8;
201 const MSGS_PER_THREAD: usize = 100;
202
203 let mut handles = vec![];
204 for t in 0..THREADS {
205 let sink = Arc::clone(&sink);
206 handles.push(thread::spawn(move || {
207 for i in 0..MSGS_PER_THREAD {
208 sink.write(&LogRecord {
209 ts: (t * MSGS_PER_THREAD + i) as u64,
210 level: Level::Info,
211 tag: "shared".into(),
212 msg: format!("t{t}-m{i}"),
213 })
214 .unwrap();
215 }
216 }));
217 }
218
219 for h in handles {
220 h.join().unwrap();
221 }
222
223 let lines = read_lines(&tmp.path().join("shared.log"));
224 assert_eq!(lines.len(), THREADS * MSGS_PER_THREAD);
225 for line in &lines {
227 let _rec: LogRecord = serde_json::from_str(line).unwrap();
228 }
229 }
230
231 #[test]
232 fn sanitize_tag_replaces_dangerous_chars() {
233 let (tmp, sink) = test_sink();
234 let rec = LogRecord {
236 ts: 0,
237 level: Level::Info,
238 tag: "a/../b c".into(),
239 msg: "m".into(),
240 };
241 sink.write(&rec).unwrap();
242 let safe_path = tmp.path().join("a_.._b_c.log");
244 assert!(
245 safe_path.exists(),
246 "expected {safe_path:?} to exist after sanitization"
247 );
248 }
249
250 #[test]
251 fn write_does_not_panic_on_bad_path() {
252 let sink = FileSink::new("/proc/sysrq-trigger"); assert!(
255 sink.is_err(),
256 "should fail to create sink under a non-directory"
257 );
258 }
259
260 #[test]
261 fn sink_creation_fails_gracefully() {
262 let result = FileSink::new("/dev/null/logs");
264 assert!(result.is_err());
265 }
266}