1use std::fs::{File, OpenOptions};
4use std::io::{BufWriter, Write};
5use std::path::Path;
6use std::sync::Mutex;
7
8use super::{CachePadded, Sink, apply_mode, lock_recover, 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> {
85 Self::builder(path, format).append(false).open()
86 }
87
88 pub fn builder<P: AsRef<Path>>(path: P, format: F) -> FileSinkBuilder<F> {
91 FileSinkBuilder {
92 path: path.as_ref().to_path_buf(),
93 format,
94 append: true,
95 buffer_capacity: DEFAULT_BUFFER,
96 sync_on_flush: false,
97 mode: None,
98 }
99 }
100}
101
102impl<F: Format> Sink for FileSink<F> {
103 fn write_record(&self, record: &Record<'_>) -> Result<()> {
104 write_locked(&self.target, &self.format, record)
105 }
106
107 fn flush(&self) -> Result<()> {
108 let mut file = lock_recover(&self.target);
109 file.flush()?;
110 if self.sync_on_flush {
111 file.get_ref().sync_data().map_err(Error::from)?;
112 }
113 Ok(())
114 }
115}
116
117impl<F: Format + core::fmt::Debug> core::fmt::Debug for FileSink<F> {
118 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
119 f.debug_struct("FileSink")
120 .field("format", &self.format)
121 .field("sync_on_flush", &self.sync_on_flush)
122 .finish_non_exhaustive()
123 }
124}
125
126#[derive(Debug)]
128#[must_use = "call `open()` to create the sink"]
129pub struct FileSinkBuilder<F> {
130 path: std::path::PathBuf,
131 format: F,
132 append: bool,
133 buffer_capacity: usize,
134 sync_on_flush: bool,
135 mode: Option<u32>,
136}
137
138impl<F: Format> FileSinkBuilder<F> {
139 pub const fn append(mut self, append: bool) -> Self {
142 self.append = append;
143 self
144 }
145
146 pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
151 self.buffer_capacity = bytes;
152 self
153 }
154
155 pub const fn sync_on_flush(mut self, enabled: bool) -> Self {
159 self.sync_on_flush = enabled;
160 self
161 }
162
163 #[cfg_attr(all(feature = "json"), doc = "```no_run")]
173 #[cfg_attr(not(all(feature = "json")), doc = "```ignore")]
174 pub const fn mode(mut self, mode: u32) -> Self {
183 self.mode = Some(mode);
184 self
185 }
186
187 pub fn open(self) -> Result<FileSink<F>> {
193 let mut options = OpenOptions::new();
194 let _options = options
195 .create(true)
196 .write(true)
197 .append(self.append)
198 .truncate(!self.append);
199 apply_mode(&mut options, self.mode);
200 let file = options.open(&self.path)?;
201 Ok(FileSink {
202 format: self.format,
203 target: CachePadded(Mutex::new(BufWriter::with_capacity(
204 self.buffer_capacity,
205 file,
206 ))),
207 sync_on_flush: self.sync_on_flush,
208 })
209 }
210}
211
212#[cfg(all(test, feature = "json"))]
213mod tests {
214 use super::*;
215 use crate::format::JsonFormat;
216 use crate::{Level, Metadata};
217
218 fn temp_path(name: &str) -> std::path::PathBuf {
219 let dir = std::env::temp_dir().join(format!("log-io-file-{}-{name}", std::process::id()));
220 let _ = std::fs::remove_file(&dir);
221 dir
222 }
223
224 fn record(msg: &str) -> Record<'_> {
225 Record::new(Metadata::new(Level::Info, "t"), msg, &[])
226 }
227
228 #[test]
229 fn test_file_sink_append_preserves_existing_content() {
230 let path = temp_path("append");
231 std::fs::write(&path, "existing\n").unwrap();
232 let sink = FileSink::append(&path, JsonFormat::new()).unwrap();
233 sink.write_record(&record("one")).unwrap();
234 sink.flush().unwrap();
235 let text = std::fs::read_to_string(&path).unwrap();
236 assert!(text.starts_with("existing\n"), "{text}");
237 assert!(text.contains("\"message\":\"one\""), "{text}");
238 let _ = std::fs::remove_file(&path);
239 }
240
241 #[test]
242 fn test_file_sink_create_truncates() {
243 let path = temp_path("create");
244 std::fs::write(&path, "old content\n").unwrap();
245 let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
246 sink.write_record(&record("fresh")).unwrap();
247 sink.shutdown().unwrap();
248 let text = std::fs::read_to_string(&path).unwrap();
249 assert!(!text.contains("old content"), "{text}");
250 assert!(text.contains("fresh"), "{text}");
251 let _ = std::fs::remove_file(&path);
252 }
253
254 #[test]
255 fn test_file_sink_buffers_until_flush() {
256 let path = temp_path("buffered");
257 let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
258 sink.write_record(&record("pending")).unwrap();
259 assert_eq!(std::fs::read(&path).unwrap().len(), 0, "still buffered");
260 sink.flush().unwrap();
261 assert!(!std::fs::read(&path).unwrap().is_empty());
262 let _ = std::fs::remove_file(&path);
263 }
264
265 #[test]
266 fn test_file_sink_unbuffered_writes_through() {
267 let path = temp_path("unbuffered");
268 let sink = FileSink::builder(&path, JsonFormat::new())
269 .append(false)
270 .buffer_capacity(0)
271 .sync_on_flush(true)
272 .open()
273 .unwrap();
274 sink.write_record(&record("now")).unwrap();
275 assert!(
276 std::fs::read_to_string(&path).unwrap().contains("now"),
277 "unbuffered sink must write before returning"
278 );
279 sink.flush().unwrap();
280 assert!(format!("{sink:?}").contains("sync_on_flush: true"));
281 let _ = std::fs::remove_file(&path);
282 }
283
284 #[test]
285 fn test_file_sink_open_error_is_reported() {
286 let dir = std::env::temp_dir().join(format!("log-io-missing-{}", std::process::id()));
287 let path = dir.join("nested").join("file.log");
288 let err = FileSink::append(&path, JsonFormat::new()).unwrap_err();
289 assert!(matches!(err, Error::Io(_)));
290 }
291
292 #[cfg(unix)]
293 #[test]
294 fn test_file_sink_mode_applies_to_a_new_file() {
295 use std::os::unix::fs::PermissionsExt;
296 let path = temp_path("mode.jsonl");
297 let sink = FileSink::builder(&path, JsonFormat::new())
298 .mode(0o600)
299 .open()
300 .unwrap();
301 drop(sink);
302 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
303 assert_eq!(mode, 0o600);
304 let _ = std::fs::remove_file(&path);
305 }
306
307 #[test]
308 fn test_file_sink_without_mode_still_opens() {
309 let path = temp_path("no-mode.jsonl");
310 assert!(FileSink::builder(&path, JsonFormat::new()).open().is_ok());
311 let _ = std::fs::remove_file(&path);
312 }
313}