1use std::fs::{File, OpenOptions};
4use std::io::{BufWriter, Write};
5use std::path::Path;
6use std::sync::Mutex;
7
8use super::{CachePadded, Sink, 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 }
98 }
99}
100
101impl<F: Format> Sink for FileSink<F> {
102 fn write_record(&self, record: &Record<'_>) -> Result<()> {
103 write_locked(&self.target, &self.format, record)
104 }
105
106 fn flush(&self) -> Result<()> {
107 let mut file = lock_recover(&self.target);
108 file.flush()?;
109 if self.sync_on_flush {
110 file.get_ref().sync_data().map_err(Error::from)?;
111 }
112 Ok(())
113 }
114}
115
116impl<F: Format + core::fmt::Debug> core::fmt::Debug for FileSink<F> {
117 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
118 f.debug_struct("FileSink")
119 .field("format", &self.format)
120 .field("sync_on_flush", &self.sync_on_flush)
121 .finish_non_exhaustive()
122 }
123}
124
125#[derive(Debug)]
127#[must_use = "call `open()` to create the sink"]
128pub struct FileSinkBuilder<F> {
129 path: std::path::PathBuf,
130 format: F,
131 append: bool,
132 buffer_capacity: usize,
133 sync_on_flush: bool,
134}
135
136impl<F: Format> FileSinkBuilder<F> {
137 pub const fn append(mut self, append: bool) -> Self {
140 self.append = append;
141 self
142 }
143
144 pub const fn buffer_capacity(mut self, bytes: usize) -> Self {
149 self.buffer_capacity = bytes;
150 self
151 }
152
153 pub const fn sync_on_flush(mut self, enabled: bool) -> Self {
157 self.sync_on_flush = enabled;
158 self
159 }
160
161 pub fn open(self) -> Result<FileSink<F>> {
167 let file = OpenOptions::new()
168 .create(true)
169 .write(true)
170 .append(self.append)
171 .truncate(!self.append)
172 .open(&self.path)?;
173 Ok(FileSink {
174 format: self.format,
175 target: CachePadded(Mutex::new(BufWriter::with_capacity(
176 self.buffer_capacity,
177 file,
178 ))),
179 sync_on_flush: self.sync_on_flush,
180 })
181 }
182}
183
184#[cfg(all(test, feature = "json"))]
185mod tests {
186 use super::*;
187 use crate::format::JsonFormat;
188 use crate::{Level, Metadata};
189
190 fn temp_path(name: &str) -> std::path::PathBuf {
191 let dir = std::env::temp_dir().join(format!("log-io-file-{}-{name}", std::process::id()));
192 let _ = std::fs::remove_file(&dir);
193 dir
194 }
195
196 fn record(msg: &str) -> Record<'_> {
197 Record::new(Metadata::new(Level::Info, "t"), msg, &[])
198 }
199
200 #[test]
201 fn test_file_sink_append_preserves_existing_content() {
202 let path = temp_path("append");
203 std::fs::write(&path, "existing\n").unwrap();
204 let sink = FileSink::append(&path, JsonFormat::new()).unwrap();
205 sink.write_record(&record("one")).unwrap();
206 sink.flush().unwrap();
207 let text = std::fs::read_to_string(&path).unwrap();
208 assert!(text.starts_with("existing\n"), "{text}");
209 assert!(text.contains("\"message\":\"one\""), "{text}");
210 let _ = std::fs::remove_file(&path);
211 }
212
213 #[test]
214 fn test_file_sink_create_truncates() {
215 let path = temp_path("create");
216 std::fs::write(&path, "old content\n").unwrap();
217 let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
218 sink.write_record(&record("fresh")).unwrap();
219 sink.shutdown().unwrap();
220 let text = std::fs::read_to_string(&path).unwrap();
221 assert!(!text.contains("old content"), "{text}");
222 assert!(text.contains("fresh"), "{text}");
223 let _ = std::fs::remove_file(&path);
224 }
225
226 #[test]
227 fn test_file_sink_buffers_until_flush() {
228 let path = temp_path("buffered");
229 let sink = FileSink::create(&path, JsonFormat::new()).unwrap();
230 sink.write_record(&record("pending")).unwrap();
231 assert_eq!(std::fs::read(&path).unwrap().len(), 0, "still buffered");
232 sink.flush().unwrap();
233 assert!(!std::fs::read(&path).unwrap().is_empty());
234 let _ = std::fs::remove_file(&path);
235 }
236
237 #[test]
238 fn test_file_sink_unbuffered_writes_through() {
239 let path = temp_path("unbuffered");
240 let sink = FileSink::builder(&path, JsonFormat::new())
241 .append(false)
242 .buffer_capacity(0)
243 .sync_on_flush(true)
244 .open()
245 .unwrap();
246 sink.write_record(&record("now")).unwrap();
247 assert!(
248 std::fs::read_to_string(&path).unwrap().contains("now"),
249 "unbuffered sink must write before returning"
250 );
251 sink.flush().unwrap();
252 assert!(format!("{sink:?}").contains("sync_on_flush: true"));
253 let _ = std::fs::remove_file(&path);
254 }
255
256 #[test]
257 fn test_file_sink_open_error_is_reported() {
258 let dir = std::env::temp_dir().join(format!("log-io-missing-{}", std::process::id()));
259 let path = dir.join("nested").join("file.log");
260 let err = FileSink::append(&path, JsonFormat::new()).unwrap_err();
261 assert!(matches!(err, Error::Io(_)));
262 }
263}