timefs 0.1.0

Mount a Git repository as a read-only filesystem.
Documentation
#[allow(dead_code)]
mod support;

use self::support::{with_mounted_timefs, RepositoryFixture};
use std::ffi::OsString;
use std::fs;
use std::os::unix::ffi::OsStringExt;

#[test]
fn history_resolver_lists_versions_oldest_to_newest() {
    let fixture = history_fixture();
    let expected = git_history_entries(&fixture, "docs/story.rs");

    assert_eq!(
        expected.len(),
        4,
        "fixture should create four history entries"
    );

    let (_value, mount) = with_mounted_timefs(fixture.path(), &[], &["-f"], |mounted| {
        let root_entries = sorted_dir_entries(mounted.path());
        assert_eq!(
            root_entries,
            vec![
                b"at".to_vec(),
                b"commits".to_vec(),
                b"history".to_vec(),
                b"now".to_vec(),
                b"refs".to_vec(),
            ]
        );

        let history_root = mounted.path().join("history");
        assert!(
            fs::read_dir(&history_root)
                .expect("history/ should be readable")
                .next()
                .is_none(),
            "history/ should not enumerate arbitrary paths"
        );
        assert!(
            fs::read_dir(history_root.join("docs"))
                .expect("history/docs should be readable")
                .next()
                .is_none(),
            "history prefix directories should stay synthetic"
        );

        let mounted_history = history_root.join("docs").join("story.rs");
        let mounted_versions = sorted_dir_entries(&mounted_history);
        assert_eq!(
            mounted_versions,
            expected
                .iter()
                .map(|entry| entry.name.clone())
                .collect::<Vec<_>>()
        );

        let first = fs::read(mounted_history.join(OsString::from_vec(expected[0].name.clone())))
            .expect("first history entry should be readable");
        let second = fs::read(mounted_history.join(OsString::from_vec(expected[1].name.clone())))
            .expect("second history entry should be readable");
        assert_ne!(first, second, "successive history versions should differ");

        for entry in &expected {
            let mounted_path = mounted_history.join(OsString::from_vec(entry.name.clone()));
            assert_eq!(
                fs::read(&mounted_path).expect("history file should be readable"),
                fixture.git_show_path(entry.commit.as_str(), &entry.path)
            );
        }
    });

    assert!(
        mount.stderr.is_empty(),
        "history mount emitted unexpected stderr: {}",
        mount.stderr
    );
}

#[derive(Clone, Debug, Eq, PartialEq)]
struct ExpectedHistoryEntry {
    name: Vec<u8>,
    commit: String,
    path: Vec<u8>,
}

fn history_fixture() -> RepositoryFixture {
    let fixture = RepositoryFixture::new("timefs-task9-history");
    fs::create_dir_all(fixture.path().join("docs"))
        .expect("fixture docs directory should be created");

    fs::write(fixture.path().join("docs/story.txt"), b"alpha\n")
        .expect("initial story write should succeed");
    fixture.git(["add", "."]);
    fixture.git(["commit", "-m", "Add story"]);

    fs::write(fixture.path().join("docs/story.txt"), b"alpha\nbeta\n")
        .expect("second story write should succeed");
    fixture.git(["add", "."]);
    fixture.git(["commit", "-m", "Expand story"]);

    fixture.git(["mv", "docs/story.txt", "docs/story.rs"]);
    fixture.git(["commit", "-m", "Rename story"]);

    fs::write(
        fixture.path().join("docs/story.rs"),
        b"fn main() {\n    println!(\"alpha\");\n    println!(\"beta\");\n}\n",
    )
    .expect("final story write should succeed");
    fixture.git(["add", "."]);
    fixture.git(["commit", "-m", "Update story"]);

    fixture
}

fn git_history_entries(fixture: &RepositoryFixture, path: &str) -> Vec<ExpectedHistoryEntry> {
    let output = fixture.git([
        "log",
        "--follow",
        "-z",
        "--format=commit:%H",
        "--name-status",
        "--diff-filter=AMCRT",
        "--",
        path,
    ]);
    let mut tokens = output
        .stdout
        .split(|byte| *byte == 0)
        .filter(|token| !token.is_empty());
    let mut entries = Vec::new();

    while let Some(commit_token) = tokens.next() {
        let Some(hex) = commit_token.strip_prefix(b"commit:") else {
            continue;
        };
        let commit = String::from_utf8(hex.to_vec()).expect("history commit should be UTF-8");
        let Some(status_token) = tokens.next() else {
            break;
        };
        let status = status_token.strip_prefix(b"\n").unwrap_or(status_token);
        let Some(kind) = status.first().copied() else {
            continue;
        };
        let Some(first_path) = tokens.next() else {
            break;
        };
        let path_at_commit = match kind {
            b'R' | b'C' => tokens
                .next()
                .expect("rename history should include the destination path")
                .to_vec(),
            _ => first_path.to_vec(),
        };
        entries.push(ExpectedHistoryEntry {
            name: Vec::new(),
            commit,
            path: path_at_commit,
        });
    }

    entries.reverse();
    let width = entries.len().to_string().len().max(4);
    for (index, entry) in entries.iter_mut().enumerate() {
        entry.name = history_version_name(index + 1, width, &entry.commit, &entry.path);
    }
    entries
}

fn history_version_name(ordinal: usize, width: usize, commit: &str, path: &[u8]) -> Vec<u8> {
    let mut name = format!("{ordinal:0width$}-").into_bytes();
    let short_len = commit.len().min(7);
    name.extend_from_slice(commit.as_bytes().get(..short_len).unwrap_or_default());
    name.extend_from_slice(history_version_suffix(path));
    name
}

fn history_version_suffix(path: &[u8]) -> &[u8] {
    let basename = path.rsplit(|byte| *byte == b'/').next().unwrap_or(path);
    if basename.first().is_some_and(|byte| *byte == b'.') {
        return basename;
    }
    match basename.iter().position(|byte| *byte == b'.') {
        Some(index) => basename.get(index..).unwrap_or_default(),
        None => &[],
    }
}

fn sorted_dir_entries(path: &std::path::Path) -> Vec<Vec<u8>> {
    let mut entries = fs::read_dir(path)
        .expect("directory should be readable")
        .filter_map(Result::ok)
        .map(|entry| entry.file_name().into_vec())
        .collect::<Vec<_>>();
    entries.sort();
    entries
}