Skip to main content

ciplink_server/
logging.rs

1//! A small logger for the server: INFO and above, to stderr or to a file rotated at 10 MB with 5 kept.
2
3use std::fs::{File, OpenOptions};
4use std::io::Write as _;
5use std::path::{Path, PathBuf};
6use std::sync::{Mutex, PoisonError};
7
8const MAX_BYTES: u64 = 10_000_000;
9const BACKUPS: u32 = 5;
10
11struct FileLog {
12    path: PathBuf,
13    file: File,
14    size: u64,
15}
16
17struct Logger {
18    file: Mutex<Option<FileLog>>,
19}
20
21impl log::Log for Logger {
22    fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
23        metadata.level() <= log::Level::Info
24    }
25
26    fn log(&self, record: &log::Record<'_>) {
27        if !self.enabled(record.metadata()) {
28            return;
29        }
30        let line = format!(
31            "{} {} {}: {}\n",
32            timestamp(),
33            record.level(),
34            record.target(),
35            record.args()
36        );
37        let mut file = self.file.lock().unwrap_or_else(PoisonError::into_inner);
38        match file.as_mut() {
39            Some(log) => {
40                if log.size + line.len() as u64 > MAX_BYTES {
41                    rotate(log);
42                }
43                if log.file.write_all(line.as_bytes()).is_ok() {
44                    log.size += line.len() as u64;
45                }
46            }
47            None => eprint!("{line}"),
48        }
49    }
50
51    fn flush(&self) {
52        if let Some(log) = self
53            .file
54            .lock()
55            .unwrap_or_else(PoisonError::into_inner)
56            .as_mut()
57        {
58            let _ = log.file.flush();
59        }
60    }
61}
62
63/// `name.1` ... `name.5` as Python's RotatingFileHandler names them.
64fn backup(path: &Path, n: u32) -> PathBuf {
65    let mut name = path.as_os_str().to_owned();
66    name.push(format!(".{n}"));
67    PathBuf::from(name)
68}
69
70fn rotate(log: &mut FileLog) {
71    let _ = std::fs::remove_file(backup(&log.path, BACKUPS));
72    for n in (1..BACKUPS).rev() {
73        let _ = std::fs::rename(backup(&log.path, n), backup(&log.path, n + 1));
74    }
75    let _ = log.file.flush();
76    let _ = std::fs::rename(&log.path, backup(&log.path, 1));
77    if let Ok(file) = OpenOptions::new().create(true).append(true).open(&log.path) {
78        log.file = file;
79        log.size = 0;
80    }
81}
82
83/// `2026-10-23 14:17:33.768Z` (UTC).
84fn timestamp() -> String {
85    let now = std::time::SystemTime::now()
86        .duration_since(std::time::UNIX_EPOCH)
87        .unwrap_or_default();
88    let secs = now.as_secs();
89    let (days, rem) = (secs / 86_400, secs % 86_400);
90    let z = days as i64 + 719_468;
91    let era = z.div_euclid(146_097);
92    let doe = z - era * 146_097;
93    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
94    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
95    let mp = (5 * doy + 2) / 153;
96    let day = doy - (153 * mp + 2) / 5 + 1;
97    let month = if mp < 10 { mp + 3 } else { mp - 9 };
98    let year = yoe + era * 400 + i64::from(month <= 2);
99    format!(
100        "{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}.{:03}Z",
101        rem / 3600,
102        rem % 3600 / 60,
103        rem % 60,
104        now.subsec_millis()
105    )
106}
107
108/// Installs the logger (once per process; later calls only fail if the file can't be opened).
109pub fn init(file: Option<&Path>) -> Result<(), String> {
110    let file = match file {
111        None => None,
112        Some(path) => {
113            if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
114                std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
115            }
116            let handle = OpenOptions::new()
117                .create(true)
118                .append(true)
119                .open(path)
120                .map_err(|e| format!("{}: {e}", path.display()))?;
121            let size = handle.metadata().map_or(0, |m| m.len());
122            Some(FileLog {
123                path: path.to_owned(),
124                file: handle,
125                size,
126            })
127        }
128    };
129    let logger = Box::leak(Box::new(Logger {
130        file: Mutex::new(file),
131    }));
132    if log::set_logger(logger).is_ok() {
133        log::set_max_level(log::LevelFilter::Info);
134    }
135    Ok(())
136}