main-spex 0.26.0

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

use crate::common::{
    assert_fails_mentioning, dummy_project_fixture, run_spex, run_spex_with_env, temp_project,
};

const EXPECTED_INFO: &str = r#"{
  "spex_dir": {spex_dir},
  "project_name": "dummy-project",
  "cli_version": "0.8.8",
  "layout": "multi-repo",
  "agent_root": {spex_dir},
  "description": "Minimal dummy project used by spex's own test suite to exercise `spex check` and `spex coverage` on a project whose specs cover every lifecycle state.",
  "rules_unused": [],
  "packages": [
    {
      "package_name": "dummy-project-api",
      "package_short_name": "api",
      "path": {api_dir},
      "contracts": [
        {
          "path": "./contracts.md",
          "description": "Interface contract notes for the api."
        }
      ],
      "rules": [
        "no-todo-comments"
      ],
      "exclude": [],
      "check_ignore": [],
      "coverage_ignore": []
    },
    {
      "package_name": "dummy-project-frontend",
      "package_short_name": "frontend",
      "path": {frontend_dir},
      "contracts": [],
      "rules": [],
      "exclude": [],
      "check_ignore": [],
      "coverage_ignore": []
    },
    {
      "package_name": "dummy-project-spex",
      "package_short_name": "spex",
      "path": {spex_dir},
      "contracts": [],
      "rules": [],
      "exclude": [],
      "check_ignore": [],
      "coverage_ignore": []
    }
  ]
}
"#;

fn with_project_dirs(template: &str, project: &Path) -> String {
    let as_json = |package_dir: &str| {
        let dir = project
            .join(package_dir)
            .canonicalize()
            .expect("canonical package dir");
        serde_json::to_string(&dir).expect("package dir as JSON")
    };
    template
        .replace("{spex_dir}", &as_json("dummy-project-spex"))
        .replace("{api_dir}", &as_json("dummy-project-api"))
        .replace("{frontend_dir}", &as_json("dummy-project-frontend"))
}

const EXPECTED_INFO_API: &str = r#"{
  "spex_dir": {spex_dir},
  "project_name": "dummy-project",
  "cli_version": "0.8.8",
  "layout": "multi-repo",
  "agent_root": {spex_dir},
  "description": "Minimal dummy project used by spex's own test suite to exercise `spex check` and `spex coverage` on a project whose specs cover every lifecycle state.",
  "rules_unused": [],
  "packages": [
    {
      "package_name": "dummy-project-api",
      "package_short_name": "api",
      "path": {api_dir},
      "contracts": [
        {
          "path": "./contracts.md",
          "description": "Interface contract notes for the api."
        }
      ],
      "rules": [
        "no-todo-comments"
      ],
      "exclude": [],
      "check_ignore": [],
      "coverage_ignore": []
    }
  ]
}
"#;

fn listed_short_names(stdout: &[u8]) -> Vec<String> {
    let info: serde_json::Value = serde_json::from_slice(stdout).expect("info prints JSON");
    info["packages"]
        .as_array()
        .expect("packages list")
        .iter()
        .map(|package| {
            package["package_short_name"]
                .as_str()
                .expect("package_short_name")
                .to_string()
        })
        .collect()
}

#[test]
fn info_prints_the_resolved_configuration_as_json() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());

    let info = run_spex(&project, &["info"], "");

    assert!(info.status.success(), "info should exit 0");
    assert_eq!(
        String::from_utf8_lossy(&info.stdout),
        with_project_dirs(EXPECTED_INFO, &project)
    );
}

#[test]
fn info_reports_the_spex_dir_from_any_project_package() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());

    let info = run_spex(&project.join("dummy-project-api"), &["info"], "");

    assert!(info.status.success(), "info should exit 0");
    assert_eq!(
        String::from_utf8_lossy(&info.stdout),
        with_project_dirs(EXPECTED_INFO, &project)
    );
}

#[test]
fn info_package_filters_only_the_packages_list() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());

    let by_short_name = run_spex(&project, &["info", "--package", "api"], "");
    let by_full_name = run_spex(&project, &["info", "--package", "dummy-project-api"], "");
    let several = run_spex(&project, &["info", "--package", "frontend", "api"], "");

    let expected = with_project_dirs(EXPECTED_INFO_API, &project);

    assert!(by_short_name.status.success());
    assert_eq!(String::from_utf8_lossy(&by_short_name.stdout), expected);
    assert_eq!(
        String::from_utf8_lossy(&by_full_name.stdout),
        expected,
        "--package should match a package by full name as well as short name",
    );
    assert_eq!(listed_short_names(&several.stdout), ["api", "frontend"]);
}

fn write_api_package_config(project: &Path) {
    fs::write(
        project.join("dummy-project-api/spex.package.yaml"),
        "exclude:\n  - vendor\ncoverage_ignore:\n  - spec_id: place-order\n    layer: impl\ncheck_ignore:\n  - rule_id: writing-style\n    check: no-cyrillic\n    files: [README.md]\n",
    )
    .expect("write package config");
}

fn info_json(stdout: &[u8]) -> serde_json::Value {
    serde_json::from_slice(stdout).expect("info prints JSON")
}

#[test]
fn info_merges_every_package_config_into_its_own_package() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());
    write_api_package_config(&project);

    let info = run_spex(&project, &["info"], "");

    assert!(info.status.success(), "info should exit 0");
    let info = info_json(&info.stdout);
    assert_eq!(
        info["packages"][0]["coverage_ignore"],
        serde_json::json!([{ "spec_id": "place-order", "layer": "impl" }]),
    );
    assert_eq!(
        info["packages"][0]["check_ignore"],
        serde_json::json!([
            { "rule_id": "writing-style", "check": "no-cyrillic", "files": ["README.md"] }
        ]),
    );
    assert_eq!(
        info["packages"][0]["exclude"],
        serde_json::json!(["vendor"])
    );
    assert_eq!(info["packages"][1]["exclude"], serde_json::json!([]));
    assert_eq!(info["packages"][1]["check_ignore"], serde_json::json!([]));
    assert_eq!(
        info["packages"][1]["coverage_ignore"],
        serde_json::json!([])
    );
}

#[test]
fn info_unions_a_package_entry_with_its_package_file() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());
    write_api_package_config(&project);
    let spex_yaml = project.join("dummy-project-spex/spex.yaml");
    let config = fs::read_to_string(&spex_yaml).expect("read spex.yaml");
    let patched = config.replace(
        "    rules:\n      - no-todo-comments\n",
        "    rules:\n      - no-todo-comments\n    exclude:\n      - build\n    coverage_ignore:\n      - spec_id: list-menu\n        layer: test\n",
    );
    assert_ne!(patched, config, "api package not found in spex.yaml");
    fs::write(&spex_yaml, patched).expect("write spex.yaml");

    let info = info_json(&run_spex(&project, &["info"], "").stdout);

    assert_eq!(
        info["packages"][0]["exclude"],
        serde_json::json!(["build", "vendor"]),
    );

    assert_eq!(
        info["packages"][0]["coverage_ignore"],
        serde_json::json!([
            { "spec_id": "list-menu", "layer": "test" },
            { "spec_id": "place-order", "layer": "impl" },
        ]),
    );
}

#[test]
fn info_package_omits_the_ignores_of_the_packages_it_filters_out() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());
    write_api_package_config(&project);

    let info = run_spex(&project, &["info", "--package", "frontend"], "");

    assert!(info.status.success(), "info should exit 0");
    assert_eq!(listed_short_names(&info.stdout), ["frontend"]);
    let info = info_json(&info.stdout);
    assert_eq!(
        info["packages"][0]["coverage_ignore"],
        serde_json::json!([])
    );
}

#[test]
fn info_package_rejects_an_unknown_package() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());

    let info = run_spex(&project, &["info", "--package", "api", "bogus"], "");

    assert_fails_mentioning(&info, "unknown package `bogus`");
    assert!(info.stdout.is_empty());
}

#[test]
fn tool_failure_prints_one_plain_line_and_exits_two() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());

    let info = run_spex(&project, &["info", "--package", "bogus"], "");

    let stderr = String::from_utf8_lossy(&info.stderr);
    assert_eq!(info.status.code(), Some(2));
    assert!(
        stderr.ends_with("error: unknown package `bogus` — not listed in spex.yaml\n"),
        "unexpected stderr:\n{stderr}",
    );
    assert!(
        !stderr.contains('\u{1b}') && !stderr.contains("╰╴"),
        "stderr carries colour or a source location:\n{stderr}",
    );
}

#[test]
fn tool_failure_with_rust_backtrace_follows_the_error_line_with_a_detailed_report() {
    let (_temp_dir, project) = temp_project(&dummy_project_fixture());

    let info = run_spex_with_env(
        &project,
        &["info", "--package", "bogus"],
        "",
        &[("RUST_BACKTRACE", "1")],
    );

    let stderr = String::from_utf8_lossy(&info.stderr);
    assert_eq!(info.status.code(), Some(2));
    assert!(
        stderr.contains("error: unknown package `bogus` — not listed in spex.yaml\n\n"),
        "the error line is missing:\n{stderr}",
    );
    assert!(
        stderr.contains("[src/config/spex.rs:"),
        "the error location is missing:\n{stderr}",
    );
    assert!(
        stderr.contains("Backtrace (captured)"),
        "the backtrace is missing:\n{stderr}",
    );
}