codecraft 0.1.2

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, a yakui-drawn UI, audio and gamepad haptics; its binary maps any folder, and the symbols of its Rust files, as a 3D wall of boxes
Documentation
//! Logging that outlives the terminal it was printed in.
//! ```no_run
//! let log = codecraft::logging::init("chessrs");
//! log::info!("started");
//! ```
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

/// Size at which the previous run's log is set aside.
const ROLL_AT: u64 = 8 * 1024 * 1024;

/// Starts logging to the terminal and to a file; `None` if the file could not be opened (the terminal still gets everything).
pub fn init(app_name: &str) -> Option<PathBuf> {
    let path = log_path(app_name);
    let file = path.as_deref().and_then(open);

    let mut builder =
        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"));
    let writing = match file {
        Some(file) => {
            builder.target(env_logger::Target::Pipe(Box::new(Tee {
                file,
                terminal: std::io::stderr(),
            })));
            true
        }
        None => false,
    };
    builder.init();

    let path = path.filter(|_| writing)?;
    log::info!("logging to {}", path.display());
    Some(path)
}

/// `$XDG_STATE_HOME/<app>/<app>.log`, falling back to `~/.local/state`.
pub fn log_path(app_name: &str) -> Option<PathBuf> {
    let base = match std::env::var("XDG_STATE_HOME") {
        Ok(state) if !state.is_empty() => PathBuf::from(state),
        // `home_dir` knows about `USERPROFILE`; `HOME` is only set on Windows under a Unix-style shell.
        _ => std::env::home_dir()?.join(".local").join("state"),
    };
    Some(base.join(app_name).join(format!("{app_name}.log")))
}

fn open(path: &Path) -> Option<File> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).ok()?;
    }
    if should_roll(std::fs::metadata(path).ok().map(|meta| meta.len())) {
        let _ = std::fs::rename(path, path.with_extension("log.1"));
    }

    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .ok()?;
    let _ = writeln!(
        file,
        "\n=== run started at {} (unix seconds) ===",
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|since| since.as_secs())
            .unwrap_or_default(),
    );
    Some(file)
}

fn should_roll(size: Option<u64>) -> bool {
    size.is_some_and(|size| size >= ROLL_AT)
}

struct Tee {
    file: File,
    terminal: std::io::Stderr,
}

impl Write for Tee {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        // The terminal goes first, so a file that has gone away does not stop it.
        let written = self.terminal.write(buf)?;
        let _ = self.file.write_all(buf);
        Ok(written)
    }

    fn flush(&mut self) -> std::io::Result<()> {
        let _ = self.file.flush();
        self.terminal.flush()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_log_goes_under_the_state_directory() {
        let path = log_path("chessrs").expect("there is a home to put it in");
        assert!(path.ends_with("chessrs/chessrs.log"), "{}", path.display());
    }

    #[test]
    fn a_log_is_set_aside_only_once_it_is_big() {
        assert!(!should_roll(None), "there is nothing to set aside yet");
        assert!(!should_roll(Some(0)));
        assert!(!should_roll(Some(ROLL_AT - 1)));
        assert!(should_roll(Some(ROLL_AT)));
    }

    #[test]
    fn a_run_is_marked_and_appended_to_rather_than_replacing_the_last_one() {
        let dir = std::env::temp_dir().join("renderer-logging-append");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("chessrs.log");
        std::fs::write(&path, "from the run before\n").unwrap();

        let mut file = open(&path).expect("the log opens");
        writeln!(file, "and this run").unwrap();
        drop(file);

        let written = std::fs::read_to_string(&path).unwrap();
        assert!(written.contains("from the run before"), "{written}");
        assert!(written.contains("run started at"), "{written}");
        assert!(written.contains("and this run"), "{written}");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn everything_written_lands_in_both_places() {
        let dir = std::env::temp_dir().join("renderer-logging-tee");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("tee.log");

        let mut tee = Tee {
            file: File::create(&path).unwrap(),
            terminal: std::io::stderr(),
        };
        tee.write_all(b"a line for the record\n").unwrap();
        tee.flush().unwrap();
        drop(tee);

        assert_eq!(
            std::fs::read_to_string(&path).unwrap(),
            "a line for the record\n",
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}