fallow-cli 3.5.0

CLI for fallow, codebase intelligence for TypeScript and JavaScript
Documentation
#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    reason = "tests use expect to keep fixture setup concise"
)]

#[path = "common/mod.rs"]
mod common;

use std::process::Command;

use common::{CommandOutput, fallow_bin, parse_json};

fn run_rule_pack(root: &std::path::Path, args: &[&str]) -> CommandOutput {
    common::run_fallow_in_root("rule-pack", root, args)
}

fn write_project(root: &std::path::Path) {
    std::fs::write(root.join("package.json"), "{\"name\":\"t\"}\n").expect("write package.json");
    std::fs::write(root.join(".fallowrc.json"), "{\n  \"rules\": {}\n}\n")
        .expect("write fallow config");
}

fn write_project_with_rule_pack(root: &std::path::Path) {
    write_project(root);
    std::fs::create_dir_all(root.join("rule-packs")).expect("create rule-packs dir");
    std::fs::write(
        root.join(".fallowrc.json"),
        r#"{
  "rules": {
    "policy-violation": "warn"
  },
  "rulePacks": ["rule-packs/team-policy.jsonc"]
}
"#,
    )
    .expect("write config");
    std::fs::write(
        root.join("rule-packs/team-policy.jsonc"),
        r#"{
  "version": 1,
  "name": "team-policy",
  "description": "Repository policy guardrails",
  "rules": [
    {
      "id": "no-moment",
      "kind": "banned-import",
      "specifiers": ["moment"],
      "severity": "error",
      "message": "Use date-fns."
    },
    {
      "id": "no-network",
      "kind": "banned-effect",
      "effects": ["network"],
      "files": ["src/domain/**"]
    }
  ]
}
"#,
    )
    .expect("write rule pack");
}

fn write_policy_test_project(root: &std::path::Path, banned_specifier: &str) {
    std::fs::create_dir_all(root.join("src")).expect("create src dir");
    std::fs::create_dir_all(root.join("packs")).expect("create packs dir");
    std::fs::write(
        root.join("package.json"),
        r#"{
  "name": "t",
  "main": "src/index.ts"
}
"#,
    )
    .expect("write package.json");
    std::fs::write(root.join(".fallowrc.json"), "{\n  \"rules\": {}\n}\n")
        .expect("write fallow config");
    std::fs::write(
        root.join("src/index.ts"),
        "import value from 'moment';\nconsole.log(value);\n",
    )
    .expect("write source");
    std::fs::write(
        root.join("packs/p.jsonc"),
        format!(
            r#"{{
  "version": 1,
  "name": "team-policy",
  "rules": [
    {{
      "id": "no-import",
      "kind": "banned-import",
      "specifiers": ["{banned_specifier}"]
    }}
  ]
}}
"#
        ),
    )
    .expect("write rule pack");
}

#[test]
fn rule_pack_schema_matches_legacy_top_level_command() {
    let dir = tempfile::tempdir().expect("create temp dir");
    std::fs::write(dir.path().join("package.json"), "{\"name\":\"t\"}\n")
        .expect("write package.json");

    let new = run_rule_pack(dir.path(), &["schema"]);
    let old = Command::new(fallow_bin())
        .arg("--root")
        .arg(dir.path())
        .arg("rule-pack-schema")
        .output()
        .expect("run legacy schema command");

    assert_eq!(new.code, 0, "stderr: {}", new.stderr);
    assert_eq!(new.stdout, String::from_utf8_lossy(&old.stdout));
    let schema = parse_json(&new);
    assert!(
        schema
            .get("properties")
            .and_then(|properties| properties.get("rules"))
            .is_some()
    );
}

#[test]
fn rule_pack_init_creates_pack_and_updates_json_config() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_project(dir.path());

    let output = run_rule_pack(dir.path(), &["init"]);

    assert_eq!(output.code, 0, "stderr: {}", output.stderr);
    assert!(dir.path().join("rule-packs/team-policy.jsonc").exists());
    let config: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(dir.path().join(".fallowrc.json")).unwrap())
            .expect("parse config");
    assert_eq!(
        config["rulePacks"],
        serde_json::json!(["rule-packs/team-policy.jsonc"])
    );
}

#[test]
fn rule_pack_init_refuses_to_overwrite_existing_pack() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_project(dir.path());
    let first = run_rule_pack(dir.path(), &["init"]);
    assert_eq!(first.code, 0, "stderr: {}", first.stderr);

    let pack = dir.path().join("rule-packs/team-policy.jsonc");
    std::fs::write(&pack, "sentinel").expect("write sentinel");
    let second = run_rule_pack(dir.path(), &["init"]);

    assert_eq!(second.code, 2);
    assert!(second.stderr.contains("already exists"));
    assert_eq!(std::fs::read_to_string(pack).unwrap(), "sentinel");
}

#[test]
fn rule_pack_init_unknown_template_lists_available_templates() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_project(dir.path());

    let output = run_rule_pack(dir.path(), &["init", "--template", "nope"]);

    assert_eq!(output.code, 2);
    assert!(output.stderr.contains("unknown rule-pack template"));
    assert!(output.stderr.contains("ai-safe-repo"));
}

#[test]
fn rule_pack_init_no_config_leaves_config_unchanged() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_project(dir.path());
    let config_path = dir.path().join(".fallowrc.json");
    let before = std::fs::read_to_string(&config_path).expect("read config");

    let output = run_rule_pack(dir.path(), &["init", "--no-config"]);

    assert_eq!(output.code, 0, "stderr: {}", output.stderr);
    assert_eq!(
        std::fs::read_to_string(config_path).expect("read config"),
        before
    );
    assert!(output.stdout.contains("\"rulePacks\""));
}

#[test]
fn rule_pack_init_json_output_reports_config_update() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_project(dir.path());

    let output = run_rule_pack(dir.path(), &["init", "--format", "json"]);

    assert_eq!(output.code, 0, "stderr: {}", output.stderr);
    let json = parse_json(&output);
    assert_eq!(json["kind"], "rule-pack-init");
    assert_eq!(json["pack_path"], "rule-packs/team-policy.jsonc");
    assert_eq!(json["template"], "starter");
    assert_eq!(json["config_updated"], true);
    assert_eq!(json["config_path"], ".fallowrc.json");
}

#[test]
fn rule_pack_list_json_reports_loaded_packs() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_project_with_rule_pack(dir.path());

    let output = run_rule_pack(dir.path(), &["list", "--format", "json"]);

    assert_eq!(output.code, 0, "stderr: {}", output.stderr);
    let json = parse_json(&output);
    assert_eq!(json["kind"], "rule-pack-list");
    assert_eq!(json["packs"][0]["name"], "team-policy");
    assert_eq!(json["packs"][0]["source"], "rule-packs/team-policy.jsonc");
    assert_eq!(
        json["packs"][0]["description"],
        "Repository policy guardrails"
    );
    assert_eq!(json["packs"][0]["rules"][0]["id"], "no-moment");
    assert_eq!(json["packs"][0]["rules"][0]["kind"], "banned-import");
    assert_eq!(json["packs"][0]["rules"][0]["severity"], "error");
    assert_eq!(
        json["packs"][0]["rules"][0]["patterns"],
        serde_json::json!(["moment"])
    );
    assert_eq!(json["packs"][0]["rules"][1]["id"], "no-network");
    assert_eq!(json["packs"][0]["rules"][1]["kind"], "banned-effect");
    assert_eq!(json["packs"][0]["rules"][1]["severity"], "warn");
    assert_eq!(
        json["packs"][0]["rules"][1]["patterns"],
        serde_json::json!(["network"])
    );
    assert_eq!(
        json["packs"][0]["rules"][1]["files"],
        serde_json::json!(["src/domain/**"])
    );
}

#[test]
fn rule_pack_list_empty_human_points_to_init() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_project(dir.path());

    let output = run_rule_pack(dir.path(), &["list"]);

    assert_eq!(output.code, 0, "stderr: {}", output.stderr);
    assert!(output.stdout.contains("No rule packs configured."));
    assert!(output.stdout.contains("fallow rule-pack init"));
}

#[test]
fn rule_pack_test_explicit_pack_reports_policy_findings() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_policy_test_project(dir.path(), "moment");

    let output = run_rule_pack(dir.path(), &["test", "packs/p.jsonc", "--format", "json"]);

    assert_eq!(output.code, 0, "stderr: {}", output.stderr);
    let json = parse_json(&output);
    assert_eq!(json["kind"], "rule-pack-test");
    assert_eq!(json["packs"], serde_json::json!(["team-policy"]));
    assert_eq!(json["forced_severity"], false);
    assert_eq!(json["rules"][0]["pack"], "team-policy");
    assert_eq!(json["rules"][0]["rule_id"], "no-import");
    assert_eq!(json["rules"][0]["kind"], "banned-import");
    assert_eq!(json["rules"][0]["findings"], 1);
    assert_eq!(json["findings"][0]["rule_id"], "no-import");
}

#[test]
fn rule_pack_test_lists_zero_finding_rules() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_policy_test_project(dir.path(), "lodash");

    let output = run_rule_pack(dir.path(), &["test", "packs/p.jsonc", "--format", "json"]);

    assert_eq!(output.code, 0, "stderr: {}", output.stderr);
    let json = parse_json(&output);
    assert_eq!(json["rules"][0]["findings"], 0);
    assert_eq!(json["findings"], serde_json::json!([]));
}

#[test]
fn rule_pack_test_without_pack_requires_configured_packs() {
    let dir = tempfile::tempdir().expect("create temp dir");
    write_project(dir.path());

    let output = run_rule_pack(dir.path(), &["test"]);

    assert_eq!(output.code, 2);
    assert!(
        output
            .stderr
            .contains("no rule packs configured; pass a pack path or run: fallow rule-pack init")
    );
}