nils-agent-scope-lock 1.12.0

CLI crate for nils-agent-scope-lock in the nils-cli workspace.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use nils_test_support::cmd::{CmdOutput, run_resolved_in_dir};
use nils_test_support::git::{InitRepoOptions, git, init_repo_with};
use pretty_assertions::assert_eq;

fn init_repo() -> tempfile::TempDir {
    init_repo_with(
        InitRepoOptions::new()
            .with_branch("main")
            .with_initial_commit(),
    )
}

fn run(dir: &Path, args: &[&str]) -> CmdOutput {
    run_resolved_in_dir("agent-scope-lock", dir, args, &[], None)
}

fn lock_arg(path: &Path) -> String {
    path.to_string_lossy().to_string()
}

fn combined_output(output: &CmdOutput) -> String {
    format!("{}{}", output.stdout_text(), output.stderr_text())
}

#[test]
fn help_includes_version_flag_and_examples() {
    let tmp = tempfile::TempDir::new().expect("tempdir");
    let output = run(tmp.path(), &["--help"]);

    assert_eq!(output.code, 0, "stderr={}", output.stderr_text());
    let stdout = output.stdout_text();
    assert!(
        stdout.contains("-V, --version"),
        "missing version flag: {stdout}"
    );
    assert!(stdout.contains("EXAMPLES:"), "missing examples: {stdout}");
}

#[test]
fn create_read_clear_lifecycle_uses_git_path_default() {
    let repo = init_repo();

    let create = run(
        repo.path(),
        &[
            "create",
            "--path",
            "README.md",
            "--owner",
            "T3",
            "--note",
            "focused lock",
        ],
    );
    assert_eq!(create.code, 0, "stderr={}", create.stderr_text());
    assert!(
        repo.path().join(".git/agent-scope-lock.json").is_file(),
        "default lock should be stored under .git"
    );

    let read = run(repo.path(), &["read"]);
    assert_eq!(read.code, 0, "stderr={}", read.stderr_text());
    let stdout = read.stdout_text();
    assert!(stdout.contains("owner: T3"), "missing owner: {stdout}");
    assert!(
        stdout.contains("README.md"),
        "missing allowed path: {stdout}"
    );

    let clear = run(repo.path(), &["clear"]);
    assert_eq!(clear.code, 0, "stderr={}", clear.stderr_text());
    assert!(
        !repo.path().join(".git/agent-scope-lock.json").exists(),
        "clear should remove default lock"
    );

    let clear_again = run(repo.path(), &["clear"]);
    assert_eq!(clear_again.code, 0, "stderr={}", clear_again.stderr_text());
    assert!(
        clear_again.stdout_text().contains("already clear"),
        "clear should be idempotent: {}",
        clear_again.stdout_text()
    );
}

#[test]
fn json_create_uses_versioned_envelope() {
    let repo = init_repo();
    let lock = repo.path().join("custom-lock.json");
    let lock_arg = lock_arg(&lock);

    let output = run(
        repo.path(),
        &[
            "create",
            "--path",
            "README.md",
            "--lock-file",
            &lock_arg,
            "--format",
            "json",
        ],
    );

    assert_eq!(output.code, 0, "stderr={}", output.stderr_text());
    let value = output.stdout_json();
    assert_eq!(value["schema_version"], "cli.agent-scope-lock.create.v1");
    assert_eq!(value["command"], "agent-scope-lock create");
    assert_eq!(value["ok"], true);
    assert_eq!(
        value["result"]["lock"]["schema_version"],
        "agent-scope-lock.v1"
    );
    assert_eq!(value["result"]["lock"]["allowed_paths"][0], "README.md");
    assert_eq!(value["result"]["lock_file"], lock_arg);
}

#[test]
fn validate_succeeds_when_changed_paths_are_allowed() {
    let repo = init_repo();
    fs::create_dir_all(repo.path().join("src")).expect("src dir");
    fs::write(repo.path().join("src/lib.rs"), "pub fn ok() {}\n").expect("write src");

    let create = run(repo.path(), &["create", "--path", "src"]);
    assert_eq!(create.code, 0, "stderr={}", create.stderr_text());

    let validate = run(repo.path(), &["validate", "--format", "json"]);
    assert_eq!(validate.code, 0, "stderr={}", validate.stderr_text());
    let value = validate.stdout_json();
    assert_eq!(value["schema_version"], "cli.agent-scope-lock.validate.v1");
    assert_eq!(value["command"], "agent-scope-lock validate");
    assert_eq!(value["ok"], true);
    assert_eq!(value["result"]["violations"].as_array().unwrap().len(), 0);
    assert_eq!(value["result"]["changed_paths"][0], "src/lib.rs");
}

#[test]
fn validate_fails_with_concise_violation_list() {
    let repo = init_repo();
    fs::create_dir_all(repo.path().join("src")).expect("src dir");
    fs::write(repo.path().join("other.txt"), "outside\n").expect("write outside");

    let create = run(repo.path(), &["create", "--path", "src"]);
    assert_eq!(create.code, 0, "stderr={}", create.stderr_text());

    let validate = run(repo.path(), &["validate"]);
    assert_eq!(validate.code, 1, "stdout={}", validate.stdout_text());
    let stderr = validate.stderr_text();
    assert!(
        stderr.contains("scope violations"),
        "missing header: {stderr}"
    );
    assert!(stderr.contains("other.txt"), "missing path: {stderr}");
    assert!(
        stderr.contains("allowed paths:"),
        "missing allowed paths: {stderr}"
    );
}

#[test]
fn missing_lock_fails_with_json_error() {
    let repo = init_repo();
    let missing = repo.path().join("missing-lock.json");
    let missing_arg = lock_arg(&missing);

    let output = run(
        repo.path(),
        &["validate", "--lock-file", &missing_arg, "--format", "json"],
    );

    assert_eq!(output.code, 1, "stderr={}", output.stderr_text());
    let value = output.stdout_json();
    assert_eq!(value["schema_version"], "cli.agent-scope-lock.validate.v1");
    assert_eq!(value["ok"], false);
    assert_eq!(value["error"]["code"], "missing-lock");
    assert_eq!(value["error"]["details"]["lock_file"], missing_arg);
}

#[test]
fn secret_like_file_contents_are_not_emitted_on_violation() {
    let repo = init_repo();
    fs::create_dir_all(repo.path().join("src")).expect("src dir");
    fs::create_dir_all(repo.path().join("secrets")).expect("secrets dir");
    fs::write(
        repo.path().join("secrets/api-key.txt"),
        "sk-proj-secret-token\n",
    )
    .expect("write secret-like content");

    let create = run(repo.path(), &["create", "--path", "src"]);
    assert_eq!(create.code, 0, "stderr={}", create.stderr_text());

    let validate = run(repo.path(), &["validate", "--format", "json"]);
    assert_eq!(validate.code, 1, "stdout={}", validate.stdout_text());
    let combined = combined_output(&validate);
    assert!(
        combined.contains("secrets/api-key.txt"),
        "violation should include path: {combined}"
    );
    assert!(
        !combined.contains("sk-proj-secret-token"),
        "violation output must not include file contents: {combined}"
    );
}

#[test]
fn validate_modes_split_staged_and_unstaged_paths() {
    let repo = init_repo();
    fs::create_dir_all(repo.path().join("src")).expect("src dir");
    fs::write(repo.path().join("src/staged.rs"), "staged\n").expect("staged");
    git(repo.path(), &["add", "src/staged.rs"]);
    fs::write(repo.path().join("outside.txt"), "unstaged\n").expect("unstaged");

    let create = run(repo.path(), &["create", "--path", "src"]);
    assert_eq!(create.code, 0, "stderr={}", create.stderr_text());

    let staged = run(repo.path(), &["validate", "--changes", "staged"]);
    assert_eq!(staged.code, 0, "stderr={}", staged.stderr_text());

    let unstaged = run(repo.path(), &["validate", "--changes", "unstaged"]);
    assert_eq!(unstaged.code, 1, "stdout={}", unstaged.stdout_text());
    assert!(
        unstaged.stderr_text().contains("outside.txt"),
        "missing unstaged violation: {}",
        unstaged.stderr_text()
    );
}

#[test]
fn create_treats_relative_paths_as_repo_relative_from_subdirectories() {
    let repo = init_repo();
    fs::create_dir_all(repo.path().join("nested")).expect("nested dir");
    let lock = repo.path().join("scope.json");
    let lock_arg = lock_arg(&lock);

    let output = run(
        &repo.path().join("nested"),
        &[
            "create",
            "--path",
            "README.md",
            "--lock-file",
            &lock_arg,
            "--format",
            "json",
        ],
    );

    assert_eq!(output.code, 0, "stderr={}", output.stderr_text());
    let value = output.stdout_json();
    assert_eq!(value["result"]["lock"]["allowed_paths"][0], "README.md");
}

#[test]
fn completion_export_succeeds_outside_git_repo() {
    let tmp = tempfile::TempDir::new().expect("tempdir");
    let output = run(tmp.path(), &["completion", "zsh"]);

    assert_eq!(output.code, 0, "stderr={}", output.stderr_text());
    assert!(
        output.stdout_text().contains("#compdef agent-scope-lock"),
        "missing completion header: {}",
        output.stdout_text()
    );
}

#[test]
fn lock_file_override_accepts_relative_path() {
    let repo = init_repo();
    let lock_rel = PathBuf::from("tmp-lock.json");

    let output = run(
        repo.path(),
        &[
            "create",
            "--path",
            "README.md",
            "--lock-file",
            lock_rel.to_str().unwrap(),
        ],
    );

    assert_eq!(output.code, 0, "stderr={}", output.stderr_text());
    assert!(repo.path().join(lock_rel).is_file());
}