merlon 1.3.1

Mod package manager for the Paper Mario (N64) decompilation
Documentation
use std::process::Command;
use std::io::prelude::*;
use std::fs::File;
use temp_dir::TempDir;
use anyhow::Result;
use merlon::package::{*, init::*, manifest::*, distribute::ExportOptions};

/// Pinned decomp commit hash so that tests don't break when decomp updates
const DECOMP_REV: &str = "7a9df943ad079e7b19df0f8690bdc92e2beed964";

#[path = "rom.rs"]
mod rom;

#[test]
fn initialising_package_gives_decomp_dependency() -> Result<()> {
    let tempdir = TempDir::new()?;
    let pkg_path = tempdir.path().join("test");
    let package = Package::new("Test", pkg_path)?;
    let mut registry = Registry::new();
    let id = registry.register(package)?;
    assert_eq!(registry.all_dependencies()?.len(), 0);
    let package = registry.get_or_error(id)?;
    let _initialised = package.clone().to_initialised(InitialiseOptions {
        baserom: rom::baserom(),
        rev: Some(DECOMP_REV.to_string()),
    })?;
    let all_dependencies = registry.all_dependencies()?;
    assert_eq!(all_dependencies.len(), 1);
    assert!(matches!(all_dependencies.iter().next(), Some(Dependency::Decomp { .. })));
    Ok(())
}

#[test]
#[ignore] // TODO: this test is broken; single_dependency tests enough behaviour so could remove it
fn sync_complex_dependency_graph_to_repo() -> Result<()> {
    let tempdir = TempDir::new()?;
    let dir_path = tempdir.path();
    let mut registry = Registry::new();

    // Helper function to create a package with one patch and register it with the registry
    let mut create_and_register_package = |name: &str| -> Result<Id> {
        let pkg_path = dir_path.join(name);
        let package = Package::new(name, pkg_path)?;

        // Add a single commit adding a test file
        let mut file = File::create(package.path().join("patches/0001-test.patch")).unwrap();
        write!(&mut file, "{}", touch_file_patch(&format!("src/merlon_test_{name}"))).unwrap(); // TODO

        let id = registry.register(package)?;
        Ok(id)
    };

    // Create this dependency graph:
    //        Root      <-- We want to build this package 
    //      /     \
    //    DepA   DepB
    //      \     /
    //     SharedDep
    let root = create_and_register_package("Root")?;
    let dep_a = create_and_register_package("DepA")?;
    let dep_b = create_and_register_package("DepB")?;
    let shared_dep = create_and_register_package("SharedDep")?;
    dbg!(&root, &dep_a, &dep_b, &shared_dep);
    registry.add_direct_dependency(root, dep_a)?;
    registry.add_direct_dependency(root, dep_b)?;
    registry.add_direct_dependency(dep_a, shared_dep)?;

    // Initialise the root package and sync
    let root_package = registry.get_or_error(root)?.clone();
    let mut initialised = root_package.clone().to_initialised(InitialiseOptions {
        baserom: rom::baserom(),
        rev: Some(DECOMP_REV.to_string()),
    })?;
    initialised.set_registry(registry); // XXX
    initialised.setup_git_branches()?;
    initialised.update_patches_dir()?;

    // There should be 1 patch in the root package now
    let root_patches = initialised.package().path().join("patches");
    dbg!(root_patches
        .read_dir()?
        .map(|e| Ok(e?.file_name()))
        .collect::<Result<Vec<_>>>()?
    );
    assert_eq!(root_patches.read_dir()?.count(), 1);

    // If the patches applied correctly, all the test files should have been made
    assert!(initialised.subrepo_path().join("src/merlon_test_Root.c").is_file());
    assert!(initialised.subrepo_path().join("src/merlon_test_DepA.c").is_file());
    assert!(initialised.subrepo_path().join("src/merlon_test_DepB.c").is_file());
    assert!(initialised.subrepo_path().join("src/merlon_test_SharedDep.c").is_file());

    Ok(())
}

// Generate a random git-like commit hash
fn gen_random_commit_hash_for_patch() -> String {
    use rand::Rng;
    let mut rng = rand::thread_rng();
    let mut hash = String::with_capacity(40);
    for _ in 0..40 {
        hash.push(rng.gen_range(b'0'..b'9') as char);
    }
    hash
}

fn touch_file_patch(filename: &str) -> String {
    let hash = gen_random_commit_hash_for_patch();
    format!(r#"From {hash} Mon Sep 17 00:00:00 2001
From: Merlon test <merlontest@nanaian.town>
Date: Wed, 26 Apr 2023 22:40:19 +0100
Subject: test

---
    {filename}.c | 0
    1 file changed, 0 insertions(+), 0 deletions(-)
    create mode 100644 {filename}

diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
-- 
2.39.0"#)
}

#[test]
fn single_dependency() -> Result<()> {
    let tempdir = TempDir::new()?;

    // Root package with no commits
    let root = Package::new("Root", tempdir.path().join("root"))?;
    let mut root = root.to_initialised(InitialiseOptions {
        baserom: rom::baserom(),
        rev: Some(DECOMP_REV.to_string()),
    })?;

    // Dependency package with single commit
    let dependency = Package::new("Dependency", tempdir.path().join("dependency"))?;
    let mut file = File::create(dependency.path().join("patches/0001-set-bSkipIntro-to-true.patc"))?;
    write!(&mut file, "{}", skip_intro_patch())?;

    // Add dependency, sync repo, check skip intro commit was added
    root.add_dependency(AddDependencyOptions {
        path: dependency.path().to_path_buf(),
    })?;
    root.setup_git_branches()?;
    let output = Command::new("git")
        .arg("log")
        .arg("-1")
        .arg("--pretty=format:%s")
        .current_dir(root.subrepo_path())
        .output()?;
    let head_commit = String::from_utf8(output.stdout)?.trim().to_string();
    assert_eq!(&head_commit, "set bSkipIntro to true");

    // Export root and make some assertions
    let distributable = root.package().export_distributable(ExportOptions {
        baserom: Some(rom::baserom()),
        output: Some(tempdir.path().join("output.merlon")),
    })?;
    distributable.open_scoped(rom::baserom(), |package| {
        let manifest = package.manifest()?;

        // Should have 2 dependencies: decomp & dependency
        assert_eq!(manifest.iter_direct_dependencies().count(), 2);

        // Should have no patches (only dependency has patches)
        let patches_count = package.path().join("patches")
            .read_dir()?
            .count();
        assert_eq!(patches_count, 0);

        Ok(())
    })?;

    Ok(())
}

fn skip_intro_patch() -> &'static str {
    include_str!(
        concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/dependencies/skip_intro_patch.patch"
        )
    )
}

#[test]
fn initialised_patches_maintained() -> Result<()> {
    pretty_env_logger::init();
    let tempdir = TempDir::new()?;

    let package = Package::new("Package", tempdir.path().join("package"))?;
    let patch_path = package.path().join("patches/0001-set-bSkipIntro-to-true.patch");

    assert!(!patch_path.is_file());

    let mut file = File::create(&patch_path)?;
    write!(&mut file, "{}", skip_intro_patch())?;
    assert!(patch_path.is_file());

    // Initialise package
    let initialised = package.to_initialised(InitialiseOptions {
        baserom: rom::baserom(),
        rev: Some(DECOMP_REV.to_string()),
    })?;
    assert!(patch_path.is_file());

    // Assert we are on branch for package
    let output = Command::new("git")
        .arg("rev-parse")
        .arg("--abbrev-ref")
        .arg("HEAD")
        .current_dir(initialised.subrepo_path())
        .output()?;
    let branch = String::from_utf8(output.stdout)?.trim().to_string();
    assert_eq!(branch, initialised.package_id().to_string());

    // Update, sync, check patch is still there
    initialised.update_decomp()?; // XXX: brittle; will fail if patch becomes unmergeable
    assert!(patch_path.is_file());
    initialised.setup_git_branches()?;
    assert!(patch_path.is_file());

    // Assert patch is applied to repo
    let output = Command::new("git")
        .arg("log")
        .arg("-1")
        .arg("--pretty=format:%s")
        .current_dir(initialised.subrepo_path())
        .output()?;
    let head_commit = String::from_utf8(output.stdout)?.trim().to_string();
    assert_eq!(&head_commit, "set bSkipIntro to true");

    Ok(())
}