artifactize-core 0.13.1

The library behind artifactize: Artifacts, evals, reviews, the monitor and review models, and the shared operations every interface calls.
Documentation
use super::*;
use serde_json::json;
use std::fs;
use tokio_util::sync::CancellationToken;

const GRAPH: &str = "name = 'graph'\n[evals.review]\ntitle = 'Review graph'\nprofile = { kind = 'human' }\npayload = { instruction = 'Review the project structure.' }\n";
const APP: &str = "name = 'app'\ntags = ['code']\n[evals.review]\ntitle = 'Review app'\nprofile = { kind = 'agent', backend = 'openai', model = 'model-one', reasoning = 'high', timeout_ms = 10000, max_tool_calls = 10, max_tokens = 1000 }\npayload = { instruction = 'Review {leaf} and {app}.', context = 'criteria' }\npass_schema = { type = 'object' }\nfail_schema = { type = 'object' }\n[evals.command]\ntitle = 'Runtime criteria'\nprofile = { kind = 'runtime', command = 'runtime-program', args = ['{leaf}/input.txt'], timeout_ms = 1234 }\npayload = { instruction = 'Check.' }\n";

fn fixture() -> tempfile::TempDir {
    let repo = crate::test_os::tempdir();
    fs::write(repo.path().join(".artf"), GRAPH).unwrap();
    fs::write(repo.path().join("index.artf"), APP).unwrap();
    fs::create_dir(repo.path().join("child")).unwrap();
    fs::write(repo.path().join("child/index.artf"), "name = 'leaf'\nbasis = true\nfingerprint = { script = { command = 'fingerprint-program', args = ['version'], files = ['input.txt'], timeout_ms = 1000 } }\n").unwrap();
    fs::write(repo.path().join("child/input.txt"), "data").unwrap();
    repo
}

#[test]
fn graph_fingerprint_tracks_relations_and_criteria_not_execution_or_location() {
    let repo = fixture();
    let fingerprint = || read(repo.path()).unwrap().fingerprint();
    let baseline = fingerprint();
    // Each independent change is meaningful even when no input bytes change.
    for changed in [
        APP.replace("Review app", "A different title"),
        APP.replace("command = 'runtime-program'", "command = 'different-program'"),
        APP.replace("args = ['{leaf}/input.txt']", "args = ['{leaf}/input.txt', 'criterion']"),
        APP.replace("context = 'criteria'", "context = 'different criteria'"),
        APP.replace("Review {leaf} and {app}.", "Review {app}."),
        APP.replace("pass_schema = { type = 'object' }", "pass_schema = { type = 'object', required = ['summary'] }"),
        APP.replace("kind = 'agent', backend = 'openai', model = 'model-one', reasoning = 'high', timeout_ms = 10000, max_tool_calls = 10, max_tokens = 1000", "kind = 'human'"),
    ] {
        fs::write(repo.path().join("index.artf"), changed).unwrap();
        assert_ne!(fingerprint(), baseline);
    }
    fs::write(repo.path().join("index.artf"), APP).unwrap();
    // Options, variants and views never enter extraction, including runtime variants.
    let excluded = APP
        .replace("['code']", "['changed-tag']")
        .replace(
            "title = 'Review app'",
            "title = 'Review app'\nmutex = ['use-computer:display']",
        )
        .replace("backend = 'openai'", "backend = 'codex'")
        .replace("model-one", "model-two")
        .replace("reasoning = 'high'", "reasoning = 'low'")
        .replace("timeout_ms = 10000", "timeout_ms = 20000")
        .replace("timeout_ms = 1234", "timeout_ms = 5678")
        .replace("max_tool_calls = 10", "max_tool_calls = 20")
        .replace("max_tokens = 1000", "max_tokens = 2000")
        + "\n[evals.review.profile_variants]\nalternate = { kind = 'agent', backend = 'openai', model = 'alternate' }\n[views.agent_tools.graph]\nbuiltin = 'graph'\nargs = []\n";
    fs::write(repo.path().join("index.artf"), excluded).unwrap();
    assert_eq!(fingerprint(), baseline);
    let leaf = fs::read_to_string(repo.path().join("child/index.artf")).unwrap();
    fs::write(
        repo.path().join("child/index.artf"),
        leaf.replace("timeout_ms = 1000", "timeout_ms = 9000"),
    )
    .unwrap();
    fs::write(repo.path().join("child/input.txt"), "changed contents").unwrap();
    assert_eq!(fingerprint(), baseline);
    fs::rename(repo.path().join("child"), repo.path().join("elsewhere")).unwrap();
    assert_eq!(fingerprint(), baseline);
    fs::write(
        repo.path().join("elsewhere/index.artf"),
        leaf.replace("['version']", "['new-version']"),
    )
    .unwrap();
    assert_ne!(fingerprint(), baseline);
    // Its own criteria belong to the graph too; no recursive fingerprint dependency.
    fs::write(repo.path().join("elsewhere/index.artf"), leaf).unwrap();
    fs::write(
        repo.path().join(".artf"),
        GRAPH.replace("Review graph", "New graph title"),
    )
    .unwrap();
    assert_ne!(fingerprint(), baseline);
    fs::write(
        repo.path().join(".artf"),
        GRAPH.replace(
            "title = 'Review graph'",
            "gate = 'blocking'\ntitle = 'Review graph'",
        ),
    )
    .unwrap();
    assert_ne!(fingerprint(), baseline);
}

#[test]
fn graph_declaration_is_root_only_and_rejects_fingerprint_overrides() {
    let repo = fixture();
    for field in ["fingerprint = false", "fingerprint = {}"] {
        fs::write(
            repo.path().join(".artf"),
            format!("name = 'graph'\n{field}\n"),
        )
        .unwrap();
        let error = read(repo.path()).unwrap_err();
        assert!(
            error.contains("The graph Artifact cannot declare"),
            "{error}"
        );
    }
    fs::write(
        repo.path().join(".artf"),
        "name = 'graph'\nreview_policy = {}\n",
    )
    .unwrap();
    assert!(
        read(repo.path())
            .unwrap_err()
            .contains("unknown field `review_policy`")
    );
    fs::write(repo.path().join(".artf"), "name = 'graph'\n").unwrap();
    let config = crate::config::read_workspace_config(repo.path()).unwrap();
    let graph = &config.artifacts["graph"];
    assert_eq!(graph.kind, ArtifactKind::Graph);
    assert!(graph.children.is_empty());
    assert!(!config.evals.iter().any(|eval| eval.target == "graph"));
    for basis in [false, true] {
        fs::write(
            repo.path().join(".artf"),
            format!("name = 'graph'\nbasis = {basis}\n"),
        )
        .unwrap();
        let config = crate::config::read_workspace_config(repo.path()).unwrap();
        assert_eq!(config.artifacts["graph"].basis, Some(basis));
        assert_eq!(config.artifacts["graph"].kind, ArtifactKind::Graph);
        assert!(matches!(
            config.artifacts["graph"].fingerprint,
            Some(Fingerprint::Graph)
        ));
    }
    fs::write(
        repo.path().join(".artf"),
        GRAPH.replace("name = 'graph'", "name = 'graph'\nbasis = true"),
    )
    .unwrap();
    assert!(
        read(repo.path())
            .unwrap_err()
            .contains("Basis Artifact graph cannot own Evals.")
    );
    fs::write(repo.path().join(".artf"), "name = 'graph'\nbasis = true\n").unwrap();
    fs::write(repo.path().join("child/.artf"), "name = 'other-graph'\n").unwrap();
    assert!(
        read(repo.path())
            .unwrap_err()
            .contains("only at the repository root")
    );
}

#[tokio::test]
async fn graph_agent_tool_prints_current_extracted_structure_from_any_artifact() {
    let repo = fixture();
    fs::write(repo.path().join("index.artf"), format!("{APP}\n[views.agent_tools.structure]\nbuiltin = 'graph'\nargs = []\n[views.human_tools.viewer]\nbuiltin = 'graph'\nkind = 'launch'\nargs = []\n")).unwrap();
    let config = crate::config::read_workspace_config(repo.path()).unwrap();
    let registry = crate::tools::Registry::for_artifact(&config, "app").unwrap();
    // Change an unrelated definition after creating the registry: current, not saved state.
    fs::write(
        repo.path().join(".artf"),
        GRAPH.replace("Review graph", "Changed graph title"),
    )
    .unwrap();
    let result = registry
        .call(
            "structure_app",
            json!({}),
            repo.path(),
            CancellationToken::new(),
        )
        .await;
    assert!(!result.is_error(), "{result:?}");
    let content = result.into_content();
    let crate::tools::Content::Text { text } = &content[0] else {
        panic!("text tree")
    };
    assert!(text.contains("leaf → app"));
    assert!(text.contains("graph/review: Changed graph title"));
    let human = crate::tools::human::Registry::for_artifact(&config, "app").unwrap();
    human.preflight(&"viewer_app".parse().unwrap()).unwrap();
    assert_eq!(
        human.list().next().unwrap().kind,
        crate::config::HumanToolKind::Launch
    );
}