mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 03:49:34 -06:00
* Use chrono for UTC/calendar conversion instead of hand-rolled copies
Replaces the hand-rolled civil_from_days / civil copies and the hand-written
weekday / leap-year / days-in-month helpers with chrono. chrono is already a
workspace dependency (used by crates/text), so this reuses that entry and just
adds `chrono = { workspace = true }` to the engine and desktop app rather than
a second version.
- apps/deckcraft logging `timestamp` now formats through chrono (private
civil_from_days deleted).
- engine cmd::design `civil` (days since 1970 -> date, used for comment
timestamps in review.rs) uses chrono::NaiveDate.
- text datetime `DateTime::is_valid` / `weekday` use chrono::NaiveDate instead
of a hand-rolled leap-year table and days-since-1970 weekday.
Output is byte-identical and pinned by the existing logging, design and
datetime-field tests; each converted helper keeps a proleptic-Gregorian
fallback for dates outside chrono's NaiveDate range.
Follow-up to storytold/cadcraft#1785
* text: make chrono available on wasm32
is_valid/weekday in crates/text/src/datetime.rs use chrono unconditionally,
but chrono was only declared under the non-wasm target, so the crate (and
engine/pdf/render/ui-egui depending on it) failed the wasm32-unknown-unknown
check in `cargo xtask ci`.
- crates/text: declare chrono unconditionally (wasm-safe `std` baseline),
and keep `clock` in a separate cfg(not(target_arch="wasm32")) entry for
now_local()'s local clock, which is already wasm-gated to return None.
- workspace: drop `clock` from the shared chrono entry so it is no longer
forced onto wasm builds (engine uses chrono unconditionally too); `clock`
is now requested per-crate only where the local clock is read.
* Never panic on a clock outside chrono's range; harden the civil fallback
logging.rs timestamp(): chrono's From<SystemTime> unwraps, so a clock past
year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
and read an out-of-range or pre-epoch clock as the epoch, as the old saturating
code did. Years past 9999 are written with {:04} like before, because %Y adds a
'+'. Tests cover both cases.
cmd/design.rs civil_fallback(): make the hand-rolled fallback's year arithmetic
saturating so civil() cannot overflow or panic at i64::MAX/MIN (the engine rule
forbids crashes and unchecked arithmetic). Behaviour is unchanged for every real
input; a test exercises both extremes. The output year is formatted with {y:04}
by review.rs, so no %Y prefix issue.
crates/text/src/datetime.rs: checked after review; is_valid already limited the
year to 1..=9999 before this PR, weekday() is only reached for valid dates, and
all its arithmetic is checked/euclidean, so no change is needed.
* Don't build an unrepresentable SystemTime in the far-future test
UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.
* Make civil() exact for every i64 instead of saturating
The out-of-range fallback saturated z + 719_468, so civil(i64::MAX)
returned a date 719,468 days early. civil() now has chrono convert the day
within its 400-year Gregorian cycle and adds whole cycles back as years
(i128 for the extremes), so the hand-rolled fallback is gone. Exact results
for i64::MAX/MIN and both sides of chrono's range are tested against Hinnant's formula.
* time: preserve out-of-range years in the log timestamp instead of the epoch
`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a
clock set far ahead (an input the old hand-rolled civil-from-days converted)
collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based
helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the
in-range remainder with chrono, and carry the year back as i128. Only the year
leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the
epoch as before. chrono stays the only calendar logic (no new features).
apps/deckcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a
platform-safe `UNIX_EPOCH.checked_add`:
u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."),
10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...".
* Name the 400-year range extension as the one exception to the time rule
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
* Say the library computes month and day; time-of-day is plain arithmetic
Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
585 lines
26 KiB
Rust
585 lines
26 KiB
Rust
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
|
//!
|
|
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic
|
|
//! report, a failed crash-recovery save, PowerPoint parts that were skipped on import, audio output
|
|
//! problems) vanished. A launch from a desktop menu has no terminal, so the file is what a bug report
|
|
//! can attach: `<settings dir>/logs/deckcraft.log`, beside `ui.json` and `prefs.json`. Each start
|
|
//! moves the previous log to `deckcraft.1.log` (and that one to `.2`), so the log of a run that
|
|
//! crashed survives the next launch.
|
|
//!
|
|
//! Levels: `info` for DeckCraft's own crates, `warn` for everything else (wgpu and naga are
|
|
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
|
//! `warn,deckcraft_pptx=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
|
//! that starts with it (`deckcraft*=debug`).
|
|
//!
|
|
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
|
|
//! written once the file is attached. Writing never panics: a file that can't be created or written
|
|
//! leaves standard error as the only sink.
|
|
//!
|
|
//! Realtime audio: the cpal callbacks (`audio.rs`) do not log; a stream error is kept and logged
|
|
//! from the UI thread when the stream stops. A record takes the sink lock only for one `write_all`
|
|
//! of an already formatted line (standard error is written outside the lock).
|
|
|
|
use std::fs::File;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Mutex, OnceLock, PoisonError};
|
|
use std::time::SystemTime;
|
|
|
|
use log::LevelFilter;
|
|
|
|
/// The current log file's name inside the log directory.
|
|
pub const LOG_FILE: &str = "deckcraft.log";
|
|
/// The log directory inside the settings directory.
|
|
pub const LOG_DIR: &str = "logs";
|
|
/// How many previous logs are kept (`deckcraft.1.log` … `deckcraft.<KEEP>.log`).
|
|
pub const KEEP: usize = 2;
|
|
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
|
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
|
/// Records kept in memory until the log file is attached.
|
|
pub const MAX_PENDING: usize = 512;
|
|
/// The built-in filter when `RUST_LOG` is unset or empty.
|
|
pub const DEFAULT_FILTER: &str = "warn,deckcraft*=info";
|
|
|
|
/// Per-target level filter parsed from env_logger-style directives.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Filter {
|
|
default: LevelFilter,
|
|
directives: Vec<(String, LevelFilter)>,
|
|
}
|
|
|
|
impl Filter {
|
|
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
|
/// directive names log errors only.
|
|
pub fn parse(spec: &str) -> Filter {
|
|
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
|
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
|
match part.split_once('=') {
|
|
Some((name, level)) => {
|
|
let name = name.trim();
|
|
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
|
f.directives.push((name.to_owned(), level));
|
|
}
|
|
}
|
|
None => match parse_level(part) {
|
|
Some(level) => f.default = level,
|
|
// A bare target name: everything from it (env_logger does the same).
|
|
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
|
},
|
|
}
|
|
}
|
|
f
|
|
}
|
|
|
|
/// The level that applies to `target` (the most specific matching directive wins).
|
|
pub fn level_for(&self, target: &str) -> LevelFilter {
|
|
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
|
}
|
|
|
|
/// The most verbose level any target can reach (for `log::set_max_level`).
|
|
pub fn max(&self) -> LevelFilter {
|
|
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
|
}
|
|
}
|
|
|
|
fn parse_level(s: &str) -> Option<LevelFilter> {
|
|
s.trim().parse().ok()
|
|
}
|
|
|
|
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
|
/// `name*` matches every target starting with `name`.
|
|
fn matches(name: &str, target: &str) -> bool {
|
|
match name.strip_suffix('*') {
|
|
Some(prefix) => target.starts_with(prefix),
|
|
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
|
}
|
|
}
|
|
|
|
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
|
|
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
|
|
/// instead of panicking.
|
|
pub fn timestamp(t: SystemTime) -> String {
|
|
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
|
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
|
|
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
|
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
|
|
}
|
|
|
|
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
|
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
|
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
|
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
|
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
|
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
|
use chrono::Datelike;
|
|
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
|
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
|
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
|
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
|
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
|
match (date, time) {
|
|
(Some(d), Some(t)) => {
|
|
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
|
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
|
}
|
|
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
|
}
|
|
}
|
|
|
|
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
|
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("deckcraft.{n}.log")) }
|
|
}
|
|
|
|
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
|
/// and create a fresh, empty log file in `dir` (created if missing).
|
|
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
|
std::fs::create_dir_all(dir)?;
|
|
for n in (1..=KEEP).rev() {
|
|
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
|
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
|
_ => {}
|
|
}
|
|
}
|
|
let path = numbered(dir, 0);
|
|
let file = File::create(&path)?;
|
|
Ok((path, file))
|
|
}
|
|
|
|
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
|
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
|
let message = message.strip_suffix('\n').unwrap_or(message);
|
|
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
|
}
|
|
|
|
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
|
/// then appended to it until it reaches its size cap.
|
|
pub struct Sink {
|
|
file: Option<File>,
|
|
pending: Vec<String>,
|
|
dropped: usize,
|
|
written: u64,
|
|
max_bytes: u64,
|
|
capped: bool,
|
|
}
|
|
|
|
impl Sink {
|
|
pub fn new(max_bytes: u64) -> Sink {
|
|
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
|
}
|
|
|
|
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
|
pub fn write(&mut self, line: &str) {
|
|
if self.file.is_none() {
|
|
if self.capped {
|
|
return;
|
|
}
|
|
if self.pending.len() < MAX_PENDING {
|
|
self.pending.push(line.to_owned());
|
|
} else {
|
|
self.dropped = self.dropped.saturating_add(1);
|
|
}
|
|
return;
|
|
}
|
|
self.append(line);
|
|
}
|
|
|
|
/// Start writing to `file`, first the lines kept so far.
|
|
pub fn attach(&mut self, file: File) {
|
|
self.file = Some(file);
|
|
for line in std::mem::take(&mut self.pending) {
|
|
self.append(&line);
|
|
}
|
|
if self.dropped > 0 {
|
|
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
|
self.dropped = 0;
|
|
self.append(¬e);
|
|
}
|
|
}
|
|
|
|
/// No file will come: drop the kept lines and keep none from now on.
|
|
pub fn close(&mut self) {
|
|
self.file = None;
|
|
self.pending = Vec::new();
|
|
self.capped = true;
|
|
}
|
|
|
|
fn append(&mut self, line: &str) {
|
|
if self.capped {
|
|
return;
|
|
}
|
|
let Some(file) = self.file.as_mut() else { return };
|
|
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
|
let text = if self.written.saturating_add(len) > self.max_bytes {
|
|
self.capped = true;
|
|
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
|
} else {
|
|
self.written = self.written.saturating_add(len);
|
|
line.to_owned()
|
|
};
|
|
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
|
// stop buffering, since no file will come).
|
|
if file.write_all(text.as_bytes()).is_err() {
|
|
self.file = None;
|
|
self.capped = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
|
pub struct AppLogger {
|
|
filter: Filter,
|
|
stderr: bool,
|
|
sink: Mutex<Sink>,
|
|
}
|
|
|
|
impl AppLogger {
|
|
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
|
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
|
}
|
|
|
|
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
|
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
|
|
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
|
match rotate(dir) {
|
|
Ok((path, file)) => {
|
|
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
|
Ok(path)
|
|
}
|
|
Err(e) => {
|
|
self.stderr_only();
|
|
Err(format!("{}: {e}", dir.display()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// No log file this run (no settings directory, or it can't be used): stop keeping records for one.
|
|
pub fn stderr_only(&self) {
|
|
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
|
|
}
|
|
}
|
|
|
|
impl log::Log for AppLogger {
|
|
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
|
metadata.level() <= self.filter.level_for(metadata.target())
|
|
}
|
|
|
|
fn log(&self, record: &log::Record<'_>) {
|
|
if !self.enabled(record.metadata()) {
|
|
return;
|
|
}
|
|
// Format before taking the lock, so the lock covers one write only.
|
|
let thread = std::thread::current();
|
|
let line =
|
|
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
|
if self.stderr {
|
|
let _ = std::io::stderr().write_all(line.as_bytes());
|
|
}
|
|
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
|
}
|
|
|
|
fn flush(&self) {}
|
|
}
|
|
|
|
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
|
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
|
|
pub fn install() -> Option<&'static AppLogger> {
|
|
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
|
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
|
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
|
let max = filter.max();
|
|
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
|
log::set_logger(logger).ok()?;
|
|
log::set_max_level(max);
|
|
Some(logger)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use log::Log;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::time::{Duration, UNIX_EPOCH};
|
|
|
|
fn temp_dir(tag: &str) -> PathBuf {
|
|
static N: AtomicUsize = AtomicUsize::new(0);
|
|
let d = std::env::temp_dir().join(format!("deckcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
d
|
|
}
|
|
|
|
fn read(path: &Path) -> String {
|
|
std::fs::read_to_string(path).expect("read log")
|
|
}
|
|
|
|
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
|
|
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
|
}
|
|
|
|
#[test]
|
|
fn the_default_filter_shows_deckcraft_info_and_other_crates_warnings() {
|
|
let f = Filter::parse(DEFAULT_FILTER);
|
|
assert_eq!(f.level_for("deckcraft"), LevelFilter::Info);
|
|
assert_eq!(f.level_for("deckcraft::audio"), LevelFilter::Info);
|
|
assert_eq!(f.level_for("deckcraft_engine::guard"), LevelFilter::Info);
|
|
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Info);
|
|
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
|
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
|
assert_eq!(f.max(), LevelFilter::Info);
|
|
}
|
|
|
|
#[test]
|
|
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
|
let f = Filter::parse("info,wgpu_core=error,deckcraft_pptx=trace,deckcraft_pptx::opc=off");
|
|
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
|
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
|
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Trace);
|
|
assert_eq!(f.level_for("deckcraft_pptx::opc"), LevelFilter::Off);
|
|
assert_eq!(f.max(), LevelFilter::Trace);
|
|
// A module name is matched at `::` boundaries, not as a bare prefix.
|
|
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
|
// A trailing `*` is a prefix.
|
|
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_ui_egui::canvas"), LevelFilter::Debug);
|
|
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_engine"), LevelFilter::Warn);
|
|
// A bare target name sets that target to the most verbose level, as env_logger does.
|
|
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
|
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
|
// Levels are case-insensitive; the last bare level wins.
|
|
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
|
let long = "a".repeat(100_000);
|
|
let specs = [
|
|
"",
|
|
",,,",
|
|
"=",
|
|
"==",
|
|
"=debug",
|
|
"deckcraft=",
|
|
"nonsense=loud",
|
|
"🦀=info",
|
|
"*",
|
|
"*=debug",
|
|
"a=b=c",
|
|
"warn,,deckcraft=DEBUG",
|
|
" \t ",
|
|
"\u{0}",
|
|
"é*=trace",
|
|
&long,
|
|
];
|
|
for spec in specs {
|
|
let f = Filter::parse(spec);
|
|
for target in ["deckcraft", "", "🦀", "é", "a::b"] {
|
|
let _ = f.level_for(target);
|
|
}
|
|
let _ = f.max();
|
|
}
|
|
assert_eq!(Filter::parse("warn,,deckcraft=DEBUG").level_for("deckcraft"), LevelFilter::Debug);
|
|
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
|
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
|
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
|
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
|
}
|
|
|
|
#[test]
|
|
fn timestamps_are_utc_with_milliseconds() {
|
|
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
|
// Leap day, and the day after it.
|
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
|
// 2000 is a leap year (divisible by 400), 2100 is not.
|
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
|
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
|
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
|
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
|
|
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
|
|
// with a checked add and only asserted where it exists.
|
|
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
|
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
|
}
|
|
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
|
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
|
}
|
|
// Years past 9999 keep the plain zero-padded year (no `+`).
|
|
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
|
}
|
|
|
|
#[test]
|
|
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
|
let dir = temp_dir("rotate");
|
|
for run in 1..=4 {
|
|
let (path, _file) = rotate(&dir).expect("rotate");
|
|
assert_eq!(path, dir.join(LOG_FILE));
|
|
assert_eq!(read(&path), "");
|
|
std::fs::write(&path, format!("run {run}")).expect("write");
|
|
}
|
|
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
|
assert_eq!(read(&dir.join("deckcraft.1.log")), "run 3");
|
|
assert_eq!(read(&dir.join("deckcraft.2.log")), "run 2");
|
|
assert!(!dir.join("deckcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
|
let dir = temp_dir("blocked");
|
|
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
|
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
|
assert!(rotate(&dir).is_err());
|
|
assert!(rotate(&dir.join("below")).is_err());
|
|
let _ = std::fs::remove_file(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn a_line_carries_time_level_thread_target_and_message() {
|
|
assert_eq!(
|
|
format_line(
|
|
"2026-10-08T07:59:17.728Z",
|
|
log::Level::Warn,
|
|
"main",
|
|
"deckcraft_pptx::opc",
|
|
"pptx: skipping oversized part ppt/media/image1.png"
|
|
),
|
|
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
|
|
);
|
|
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
|
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
|
let dir = temp_dir("pending");
|
|
let (path, file) = rotate(&dir).expect("rotate");
|
|
let mut sink = Sink::new(MAX_FILE_BYTES);
|
|
sink.write("early 1\n");
|
|
sink.write("early 2\n");
|
|
sink.attach(file);
|
|
sink.write("late\n");
|
|
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
|
let dir = temp_dir("bounded");
|
|
let (path, file) = rotate(&dir).expect("rotate");
|
|
let mut sink = Sink::new(MAX_FILE_BYTES);
|
|
for i in 0..MAX_PENDING + 5 {
|
|
sink.write(&format!("line {i}\n"));
|
|
}
|
|
sink.attach(file);
|
|
let text = read(&path);
|
|
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
|
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
|
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn exactly_max_pending_lines_drop_nothing() {
|
|
let dir = temp_dir("exact");
|
|
let (path, file) = rotate(&dir).expect("rotate");
|
|
let mut sink = Sink::new(MAX_FILE_BYTES);
|
|
for i in 0..MAX_PENDING {
|
|
sink.write(&format!("line {i}\n"));
|
|
}
|
|
sink.attach(file);
|
|
let text = read(&path);
|
|
assert_eq!(text.lines().count(), MAX_PENDING);
|
|
assert!(!text.contains("dropped"), "{text}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn the_file_stops_at_its_size_cap_with_one_note() {
|
|
let dir = temp_dir("cap");
|
|
let (path, file) = rotate(&dir).expect("rotate");
|
|
let mut sink = Sink::new(100);
|
|
sink.attach(file);
|
|
for i in 0..50 {
|
|
sink.write(&format!("line {i:02} with some padding\n"));
|
|
}
|
|
let text = read(&path);
|
|
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
|
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
|
let dir = temp_dir("logger");
|
|
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
|
record(&logger, log::Level::Info, "deckcraft", "DeckCraft starting");
|
|
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
|
let path = logger.attach_dir(&dir).expect("attach");
|
|
assert_eq!(path, dir.join(LOG_FILE));
|
|
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
|
record(&logger, log::Level::Debug, "deckcraft", "below info");
|
|
let text = read(&path);
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
assert_eq!(lines.len(), 2, "{text}");
|
|
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("deckcraft: DeckCraft starting")), "{text}");
|
|
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
|
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("deckcraft_engine::recovery").build()));
|
|
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
|
// A second launch rotates: the first log becomes `.1`.
|
|
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
|
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
|
assert_eq!(read(&dir.join("deckcraft.1.log")), text);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn records_from_many_threads_are_whole_lines() {
|
|
let dir = temp_dir("threads");
|
|
let logger = AppLogger::new(Filter::parse("info"), false);
|
|
let path = logger.attach_dir(&dir).expect("attach");
|
|
std::thread::scope(|s| {
|
|
for _ in 0..4 {
|
|
s.spawn(|| {
|
|
for _ in 0..100 {
|
|
record(&logger, log::Level::Info, "deckcraft", "from a worker");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
let text = read(&path);
|
|
assert_eq!(text.lines().count(), 400);
|
|
assert!(text.lines().all(|l| l.ends_with("deckcraft: from a worker")), "{text}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
|
let dir = temp_dir("unusable");
|
|
std::fs::write(&dir, "not a directory").expect("block");
|
|
let logger = AppLogger::new(Filter::parse("info"), false);
|
|
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
|
assert!(e.contains(&dir.display().to_string()), "{e}");
|
|
record(&logger, log::Level::Error, "x", "still fine");
|
|
let sink = logger.sink.lock().expect("sink");
|
|
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
|
drop(sink);
|
|
let _ = std::fs::remove_file(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn a_poisoned_lock_does_not_stop_logging() {
|
|
let dir = temp_dir("poison");
|
|
let logger = AppLogger::new(Filter::parse("info"), false);
|
|
let path = logger.attach_dir(&dir).expect("attach");
|
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
let _guard = logger.sink.lock();
|
|
panic!("poison the sink");
|
|
}));
|
|
assert!(logger.sink.is_poisoned());
|
|
record(&logger, log::Level::Info, "deckcraft", "after the panic");
|
|
assert!(read(&path).contains("after the panic"));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|