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