rmcl 0.4.0

A fully featured Minecraft TUI launcher
use super::*;

#[test]
fn curseforge_fingerprint_ignores_whitespace() {
    let temp = tempfile::tempdir().unwrap();
    let compact = temp.path().join("compact.jar");
    let spaced = temp.path().join("spaced.jar");
    std::fs::write(&compact, b"abc").unwrap();
    std::fs::write(&spaced, b"a b\nc\r\t").unwrap();
    let compact = fingerprint(&compact).unwrap();
    assert_eq!(compact.hash("curseforge"), Some("1621425345"));
    assert_eq!(
        compact.hash("curseforge"),
        fingerprint(&spaced).unwrap().hash("curseforge")
    );
}

#[test]
fn manifest_round_trip_and_lookup_are_exact() {
    let temp = tempfile::tempdir().unwrap();
    let path = temp.path().join("rmcl/content/manifest.json");
    let record = ContentFileRecord {
        relative_path: PathBuf::from("mods/fabric-api.jar"),
        kind: ContentKind::Mod,
        enabled: true,
        fingerprint: FileFingerprint {
            size: 3,
            modified_ns: 4,
            hashes: BTreeMap::from([("sha1".to_owned(), "abc".to_owned())]),
        },
        resolution: Resolution::Resolved {
            project: ProviderProject {
                provider: "modrinth".to_owned(),
                project_id: "P7dR8mSH".to_owned(),
                version_id: "version".to_owned(),
            },
        },
        provider_aliases: Vec::new(),
        provider_checks: Vec::new(),
        required_dependencies: Vec::new(),
        automatic_dependency: false,
        cleanup_eligible: false,
    };
    let mut manifest = ContentManifest::default();
    manifest.upsert(record);
    manifest.save(&path).unwrap();

    let loaded = ContentManifest::load(&path).unwrap();
    assert_eq!(
        loaded.resolved_project_path("modrinth", "P7dR8mSH", Path::new("/instance/minecraft")),
        Some(PathBuf::from("/instance/minecraft/mods/fabric-api.jar"))
    );
    assert!(
        loaded
            .resolved_project_path("modrinth", "fabric-api", Path::new("/minecraft"))
            .is_none()
    );
}

#[test]
fn fingerprint_contains_both_modrinth_hashes() {
    let temp = tempfile::tempdir().unwrap();
    let path = temp.path().join("example.jar");
    std::fs::write(&path, b"abc").unwrap();
    let fingerprint = fingerprint(&path).unwrap();
    assert_eq!(
        fingerprint.hash("sha1"),
        Some("a9993e364706816aba3e25717850c26c9cd0d89d")
    );
    assert_eq!(fingerprint.hash("sha512").unwrap().len(), 128);
}

#[test]
fn renaming_a_record_preserves_its_resolution() {
    let mut manifest = ContentManifest::default();
    manifest.upsert(ContentFileRecord {
        relative_path: PathBuf::from("mods/example.jar"),
        kind: ContentKind::Mod,
        enabled: true,
        fingerprint: FileFingerprint {
            size: 3,
            modified_ns: 4,
            hashes: BTreeMap::new(),
        },
        resolution: Resolution::Resolved {
            project: ProviderProject {
                provider: "modrinth".to_owned(),
                project_id: "project".to_owned(),
                version_id: "version".to_owned(),
            },
        },
        provider_aliases: Vec::new(),
        provider_checks: Vec::new(),
        required_dependencies: Vec::new(),
        automatic_dependency: false,
        cleanup_eligible: false,
    });

    assert!(manifest.rename_record(
        Path::new("mods/example.jar"),
        Path::new("mods/example.jar.disabled"),
        false,
    ));
    let record = manifest
        .record(Path::new("mods/example.jar.disabled"))
        .unwrap();
    assert!(!record.enabled);
    assert!(matches!(record.resolution, Resolution::Resolved { .. }));
}

fn managed_record(
    path: &str,
    project_id: &str,
    automatic_dependency: bool,
    required_dependencies: Vec<ProviderProject>,
) -> ContentFileRecord {
    ContentFileRecord {
        relative_path: PathBuf::from(path),
        kind: ContentKind::Mod,
        enabled: true,
        fingerprint: FileFingerprint {
            size: 1,
            modified_ns: 1,
            hashes: BTreeMap::new(),
        },
        resolution: Resolution::Resolved {
            project: ProviderProject {
                provider: "modrinth".to_owned(),
                project_id: project_id.to_owned(),
                version_id: format!("{project_id}-version"),
            },
        },
        provider_aliases: Vec::new(),
        provider_checks: Vec::new(),
        required_dependencies,
        automatic_dependency,
        cleanup_eligible: automatic_dependency,
    }
}

fn dependency(project_id: &str) -> ProviderProject {
    ProviderProject {
        provider: "modrinth".to_owned(),
        project_id: project_id.to_owned(),
        version_id: format!("{project_id}-version"),
    }
}

#[test]
fn provider_aliases_match_the_same_installed_record() {
    let mut record = managed_record("mods/library.jar", "curseforge-library", false, Vec::new());
    record.provider_aliases.push(ProviderProject {
        provider: "modrinth".to_owned(),
        project_id: "modrinth-library".to_owned(),
        version_id: "modrinth-version".to_owned(),
    });
    let manifest = ContentManifest {
        version: 1,
        files: vec![record],
    };

    assert!(
        manifest
            .resolved_project_record("modrinth", "modrinth-library")
            .is_some()
    );
}

#[test]
fn orphan_cleanup_keeps_shared_and_user_managed_libraries() {
    let manifest = ContentManifest {
        version: 1,
        files: vec![
            managed_record("mods/first.jar", "first", false, vec![dependency("shared")]),
            managed_record(
                "mods/second.jar",
                "second",
                false,
                vec![dependency("shared"), dependency("explicit")],
            ),
            managed_record("mods/shared.jar", "shared", true, Vec::new()),
            managed_record("mods/explicit.jar", "explicit", false, Vec::new()),
        ],
    };

    assert_eq!(
        manifest.orphaned_dependencies_after_removing(Path::new("mods/first.jar")),
        Vec::<PathBuf>::new()
    );
    assert_eq!(
        manifest.orphaned_dependencies_after_removing(Path::new("mods/second.jar")),
        Vec::<PathBuf>::new()
    );
}

#[test]
fn orphan_cleanup_follows_automatic_dependency_chains() {
    let manifest = ContentManifest {
        version: 1,
        files: vec![
            managed_record("mods/root.jar", "root", false, vec![dependency("library")]),
            managed_record(
                "mods/library.jar",
                "library",
                true,
                vec![dependency("nested")],
            ),
            managed_record("mods/nested.jar", "nested", true, Vec::new()),
        ],
    };

    assert_eq!(
        manifest.orphaned_dependencies_after_removing(Path::new("mods/root.jar")),
        vec![
            PathBuf::from("mods/library.jar"),
            PathBuf::from("mods/nested.jar")
        ]
    );
}

#[test]
fn orphan_cleanup_keeps_automatic_non_library_dependencies() {
    let mut dependency_record = managed_record("mods/sodium.jar", "sodium", true, Vec::new());
    dependency_record.cleanup_eligible = false;
    let manifest = ContentManifest {
        version: 1,
        files: vec![
            managed_record("mods/root.jar", "root", false, vec![dependency("sodium")]),
            dependency_record,
        ],
    };

    assert!(
        manifest
            .orphaned_dependencies_after_removing(Path::new("mods/root.jar"))
            .is_empty()
    );
}

#[test]
fn datapack_dependencies_are_scoped_to_their_world() {
    let mut first_root = managed_record(
        "saves/first/datapacks/root.zip",
        "root",
        false,
        vec![dependency("library")],
    );
    first_root.kind = ContentKind::DataPack;
    let mut second_root = managed_record(
        "saves/second/datapacks/root.zip",
        "root",
        false,
        vec![dependency("library")],
    );
    second_root.kind = ContentKind::DataPack;
    let mut first_library = managed_record(
        "saves/first/datapacks/library.zip",
        "library",
        true,
        Vec::new(),
    );
    first_library.kind = ContentKind::DataPack;
    let mut second_library = managed_record(
        "saves/second/datapacks/library.zip",
        "library",
        true,
        Vec::new(),
    );
    second_library.kind = ContentKind::DataPack;
    let manifest = ContentManifest {
        version: 1,
        files: vec![first_root, second_root, first_library, second_library],
    };

    assert_eq!(
        manifest.orphaned_dependencies_after_removing(Path::new("saves/first/datapacks/root.zip")),
        vec![PathBuf::from("saves/first/datapacks/library.zip")]
    );
}