cueloop 0.5.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Release script contract tests.
//!
//! These tests guard release-script invariants that should not regress silently.
//! They do not run an end-to-end release or require GitHub/crates.io credentials.

use std::{
    collections::{BTreeMap, BTreeSet},
    path::PathBuf,
};

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 read_repo_file(relative_path: &str) -> String {
    std::fs::read_to_string(repo_root().join(relative_path))
        .unwrap_or_else(|err| panic!("failed to read {relative_path}: {err}"))
}

fn parse_shell_string_arrays(script: &str) -> BTreeMap<String, Vec<String>> {
    let mut arrays = BTreeMap::new();
    let mut lines = script.lines();

    while let Some(line) = lines.next() {
        let Some(name) = line.trim().strip_suffix("=(") else {
            continue;
        };

        let mut entries = Vec::new();
        for array_line in lines.by_ref() {
            let array_line = array_line.trim();
            if array_line == ")" {
                break;
            }
            if let Some(entry) = array_line
                .strip_prefix('"')
                .and_then(|line| line.strip_suffix('"'))
            {
                entries.push(entry.to_owned());
            }
        }
        arrays.insert(name.to_owned(), entries);
    }

    arrays
}

fn parse_shell_string_array(script: &str, name: &str) -> Vec<String> {
    parse_shell_string_arrays(script)
        .remove(name)
        .unwrap_or_else(|| panic!("release policy should define {name}"))
}

fn parse_release_metadata_paths(script: &str) -> BTreeSet<String> {
    parse_shell_string_array(script, "RELEASE_METADATA_PATHS")
        .into_iter()
        .collect()
}

fn parse_makefile_generated_schema_paths(makefile: &str) -> BTreeSet<String> {
    makefile
        .lines()
        .filter_map(|line| {
            let (_, output) = line.split_once('>')?;
            let path = output.trim();
            (path.starts_with("schemas/") && path.ends_with(".schema.json"))
                .then(|| path.to_owned())
        })
        .collect()
}

#[test]
fn release_policy_arrays_do_not_repeat_entries() {
    let script = read_repo_file("scripts/lib/release_policy.sh");
    let arrays = parse_shell_string_arrays(&script);

    assert!(
        !arrays.is_empty(),
        "release policy should define shell string arrays"
    );

    let mut duplicates_by_array = BTreeMap::new();
    for (array, entries) in arrays {
        let mut seen = BTreeSet::new();
        let duplicates: Vec<_> = entries
            .into_iter()
            .filter(|entry| !seen.insert(entry.clone()))
            .collect();
        if !duplicates.is_empty() {
            duplicates_by_array.insert(array, duplicates);
        }
    }

    assert!(
        duplicates_by_array.is_empty(),
        "release policy arrays should not contain duplicate entries: {duplicates_by_array:?}"
    );
}

#[test]
fn release_script_derives_repo_url_from_origin_remote() {
    let script = read_repo_file("scripts/lib/release_verify_pipeline.sh");
    assert!(
        script.contains("cueloop_get_repo_http_url"),
        "release pipeline should derive the repo URL from git remote origin"
    );
    assert!(
        !script.contains("https://github.com/fitchmultz/cueloop/compare"),
        "release pipeline should not hardcode compare links to a specific owner"
    );
}

#[test]
fn release_notes_template_uses_repo_placeholders() {
    let template = read_repo_file(".github/release-notes-template.md");
    assert!(
        template.contains("{{REPO_URL}}"),
        "release-notes template should use repo URL placeholders"
    );
    assert!(
        template.contains("{{REPO_CLONE_URL}}"),
        "release-notes template should use clone URL placeholders"
    );
}

#[test]
fn release_policy_treats_cargo_lock_as_release_metadata() {
    let script = read_repo_file("scripts/lib/release_policy.sh");
    assert!(
        script.contains("\"Cargo.lock\""),
        "release policy should treat Cargo.lock as release metadata"
    );
}

#[test]
fn release_metadata_includes_every_generated_schema() {
    let make_surface = format!(
        "{}\n{}",
        read_repo_file("Makefile"),
        read_repo_file("mk/rust.mk")
    );
    let generated_schema_paths = parse_makefile_generated_schema_paths(&make_surface);
    assert!(
        !generated_schema_paths.is_empty(),
        "Makefile generate should produce committed schemas"
    );

    let release_metadata_paths =
        parse_release_metadata_paths(&read_repo_file("scripts/lib/release_policy.sh"));
    let missing_paths: Vec<_> = generated_schema_paths
        .difference(&release_metadata_paths)
        .cloned()
        .collect();
    assert!(
        missing_paths.is_empty(),
        "release metadata should include every schema generated by Makefile generate; missing: {missing_paths:?}"
    );
}

#[test]
fn release_artifact_builder_cleans_release_artifacts_directory() {
    let script = read_repo_file("scripts/build-release-artifacts.sh");
    let cleanup_count = script.matches("rm -rf \"$RELEASE_ARTIFACTS_DIR\"").count();
    assert!(
        cleanup_count >= 1,
        "artifact builder should clean target/release-artifacts before packaging"
    );
}

#[test]
fn release_artifact_builder_packages_primary_binary() {
    let script = read_repo_file("scripts/build-release-artifacts.sh");
    assert!(
        script.contains("cueloop-${version}-${platform_name}.tar.gz"),
        "artifact tarballs should use the primary cueloop name"
    );
    assert!(
        script.contains("tar -czf \"$RELEASE_ARTIFACTS_DIR/$tarball_name\" -C \"$(dirname \"$binary_path\")\" cueloop"),
        "artifact tarballs should include the primary cueloop binary"
    );

    let publish_pipeline = read_repo_file("scripts/lib/release_publish_pipeline.sh");
    assert!(
        publish_pipeline.contains("/cueloop-\"${VERSION}\"-*.tar.gz"),
        "release upload should target cueloop-named artifacts"
    );
}

#[test]
fn release_policy_uses_target_transaction_state() {
    let script = read_repo_file("scripts/lib/release_state.sh");
    assert!(
        script.contains("target/release-transactions"),
        "release state should keep release transaction state under target/release-transactions"
    );
}

#[test]
fn release_script_publishes_only_after_local_release_is_prepared() {
    let script = read_repo_file("scripts/lib/release_publish_pipeline.sh");
    assert!(
        read_repo_file("scripts/lib/release_verify_pipeline.sh")
            .contains("release_prepare_verified_snapshot")
            && script.contains("release_create_commit_and_tag")
            && script.contains("release_create_or_update_github_release_draft")
            && script.contains("release_publish_crate"),
        "release pipeline should verify locally before publishing externally"
    );
    assert!(
        script.find("release_push_remote_main") < script.find("release_publish_crate"),
        "release pipeline should push origin/main before crates.io publish"
    );
    assert!(
        script.find("release_push_remote_tag") < script.find("release_publish_crate"),
        "release pipeline should push the release tag before crates.io publish"
    );
    assert!(
        script.find("release_create_or_update_github_release_draft")
            < script.find("release_publish_crate"),
        "release pipeline should draft the GitHub release before crates.io publish"
    );
    assert!(
        script.find("release_publish_crate") < script.find("release_publish_github_release"),
        "release pipeline should only publish the GitHub release after crates.io succeeds"
    );
    assert!(
        script.contains("--draft") && script.contains("--draft=false"),
        "release pipeline should split GitHub release draft creation from final publication"
    );
}

#[test]
fn release_script_reconciles_without_legacy_skip_flags() {
    let script = read_repo_file("scripts/release.sh");
    assert!(
        script.contains("scripts/release.sh reconcile")
            || script.contains("reconcile 0.2.0")
            || script.contains("reconcile <version>"),
        "release.sh should document durable transaction-state reconciliation"
    );
    assert!(
        !script.contains("CUELOOP_RELEASE_SKIP_PUBLISH"),
        "release.sh should not rely on the old manual skip-publish recovery flag"
    );
    assert!(
        !script.contains("CUELOOP_RELEASE_ALLOW_EXISTING_TAG"),
        "release.sh should not retain the old existing-tag override flag"
    );
}

#[test]
fn release_verify_allows_release_metadata_drift_after_version_sync() {
    let script = read_repo_file("scripts/release.sh");
    assert!(
        script.contains("release_validate_repo_state 0 1"),
        "execute mode should allow release-only metadata drift after verify prepares the publish snapshot"
    );
}

#[test]
fn release_execute_initializes_transaction_after_validation() {
    let script = read_repo_file("scripts/release.sh");
    assert!(
        script.find("release_validate_repo_state 0")
            < script.find("release_state_init \"execute\""),
        "execute mode should not create transaction state before repo-state validation succeeds"
    );
}

#[test]
fn release_verify_records_a_reusable_snapshot() {
    let script = read_repo_file("scripts/release.sh");
    assert!(
        script.contains("release_verify_state_init")
            && script.contains("release_prepare_verified_snapshot")
            && script.contains("release_verify_assert_ready_for_execute"),
        "release.sh should prepare a durable verify snapshot and require it before execute"
    );
}

#[test]
fn release_verify_state_uses_target_release_verifications_directory() {
    let script = read_repo_file("scripts/lib/release_verify_state.sh");
    assert!(
        script.contains("target/release-verifications"),
        "verified release state should live under target/release-verifications"
    );
}