1use std::{
2 fs::{self, File, OpenOptions},
3 io::{self, Write},
4 path::{Path, PathBuf},
5 time::{SystemTime, UNIX_EPOCH},
6};
7
8use rustix::fs::{CWD, FlockOperation, RenameFlags, flock, renameat_with};
9use time::{OffsetDateTime, UtcOffset};
10
11const ACTIVE_NAME: &str = "saddle.log";
12const LOCK_NAME: &str = ".saddle.log.lock";
13
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub enum Rotation {
16 #[default]
17 Daily,
18 Hourly,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct FileLoggingConfig {
23 directory: PathBuf,
24 rotation: Rotation,
25 context: crate::ContextLoggingConfig,
26 active_name: String,
27}
28
29impl FileLoggingConfig {
30 pub fn new(directory: impl Into<PathBuf>, rotation: Rotation) -> Self {
31 Self {
32 directory: directory.into(),
33 rotation,
34 context: crate::ContextLoggingConfig::default(),
35 active_name: ACTIVE_NAME.into(),
36 }
37 }
38
39 pub fn with_context(mut self, context: crate::ContextLoggingConfig) -> Result<Self, crate::ContextLoggingError> {
40 context.validate()?;
41 self.context = context;
42 Ok(self)
43 }
44 pub fn context(&self) -> &crate::ContextLoggingConfig { &self.context }
45
46 pub(super) fn for_summary(&self, name: &str) -> Self {
47 let mut config = Self::new(self.directory.clone(), self.rotation);
48 config.active_name = format!("{name}.summary.log");
49 config
50 }
51
52 pub fn directory(&self) -> &Path {
53 &self.directory
54 }
55
56 pub const fn rotation(&self) -> Rotation {
57 self.rotation
58 }
59}
60
61impl Default for FileLoggingConfig {
62 fn default() -> Self {
63 Self::new(PathBuf::from("./logs"), Rotation::Daily)
64 }
65}
66
67#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
68struct Period {
69 year: i32,
70 month: u8,
71 day: u8,
72 hour: u8,
73}
74
75impl Period {
76 fn archive_name(self, rotation: Rotation) -> String { self.archive_name_for(rotation, ACTIVE_NAME) }
77 fn archive_name_for(self, rotation: Rotation, active_name: &str) -> String {
78 match rotation {
79 Rotation::Daily => format!(
80 "{}.{:04}-{:02}-{:02}",
81 active_name, self.year, self.month, self.day
82 ),
83 Rotation::Hourly => format!(
84 "{}.{:04}-{:02}-{:02}-{:02}",
85 active_name, self.year, self.month, self.day, self.hour
86 ),
87 }
88 }
89}
90
91pub(super) struct CalendarFileWriter {
92 directory: PathBuf,
93 active_path: PathBuf,
94 active_name: String,
95 rotation: Rotation,
96 period: Period,
97 file: File,
98 directory_handle: File,
99 _lock: File,
100 clock: Box<dyn Fn() -> io::Result<Period> + Send>,
101}
102
103impl CalendarFileWriter {
104 pub(super) fn open(config: FileLoggingConfig) -> io::Result<Self> {
105 let rotation = config.rotation;
106 Self::open_with_clock(config, Box::new(move || system_period(rotation)))
107 }
108
109 fn open_with_clock(
110 config: FileLoggingConfig,
111 clock: Box<dyn Fn() -> io::Result<Period> + Send>,
112 ) -> io::Result<Self> {
113 fs::create_dir_all(&config.directory)?;
114 let directory_handle = File::open(&config.directory)?;
115 reject_non_regular_if_present(&config.directory.join(if config.active_name == ACTIVE_NAME { LOCK_NAME.into() } else { format!(".{}.lock", config.active_name) }))?;
116 let lock = OpenOptions::new()
117 .read(true)
118 .write(true)
119 .create(true)
120 .truncate(false)
121 .open(config.directory.join(if config.active_name == ACTIVE_NAME { LOCK_NAME.into() } else { format!(".{}.lock", config.active_name) }))?;
122 flock(&lock, FlockOperation::NonBlockingLockExclusive)
123 .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))?;
124
125 let active_path = config.directory.join(&config.active_name);
126 reject_non_regular_if_present(&active_path)?;
127 let current = clock()?;
128 if active_path.exists() {
129 let active_period =
130 period_at(fs::metadata(&active_path)?.modified()?, config.rotation)?;
131 if active_period > current {
132 return Err(io::Error::other(
133 "saddle.log belongs to a future calendar period",
134 ));
135 }
136 if active_period < current {
137 archive_no_replace(
138 &active_path,
139 &config
140 .directory
141 .join(active_period.archive_name_for(config.rotation, &config.active_name)),
142 )?;
143 directory_handle.sync_all()?;
144 }
145 }
146 let file = open_active(&active_path)?;
147 directory_handle.sync_all()?;
148 Ok(Self {
149 directory: config.directory,
150 active_path,
151 active_name: config.active_name,
152 rotation: config.rotation,
153 period: current,
154 file,
155 directory_handle,
156 _lock: lock,
157 clock,
158 })
159 }
160
161 fn rotate_if_needed(&mut self) -> io::Result<()> {
162 let current = (self.clock)()?;
163 if current < self.period {
164 return Err(io::Error::other("system calendar moved backwards"));
165 }
166 if current == self.period {
167 return Ok(());
168 }
169
170 self.file.flush()?;
171 self.file.sync_data()?;
172 let archive = self.directory.join(self.period.archive_name_for(self.rotation, &self.active_name));
173 archive_no_replace(&self.active_path, &archive)?;
174 self.directory_handle.sync_all()?;
175 self.file = open_active(&self.active_path)?;
176 self.directory_handle.sync_all()?;
177 self.period = current;
178 Ok(())
179 }
180}
181
182impl Write for CalendarFileWriter {
183 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
184 self.rotate_if_needed()?;
185 self.file.write(buffer)
186 }
187
188 fn flush(&mut self) -> io::Result<()> {
189 self.file.flush()
190 }
191}
192
193fn open_active(path: &Path) -> io::Result<File> {
194 OpenOptions::new().create(true).append(true).open(path)
195}
196
197fn reject_non_regular_if_present(path: &Path) -> io::Result<()> {
198 match fs::symlink_metadata(path) {
199 Ok(metadata) if !metadata.file_type().is_file() => {
200 Err(io::Error::other("log path is not a regular file"))
201 }
202 Ok(_) => Ok(()),
203 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
204 Err(error) => Err(error),
205 }
206}
207
208fn archive_no_replace(active: &Path, archive: &Path) -> io::Result<()> {
209 renameat_with(CWD, active, CWD, archive, RenameFlags::NOREPLACE)
210 .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))
211}
212
213fn system_period(rotation: Rotation) -> io::Result<Period> {
214 period_at(SystemTime::now(), rotation)
215}
216
217fn period_at(time: SystemTime, rotation: Rotation) -> io::Result<Period> {
218 let seconds = time
219 .duration_since(UNIX_EPOCH)
220 .map_err(|_| io::Error::other("calendar time predates the Unix epoch"))?
221 .as_secs();
222 let seconds = i64::try_from(seconds).map_err(|_| io::Error::other("calendar time overflow"))?;
223 let utc = OffsetDateTime::from_unix_timestamp(seconds)
224 .map_err(|_| io::Error::other("calendar time is out of range"))?;
225 let offset = UtcOffset::local_offset_at(utc)
226 .map_err(|_| io::Error::other("operating-system timezone is unavailable"))?;
227 let local = utc.to_offset(offset);
228 Ok(Period {
229 year: local.year(),
230 month: u8::from(local.month()),
231 day: local.day(),
232 hour: match rotation {
233 Rotation::Daily => 0,
234 Rotation::Hourly => local.hour(),
235 },
236 })
237}
238
239#[cfg(test)]
240mod tests {
241 use std::{
242 fs::FileTimes,
243 sync::{Arc, Mutex},
244 time::Duration,
245 };
246
247 use super::*;
248
249 fn directory(name: &str) -> PathBuf {
250 let path = std::env::temp_dir().join(format!(
251 "saddle-calendar-writer-{name}-{}-{}",
252 std::process::id(),
253 SystemTime::now()
254 .duration_since(UNIX_EPOCH)
255 .unwrap()
256 .as_nanos()
257 ));
258 fs::create_dir_all(&path).unwrap();
259 path
260 }
261
262 fn period(day: u8, hour: u8) -> Period {
263 Period {
264 year: 2026,
265 month: 8,
266 day,
267 hour,
268 }
269 }
270
271 #[test]
272 fn rotates_daily_without_replacing_archive() {
273 let path = directory("daily");
274 let now = Arc::new(Mutex::new(period(28, 23)));
275 let clock_now = Arc::clone(&now);
276 let mut writer = CalendarFileWriter::open_with_clock(
277 FileLoggingConfig::new(&path, Rotation::Daily),
278 Box::new(move || Ok(*clock_now.lock().unwrap())),
279 )
280 .unwrap();
281 writer.write_all(b"before\n").unwrap();
282 *now.lock().unwrap() = period(29, 0);
283 writer.write_all(b"after\n").unwrap();
284 writer.flush().unwrap();
285
286 assert_eq!(
287 fs::read(path.join("saddle.log.2026-08-28")).unwrap(),
288 b"before\n"
289 );
290 assert_eq!(fs::read(path.join(ACTIVE_NAME)).unwrap(), b"after\n");
291 fs::remove_dir_all(path).unwrap();
292 }
293
294 #[test]
295 fn startup_appends_same_period_and_rotates_stale_active() {
296 let path = directory("restart");
297 let active = path.join(ACTIVE_NAME);
298 fs::write(&active, b"old\n").unwrap();
299 let old = UNIX_EPOCH + Duration::from_secs(1_777_000_000);
300 File::options()
301 .write(true)
302 .open(&active)
303 .unwrap()
304 .set_times(FileTimes::new().set_modified(old))
305 .unwrap();
306 let old_period = period_at(old, Rotation::Daily).unwrap();
307 let current = Period {
308 day: old_period.day.saturating_add(1),
309 ..old_period
310 };
311 let mut writer = CalendarFileWriter::open_with_clock(
312 FileLoggingConfig::new(&path, Rotation::Daily),
313 Box::new(move || Ok(current)),
314 )
315 .unwrap();
316 writer.write_all(b"new\n").unwrap();
317 writer.flush().unwrap();
318 assert_eq!(
319 fs::read(path.join(old_period.archive_name(Rotation::Daily))).unwrap(),
320 b"old\n"
321 );
322 assert_eq!(fs::read(&active).unwrap(), b"new\n");
323 fs::remove_dir_all(path).unwrap();
324 }
325
326 #[test]
327 fn lock_and_archive_collision_fail_closed() {
328 let path = directory("failure");
329 let now = Arc::new(Mutex::new(period(28, 15)));
330 let first_now = Arc::clone(&now);
331 let mut first = CalendarFileWriter::open_with_clock(
332 FileLoggingConfig::new(&path, Rotation::Hourly),
333 Box::new(move || Ok(*first_now.lock().unwrap())),
334 )
335 .unwrap();
336 assert!(
337 CalendarFileWriter::open_with_clock(
338 FileLoggingConfig::new(&path, Rotation::Hourly),
339 Box::new(|| Ok(period(28, 15))),
340 )
341 .is_err()
342 );
343
344 first.write_all(b"record\n").unwrap();
345 fs::write(path.join("saddle.log.2026-08-28-15"), b"collision\n").unwrap();
346 *now.lock().unwrap() = period(28, 16);
347 assert!(first.write_all(b"must-fail\n").is_err());
348 assert_eq!(
349 fs::read(path.join("saddle.log.2026-08-28-15")).unwrap(),
350 b"collision\n"
351 );
352 drop(first);
353 fs::remove_dir_all(path).unwrap();
354 }
355
356 #[test]
357 fn clock_rollback_fails_closed() {
358 let path = directory("rollback");
359 let now = Arc::new(Mutex::new(period(28, 16)));
360 let clock_now = Arc::clone(&now);
361 let mut writer = CalendarFileWriter::open_with_clock(
362 FileLoggingConfig::new(&path, Rotation::Hourly),
363 Box::new(move || Ok(*clock_now.lock().unwrap())),
364 )
365 .unwrap();
366 *now.lock().unwrap() = period(28, 15);
367 assert!(writer.write_all(b"must-fail\n").is_err());
368 assert_eq!(fs::read(path.join(ACTIVE_NAME)).unwrap(), b"");
369 drop(writer);
370 fs::remove_dir_all(path).unwrap();
371 }
372}