skif-cli 0.2.0

Command-line interface for skif, a dependency and package-manager linter for JS/TS monorepos.
Documentation
//! `skif migrate`: discovery, output handling and the exit codes.

#![allow(clippy::unwrap_used, clippy::expect_used)]

mod support;

use std::collections::BTreeSet;
use std::fs;
use std::path::Path;

use support::{run, skif};

const SHERIF_PACKAGE: &str = r#"{
  "name": "demo",
  "private": true,
  "scripts": { "check": "sherif --fail-on-warnings" },
  "sherif": { "ignoreRule": ["unordered-dependencies"], "select": "lowest" }
}"#;

fn project(package_json: &str) -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    fs::write(dir.path().join("package.json"), package_json).unwrap();
    dir
}

fn migrate(dir: &Path) -> assert_cmd::Command {
    let mut command = skif();
    command.arg("migrate").arg(dir);
    command
}

#[test]
fn dry_run_prints_the_configuration_and_writes_nothing() {
    let dir = project(SHERIF_PACKAGE);
    let result = run(migrate(dir.path()).args(["--dry-run", "--profile", "full"]));
    assert_eq!(result.code, 0, "{}{}", result.stdout, result.stderr);
    assert!(result.stdout.starts_with("#:schema "), "{}", result.stdout);
    assert!(
        result.stdout.contains("[lint]\nfail-on-warnings = true"),
        "{}",
        result.stdout
    );
    assert!(
        result.stdout.contains("unsorted-dependencies = \"off\""),
        "{}",
        result.stdout
    );
    assert!(result.stderr.contains("Translated"), "{}", result.stderr);
    assert!(!dir.path().join("skif.toml").exists());
}

#[test]
fn the_written_file_is_valid_and_is_not_overwritten_without_a_flag() {
    let dir = project(SHERIF_PACKAGE);
    let written = run(&mut migrate(dir.path()));
    assert_eq!(written.code, 0, "{}{}", written.stdout, written.stderr);
    assert!(written.stdout.contains("Wrote "), "{}", written.stdout);
    let target = dir.path().join("skif.toml");
    let first = fs::read_to_string(&target).unwrap();
    assert!(
        first.contains("# Migrated from sherif by `skif migrate` (profile: exact)."),
        "{first}"
    );

    let refused = run(&mut migrate(dir.path()));
    assert_eq!(refused.code, 2, "{}{}", refused.stdout, refused.stderr);
    assert!(refused.stderr.contains("already exists"), "{}", refused.stderr);
    assert_eq!(fs::read_to_string(&target).unwrap(), first);

    let merged = run(migrate(dir.path()).arg("--merge"));
    assert_eq!(merged.code, 0, "{}{}", merged.stdout, merged.stderr);
    assert_eq!(
        fs::read_to_string(&target).unwrap(),
        first,
        "merging the same migration changes nothing"
    );

    let linted = run(skif().arg("--no-js").arg(dir.path()));
    assert!(
        linted.code <= 1,
        "the migrated config must load: {}{}",
        linted.stdout,
        linted.stderr
    );
    assert!(!linted.stderr.contains("error:"), "{}", linted.stderr);
}

#[test]
fn merge_keeps_the_comments_of_the_existing_file() {
    let dir = project(SHERIF_PACKAGE);
    fs::write(
        dir.path().join("skif.toml"),
        "# mine\n[workspace]\nignore-packages = [\"legacy\"]\n",
    )
    .unwrap();
    let merged = run(migrate(dir.path()).args(["--merge", "--profile", "full"]));
    assert_eq!(merged.code, 0, "{}{}", merged.stdout, merged.stderr);
    let text = fs::read_to_string(dir.path().join("skif.toml")).unwrap();
    assert!(
        text.starts_with("# mine\n[workspace]\nignore-packages = [\"legacy\"]\n"),
        "{text}"
    );
    assert!(text.contains("[policy]\nprefer = \"lowest\""), "{text}");
}

#[test]
fn force_replaces_the_existing_file() {
    let dir = project(SHERIF_PACKAGE);
    fs::write(dir.path().join("skif.toml"), "[lint]\nfail-on-warnings = false\n").unwrap();
    let forced = run(migrate(dir.path()).arg("--force"));
    assert_eq!(forced.code, 0, "{}{}", forced.stdout, forced.stderr);
    let text = fs::read_to_string(dir.path().join("skif.toml")).unwrap();
    assert!(text.contains("fail-on-warnings = true"), "{text}");
}

#[test]
fn json_output_is_one_document() {
    let dir = project(SHERIF_PACKAGE);
    let result = run(migrate(dir.path()).args(["--dry-run", "--format", "json", "--profile", "full"]));
    assert_eq!(result.code, 0, "{}{}", result.stdout, result.stderr);
    let document = result.json();
    assert_eq!(document["version"], 1);
    assert_eq!(document["profile"], "full");
    assert_eq!(document["written"], serde_json::Value::Null);
    assert!(document["toml"].as_str().unwrap().contains("[lint]"));
    assert!(document["translated"].as_array().unwrap().len() >= 3);
    assert_eq!(document["tools"], serde_json::json!(["sherif"]));
}

#[test]
fn a_project_without_any_tool_is_a_usage_error() {
    let dir = project(r#"{ "name": "demo" }"#);
    let result = run(&mut migrate(dir.path()));
    assert_eq!(result.code, 2, "{}{}", result.stdout, result.stderr);
    assert!(result.stderr.contains("nothing to migrate"), "{}", result.stderr);
    let named = run(migrate(dir.path()).args(["--from", "knip", "--dry-run", "--profile", "full"]));
    assert_eq!(
        named.code, 0,
        "naming the tool migrates its defaults: {}{}",
        named.stdout, named.stderr
    );
}

#[test]
fn knip_files_are_discovered_and_strict_fails_on_what_needs_a_human() {
    let dir = project(r#"{ "name": "demo" }"#);
    fs::write(
        dir.path().join("knip.json"),
        r#"{ "entry": ["src/index.ts!"], "ignoreDependencies": ["left-pad"], "madeUp": true }"#,
    )
    .unwrap();
    let lenient = run(migrate(dir.path()).args(["--dry-run", "--profile", "full"]));
    assert_eq!(lenient.code, 0, "{}{}", lenient.stdout, lenient.stderr);
    assert!(
        lenient.stdout.contains("entry = [\"src/index.ts!\"]"),
        "{}",
        lenient.stdout
    );
    assert!(lenient.stderr.contains("madeUp"), "{}", lenient.stderr);
    let strict = run(migrate(dir.path()).args(["--dry-run", "--strict"]));
    assert_eq!(strict.code, 1, "{}{}", strict.stdout, strict.stderr);
}

#[test]
fn source_needs_exactly_one_tool() {
    let dir = project(SHERIF_PACKAGE);
    let result = run(migrate(dir.path()).args(["--source", "knip.json", "--from", "sherif,knip"]));
    assert_eq!(result.code, 2, "{}{}", result.stdout, result.stderr);
    assert!(result.stderr.contains("--source"), "{}", result.stderr);
}

#[test]
fn an_unreadable_configuration_names_the_file() {
    let dir = project(r#"{ "name": "demo" }"#);
    fs::write(dir.path().join(".syncpackrc.yaml"), "versionGroups: [unclosed\n  - a\n").unwrap();
    let result = run(migrate(dir.path()).args(["--from", "syncpack", "--dry-run"]));
    assert_eq!(result.code, 2, "{}{}", result.stdout, result.stderr);
    assert!(result.stderr.contains(".syncpackrc.yaml"), "{}", result.stderr);
}

#[test]
fn force_replaces_the_dot_file_that_exists_instead_of_adding_a_second_one() {
    let dir = project(SHERIF_PACKAGE);
    fs::write(dir.path().join(".skif.toml"), "[lint]\nfail-on-warnings = false\n").unwrap();
    let forced = run(migrate(dir.path()).arg("--force"));
    assert_eq!(forced.code, 0, "{}{}", forced.stdout, forced.stderr);
    assert!(
        !dir.path().join("skif.toml").exists(),
        "a second configuration file would be ambiguous"
    );
    let text = fs::read_to_string(dir.path().join(".skif.toml")).unwrap();
    assert!(text.contains("fail-on-warnings = true"), "{text}");
}

fn migrated(dir: &tempfile::TempDir) -> toml::Table {
    fs::read_to_string(dir.path().join("skif.toml"))
        .unwrap()
        .parse()
        .unwrap()
}

fn with_knip(dir: &tempfile::TempDir) {
    fs::write(dir.path().join("knip.json"), r#"{ "entry": ["src/index.ts"] }"#).unwrap();
}

#[test]
fn migrating_one_tool_after_the_other_checks_what_one_command_checks() {
    let one_shot = project(SHERIF_PACKAGE);
    with_knip(&one_shot);
    let result = run(migrate(one_shot.path()).args(["--from", "sherif,knip"]));
    assert_eq!(result.code, 0, "{}{}", result.stdout, result.stderr);
    let expected = migrated(&one_shot);

    for steps in [["sherif", "knip"], ["knip", "sherif"]] {
        let stepwise = project(SHERIF_PACKAGE);
        with_knip(&stepwise);
        let first = run(migrate(stepwise.path()).args(["--from", steps[0]]));
        assert_eq!(first.code, 0, "{}{}", first.stdout, first.stderr);
        let second = run(migrate(stepwise.path()).args(["--from", steps[1], "--merge"]));
        assert_eq!(second.code, 0, "{}{}", second.stdout, second.stderr);
        let actual = migrated(&stepwise);
        assert_eq!(actual["rules"], expected["rules"], "{steps:?}");
        assert_eq!(
            actual["js"].get("enabled"),
            expected["js"].get("enabled"),
            "{steps:?}: the JavaScript analysis stays on"
        );
        assert_ne!(actual["rules"]["unused-files"].as_str(), Some("off"), "{steps:?}");
        assert_ne!(
            actual["rules"].get("version-mismatch").and_then(toml::Value::as_str),
            Some("off")
        );
    }
}

#[test]
fn the_exact_profile_turns_off_every_default_rule_the_tool_did_not_run() {
    let rules = run(skif().args(["rules", "--format", "json"])).json();
    let default_on: BTreeSet<String> = rules["rules"]
        .as_array()
        .unwrap()
        .iter()
        .filter(|rule| !rule["default"].is_null())
        .map(|rule| rule["id"].as_str().unwrap().to_owned())
        .collect();
    let dir = project(SHERIF_PACKAGE);
    let result = run(migrate(dir.path()).args(["--dry-run", "--format", "json"]));
    assert_eq!(result.code, 0, "{}{}", result.stdout, result.stderr);
    let extra: BTreeSet<String> = result.json()["extra-checks"]
        .as_array()
        .unwrap()
        .iter()
        .map(|id| id.as_str().unwrap().to_owned())
        .collect();
    let sherif: BTreeSet<String> = [
        "empty-dependencies",
        "version-mismatch",
        "unmatched-workspace-patterns",
        "missing-package-manifest",
        "root-package-dependencies",
        "root-package-manager-field",
        "root-package-private-field",
        "types-in-dependencies",
        "unsorted-dependencies",
        "unsynced-dependency-family",
    ]
    .map(str::to_owned)
    .into();
    assert!(extra.is_subset(&default_on), "{extra:?}");
    let left: BTreeSet<String> = default_on.difference(&extra).cloned().collect();
    assert!(left.is_subset(&sherif), "{left:?}");
    assert!(extra.contains("unused-files"));
}

#[test]
fn config_hints_as_errors_are_written_as_warn_unless_strict_hints_is_given() {
    let dir = project(r#"{ "name": "demo", "scripts": { "knip": "knip --treat-config-hints-as-errors" } }"#);
    let plain = run(migrate(dir.path()).args(["--from", "knip", "--dry-run"]));
    assert_eq!(plain.code, 0, "{}{}", plain.stdout, plain.stderr);
    assert!(plain.stdout.contains("stale-js-config = \"warn\""), "{}", plain.stdout);
    let strict = run(migrate(dir.path()).args(["--from", "knip", "--dry-run", "--strict-hints"]));
    assert_eq!(strict.code, 0, "{}{}", strict.stdout, strict.stderr);
    assert!(
        strict.stdout.contains("stale-js-config = \"error\""),
        "{}",
        strict.stdout
    );
}