#[path = "common/mod.rs"]
mod common;
use common::{parse_json, run_fallow_raw};
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
const ORIGINAL_BLOCK: &str = r"export function original() {
const alpha = 'alpha';
const beta = 'beta';
const gamma = 'gamma';
const delta = 'delta';
const epsilon = 'epsilon';
const zeta = 'zeta';
const eta = 'eta';
const theta = 'theta';
const iota = 'iota';
const kappa = 'kappa';
return [alpha, beta, gamma, delta, epsilon, zeta, eta, theta, iota, kappa].join(',');
}
";
const ADDED_DUPLICATE_BLOCK: &str = r"export function dupe() {
const alpha = 'alpha';
const beta = 'beta';
const gamma = 'gamma';
const delta = 'delta';
const epsilon = 'epsilon';
const zeta = 'zeta';
const eta = 'eta';
const theta = 'theta';
const iota = 'iota';
const kappa = 'kappa';
return [alpha, beta, gamma, delta, epsilon, zeta, eta, theta, iota, kappa].join(',');
}
";
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.status()
.expect("git command failed");
assert!(status.success(), "git {args:?} failed");
}
fn build_fixture() -> TempDir {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(
dir.join("package.json"),
r#"{"name":"changed-since-added-files","private":true,"type":"module"}"#,
)
.unwrap();
fs::write(dir.join("src/base.ts"), "export const used = 1;\n").unwrap();
fs::write(
dir.join("src/index.ts"),
"import { used } from './base';\nconsole.log(used);\n",
)
.unwrap();
fs::write(dir.join("src/original.ts"), ORIGINAL_BLOCK).unwrap();
git(dir, &["init", "-b", "main"]);
git(dir, &["add", "."]);
git(
dir,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(dir.join("src/new.ts"), "export const unused = 42;\n").unwrap();
fs::write(dir.join("src/dupe.ts"), ADDED_DUPLICATE_BLOCK).unwrap();
git(dir, &["add", "."]);
git(
dir,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"add changed files",
],
);
tmp
}
fn build_dirty_modified_fixture() -> TempDir {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(
dir.join("package.json"),
r#"{"name":"changed-since-dirty-modified","private":true,"type":"module"}"#,
)
.unwrap();
fs::write(dir.join("src/base.ts"), "export const used = 1;\n").unwrap();
fs::write(
dir.join("src/index.ts"),
"import { used } from './base';\nconsole.log(used);\n",
)
.unwrap();
git(dir, &["init", "-b", "main"]);
git(dir, &["add", "."]);
git(
dir,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
dir.join("src/base.ts"),
"export const used = 1;\nexport type DirtyUnused = { value: number };\n",
)
.unwrap();
tmp
}
fn build_staged_added_export_fixture() -> TempDir {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(
dir.join("package.json"),
r#"{"name":"changed-since-staged-added","private":true,"type":"module"}"#,
)
.unwrap();
fs::write(dir.join("src/base.ts"), "export const used = 1;\n").unwrap();
fs::write(
dir.join("src/index.ts"),
"import { used } from './base';\nconsole.log(used);\n",
)
.unwrap();
git(dir, &["init", "-b", "main"]);
git(dir, &["add", "."]);
git(
dir,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
dir.join("src/extra.ts"),
"export const extra = 2;\nexport type AddedUnused = { value: number };\n",
)
.unwrap();
fs::write(
dir.join("src/index.ts"),
"import { used } from './base';\nimport { extra } from './extra';\nconsole.log(used, extra);\n",
)
.unwrap();
git(dir, &["add", "src/index.ts", "src/extra.ts"]);
tmp
}
fn build_untracked_added_export_fixture() -> TempDir {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(
dir.join("package.json"),
r#"{"name":"changed-since-untracked-added","private":true,"type":"module"}"#,
)
.unwrap();
fs::write(dir.join("src/base.ts"), "export const used = 1;\n").unwrap();
fs::write(
dir.join("src/index.ts"),
"import { used } from './base';\nconsole.log(used);\n",
)
.unwrap();
git(dir, &["init", "-b", "main"]);
git(dir, &["add", "."]);
git(
dir,
&["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
);
fs::write(
dir.join("src/extra.ts"),
"export const extra = 2;\nexport type UntrackedUnused = { value: number };\n",
)
.unwrap();
fs::write(
dir.join("src/index.ts"),
"import { used } from './base';\nimport { extra } from './extra';\nconsole.log(used, extra);\n",
)
.unwrap();
tmp
}
fn collect_paths(items: &serde_json::Value) -> Vec<String> {
items
.as_array()
.expect("expected array")
.iter()
.filter_map(|item| {
item.get("path")
.or_else(|| item.get("file"))
.and_then(serde_json::Value::as_str)
})
.map(ToOwned::to_owned)
.collect()
}
#[test]
fn check_changed_since_keeps_added_file_findings() {
let tmp = build_fixture();
let output = run_fallow_raw(&[
"check",
"--root",
tmp.path().to_str().unwrap(),
"--changed-since",
"HEAD~1",
"--format",
"json",
"--quiet",
]);
assert_eq!(output.code, 1, "check should fail on added-file issues");
let json = parse_json(&output);
let unused_files = collect_paths(&json["unused_files"]);
assert!(
unused_files.iter().any(|path| path.ends_with("src/new.ts")),
"added unused file must survive --changed-since filtering: {unused_files:?}"
);
assert!(
unused_files
.iter()
.any(|path| path.ends_with("src/dupe.ts")),
"added duplicate file must survive --changed-since filtering: {unused_files:?}"
);
}
#[test]
fn check_changed_since_keeps_dirty_modified_file_type_findings() {
let tmp = build_dirty_modified_fixture();
let output = run_fallow_raw(&[
"check",
"--root",
tmp.path().to_str().unwrap(),
"--changed-since",
"HEAD",
"--format",
"json",
"--quiet",
]);
assert_eq!(
output.code, 1,
"check should fail on dirty changed-file issues"
);
let json = parse_json(&output);
let unused_types = json["unused_types"]
.as_array()
.expect("unused_types should be an array");
assert!(
unused_types.iter().any(|item| {
item["path"]
.as_str()
.is_some_and(|path| path.ends_with("src/base.ts"))
&& item["export_name"].as_str() == Some("DirtyUnused")
}),
"dirty modified file must survive --changed-since filtering: {unused_types:?}"
);
}
#[test]
fn check_changed_since_keeps_staged_added_file_type_findings() {
let tmp = build_staged_added_export_fixture();
let output = run_fallow_raw(&[
"check",
"--root",
tmp.path().to_str().unwrap(),
"--changed-since",
"HEAD",
"--format",
"json",
"--quiet",
]);
assert_eq!(
output.code, 1,
"check should fail on staged added-file issues"
);
let json = parse_json(&output);
let unused_types = json["unused_types"]
.as_array()
.expect("unused_types should be an array");
assert!(
unused_types.iter().any(|item| {
item["path"]
.as_str()
.is_some_and(|path| path.ends_with("src/extra.ts"))
&& item["export_name"].as_str() == Some("AddedUnused")
}),
"staged added file must survive --changed-since filtering: {unused_types:?}"
);
}
#[test]
fn check_changed_since_keeps_untracked_added_file_type_findings() {
let tmp = build_untracked_added_export_fixture();
let output = run_fallow_raw(&[
"check",
"--root",
tmp.path().to_str().unwrap(),
"--changed-since",
"HEAD",
"--format",
"json",
"--quiet",
]);
assert_eq!(
output.code, 1,
"check should fail on untracked added-file issues"
);
let json = parse_json(&output);
let unused_types = json["unused_types"]
.as_array()
.expect("unused_types should be an array");
assert!(
unused_types.iter().any(|item| {
item["path"]
.as_str()
.is_some_and(|path| path.ends_with("src/extra.ts"))
&& item["export_name"].as_str() == Some("UntrackedUnused")
}),
"untracked added file must survive --changed-since filtering: {unused_types:?}"
);
}
#[test]
fn dupes_changed_since_keeps_groups_with_added_file_instances() {
let tmp = build_fixture();
let output = run_fallow_raw(&[
"dupes",
"--root",
tmp.path().to_str().unwrap(),
"--changed-since",
"HEAD~1",
"--format",
"json",
"--quiet",
]);
assert_eq!(output.code, 0, "dupes should exit 0 without a threshold");
let json = parse_json(&output);
let clone_groups = json["clone_groups"]
.as_array()
.expect("clone_groups should be an array");
assert!(
!clone_groups.is_empty(),
"added duplicate file should produce at least one clone group"
);
let files: Vec<String> = clone_groups
.iter()
.flat_map(|group| {
group["instances"]
.as_array()
.expect("instances should be an array")
.iter()
.filter_map(|instance| instance["file"].as_str())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.collect();
assert!(
files.iter().any(|path| path.ends_with("src/dupe.ts")),
"added duplicate instance must survive --changed-since filtering: {files:?}"
);
}
#[test]
fn combined_changed_since_keeps_added_file_findings() {
let tmp = build_fixture();
let output = run_fallow_raw(&[
"--root",
tmp.path().to_str().unwrap(),
"--changed-since",
"HEAD~1",
"--format",
"json",
"--quiet",
]);
assert!(
output.code == 0 || output.code == 1,
"combined mode should not crash, got exit code {}",
output.code
);
let json = parse_json(&output);
let unused_files = collect_paths(&json["check"]["unused_files"]);
let clone_groups = json["dupes"]["clone_groups"]
.as_array()
.expect("combined dupes.clone_groups should be an array");
assert!(
unused_files.iter().any(|path| path.ends_with("src/new.ts")),
"combined check output must retain added unused files: {unused_files:?}"
);
assert!(
!clone_groups.is_empty(),
"combined dupes output must retain groups touching added files"
);
}