zoi-rs 1.18.0

Advanced Package Manager & Environment Orchestrator
Documentation
use sha2::Digest;
use std::fs;
use tempfile::tempdir;
use zoi::pkg::plugin;

mod common;

#[test]
fn test_plugin_on_project_install_hook() {
    let ctx = common::TestContextGuard::acquire();
    let tmp = tempdir().expect("Failed to create temp dir");
    let root = tmp.path().to_path_buf();
    ctx.set_sysroot(root.clone());

    let pm = plugin::PluginManager::new().expect("Failed to create PluginManager");

    pm.lua
        .load(
            r#"
        zoi.on_project_install(function()
            return true
        end)
    "#,
        )
        .exec()
        .expect("Failed to load lua");

    let handled = pm.trigger_project_install_hook().expect("Hook failed");
    assert!(handled, "Hook should have returned true");
}

#[test]
fn test_plugin_fs_symlink_api() {
    let tmp = tempdir().expect("Failed to create temp dir");
    let root = tmp.path().to_path_buf();

    let target = root.join("target.txt");
    let link = root.join("link.txt");
    fs::write(&target, "hello").unwrap();

    let pm = plugin::PluginManager::new().unwrap();
    let script = format!(
        r#"return zoi.fs.symlink("{}", "{}", false)"#,
        target.to_str().unwrap().replace("\\", "/"),
        link.to_str().unwrap().replace("\\", "/")
    );

    let success: bool = pm.lua.load(&script).eval().unwrap();
    assert!(success, "Symlink API should return true");
    assert!(link.exists(), "Link should exist");
    assert_eq!(fs::read_to_string(link).unwrap(), "hello");
}

#[test]
fn test_plugin_archive_extract_api() {
    let tmp = tempdir().expect("Failed to create temp dir");
    let root = tmp.path().to_path_buf();

    let tar_path = root.join("test.tar.gz");
    let source_dir = root.join("src");
    fs::create_dir_all(source_dir.join("package")).unwrap();
    fs::write(source_dir.join("package/hello.txt"), "world").unwrap();

    {
        let file = fs::File::create(&tar_path).unwrap();
        let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
        let mut tar = tar::Builder::new(enc);
        tar.append_dir_all("package", source_dir.join("package"))
            .unwrap();
        tar.finish().unwrap();
    }

    let pm = plugin::PluginManager::new().unwrap();
    let dest = root.join("dest");

    let script = format!(
        r#"return zoi.archive.extract("{}", "{}", 1)"#,
        tar_path.to_str().unwrap().replace("\\", "/"),
        dest.to_str().unwrap().replace("\\", "/")
    );

    let success: bool = pm.lua.load(&script).eval().unwrap();
    assert!(success, "Extract API should return true");
    assert!(
        dest.join("hello.txt").exists(),
        "hello.txt should be extracted to dest root due to strip=1"
    );
    assert_eq!(fs::read_to_string(dest.join("hello.txt")).unwrap(), "world");
}

#[test]
fn test_plugin_archive_extract_rejects_path_traversal_with_strip() {
    let tmp = tempdir().expect("Failed to create temp dir");
    let root = tmp.path().to_path_buf();

    let zip_path = root.join("evil.zip");
    {
        use std::io::Write;

        let file = fs::File::create(&zip_path).unwrap();
        let mut zip = zip::ZipWriter::new(file);
        zip.start_file(
            "package/../../escape.txt",
            zip::write::SimpleFileOptions::default(),
        )
        .unwrap();
        zip.write_all(b"escape").unwrap();
        zip.finish().unwrap();
    }

    let pm = plugin::PluginManager::new().unwrap();
    let dest = root.join("dest");
    let escaped = root.join("escape.txt");

    let script = format!(
        r#"return zoi.archive.extract("{}", "{}", 1)"#,
        zip_path.to_str().unwrap().replace("\\", "/"),
        dest.to_str().unwrap().replace("\\", "/")
    );

    let result: Result<bool, mlua::Error> = pm.lua.load(&script).eval();
    assert!(
        result.is_err(),
        "strip extraction should reject path traversal"
    );
    assert!(
        !escaped.exists(),
        "archive extraction should not create files outside the destination"
    );
}

#[test]
fn test_plugin_fs_copy_api() {
    let tmp = tempdir().expect("Failed to create temp dir");
    let root = tmp.path().to_path_buf();

    let src = root.join("src_dir");
    let dest = root.join("dest_dir");
    fs::create_dir_all(&src).unwrap();
    fs::write(src.join("file.txt"), "copy-content").unwrap();

    let pm = plugin::PluginManager::new().unwrap();
    let script = format!(
        r#"return zoi.fs.copy("{}", "{}")"#,
        src.to_str().unwrap().replace("\\", "/"),
        dest.to_str().unwrap().replace("\\", "/")
    );

    let success: bool = pm.lua.load(&script).eval().unwrap();
    assert!(success, "Copy API should return true");
    assert!(dest.join("file.txt").exists());
    assert_eq!(
        fs::read_to_string(dest.join("file.txt")).unwrap(),
        "copy-content"
    );
}

#[test]
fn test_plugin_sh_api() {
    let tmp = tempdir().expect("Failed to create temp dir");
    let root = tmp.path().to_path_buf();
    let out_file = root.join("sh_out.txt");

    let pm = plugin::PluginManager::new().unwrap();
    let script = format!(
        r#"return zoi.sh("echo 'sh-works' > {}")"#,
        out_file.to_str().unwrap().replace("\\", "/")
    );

    let exit_code: i32 = pm.lua.load(&script).eval().unwrap();
    assert_eq!(exit_code, 0, "zoi.sh should return 0 exit code");
    assert!(out_file.exists());
    assert_eq!(fs::read_to_string(out_file).unwrap().trim(), "sh-works");
}

#[test]
fn test_plugin_env_set_is_session_local() {
    let pm = plugin::PluginManager::new().unwrap();
    let value: Option<String> = pm
        .lua
        .load(
            r#"
        zoi.env.set("ZOI_PLUGIN_TEST_ENV", "session-value")
        return zoi.env.get("ZOI_PLUGIN_TEST_ENV")
    "#,
        )
        .eval()
        .unwrap();
    assert_eq!(value.as_deref(), Some("session-value"));
}

#[test]
fn test_plugin_set_data_rejects_non_finite_number() {
    let pm = plugin::PluginManager::new().unwrap();
    let result: Result<(), mlua::Error> = pm.lua.load(r#"zoi.set_data("bad", 0/0)"#).exec();
    assert!(
        result.is_err(),
        "set_data should reject non-finite numbers instead of panicking"
    );
}

#[test]
fn test_plugin_load_all_uses_deterministic_sorted_order() {
    let mut ctx = common::TestContextGuard::acquire();
    let tmp = tempdir().expect("Failed to create temp dir");
    let root = tmp.path().to_path_buf();

    ctx.set_env_var("HOME", root.clone());

    let plugin_dir = root.join(".zoi/plugins");
    fs::create_dir_all(&plugin_dir).unwrap();
    fs::write(
        plugin_dir.join("z-last.lua"),
        r#"
zoi.on_project_install(function()
    zoi.set_data("plugin_order_winner", "z-last")
    return true
end)
"#,
    )
    .unwrap();
    fs::write(
        plugin_dir.join("a-first.lua"),
        r#"
zoi.on_project_install(function()
    zoi.set_data("plugin_order_winner", "a-first")
    return true
end)
"#,
    )
    .unwrap();

    let mut trusted = std::collections::HashMap::new();
    for file in &["a-first.lua", "z-last.lua"] {
        let content = fs::read_to_string(plugin_dir.join(file)).unwrap();
        let mut hasher = sha2::Sha256::new();
        sha2::Digest::update(&mut hasher, content.as_bytes());
        trusted.insert(file.to_string(), hex::encode(hasher.finalize()));
    }
    fs::write(
        plugin_dir.join("trusted_hashes.json"),
        serde_json::to_string(&trusted).unwrap(),
    )
    .unwrap();

    let pm = plugin::PluginManager::new().unwrap();
    pm.load_all(true).unwrap();

    let handled = pm.trigger_project_install_hook().unwrap();
    assert!(
        handled,
        "the first registered plugin hook should handle the install"
    );

    let winner: Option<String> = pm
        .lua
        .load(r#"return zoi.get_data("plugin_order_winner")"#)
        .eval()
        .unwrap();
    assert_eq!(winner.as_deref(), Some("a-first"));
}

#[test]
fn test_plugin_on_project_install_precedence() {
    let pm = plugin::PluginManager::new().expect("Failed to create PluginManager");

    pm.lua
        .load(
            r#"
        local run_count = 0
        zoi.on_project_install(function()
            run_count = run_count + 1
            return true
        end)
        zoi.on_project_install(function()
            run_count = run_count + 1
            return true
        end)
        
        function get_count() return run_count end
    "#,
        )
        .exec()
        .expect("Failed to load lua");

    let handled = pm.trigger_project_install_hook().expect("Hook failed");
    assert!(handled);

    let count: i32 = pm.lua.load("return get_count()").eval().unwrap();
    assert_eq!(
        count, 1,
        "Only the first hook that returns true should execute"
    );
}