longline 0.15.4

System-installed safety hook for Claude Code
Documentation
mod support;
use std::process::{Command, Stdio};
use support::bin::longline_bin;
use support::cli::{run_subcommand, run_subcommand_with_home};
use support::paths::rules_path;

#[test]
fn test_e2e_rules_shows_table() {
    let result = run_subcommand(&["rules", "--config", &rules_path()]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("DECISION"),
        "Should have header: {}",
        result.stdout
    );
    assert!(
        result.stdout.contains("rm-recursive-root"),
        "Should list rules: {}",
        result.stdout
    );
    assert!(
        result.stdout.contains("Allowlist:"),
        "Should show allowlist: {}",
        result.stdout
    );
    assert!(
        result.stdout.contains("Safety level:"),
        "Should show safety level: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_filter_deny() {
    let result = run_subcommand(&["rules", "--config", &rules_path(), "--filter", "deny"]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("deny"),
        "Should have deny rules: {}",
        result.stdout
    );
    assert!(
        !result.stdout.contains("\nask "),
        "Should not have ask rules in filtered output"
    );
}

#[test]
fn test_e2e_rules_filter_level() {
    let result = run_subcommand(&["rules", "--config", &rules_path(), "--level", "critical"]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("critical"),
        "Should have critical rules: {}",
        result.stdout
    );
    let table_part = result.stdout.split("Safety level:").next().unwrap_or("");
    assert!(
        !table_part.contains("high"),
        "Should not have high-level rules in table: {}",
        table_part
    );
    assert!(
        !table_part.contains("strict"),
        "Should not have strict-level rules in table: {}",
        table_part
    );
}

#[test]
fn test_e2e_rules_group_by_decision() {
    let result = run_subcommand(&["rules", "--config", &rules_path(), "--group-by", "decision"]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("DENY"),
        "Should have deny group header: {}",
        result.stdout
    );
    assert!(
        result.stdout.contains("ASK"),
        "Should have ask group header: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_shows_trust_level() {
    let result = run_subcommand(&["rules", "--config", &rules_path()]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("Trust level:"),
        "Should show trust level: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_with_dir_shows_project_rules() {
    let dir = tempfile::TempDir::new().unwrap();
    std::fs::create_dir_all(dir.path().join(".git")).unwrap();
    let claude_dir = dir.path().join(".claude");
    std::fs::create_dir_all(&claude_dir).unwrap();
    std::fs::write(
        claude_dir.join("longline.yaml"),
        r#"
rules:
  - id: project-test-rule
    level: high
    match:
      command: sometool
    decision: ask
    reason: "Project test rule"
"#,
    )
    .unwrap();

    let result = run_subcommand(&[
        "rules",
        "--config",
        &rules_path(),
        "--dir",
        dir.path().to_str().unwrap(),
    ]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("project-test-rule"),
        "Should show project rule: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_auto_discovers_project_from_cwd() {
    let dir = tempfile::TempDir::new().unwrap();
    std::fs::create_dir_all(dir.path().join(".git")).unwrap();
    let claude_dir = dir.path().join(".claude");
    std::fs::create_dir_all(&claude_dir).unwrap();
    std::fs::write(
        claude_dir.join("longline.yaml"),
        r#"
rules:
  - id: cwd-test-rule
    level: high
    match:
      command: mytool
    decision: deny
    reason: "CWD discovery test"
"#,
    )
    .unwrap();

    let home = support::config::static_test_home()
        .to_string_lossy()
        .to_string();
    let child = Command::new(longline_bin())
        .args(["rules", "--config", &rules_path()])
        .env("HOME", &home)
        .current_dir(dir.path())
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("Failed to spawn longline");

    let output = child.wait_with_output().unwrap();
    let code = output.status.code().unwrap_or(-1);
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    assert_eq!(code, 0);
    assert!(
        stdout.contains("cwd-test-rule"),
        "Should auto-discover project config from cwd: {}",
        stdout
    );
}

#[test]
fn test_e2e_rules_shows_source_column() {
    let dir = tempfile::TempDir::new().unwrap();
    std::fs::create_dir_all(dir.path().join(".git")).unwrap();
    let claude_dir = dir.path().join(".claude");
    std::fs::create_dir_all(&claude_dir).unwrap();
    std::fs::write(
        claude_dir.join("longline.yaml"),
        r#"
rules:
  - id: source-test-rule
    level: high
    match:
      command: sometool
    decision: ask
    reason: "Source test"
"#,
    )
    .unwrap();

    let result = run_subcommand(&[
        "rules",
        "--config",
        &rules_path(),
        "--dir",
        dir.path().to_str().unwrap(),
    ]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("SOURCE"),
        "Should have SOURCE header column: {}",
        result.stdout
    );
    assert!(
        result.stdout.contains("project"),
        "Should show 'project' source for project rules: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_shows_project_banner() {
    let dir = tempfile::TempDir::new().unwrap();
    std::fs::create_dir_all(dir.path().join(".git")).unwrap();
    let claude_dir = dir.path().join(".claude");
    std::fs::create_dir_all(&claude_dir).unwrap();
    std::fs::write(
        claude_dir.join("longline.yaml"),
        "rules:\n  - id: banner-test\n    level: high\n    match:\n      command: foo\n    decision: ask\n    reason: test\n",
    ).unwrap();

    let result = run_subcommand(&[
        "rules",
        "--config",
        &rules_path(),
        "--dir",
        dir.path().to_str().unwrap(),
    ]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("Project config:"),
        "Should show project config banner: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_no_banner_without_project_config() {
    let result = run_subcommand(&["rules", "--config", &rules_path(), "--dir", "/tmp"]);
    assert_eq!(result.exit_code, 0);
    assert!(
        !result.stdout.contains("Project config:"),
        "Should NOT show project config banner when no project config: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_filter_trust_full() {
    let result = run_subcommand(&["rules", "--config", &rules_path(), "--filter", "trust:full"]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("ALLOWLISTED COMMANDS"),
        "Should show allowlist table: {}",
        result.stdout
    );
    for line in result.stdout.lines() {
        if line.contains("minimal") && !line.contains("Trust level") {
            panic!("Should not contain minimal trust entries: {}", line);
        }
        if line.contains("standard") && !line.contains("Trust level") {
            panic!("Should not contain standard trust entries: {}", line);
        }
    }
}

#[test]
fn test_e2e_rules_filter_trust_minimal() {
    let result = run_subcommand(&[
        "rules",
        "--config",
        &rules_path(),
        "--filter",
        "trust:minimal",
    ]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("ALLOWLISTED COMMANDS"),
        "Should show allowlist table: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_filter_decision_colon_syntax() {
    let result = run_subcommand(&[
        "rules",
        "--config",
        &rules_path(),
        "--filter",
        "decision:deny",
    ]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("deny"),
        "Should have deny rules: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_filter_bare_deny_backwards_compat() {
    let result = run_subcommand(&["rules", "--config", &rules_path(), "--filter", "deny"]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("deny"),
        "Should have deny rules: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_filter_multiple() {
    let result = run_subcommand(&[
        "rules",
        "--config",
        &rules_path(),
        "--filter",
        "deny",
        "--filter",
        "source:builtin",
    ]);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("deny"),
        "Should have deny rules: {}",
        result.stdout
    );
}

#[test]
fn test_e2e_rules_filter_invalid() {
    let result = run_subcommand(&["rules", "--config", &rules_path(), "--filter", "trust:mega"]);
    assert_ne!(result.exit_code, 0);
    assert!(
        result.stderr.contains("trust") || result.stderr.contains("invalid"),
        "Should show error for invalid filter: {}",
        result.stderr
    );
}

#[test]
fn test_e2e_rules_shows_global_config() {
    let home = tempfile::TempDir::new().unwrap();
    let config_dir = home.path().join(".config").join("longline");
    std::fs::create_dir_all(&config_dir).unwrap();
    std::fs::write(
        config_dir.join("ai-judge.yaml"),
        "command: /definitely-not-a-real-ai-judge-command-12345\ntimeout: 1\n",
    )
    .unwrap();
    std::fs::write(
        config_dir.join("longline.yaml"),
        "override_trust_level: full\n",
    )
    .unwrap();

    let home_str = home.path().to_string_lossy().to_string();
    let result = run_subcommand_with_home(&["rules"], &home_str);
    assert_eq!(result.exit_code, 0);
    assert!(
        result.stdout.contains("Global config:"),
        "Should show global config banner: {}",
        result.stdout
    );
}