cueloop 0.7.2

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Contract tests for the agent CI surface classifier (`scripts/agent-ci-surface.sh`).
//!
//! Responsibility: assert representative working-tree patterns map to the expected
//! `make agent-ci` tiers (`noop`, `ci-docs`, `ci-fast`, `ci`, `macos-ci`) using the
//! same shell entrypoint as production.
//!
//! Non-scope: executing Makefile targets; exhaustive coverage of every branch in
//! `scripts/lib/release_policy.sh`.
//!
//! Invariants/assumptions: `scripts/agent-ci-surface.sh`, `scripts/lib/cueloop-shell.sh`,
//! and `scripts/lib/release_policy.sh` are copied from this repo into a minimal temp
//! git worktree; `git` is on `PATH`.

use std::path::{Path, PathBuf};
use std::process::Command;

use tempfile::TempDir;

fn repo_root() -> PathBuf {
    let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    crate_dir
        .parent()
        .expect("crate directory should have a parent")
        .parent()
        .expect("crates directory should have a parent repo root")
        .to_path_buf()
}

fn write_file(path: &Path, content: &str) {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).expect("create parent directories");
    }
    std::fs::write(path, content).unwrap_or_else(|err| panic!("write {}: {err}", path.display()));
}

fn copy_script(repo_root: &Path, temp_repo: &Path, relative_path: &str) {
    let src = repo_root.join(relative_path);
    let dest = temp_repo.join(relative_path);
    let content =
        std::fs::read_to_string(&src).unwrap_or_else(|err| panic!("read {}: {err}", src.display()));
    write_file(&dest, &content);
}

fn git(temp_repo: &Path, args: &[&str]) {
    let output = Command::new("git")
        .args(args)
        .current_dir(temp_repo)
        .output()
        .expect("run git");
    assert!(
        output.status.success(),
        "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
        args,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

fn run_classifier(temp_repo: &Path, mode: &str) -> String {
    let output = Command::new("bash")
        .arg(temp_repo.join("scripts/agent-ci-surface.sh"))
        .arg(mode)
        .current_dir(temp_repo)
        .output()
        .expect("run agent-ci classifier");
    assert!(
        output.status.success(),
        "classifier {mode} failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

fn init_temp_repo() -> TempDir {
    let repo_root = repo_root();
    let temp_repo = tempfile::tempdir().expect("create temp repo");
    let repo_path = temp_repo.path();

    copy_script(&repo_root, repo_path, "scripts/agent-ci-surface.sh");
    copy_script(&repo_root, repo_path, "scripts/lib/cueloop-shell.sh");
    copy_script(&repo_root, repo_path, "scripts/lib/release_policy.sh");

    write_file(&repo_path.join("README.md"), "# Temp repo\n");
    write_file(&repo_path.join("docs/guide.md"), "# Docs\n");
    write_file(&repo_path.join("Makefile"), "help:\n\t@echo ok\n");
    write_file(
        &repo_path.join("crates/cueloop/src/lib.rs"),
        "// stub crate\n",
    );

    git(repo_path, &["init", "-b", "main"]);
    git(repo_path, &["config", "user.name", "Codex"]);
    git(repo_path, &["config", "user.email", "codex@example.com"]);
    git(repo_path, &["add", "."]);
    git(repo_path, &["commit", "-m", "initial"]);

    temp_repo
}

#[test]
fn classifier_routes_docs_only_working_tree_to_ci_docs() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(&repo_path.join("docs/guide.md"), "# Docs\n\nupdated\n");

    assert_eq!(run_classifier(repo_path, "--target"), "ci-docs");
}

#[test]
fn classifier_routes_non_app_working_tree_to_ci_fast() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(&repo_path.join(".gitignore"), "target/\n");

    assert_eq!(run_classifier(repo_path, "--target"), "ci-fast");
    assert!(
        run_classifier(repo_path, "--reason").contains("Rust/CLI verification"),
        "expected Rust/CLI routing explanation"
    );
}

#[test]
fn classifier_routes_makefile_ci_router_edit_to_ci_fast() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("Makefile"),
        "help:\n\t@echo ok\n\nagent-ci:\n\t@echo changed\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci-fast");
    assert!(
        run_classifier(repo_path, "--reason").contains("Makefile CI/router change"),
        "expected Makefile CI/router routing explanation"
    );
}

#[test]
fn classifier_ignores_makefile_unchanged_macos_context_lines() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("Makefile"),
        "CUELOOP_CI_JOBS ?= 0\nXCODE_DESTINATION ?= platform=macOS\nhelp:\n\t@echo ok\n",
    );
    git(repo_path, &["add", "Makefile"]);
    git(repo_path, &["commit", "-m", "add make resource knobs"]);

    write_file(
        &repo_path.join("Makefile"),
        "CUELOOP_CI_JOBS ?= 4\nXCODE_DESTINATION ?= platform=macOS\nhelp:\n\t@echo ok\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci-fast");
}

#[test]
fn classifier_routes_clean_main_without_local_changes_to_noop() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    assert_eq!(run_classifier(repo_path, "--target"), "noop");
    assert_eq!(
        run_classifier(repo_path, "--reason"),
        "no local changes; nothing to validate"
    );
}

#[test]
fn classifier_routes_crates_working_tree_to_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("crates/cueloop/src/lib.rs"),
        "// stub crate\n\npub fn touched() {}\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci");
    assert!(
        run_classifier(repo_path, "--reason").contains("Rust crate"),
        "expected Rust release gate routing explanation"
    );
}

#[test]
fn classifier_routes_rust_make_fragment_to_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(&repo_path.join("mk/rust.mk"), "build:\n\t@echo changed\n");

    assert_eq!(run_classifier(repo_path, "--target"), "ci");
}

#[test]
fn classifier_routes_makefile_release_stamp_input_edit_to_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("Makefile"),
        "CUELOOP_RELEASE_STAMP_INPUTS := Cargo.toml scripts/cueloop-cli-bundle.sh\nhelp:\n\t@echo ok\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci");
}

#[test]
fn classifier_routes_macos_make_fragment_to_macos_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("mk/macos.mk"),
        "macos-build:\n\t@echo changed\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "macos-ci");
}

#[test]
fn classifier_routes_schemas_working_tree_to_macos_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(&repo_path.join("schemas/config.schema.json"), "{}\n");

    assert_eq!(run_classifier(repo_path, "--target"), "macos-ci");
}

#[test]
fn classifier_routes_apps_cueloopmac_working_tree_to_macos_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("apps/CueLoopMac/Stub.swift"),
        "// placeholder\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "macos-ci");
}

#[test]
fn classifier_routes_makefile_macos_build_edit_to_macos_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("Makefile"),
        "help:\n\t@echo ok\n\nmacos-build:\n\t@echo changed\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "macos-ci");
    assert!(
        run_classifier(repo_path, "--reason").contains("Makefile app/macOS build change"),
        "expected Makefile macOS ship routing explanation"
    );
}

#[test]
fn classifier_routes_makefile_bundle_script_mention_without_macos_targets_to_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("Makefile"),
        "help:\n\t@echo ok\n\n# scripts/cueloop-cli-bundle.sh (Makefile-only mention)\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci");
    let reason = run_classifier(repo_path, "--reason");
    assert!(
        reason.contains("release-shaped"),
        "expected Rust release gate routing; got: {reason}"
    );
    assert!(
        !reason.contains("macOS ship"),
        "Makefile-only bundle script mentions should not escalate to macOS ship gate; got: {reason}"
    );
}

#[test]
fn classifier_routes_ci_router_script_to_ci_fast() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("scripts/pre-public-check.sh"),
        "#!/usr/bin/env bash\n# touched\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci-fast");
    assert!(
        run_classifier(repo_path, "--reason").contains("CI/router/tooling script"),
        "expected CI/router script routing explanation"
    );
}

#[test]
fn classifier_routes_cli_bundle_script_to_macos_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("scripts/cueloop-cli-bundle.sh"),
        "#!/usr/bin/env bash\n# bundle touched\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "macos-ci");
}

#[test]
fn classifier_routes_rust_toolchain_check_script_to_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("scripts/check-rust-toolchain.sh"),
        "#!/usr/bin/env bash\n# toolchain check touched\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci");
    assert!(
        run_classifier(repo_path, "--reason").contains("release/build script"),
        "expected release/build script routing explanation"
    );
}

#[test]
fn classifier_routes_release_verify_pipeline_script_to_ci() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    write_file(
        &repo_path.join("scripts/lib/release_verify_pipeline.sh"),
        "#!/usr/bin/env bash\n# release verify touched\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci");
}

#[test]
fn classifier_ignores_previous_branch_commits_when_local_diff_is_docs_only() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    git(repo_path, &["checkout", "-b", "feature/app-then-docs"]);
    write_file(
        &repo_path.join("apps/CueLoopMac/Stub.swift"),
        "// prior app change\n",
    );
    git(repo_path, &["add", "apps/CueLoopMac/Stub.swift"]);
    git(repo_path, &["commit", "-m", "touch app surface"]);

    write_file(
        &repo_path.join("docs/guide.md"),
        "# Docs\n\nupdated later\n",
    );

    assert_eq!(run_classifier(repo_path, "--target"), "ci-docs");
    assert_eq!(
        run_classifier(repo_path, "--reason"),
        "docs/community metadata only"
    );
}

#[test]
fn classifier_emit_eval_exports_assignments() {
    let temp_repo = init_temp_repo();
    let repo_path = temp_repo.path();

    let output = Command::new("bash")
        .arg(repo_path.join("scripts/agent-ci-surface.sh"))
        .arg("--emit-eval")
        .current_dir(repo_path)
        .output()
        .expect("run emit-eval");

    assert!(
        output.status.success(),
        "emit-eval failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("CUELOOP_AGENT_CI_TARGET=") && stdout.contains("noop"),
        "emit-eval should assign CUELOOP_AGENT_CI_TARGET=noop on clean tree:\n{stdout}"
    );
    assert!(
        stdout.contains("CUELOOP_AGENT_CI_REASON="),
        "emit-eval should assign CUELOOP_AGENT_CI_REASON:\n{stdout}"
    );
}