agnix-cli 0.27.1

CLI for agnix - agent config linter
use agnix_core::{FileType, detect_file_type};
use assert_cmd::Command;
use serde_json::Value;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};

fn agnix() -> Command {
    let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("agnix");
    cmd.current_dir(workspace_root());
    cmd
}

fn workspace_root() -> &'static Path {
    use std::sync::OnceLock;

    static ROOT: OnceLock<PathBuf> = OnceLock::new();
    ROOT.get_or_init(|| {
        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        for ancestor in manifest_dir.ancestors() {
            let cargo_toml = ancestor.join("Cargo.toml");
            if let Ok(content) = fs::read_to_string(&cargo_toml)
                && (content.contains("[workspace]") || content.contains("[workspace."))
            {
                return ancestor.to_path_buf();
            }
        }
        panic!(
            "Failed to locate workspace root from CARGO_MANIFEST_DIR={}",
            manifest_dir.display()
        );
    })
    .as_path()
}

fn required_paths() -> Vec<&'static str> {
    vec![
        "tests/fixtures/kiro-powers/.kiro/powers/valid-power/POWER.md",
        "tests/fixtures/kiro-powers/.kiro/powers/valid-power/mcp.json",
        "tests/fixtures/kiro-powers/.kiro/powers/missing-frontmatter/POWER.md",
        "tests/fixtures/kiro-powers/.kiro/powers/empty-keywords/POWER.md",
        "tests/fixtures/kiro-powers/.kiro/powers/empty-body/POWER.md",
        "tests/fixtures/kiro-powers/.kiro/powers/bad-mcp/POWER.md",
        "tests/fixtures/kiro-powers/.kiro/powers/bad-mcp/mcp.json",
        "tests/fixtures/kiro-agents/.kiro/agents/valid-agent.json",
        "tests/fixtures/kiro-agents/.kiro/agents/minimal-agent.json",
        "tests/fixtures/kiro-agents/.kiro/agents/invalid-resource.json",
        "tests/fixtures/kiro-agents/.kiro/agents/invalid-model.json",
        "tests/fixtures/kiro-agents/.kiro/agents/mismatched-tools.json",
        "tests/fixtures/kiro-agents/.kiro/agents/unknown-fields.json",
        "tests/fixtures/kiro-agents/.kiro/agents/no-mcp-access.json",
        "tests/fixtures/kiro-agents/.kiro/agents/valid-hooks.json",
        "tests/fixtures/kiro-agents/.kiro/agents/invalid-hook-event.json",
        "tests/fixtures/kiro-agents/.kiro/agents/missing-hook-command.json",
        "tests/fixtures/kiro-hooks/.kiro/hooks/valid-file-save.kiro.hook",
        "tests/fixtures/kiro-hooks/.kiro/hooks/valid-prompt-submit.kiro.hook",
        "tests/fixtures/kiro-hooks/.kiro/hooks/valid-pre-tool.kiro.hook",
        "tests/fixtures/kiro-hooks/.kiro/hooks/invalid-event.kiro.hook",
        "tests/fixtures/kiro-hooks/.kiro/hooks/missing-patterns.kiro.hook",
        "tests/fixtures/kiro-hooks/.kiro/hooks/missing-action.kiro.hook",
        "tests/fixtures/kiro-hooks/.kiro/hooks/missing-tool-types.kiro.hook",
        "tests/fixtures/kiro-mcp/.kiro/settings/mcp.json",
        "tests/fixtures/kiro-mcp/.kiro/settings/valid-local-mcp.json",
        "tests/fixtures/kiro-mcp/.kiro/settings/valid-remote-mcp.json",
        "tests/fixtures/kiro-mcp/.kiro/settings/missing-command-url.json",
        "tests/fixtures/kiro-mcp/.kiro/settings/hardcoded-secrets.json",
    ]
}

fn is_allowed_hidden_fixture_file(name: &str) -> bool {
    matches!(name, ".gitkeep" | ".DS_Store")
}

fn collect_relative_files(root: &Path, dir: &Path, out: &mut BTreeSet<String>) {
    for entry in fs::read_dir(dir)
        .unwrap_or_else(|e| panic!("Failed to read fixture directory {}: {}", dir.display(), e))
    {
        let entry =
            entry.unwrap_or_else(|e| panic!("Failed to read entry under {}: {}", dir.display(), e));
        let path = entry.path();
        let metadata = fs::symlink_metadata(&path)
            .unwrap_or_else(|e| panic!("Failed to stat {}: {}", path.display(), e));

        if metadata.file_type().is_symlink() {
            panic!(
                "Fixture inventory must not traverse symlinks: {}",
                path.display()
            );
        }

        if metadata.is_dir() {
            collect_relative_files(root, &path, out);
        } else if metadata.is_file() {
            if let Some(name) = path.file_name().and_then(|name| name.to_str())
                && name.starts_with('.')
            {
                if is_allowed_hidden_fixture_file(name) {
                    continue;
                }
                panic!(
                    "Unexpected hidden fixture file {}; add an allowlist entry if intentional",
                    path.display()
                );
            }

            let relative = path
                .strip_prefix(root)
                .unwrap_or_else(|_| panic!("{} should be under workspace root", path.display()));
            out.insert(relative.to_string_lossy().replace('\\', "/"));
        }
    }
}

fn run_agnix_json(target: &Path) -> Value {
    let output = agnix()
        .arg(target)
        .arg("--format")
        .arg("json")
        .output()
        .unwrap_or_else(|e| panic!("Failed to run agnix on {}: {}", target.display(), e));

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        output.status.success() || output.status.code() == Some(1),
        "agnix exited with unexpected status {} for {}\nstdout:\n{}\nstderr:\n{}",
        output.status,
        target.display(),
        stdout,
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_str(&stdout).unwrap_or_else(|_| {
        panic!(
            "Expected valid JSON output for {}, got stdout:\n{}\nstderr:\n{}",
            target.display(),
            stdout,
            String::from_utf8_lossy(&output.stderr)
        )
    })
}

fn json_u64(json: &Value, key: &str, fixture_path: &Path) -> u64 {
    json.get(key).and_then(Value::as_u64).unwrap_or_else(|| {
        panic!(
            "Expected numeric {} in JSON output for {}",
            key,
            fixture_path.display()
        )
    })
}

fn diagnostics_len(json: &Value, fixture_path: &Path) -> u64 {
    json.get("diagnostics")
        .and_then(Value::as_array)
        .unwrap_or_else(|| {
            panic!(
                "Expected diagnostics array in JSON output for {}",
                fixture_path.display()
            )
        })
        .len() as u64
}

#[test]
fn kiro_fixture_families_exist_with_required_cases() {
    let root = workspace_root();

    let fixture_roots = [
        "tests/fixtures/kiro-powers",
        "tests/fixtures/kiro-agents",
        "tests/fixtures/kiro-hooks",
        "tests/fixtures/kiro-mcp",
    ];

    for rel in fixture_roots {
        let path = root.join(rel);
        assert!(
            path.exists(),
            "Expected fixture directory {}",
            path.display()
        );
    }

    for rel in required_paths() {
        let path = root.join(rel);
        assert!(path.exists(), "Expected fixture file {}", path.display());
    }

    let expected: BTreeSet<String> = required_paths()
        .into_iter()
        .map(ToString::to_string)
        .collect();

    let mut actual: BTreeSet<String> = BTreeSet::new();
    for rel in fixture_roots {
        let dir = root.join(rel);
        collect_relative_files(root, &dir, &mut actual);
    }

    assert_eq!(
        actual, expected,
        "Kiro fixture inventory drift detected. Update required_paths() intentionally when fixture corpus changes."
    );
}

#[test]
fn kiro_fixture_families_are_cli_runnable() {
    let root = workspace_root();
    let fixture_roots = [
        ("tests/fixtures/kiro-powers", 7_u64, 4_u64),
        ("tests/fixtures/kiro-agents", 10_u64, 18_u64),
        ("tests/fixtures/kiro-hooks", 7_u64, 4_u64),
        ("tests/fixtures/kiro-mcp", 1_u64, 0_u64),
    ];

    for (rel, expected_files_checked, expected_diagnostics) in fixture_roots {
        let fixture_path = root.join(rel);
        let parsed = run_agnix_json(&fixture_path);

        assert!(
            parsed.get("summary").and_then(Value::as_object).is_some(),
            "Expected summary in JSON output for {}",
            fixture_path.display()
        );

        let files_checked = json_u64(&parsed, "files_checked", &fixture_path);
        assert_eq!(
            files_checked,
            expected_files_checked,
            "Unexpected files_checked baseline for {}",
            fixture_path.display()
        );

        let diagnostics = diagnostics_len(&parsed, &fixture_path);
        assert_eq!(
            diagnostics,
            expected_diagnostics,
            "Unexpected diagnostics baseline for {}",
            fixture_path.display()
        );
    }
}

#[test]
fn kiro_fixture_files_have_explicit_detection_baselines() {
    for rel in required_paths() {
        let is_detected = detect_file_type(Path::new(rel)).is_validatable();
        let should_be_detected = rel.starts_with("tests/fixtures/kiro-powers/")
            || rel.starts_with("tests/fixtures/kiro-agents/.kiro/agents/")
            || rel.starts_with("tests/fixtures/kiro-hooks/.kiro/hooks/")
            || rel == "tests/fixtures/kiro-mcp/.kiro/settings/mcp.json";

        assert_eq!(
            is_detected, should_be_detected,
            "Unexpected file-type detection baseline for fixture {}",
            rel
        );
    }
}

#[test]
fn kiro_fixture_representatives_map_to_expected_file_types() {
    let expectations = [
        (
            "tests/fixtures/kiro-powers/.kiro/powers/valid-power/POWER.md",
            FileType::KiroPower,
        ),
        (
            "tests/fixtures/kiro-agents/.kiro/agents/valid-agent.json",
            FileType::KiroAgent,
        ),
        (
            "tests/fixtures/kiro-hooks/.kiro/hooks/valid-file-save.kiro.hook",
            FileType::KiroHook,
        ),
        (
            "tests/fixtures/kiro-mcp/.kiro/settings/mcp.json",
            FileType::KiroMcp,
        ),
    ];

    for (rel, expected) in expectations {
        let detected = detect_file_type(Path::new(rel));
        assert_eq!(
            detected, expected,
            "Unexpected representative file type for fixture {}",
            rel
        );
    }
}