fallow-cli 2.63.0

CLI for fallow, Rust-native codebase intelligence for TypeScript and JavaScript
Documentation
#[path = "common/mod.rs"]
mod common;

use common::run_fallow_raw;
use std::fs;
use std::process::Command;

/// Create a unique temp dir for init tests.
fn init_temp_dir(suffix: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!(
        "fallow-init-test-{}-{}",
        std::process::id(),
        suffix
    ));
    if dir.exists() {
        let _ = fs::remove_dir_all(&dir);
    }
    fs::create_dir_all(&dir).unwrap();
    // init requires a package.json to exist
    fs::write(
        dir.join("package.json"),
        r#"{"name": "init-test", "main": "index.ts"}"#,
    )
    .unwrap();
    dir
}

/// Clean up a temp dir after a test.
fn cleanup(dir: &std::path::Path) {
    let _ = fs::remove_dir_all(dir);
}

// ---------------------------------------------------------------------------
// Init creates config files
// ---------------------------------------------------------------------------

#[test]
fn init_creates_fallowrc_json() {
    let dir = init_temp_dir("json");
    let output = run_fallow_raw(&["init", "--root", dir.to_str().unwrap(), "--quiet"]);
    assert_eq!(
        output.code, 0,
        "init should succeed, stderr: {}",
        output.stderr
    );
    assert!(
        dir.join(".fallowrc.json").exists(),
        "init should create .fallowrc.json"
    );
    cleanup(&dir);
}

#[test]
fn init_creates_toml_with_flag() {
    let dir = init_temp_dir("toml");
    let output = run_fallow_raw(&["init", "--toml", "--root", dir.to_str().unwrap(), "--quiet"]);
    assert_eq!(
        output.code, 0,
        "init --toml should succeed, stderr: {}",
        output.stderr
    );
    assert!(
        dir.join("fallow.toml").exists(),
        "init --toml should create fallow.toml"
    );
    cleanup(&dir);
}

#[test]
fn init_exits_nonzero_if_config_exists() {
    let dir = init_temp_dir("exists");
    run_fallow_raw(&["init", "--root", dir.to_str().unwrap(), "--quiet"]);
    let output = run_fallow_raw(&["init", "--root", dir.to_str().unwrap(), "--quiet"]);
    assert_ne!(
        output.code, 0,
        "init should fail when config already exists"
    );
    cleanup(&dir);
}

#[test]
fn init_created_config_is_valid_json() {
    let dir = init_temp_dir("valid");
    run_fallow_raw(&["init", "--root", dir.to_str().unwrap(), "--quiet"]);
    let content = fs::read_to_string(dir.join(".fallowrc.json")).unwrap();
    let mut stripped = String::new();
    std::io::Read::read_to_string(
        &mut json_comments::StripComments::new(content.as_bytes()),
        &mut stripped,
    )
    .unwrap_or_else(|e| panic!("init should produce valid JSONC: {e}\ncontent: {content}"));
    let _: serde_json::Value = serde_json::from_str(&stripped)
        .unwrap_or_else(|e| panic!("init should produce valid JSONC: {e}\ncontent: {content}"));
    cleanup(&dir);
}

#[test]
fn hooks_namespace_installs_and_uninstalls_git_hook() {
    let dir = init_temp_dir("hooks-namespace-git");
    let git = Command::new("git")
        .arg("init")
        .arg("-q")
        .current_dir(&dir)
        .status()
        .expect("git init should run");
    assert!(git.success());

    let root = dir.to_str().unwrap();
    let install = run_fallow_raw(&[
        "--root", root, "hooks", "install", "--target", "git", "--branch", "develop",
    ]);
    assert_eq!(
        install.code, 0,
        "hooks install --target git should succeed, stderr: {}",
        install.stderr
    );

    let hook_path = dir.join(".git/hooks/pre-commit");
    let hook = fs::read_to_string(&hook_path).unwrap();
    assert!(hook.contains("Generated by fallow hooks install --target git"));
    assert!(hook.contains("BASE=\"develop\""));

    let uninstall = run_fallow_raw(&["--root", root, "hooks", "uninstall", "--target", "git"]);
    assert_eq!(
        uninstall.code, 0,
        "hooks uninstall --target git should succeed, stderr: {}",
        uninstall.stderr
    );
    assert!(!hook_path.exists());
    cleanup(&dir);
}

#[test]
fn hooks_namespace_agent_dry_run_uses_setup_hooks_engine() {
    let dir = init_temp_dir("hooks-namespace-agent");
    let output = run_fallow_raw(&[
        "--root",
        dir.to_str().unwrap(),
        "hooks",
        "install",
        "--target",
        "agent",
        "--agent",
        "claude",
        "--dry-run",
    ]);
    assert_eq!(
        output.code, 0,
        "hooks install --target agent should succeed, stderr: {}",
        output.stderr
    );
    assert!(
        output
            .stderr
            .contains("fallow hooks install --target agent (install) (dry run)"),
        "expected hooks namespace summary, stderr: {}",
        output.stderr
    );
    assert!(!dir.join(".claude").exists());
    cleanup(&dir);
}

#[test]
fn hooks_namespace_validation_respects_json_format() {
    let output = run_fallow_raw(&[
        "--format", "json", "hooks", "install", "--target", "git", "--agent", "claude",
    ]);
    assert_eq!(
        output.code, 2,
        "target mismatch should exit 2, stderr: {}",
        output.stderr
    );

    assert!(
        output.stderr.is_empty(),
        "json errors should not emit human stderr: {}",
        output.stderr
    );
    let json: serde_json::Value =
        serde_json::from_str(&output.stdout).expect("stdout should be structured JSON");
    assert_eq!(json["error"], true);
    assert!(
        json["message"]
            .as_str()
            .unwrap_or_default()
            .contains("--agent, --user, and --gitignore-claude"),
        "unexpected error payload: {json}"
    );
}