mini-docs 0.3.5

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
use std::fs;
use std::path::Path;
use std::time::SystemTime;

use crate::error::DocError;
use crate::walk;

/// Returns `true` if `output_path` exists and is at least as new as both `md_mtime`
/// and `template_mtime` (the newest mtime across every file under the templates
/// dir, or `None` if there are no templates yet).
///
/// This is the render cache: the key is the pair `(md_mtime, template_mtime)`, and
/// the cached value is simply whatever is already sitting on disk at `output_path`.
/// No in-memory state is needed — and none would survive across separate process
/// invocations of a build anyway — the filesystem's own mtimes are the cache.
pub(crate) fn is_up_to_date(
    output_path: &Path,
    md_mtime: SystemTime,
    template_mtime: Option<SystemTime>,
) -> Result<bool, DocError> {
    let Ok(output_meta) = fs::metadata(output_path) else {
        return Ok(false);
    };
    let output_mtime = output_meta.modified()?;

    let newest_input = match template_mtime {
        Some(t) if t > md_mtime => t,
        _ => md_mtime,
    };

    Ok(output_mtime >= newest_input)
}

/// Returns the newest mtime across every file with `extension` under `dir`, or
/// `None` if there are no such files.
pub(crate) fn latest_mtime(dir: &Path, extension: &str) -> Result<Option<SystemTime>, DocError> {
    let mut latest: Option<SystemTime> = None;

    for path in walk::walk_files_with_extension(dir, extension)? {
        let mtime = fs::metadata(&path)?.modified()?;
        latest = Some(match latest {
            Some(l) if l > mtime => l,
            _ => mtime,
        });
    }

    Ok(latest)
}

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

    #[test]
    fn missing_output_is_never_up_to_date() {
        let dir = tempfile::tempdir().expect("create tempdir");
        let missing = dir.path().join("does-not-exist.html");

        let up_to_date =
            is_up_to_date(&missing, SystemTime::now(), None).expect("check should succeed");

        assert!(!up_to_date);
    }

    #[test]
    fn output_older_than_md_is_not_up_to_date() {
        let dir = tempfile::tempdir().expect("create tempdir");
        let output = dir.path().join("out.html");
        fs::write(&output, "stale").expect("write output");
        let output_mtime = fs::metadata(&output)
            .expect("stat output")
            .modified()
            .expect("mtime");

        let newer_md_mtime = output_mtime + std::time::Duration::from_secs(1);

        let up_to_date =
            is_up_to_date(&output, newer_md_mtime, None).expect("check should succeed");

        assert!(!up_to_date);
    }

    #[test]
    fn output_newer_than_both_inputs_is_up_to_date() {
        let dir = tempfile::tempdir().expect("create tempdir");
        let output = dir.path().join("out.html");
        fs::write(&output, "fresh").expect("write output");
        let output_mtime = fs::metadata(&output)
            .expect("stat output")
            .modified()
            .expect("mtime");

        let older = output_mtime - std::time::Duration::from_secs(1);

        let up_to_date = is_up_to_date(&output, older, Some(older)).expect("check should succeed");

        assert!(up_to_date);
    }

    #[test]
    fn output_older_than_template_is_not_up_to_date_even_if_newer_than_md() {
        let dir = tempfile::tempdir().expect("create tempdir");
        let output = dir.path().join("out.html");
        fs::write(&output, "stale").expect("write output");
        let output_mtime = fs::metadata(&output)
            .expect("stat output")
            .modified()
            .expect("mtime");

        let older_md = output_mtime - std::time::Duration::from_secs(2);
        let newer_template = output_mtime + std::time::Duration::from_secs(1);

        let up_to_date =
            is_up_to_date(&output, older_md, Some(newer_template)).expect("check should succeed");

        assert!(!up_to_date);
    }
}