codecraft 0.1.1

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, an immediate-mode UI, audio and gamepad haptics
Documentation
//! Logging that outlives the terminal it was printed in.
//!
//! A game is played once and the interesting part — what the server said,
//! what was clicked, what broke — scrolls past and is gone with the window.
//! This writes everything to a file as well as the terminal, so a session
//! can be read back afterwards.
//!
//! ```no_run
//! let log = codecraft::logging::init("chessrs");
//! log::info!("started");
//! ```
//!
//! The level is [`RUST_LOG`](https://docs.rs/env_logger) as usual, `info` if
//! it is unset.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

/// How big the file gets before the run before it is set aside. Big enough
/// for a long session with every packet in it.
const ROLL_AT: u64 = 8 * 1024 * 1024;

/// Starts logging to the terminal and to a file, and says where the file is.
///
/// `None` if the file could not be opened — the terminal still gets
/// everything, since losing the log is not worth refusing to run over.
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)
}

/// Where the log lives: `$XDG_STATE_HOME/<app>/<app>.log`, falling back to
/// `~/.local/state`, which is where state that is not configuration and not
/// data belongs.
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 such as Git Bash.
        _ => std::env::home_dir()?.join(".local").join("state"),
    };
    Some(base.join(app_name).join(format!("{app_name}.log")))
}

/// Opens the log for appending, setting aside one that has grown too big, and
/// marks where this run starts.
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())) {
        // One generation back is enough to cover "it broke, then I restarted".
        let _ = std::fs::rename(path, path.with_extension("log.1"));
    }

    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .ok()?;
    // A run has to be findable in a file several of them share.
    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)
}

/// Whether a log of this size should be set aside before writing more.
fn should_roll(size: Option<u64>) -> bool {
    size.is_some_and(|size| size >= ROLL_AT)
}

/// Writes everything twice: once where it can be watched, once where it can
/// be read back.
struct Tee {
    file: File,
    terminal: std::io::Stderr,
}

impl Write for Tee {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        // The terminal is the one being watched, so it goes first, and 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);
    }
}