1use std::fs::File;
4use std::io::{BufWriter, Write};
5use std::path::Path;
6use std::sync::Mutex;
7
8use super::{CachePadded, Sink, lock_recover, open_log_file, write_locked};
9use crate::error::{Error, Result};
10use crate::format::Format;
11use crate::record::Record;
12
13const DEFAULT_BUFFER: usize = 8 * 1024;
15
16#[cfg_attr(all(feature = "json"), doc = "```no_run")]
49#[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
50pub struct FileSink<F: Format> {
63 format: F,
64 target: CachePadded<Mutex<BufWriter<File>>>,
65 sync_on_flush: bool,
66}
67
68impl<F: Format> FileSink<F> {
69 pub fn append<P: AsRef<Path>>(path: P, format: F) -> Result<Self> {
76 Self::builder(path, format).append(true).open()
77 }
78
79 pub fn create<P: AsRef<Path>>(path: P, format: F) -> Result<Self> {
90 Self::builder(path, format).append(false).open()
91 }
92
93 pub fn builder<P: AsRef<Path>>(path: P, format: F) -> FileSinkBuilder<F> {
97 FileSinkBuilder {
98 path: path.as_ref().to_path_buf(),
99 format,
100 append: true,
101 buffer_capacity: DEFAULT_BUFFER,
102 sync_on_flush: false,
103 mode: None,
104 follow_symlinks: false,
105 }
106 }
107}
108
109impl<F: Format> Sink for FileSink<F> {
110 fn write_record(&self, record: &Record<'_>) -> Result<()> {
111 write_locked(&self.target, &self.format, record)
112 }
113
114 fn flush(&self) -> Result<()> {
115 let mut file = lock_recover(&self.target);
116 file.flush()?;
117 if self.sync_on_flush {
118 file.get_ref().sync_data().map_err(Error::from)?;
119 }
120 Ok(())
121 }
122}
123
124impl<F: Format + core::fmt::Debug> core::fmt::Debug for FileSink<F> {
125 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
126 f.debug_struct("FileSink")
127 .field("format", &self.format)
128 .field("sync_on_flush", &self.sync_on_flush)
129 .finish_non_exhaustive()
130 }
131}
132
133#[derive(Debug)]
135#[must_use = "call `open()` to create the sink"]
136pub struct FileSinkBuilder<F> {
137 path: std::path::PathBuf,
138 format: F,
139 append: bool,
140 buffer_capacity: usize,
141 sync_on_flush: bool,
142 mode: Option<u32>,
143 follow_symlinks: bool,
144}
145
146impl<F: Format> FileSinkBuilder<F> {
147 pub const fn append(mut self, append: bool) -> Self {
150 self.append = append;
151 self
152 }
153
154 pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
159 self.buffer_capacity = bytes;
160 self
161 }
162
163 pub const fn sync_on_flush(mut self, enabled: bool) -> Self {
167 self.sync_on_flush = enabled;
168 self
169 }
170
171 #[cfg_attr(all(feature = "json"), doc = "```no_run")]
182 #[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
183 pub const fn mode(mut self, mode: u32) -> Self {
192 self.mode = Some(mode);
193 self
194 }
195
196 pub const fn follow_symlinks(mut self, follow: bool) -> Self {
203 self.follow_symlinks = follow;
204 self
205 }
206
207 pub fn open(self) -> Result<FileSink<F>> {
215 let file = open_log_file(&self.path, self.append, self.mode, self.follow_symlinks)?;
216 Ok(FileSink {
217 format: self.format,
218 target: CachePadded(Mutex::new(BufWriter::with_capacity(
219 self.buffer_capacity,
220 file,
221 ))),
222 sync_on_flush: self.sync_on_flush,
223 })
224 }
225}
226
227#[cfg(all(test, feature = "json"))]
228mod tests {
229 use super::*;
230 use crate::format::JsonFormat;
231 use crate::{Level, Metadata};
232
233 fn temp_path(name: &str) -> std::path::PathBuf {
234 let dir = std::env::temp_dir().join(format!("log-io-file-{}-{name}", std::process::id()));
235 let _ = std::fs::remove_file(&dir);
236 dir
237 }
238
239 fn record(msg: &str) -> Record<'_> {
240 Record::new(Metadata::new(Level::Info, "t"), msg, &[])
241 }
242
243 #[test]
244 fn test_file_sink_append_preserves_existing_content() {
245 let path = temp_path("append");
246 std::fs::write(&path, "existing\n").unwrap();
247 let sink = FileSink::append(&path, JsonFormat::new()).unwrap();
248 sink.write_record(&record("one")).unwrap();
249 sink.flush().unwrap();
250 let text = std::fs::read_to_string(&path).unwrap();
251 assert!(text.starts_with("existing\n"), "{text}");
252 assert!(text.contains("\"message\":\"one\""), "{text}");
253 let _ = std::fs::remove_file(&path);
254 }
255
256 #[test]
257 fn test_file_sink_create_truncates() {
258 let path = temp_path("create");
259 std::fs::write(&path, "old content\n").unwrap();
260 let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
261 sink.write_record(&record("fresh")).unwrap();
262 sink.shutdown().unwrap();
263 let text = std::fs::read_to_string(&path).unwrap();
264 assert!(!text.contains("old content"), "{text}");
265 assert!(text.contains("fresh"), "{text}");
266 let _ = std::fs::remove_file(&path);
267 }
268
269 #[test]
270 fn test_file_sink_buffers_until_flush() {
271 let path = temp_path("buffered");
272 let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
273 sink.write_record(&record("pending")).unwrap();
274 assert_eq!(std::fs::read(&path).unwrap().len(), 0, "still buffered");
275 sink.flush().unwrap();
276 assert!(!std::fs::read(&path).unwrap().is_empty());
277 let _ = std::fs::remove_file(&path);
278 }
279
280 #[test]
281 fn test_file_sink_unbuffered_writes_through() {
282 let path = temp_path("unbuffered");
283 let sink = FileSink::builder(&path, JsonFormat::new())
284 .append(false)
285 .buffer_capacity(0)
286 .sync_on_flush(true)
287 .open()
288 .unwrap();
289 sink.write_record(&record("now")).unwrap();
290 assert!(
291 std::fs::read_to_string(&path).unwrap().contains("now"),
292 "unbuffered sink must write before returning"
293 );
294 sink.flush().unwrap();
295 assert!(format!("{sink:?}").contains("sync_on_flush: true"));
296 let _ = std::fs::remove_file(&path);
297 }
298
299 #[test]
300 fn test_file_sink_open_error_is_reported() {
301 let dir = std::env::temp_dir().join(format!("log-io-missing-{}", std::process::id()));
302 let path = dir.join("nested").join("file.log");
303 let err = FileSink::append(&path, JsonFormat::new()).unwrap_err();
304 assert!(matches!(err, Error::Io(_)));
305 }
306
307 #[cfg(unix)]
308 #[test]
309 fn test_file_sink_mode_applies_to_a_new_file() {
310 use std::os::unix::fs::PermissionsExt;
311 let path = temp_path("mode.jsonl");
312 let sink = FileSink::builder(&path, JsonFormat::new())
313 .mode(0o600)
314 .open()
315 .unwrap();
316 drop(sink);
317 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
318 assert_eq!(mode, 0o600);
319 let _ = std::fs::remove_file(&path);
320 }
321
322 #[cfg(unix)]
324 #[test]
325 fn test_file_sink_default_mode_is_owner_only() {
326 use std::os::unix::fs::PermissionsExt;
327 let path = temp_path("default-mode.jsonl");
328 let sink = FileSink::append(&path, JsonFormat::new()).unwrap();
329 drop(sink);
330 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
331 assert_eq!(mode, 0o600);
332 let _ = std::fs::remove_file(&path);
333 }
334
335 #[test]
338 fn test_file_sink_refuses_a_symlink_and_leaves_its_target_alone() {
339 let victim = temp_path("victim.txt");
340 let link = temp_path("link.jsonl");
341 std::fs::write(&victim, "important=1\n").unwrap();
342 let _ = std::fs::remove_file(&link);
343 #[cfg(unix)]
344 let made = std::os::unix::fs::symlink(&victim, &link);
345 #[cfg(windows)]
346 let made = std::os::windows::fs::symlink_file(&victim, &link);
347 if made.is_err() {
348 let _ = std::fs::remove_file(&victim);
350 return;
351 }
352 assert!(FileSink::create(&link, JsonFormat::new()).is_err());
353 assert!(FileSink::append(&link, JsonFormat::new()).is_err());
354 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "important=1\n");
355 let followed = FileSink::builder(&link, JsonFormat::new())
356 .follow_symlinks(true)
357 .open();
358 assert!(
359 followed.is_ok(),
360 "follow_symlinks(true) must allow the link"
361 );
362 drop(followed);
363 let _ = std::fs::remove_file(&link);
364 let _ = std::fs::remove_file(&victim);
365 }
366
367 #[test]
368 fn test_file_sink_refuses_a_directory() {
369 let dir = temp_path("a-directory");
370 let _ = std::fs::remove_dir(&dir);
371 std::fs::create_dir(&dir).unwrap();
372 assert!(FileSink::append(&dir, JsonFormat::new()).is_err());
373 let _ = std::fs::remove_dir(&dir);
374 }
375
376 #[test]
377 fn test_file_sink_create_truncates_a_regular_file() {
378 let path = temp_path("truncate.jsonl");
379 std::fs::write(&path, "old content that is long\n").unwrap();
380 let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
381 sink.write_record(&record("new")).unwrap();
382 sink.flush().unwrap();
383 let text = std::fs::read_to_string(&path).unwrap();
384 assert!(!text.contains("old content"), "{text}");
385 assert!(text.contains("\"message\":\"new\""), "{text}");
386 let _ = std::fs::remove_file(&path);
387 }
388
389 #[test]
390 fn test_file_sink_without_mode_still_opens() {
391 let path = temp_path("no-mode.jsonl");
392 assert!(FileSink::builder(&path, JsonFormat::new()).open().is_ok());
393 let _ = std::fs::remove_file(&path);
394 }
395}