main-spex 0.23.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::fs;
use std::path::Path;

use tempfile::tempdir;

use crate::common::{
    add_packages, assert_fails_mentioning, copy_no_spex, report_text, run_spex, with_version_header,
};

const EXPECTED_CHECK_REPORT: &str = r#"proj-spex
  no issues

proj-api
  warn  secret-handling/no-backup-files  (data.backup)
      Backup and scratch files may carry pre-redaction content
  0 errors, 1 warning

proj-frontend
  error  writing-style/no-cyrillic  (README.md:1)
      Requirements and documentation must be in English
  1 error, 0 warnings

proj: 1 error, 1 warning
"#;

const EXPECTED_COVERAGE_REPORT: &str = r#"Specs (0)
  none found

Gaps (0)
  no active or in-progress specs

planned (0), in-progress (0), active (0), gaps (0)
"#;

#[test]
fn init_then_check_no_spex_project() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("proj");

    copy_no_spex(&project);

    let init = run_spex(&project, &["init"], "multi-repo\n\n");
    assert!(
        init.status.success(),
        "init failed (exit {:?})\nstdout:\n{}\nstderr:\n{}",
        init.status.code(),
        String::from_utf8_lossy(&init.stdout),
        String::from_utf8_lossy(&init.stderr),
    );
    assert!(
        project.join("proj-spex/spex.yaml").is_file(),
        "init did not write proj-spex/spex.yaml"
    );
    assert!(
        project.join("proj-spex/rules").is_dir(),
        "init did not scaffold rules"
    );

    add_packages(&project);

    let check = run_spex(&project, &["check"], "");
    assert_eq!(
        report_text(&check.stdout),
        with_version_header(EXPECTED_CHECK_REPORT),
        "check report did not match\nstderr:\n{}",
        String::from_utf8_lossy(&check.stderr),
    );
    assert_eq!(
        check.status.code(),
        Some(1),
        "check should exit 1 when errors are present"
    );

    let coverage = run_spex(&project, &["coverage"], "");
    assert_eq!(
        report_text(&coverage.stdout),
        with_version_header(EXPECTED_COVERAGE_REPORT),
        "coverage report did not match\nstderr:\n{}",
        String::from_utf8_lossy(&coverage.stderr),
    );
    assert!(
        coverage.status.success(),
        "coverage should exit 0 when there are no gaps (exit {:?})",
        coverage.status.code(),
    );
}

fn package_with_existing_agent_files(temp_dir: &Path) -> std::path::PathBuf {
    let project = temp_dir.join("snake");
    fs::create_dir_all(project.join("src")).expect("create src");
    fs::write(project.join("src/main.rs"), "fn main() {}\n").expect("write source");
    fs::write(project.join("AGENTS.md"), "# Snake notes\n\nuse tabs\n").expect("write AGENTS.md");
    fs::create_dir_all(project.join(".claude/skills/my-skill")).expect("create user skill");
    fs::write(project.join(".claude/skills/my-skill/SKILL.md"), "mine\n").expect("write skill");
    fs::write(project.join(".claude/settings.json"), "{}\n").expect("write settings");
    project
}

fn init_with(project: &Path, answers: &str) -> std::process::Output {
    run_spex(project, &["init"], answers)
}

#[test]
fn init_single_repo_keeps_the_package_files_and_checks_clean() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = package_with_existing_agent_files(temp_dir.path());

    let init = init_with(&project, "2\n\n");
    assert!(
        init.status.success(),
        "init failed\nstderr:\n{}",
        String::from_utf8_lossy(&init.stderr)
    );

    let config = fs::read_to_string(project.join("spex/spex.yaml")).expect("read spex.yaml");
    assert!(config.contains("project_name: snake\n"), "{config}");
    assert!(config.contains("layout: single-repo\n"), "{config}");
    assert!(config.contains("agent_root: ..\n"), "{config}");
    assert!(config.contains("package_name: spex\n"), "{config}");
    assert!(config.contains("package_short_name: spex\n"), "{config}");
    assert!(config.contains("path: .\n"), "{config}");

    for folder in ["rules", "schemas", "docs", "specs", "plans", "contracts"] {
        assert!(
            project.join("spex").join(folder).is_dir(),
            "missing spex/{folder}"
        );
    }
    assert!(
        !project.join("spex/.agents").exists(),
        "agent files belong in the root"
    );
    assert!(!project.join("spex/AGENTS.md").exists());
    assert!(!project.join("spex/snake.code-workspace").exists());

    assert!(project.join(".agents/skills/spex-spec/SKILL.md").is_file());
    assert_eq!(
        fs::read_to_string(project.join(".agents/skills/my-skill/SKILL.md")).expect("moved skill"),
        "mine\n",
        "an existing .claude skill moves into .agents"
    );
    assert!(project.join(".claude/settings.json").is_file());
    assert!(
        project.join(".claude/skills/my-skill/SKILL.md").is_file(),
        ".claude/skills should resolve to .agents/skills"
    );

    let agents = fs::read_to_string(project.join("AGENTS.md")).expect("read AGENTS.md");
    assert!(agents.starts_with("# spex"), "managed section is on top");
    assert!(
        agents.ends_with("# Snake notes\n\nuse tabs\n"),
        "existing notes are kept"
    );
    assert_eq!(
        fs::read_link(project.join("CLAUDE.md")).expect("CLAUDE.md symlink"),
        Path::new("AGENTS.md")
    );

    let check = run_spex(&project, &["check"], "");
    assert_eq!(
        report_text(&check.stdout),
        with_version_header("spex\n  no issues\n\nsnake: 0 errors, 0 warnings\n"),
        "stderr:\n{}",
        String::from_utf8_lossy(&check.stderr),
    );
    assert!(check.status.success());

    let coverage = run_spex(&project, &["coverage"], "");
    assert!(
        coverage.status.success(),
        "coverage should pass on a fresh project"
    );
}

#[test]
fn init_monorepo_lists_only_the_spex_package_and_puts_agent_files_in_the_root() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("shop");
    fs::create_dir_all(project.join("apps/api")).expect("create package");

    let init = init_with(&project, "3\n\n");
    assert!(
        init.status.success(),
        "init failed\nstderr:\n{}",
        String::from_utf8_lossy(&init.stderr)
    );

    let config = fs::read_to_string(project.join("spex/spex.yaml")).expect("read spex.yaml");
    assert!(config.contains("layout: monorepo\n"), "{config}");
    assert!(config.contains("agent_root: ..\n"), "{config}");
    assert!(config.contains("package_name: spex\n"), "{config}");
    assert!(config.contains("path: .\n"), "{config}");
    assert!(project.join(".agents/skills/spex-spec/SKILL.md").is_file());
    assert!(project.join("AGENTS.md").is_file());
    assert!(!project.join("spex/.agents").exists());

    let claude = project.join(".claude");
    assert!(
        fs::symlink_metadata(&claude)
            .expect("read .claude metadata")
            .is_dir(),
        ".claude stays a real directory so its settings stay out of .agents"
    );
    assert_eq!(
        fs::read_link(claude.join("skills")).expect("skills symlink"),
        Path::new("../.agents/skills")
    );
}

#[test]
fn init_multi_repo_keeps_everything_in_the_spex_repository() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("proj");
    fs::create_dir_all(&project).expect("create project");

    let init = init_with(&project, "\n\n");
    assert!(init.status.success());

    let spex = project.join("proj-spex");
    let config = fs::read_to_string(spex.join("spex.yaml")).expect("read spex.yaml");
    assert!(config.contains("layout: multi-repo\n"), "{config}");
    assert!(config.contains("agent_root: .\n"), "{config}");
    assert!(spex.join("proj.code-workspace").is_file());
    assert!(spex.join(".agents/skills/spex-spec/SKILL.md").is_file());
    assert!(spex.join("AGENTS.md").is_file());
    assert!(!project.join(".agents").exists());
}

#[test]
fn init_prefers_claude_md_over_agents_md_when_both_exist() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("snake");
    fs::create_dir_all(&project).expect("create project");
    fs::write(project.join("AGENTS.md"), "agents\n").expect("write AGENTS.md");
    fs::write(project.join("CLAUDE.md"), "claude\n").expect("write CLAUDE.md");

    let init = init_with(&project, "2\n\n");

    assert!(
        init.status.success(),
        "init failed\nstderr:\n{}",
        String::from_utf8_lossy(&init.stderr)
    );
    let agents = fs::read_to_string(project.join("AGENTS.md")).expect("read AGENTS.md");
    assert!(agents.ends_with("claude\n"), "CLAUDE.md content is kept");
    assert!(
        !agents.contains("agents\n"),
        "AGENTS.md content is replaced"
    );
    assert_eq!(
        fs::read_link(project.join("CLAUDE.md")).expect("CLAUDE.md symlink"),
        Path::new("AGENTS.md")
    );
}

#[test]
fn init_fails_without_writing_when_a_claude_skill_clashes_with_a_bundled_skill() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("snake");
    fs::create_dir_all(project.join(".claude/skills/spex-spec")).expect("create skill");
    fs::write(project.join(".claude/skills/spex-spec/SKILL.md"), "mine\n").expect("write skill");

    let init = init_with(&project, "2\n\n");

    assert_fails_mentioning(&init, "spex-spec");
    assert!(!project.join("spex").exists(), "nothing may be written");
    assert!(project.join(".claude/skills/spex-spec/SKILL.md").is_file());
}

#[test]
fn init_rejects_an_unknown_layout() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("snake");
    fs::create_dir_all(&project).expect("create project");

    let init = init_with(&project, "polyrepo\n");

    assert_fails_mentioning(&init, "unknown layout `polyrepo`");
    assert!(!project.join("spex").exists());
}

#[test]
fn check_validates_the_spex_dir_in_single_repo_even_for_a_selected_package() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("snake");
    fs::create_dir_all(&project).expect("create project");
    assert!(init_with(&project, "2\n\n").status.success());
    let config_path = project.join("spex/spex.yaml");
    let mut config = fs::read_to_string(&config_path).expect("read spex.yaml");
    config.push_str(
        "  - package_name: snake\n    package_short_name: snake\n    description: Snake game.\n    path: ..\n    rules:\n      - commit-message-format\n",
    );
    fs::write(&config_path, config).expect("add the project package");
    fs::write(
        project.join("spex/specs/bad.yaml"),
        "id: other\ntitle: Other\ndescription: A spec in a file named after another id.\nacceptance_criteria:\n  - id: works\n    must: work\ncontract: none\n",
    )
    .expect("write mismatched spec");

    let check = run_spex(&project, &["check", "--package", "snake"], "");

    let report = report_text(&check.stdout);
    assert!(report.contains("\nspex\n"), "no spex heading in:\n{report}");
    assert!(
        report.contains("filename stem `bad` does not match id `other`"),
        "{report}"
    );
    assert_eq!(check.status.code(), Some(1));
}

#[test]
fn init_accepts_the_layout_name_as_well_as_its_number() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("snake");
    fs::create_dir_all(&project).expect("create project");

    let init = init_with(&project, "single-repo\n\n");

    assert!(init.status.success());
    let config = fs::read_to_string(project.join("spex/spex.yaml")).expect("read spex.yaml");
    assert!(config.contains("layout: single-repo\n"), "{config}");
}

#[test]
fn init_lists_the_layouts_with_numbers() {
    let temp_dir = tempdir().expect("create temp dir");
    let project = temp_dir.path().join("snake");
    fs::create_dir_all(&project).expect("create project");

    let init = init_with(&project, "\n\n");

    let stdout = String::from_utf8_lossy(&init.stdout);
    assert!(
        stdout.contains("Layout:\n  1) multi-repo\n  2) single-repo\n  3) monorepo\nChoose [1]: "),
        "{stdout}"
    );
}