bonsai-ninja-sdk 0.2.2

Ergonomic facade SDK for bonsai-ninja.
use super::{
    default_export_cache_metadata_path, default_export_cache_path, export_cache_is_fresh_via_fd,
    unique_default_export_tmp_path, workspace_source_fingerprint_from_disk, write_default_export_cache,
    write_default_export_cache_with,
};
use std::path::Path;

#[test]
fn default_export_tmp_paths_are_unique_per_write() {
    let path = Path::new("/tmp/.bonsai/export.default.v14.json");
    let first = unique_default_export_tmp_path(path);
    let second = unique_default_export_tmp_path(path);

    assert_ne!(first, second);
    assert_eq!(first.parent(), path.parent());
    assert!(first
        .file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.starts_with("export.default.v14.json.tmp.")));
}

fn tempdir(name: &str) -> std::path::PathBuf {
    let stamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("clock")
        .as_nanos();
    let path = std::env::temp_dir().join(format!("bonsai-sdk-{name}-{}-{stamp}", std::process::id()));
    std::fs::create_dir(&path).expect("create temp dir");
    path
}

fn write_cache(root: &Path, rulepack_root: Option<&Path>) {
    let cache = default_export_cache_path(root);
    let sources = workspace_source_fingerprint_from_disk(root).expect("source fingerprint");
    write_default_export_cache(&cache, root, rulepack_root, sources, r#"{"ok":true}"#)
        .expect("write export cache");
}

fn cache_is_fresh(root: &Path, rulepack_root: Option<&Path>) -> bool {
    let cache = default_export_cache_path(root);
    let file = std::fs::File::open(cache).expect("open export cache");
    export_cache_is_fresh_via_fd(root, rulepack_root, &file).expect("freshness check")
}

#[test]
fn export_cache_writer_writes_one_valid_json_document() {
    let root = tempdir("export-single-json");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
    write_cache(&root, None);

    let cache = default_export_cache_path(&root);
    let bytes = std::fs::read_to_string(&cache).expect("read export cache");
    assert_eq!(
        bytes, "{\"ok\":true}\n",
        "string export cache writer must not duplicate the JSON document"
    );
    let parsed: serde_json::Value =
        serde_json::from_str(&bytes).expect("cache should parse as one JSON document");
    assert_eq!(parsed["ok"], true);
    assert!(cache_is_fresh(&root, None));

    std::fs::remove_dir_all(&root).ok();
}

#[test]
fn export_cache_writer_removes_abandoned_temp_siblings_under_lock() {
    let root = tempdir("export-stale-temp");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
    let cache = default_export_cache_path(&root);
    std::fs::create_dir_all(cache.parent().expect("cache parent")).expect("create cache dir");
    let abandoned = unique_default_export_tmp_path(&cache);
    std::fs::write(&abandoned, b"abandoned").expect("write abandoned temp");

    write_cache(&root, None);

    assert!(
        !abandoned.exists(),
        "exclusive writer should reclaim abandoned temp files"
    );
    std::fs::remove_dir_all(&root).ok();
}

#[test]
fn streaming_export_error_removes_its_temp_file() {
    let root = tempdir("export-error-temp");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
    let cache = default_export_cache_path(&root);
    let sources = workspace_source_fingerprint_from_disk(&root).expect("source fingerprint");

    let error = write_default_export_cache_with(&cache, &root, None, sources, |_writer| {
        anyhow::bail!("synthetic streaming failure")
    })
    .expect_err("writer should fail");

    assert!(error.to_string().contains("synthetic streaming failure"));
    let prefix = format!("{}.tmp.", cache.file_name().unwrap().to_string_lossy());
    let leftovers = std::fs::read_dir(cache.parent().expect("cache parent"))
        .expect("read cache dir")
        .filter_map(Result::ok)
        .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix))
        .count();
    assert_eq!(leftovers, 0, "failed streaming writes must not leak temp files");
    std::fs::remove_dir_all(&root).ok();
}

#[test]
fn export_cache_requires_metadata_sidecar() {
    let root = tempdir("export-missing-meta");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
    let cache = default_export_cache_path(&root);
    std::fs::create_dir_all(cache.parent().expect("cache parent")).expect("create cache dir");
    std::fs::write(&cache, "{}\n").expect("write raw cache");

    let file = std::fs::File::open(&cache).expect("open raw cache");
    assert!(
        !export_cache_is_fresh_via_fd(&root, None, &file).expect("freshness check"),
        "cache without fingerprint metadata must not be replayed"
    );
    assert!(!default_export_cache_metadata_path(&root).exists());

    std::fs::remove_dir_all(&root).ok();
}

#[test]
fn export_cache_rejects_source_content_mismatch() {
    let root = tempdir("export-source-content");
    std::fs::write(root.join("app.py"), "print('before')\n").expect("write source");
    write_cache(&root, None);
    assert!(cache_is_fresh(&root, None));

    std::fs::write(root.join("app.py"), "print('after')\n").expect("rewrite source");
    assert!(
        !cache_is_fresh(&root, None),
        "source content changes must invalidate the export cache even when cache bytes still exist"
    );

    std::fs::remove_dir_all(&root).ok();
}

#[test]
fn export_cache_rejects_pipeline_version_mismatch() {
    let root = tempdir("export-pipeline-version");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
    write_cache(&root, None);
    assert!(cache_is_fresh(&root, None));

    let metadata_path = default_export_cache_metadata_path(&root);
    let mut metadata: serde_json::Value =
        serde_json::from_slice(&std::fs::read(&metadata_path).expect("read metadata"))
            .expect("parse metadata");
    metadata["pipeline_version"] = serde_json::Value::String("native-export-cache-old".to_string());
    let mut bytes = serde_json::to_vec_pretty(&metadata).expect("serialize metadata");
    bytes.push(b'\n');
    std::fs::write(&metadata_path, bytes).expect("rewrite metadata");

    assert!(
        !cache_is_fresh(&root, None),
        "native export cache pipeline changes must invalidate stale semantic export JSON"
    );

    std::fs::remove_dir_all(&root).ok();
}

#[test]
fn export_cache_rejects_dependency_metadata_mismatch() {
    let root = tempdir("export-dependency-content");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
    std::fs::write(root.join("requirements.txt"), "flask==3.0.0\n").expect("write deps");
    write_cache(&root, None);
    assert!(cache_is_fresh(&root, None));

    std::fs::write(root.join("requirements.txt"), "flask==3.0.0\nrequests==2.32.0\n").expect("rewrite deps");
    assert!(
        !cache_is_fresh(&root, None),
        "dependency metadata content changes must invalidate the export cache"
    );

    std::fs::remove_dir_all(&root).ok();
}

#[test]
fn export_cache_rejects_common_dependency_metadata_shapes() {
    for (label, manifest) in [
        ("go-work", "go.work"),
        ("requirements-dev", "requirements-dev.txt"),
        ("dotnet-project", "Service.csproj"),
    ] {
        let root = tempdir(label);
        std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
        std::fs::write(root.join(manifest), "before\n").expect("write dependency metadata");
        write_cache(&root, None);
        assert!(
            cache_is_fresh(&root, None),
            "{manifest} precondition should be fresh"
        );

        std::fs::write(root.join(manifest), "after\n").expect("rewrite dependency metadata");
        assert!(
            !cache_is_fresh(&root, None),
            "{manifest} changes must invalidate the export cache"
        );

        std::fs::remove_dir_all(&root).ok();
    }
}

#[test]
fn export_cache_rejects_deep_dependency_metadata_mismatch() {
    let root = tempdir("export-deep-dependency-content");
    let manifest_dir = root
        .join("a")
        .join("b")
        .join("c")
        .join("d")
        .join("e")
        .join("service");
    std::fs::create_dir_all(&manifest_dir).expect("create deep manifest dir");
    let manifest = manifest_dir.join("pom.xml");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
    std::fs::write(&manifest, "<project />\n").expect("write deep dependency metadata");
    write_cache(&root, None);
    assert!(cache_is_fresh(&root, None));

    std::fs::write(&manifest, "<project><dependencies /></project>\n")
        .expect("rewrite deep dependency metadata");
    assert!(
        !cache_is_fresh(&root, None),
        "deep dependency metadata changes must invalidate the export cache"
    );

    std::fs::remove_dir_all(&root).ok();
}

#[test]
fn export_cache_rejects_rulepack_content_mismatch() {
    let root = tempdir("export-rulepack-content");
    let rulepack = tempdir("export-rulepack");
    let sinks = rulepack.join("sinks");
    std::fs::create_dir_all(&sinks).expect("create flat rule bucket");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write source");
    std::fs::write(sinks.join("rule.yml"), "id: before\n").expect("write rule");
    write_cache(&root, Some(&rulepack));
    assert!(cache_is_fresh(&root, Some(&rulepack)));

    std::fs::write(rulepack.join("README.md"), "packaging note\n").expect("write nonsemantic file");
    assert!(
        cache_is_fresh(&root, Some(&rulepack)),
        "nonsemantic rulepack files must not invalidate the export cache"
    );

    std::fs::write(sinks.join("rule.yml"), "id: after\n").expect("rewrite rule");
    assert!(
        !cache_is_fresh(&root, Some(&rulepack)),
        "loaded rule YAML changes must invalidate the export cache"
    );

    std::fs::remove_dir_all(&root).ok();
    std::fs::remove_dir_all(&rulepack).ok();
}

#[cfg(unix)]
#[test]
fn export_freshness_skips_symlinked_directories() {
    let root = tempdir("export-root");
    let outside = tempdir("export-outside");
    std::fs::write(root.join("app.py"), "print('root')\n").expect("write root app");
    std::fs::write(outside.join("external.py"), "print('outside')\n").expect("write outside app");
    std::os::unix::fs::symlink(&outside, root.join("linked")).expect("create symlink dir");

    let before = workspace_source_fingerprint_from_disk(&root).expect("fingerprint before");
    std::fs::write(outside.join("external.py"), "print('changed')\n").expect("rewrite outside app");
    let after = workspace_source_fingerprint_from_disk(&root).expect("fingerprint after");
    std::fs::remove_dir_all(&root).ok();
    std::fs::remove_dir_all(&outside).ok();

    assert_eq!(before, after);
}