use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
fn bush() -> Command {
let mut cmd = Command::cargo_bin("bush").unwrap();
cmd.env_remove("HOME");
cmd
}
#[test]
fn default_run_honors_builtin_ignore_set() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("node_modules")).unwrap();
fs::write(tmp.path().join("node_modules/dep.js"), "x").unwrap();
fs::write(tmp.path().join("keep.txt"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("keep.txt"))
.stdout(predicate::str::contains("node_modules").not());
}
#[test]
fn default_run_honors_dockerignore() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("secrets")).unwrap();
fs::write(tmp.path().join("secrets/key.pem"), "x").unwrap();
fs::write(tmp.path().join("app.py"), "x").unwrap();
fs::write(tmp.path().join(".dockerignore"), "secrets\n").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("app.py"))
.stdout(predicate::str::contains("secrets").not());
}
#[test]
fn default_run_honors_npmignore() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("docs")).unwrap();
fs::write(tmp.path().join("docs/manual.md"), "x").unwrap();
fs::write(tmp.path().join("index.js"), "x").unwrap();
fs::write(tmp.path().join(".npmignore"), "docs\n").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("index.js"))
.stdout(predicate::str::contains("docs").not());
}
#[test]
fn no_ignore_flag_shows_everything_non_hidden() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("node_modules")).unwrap();
fs::write(tmp.path().join("node_modules/dep.js"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
bush()
.arg(tmp.path())
.arg("--no-ignore")
.assert()
.success()
.stdout(predicate::str::contains("node_modules"));
}
#[test]
fn hidden_flag_shows_dotfiles() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".hidden"), "x").unwrap();
fs::write(tmp.path().join("visible"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("visible"))
.stdout(predicate::str::contains(".hidden").not());
bush()
.arg(tmp.path())
.arg("--hidden")
.assert()
.success()
.stdout(predicate::str::contains(".hidden"));
}
#[test]
fn max_depth_flag_limits_traversal() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("a/b/c")).unwrap();
fs::write(tmp.path().join("a/b/c/deep.txt"), "x").unwrap();
fs::write(tmp.path().join("top.txt"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["-L", "1"])
.assert()
.success()
.stdout(predicate::str::contains("top.txt"))
.stdout(predicate::str::contains("deep.txt").not());
}
#[test]
fn dirs_only_filters_files() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("d1")).unwrap();
fs::write(tmp.path().join("file.txt"), "x").unwrap();
bush()
.arg(tmp.path())
.arg("-d")
.assert()
.success()
.stdout(predicate::str::contains("d1"))
.stdout(predicate::str::contains("file.txt").not());
}
#[test]
fn output_flag_writes_to_file_no_stdout() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("keep.txt"), "x").unwrap();
let out_path = tmp.path().join("tree.txt");
bush()
.arg(tmp.path())
.args(["-o", out_path.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::is_empty());
let contents = fs::read_to_string(&out_path).unwrap();
assert!(contents.contains("keep.txt"), "got: {contents}");
}
#[test]
fn stdout_flag_overrides_config_output() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("keep.txt"), "x").unwrap();
fs::write(
tmp.path().join(".bush"),
r#"{"output": "should_not_be_written.txt"}"#,
)
.unwrap();
bush()
.arg(tmp.path())
.arg("--stdout")
.assert()
.success()
.stdout(predicate::str::contains("keep.txt"));
assert!(!tmp.path().join("should_not_be_written.txt").exists());
}
#[test]
fn cli_ignore_file_adds_to_default_set() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".customignore"), "skipme\n").unwrap();
fs::write(tmp.path().join("skipme"), "x").unwrap();
fs::write(tmp.path().join("keepme"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("skipme"));
bush()
.arg(tmp.path())
.args(["-I", ".customignore"])
.assert()
.success()
.stdout(predicate::str::contains("keepme"))
.stdout(predicate::str::contains("skipme").not());
}
#[test]
fn local_bush_in_target_applies() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join("quiet.txt"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
fs::write(tmp.path().join(".bush"), r#"{"ignore_files": []}"#).unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"))
.stdout(predicate::str::contains("quiet.txt"));
}
#[test]
fn walk_up_finds_parent_bush() {
let tmp = TempDir::new().unwrap();
let child = tmp.path().join("child");
fs::create_dir_all(&child).unwrap();
fs::write(child.join("noisy.log"), "x").unwrap();
fs::write(child.join(".gitignore"), "*.log\n").unwrap();
fs::write(tmp.path().join(".bush"), r#"{"ignore_files": []}"#).unwrap();
bush()
.arg(&child)
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn home_bush_applies_via_home_env() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("fake_home");
let project = tmp.path().join("project");
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&project).unwrap();
fs::write(project.join("noisy.log"), "x").unwrap();
fs::write(project.join(".gitignore"), "*.log\n").unwrap();
fs::write(home.join(".bush"), r#"{"ignore_files": []}"#).unwrap();
bush()
.env("HOME", &home)
.arg(&project)
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn local_overrides_home_bush() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("fake_home");
let project = tmp.path().join("project");
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&project).unwrap();
fs::write(project.join("noisy.log"), "x").unwrap();
fs::write(project.join(".gitignore"), "*.log\n").unwrap();
fs::write(home.join(".bush"), r#"{"ignore_files": []}"#).unwrap();
fs::write(project.join(".bush"), r#"{"ignore_files": [".gitignore"]}"#).unwrap();
bush()
.env("HOME", &home)
.arg(&project)
.assert()
.success()
.stdout(predicate::str::contains("noisy.log").not());
}
#[test]
fn explicit_config_bypasses_discovery() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
fs::write(
tmp.path().join(".bush"),
r#"{"ignore_files": [".gitignore"]}"#,
)
.unwrap();
let explicit = tmp.path().join("custom.json");
fs::write(&explicit, r#"{"ignore_files": []}"#).unwrap();
bush()
.arg(tmp.path())
.args(["--config", explicit.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn no_config_flag_skips_discovery() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
fs::write(tmp.path().join(".bush"), r#"{"ignore_files": []}"#).unwrap();
bush()
.arg(tmp.path())
.arg("--no-config")
.assert()
.success()
.stdout(predicate::str::contains("noisy.log").not());
}
#[test]
fn invalid_json_in_local_bush_errors() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".bush"), "{ not valid").unwrap();
bush()
.arg(tmp.path())
.assert()
.failure()
.stderr(predicate::str::contains("parsing config"));
}
#[test]
fn unknown_config_key_errors() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".bush"), r#"{"typo_field": 1}"#).unwrap();
bush()
.arg(tmp.path())
.assert()
.failure()
.stderr(predicate::str::contains("unknown field"));
}
#[test]
fn nonexistent_path_exits_nonzero() {
bush()
.arg("/_does_not_exist_bush_test_42")
.assert()
.failure()
.stderr(predicate::str::contains("no such file"));
}
#[test]
fn version_prints_name_and_version() {
bush()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("bush"));
}
#[test]
fn help_lists_key_flags() {
bush()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("Usage:"))
.stdout(predicate::str::contains("--ignore-file"))
.stdout(predicate::str::contains("--max-depth"))
.stdout(predicate::str::contains("--no-ignore"))
.stdout(predicate::str::contains("--config"));
}
#[test]
fn relative_target_via_cwd() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("hello.txt"), "x").unwrap();
bush()
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("hello.txt"));
}
#[test]
fn footer_counts_match_contents() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("d1")).unwrap();
fs::create_dir(tmp.path().join("d2")).unwrap();
fs::write(tmp.path().join("f1"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("2 directories, 1 file"));
}
#[test]
fn unicode_tree_characters_in_output() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a"), "x").unwrap();
fs::write(tmp.path().join("b"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("├──"))
.stdout(predicate::str::contains("└──"));
}
#[test]
fn output_to_relative_path_resolves_against_cwd() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("hello.txt"), "x").unwrap();
bush()
.current_dir(tmp.path())
.args(["-o", "out.txt"])
.assert()
.success();
let contents = fs::read_to_string(tmp.path().join("out.txt")).unwrap();
assert!(contents.contains("hello.txt"), "got: {contents}");
}
#[test]
fn no_ignore_makes_cli_ignore_file_noop() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".customignore"), "skipme\n").unwrap();
fs::write(tmp.path().join("skipme"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["--no-ignore", "-I", ".customignore"])
.assert()
.success()
.stdout(predicate::str::contains("skipme"));
}
#[test]
fn cli_hidden_overrides_config_false() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".hidden_thing"), "x").unwrap();
fs::write(tmp.path().join(".bush"), r#"{"include_hidden": false}"#).unwrap();
bush()
.arg(tmp.path())
.arg("--hidden")
.assert()
.success()
.stdout(predicate::str::contains(".hidden_thing"));
}
#[test]
fn repeated_ignore_file_flag_is_idempotent() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".customignore"), "skipme\n").unwrap();
fs::write(tmp.path().join("skipme"), "x").unwrap();
fs::write(tmp.path().join("keepme"), "x").unwrap();
bush()
.arg(tmp.path())
.args([
"-I",
".customignore",
"-I",
".customignore",
"-I",
".customignore",
])
.assert()
.success()
.stdout(predicate::str::contains("keepme"))
.stdout(predicate::str::contains("skipme").not());
}
#[test]
fn multiple_distinct_ignore_file_flags_all_apply() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".oneignore"), "alpha\n").unwrap();
fs::write(tmp.path().join(".twoignore"), "beta\n").unwrap();
fs::write(tmp.path().join("alpha"), "x").unwrap();
fs::write(tmp.path().join("beta"), "x").unwrap();
fs::write(tmp.path().join("gamma"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["-I", ".oneignore", "-I", ".twoignore"])
.assert()
.success()
.stdout(predicate::str::contains("gamma"))
.stdout(predicate::str::contains("alpha").not())
.stdout(predicate::str::contains("beta").not());
}
#[test]
fn output_and_stdout_flags_conflict() {
bush()
.args(["-o", "x.txt", "--stdout", "."])
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used"));
}
#[test]
fn config_and_no_config_together_explicit_wins() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
let explicit = tmp.path().join("custom.json");
fs::write(&explicit, r#"{"ignore_files": []}"#).unwrap();
bush()
.arg(tmp.path())
.args(["--config", explicit.to_str().unwrap(), "--no-config"])
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn explicit_config_with_cli_overrides() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
let explicit = tmp.path().join("custom.json");
fs::write(&explicit, r#"{"ignore_files": [".gitignore"]}"#).unwrap();
bush()
.arg(tmp.path())
.args(["--config", explicit.to_str().unwrap(), "--no-ignore"])
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn explicit_config_nonexistent_path_errors() {
let tmp = TempDir::new().unwrap();
bush()
.arg(tmp.path())
.args(["--config", "/_bush_test_no_such_config_xyz_42"])
.assert()
.failure()
.stderr(predicate::str::contains("reading config"));
}
#[test]
fn max_depth_zero_via_cli() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("file.txt"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["-L", "0"])
.assert()
.success()
.stdout(predicate::str::contains("file.txt").not())
.stdout(predicate::str::contains("0 directories, 0 files"));
}
#[test]
fn max_depth_non_integer_clap_errors() {
bush().args(["-L", "not-an-int", "."]).assert().failure();
}
#[test]
fn max_depth_negative_clap_errors() {
bush().args(["--max-depth=-1", "."]).assert().failure();
}
#[test]
fn max_depth_overflow_clap_errors() {
bush()
.args(["--max-depth=99999999999999999999999", "."])
.assert()
.failure();
}
#[test]
fn no_path_arg_uses_cwd() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("hello.txt"), "x").unwrap();
bush()
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("hello.txt"));
}
#[test]
fn include_hidden_alias_works() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".hidden"), "x").unwrap();
bush()
.arg(tmp.path())
.arg("--include-hidden")
.assert()
.success()
.stdout(predicate::str::contains(".hidden"));
}
#[cfg(unix)]
#[test]
fn follow_symlinks_traverses_into_symlinked_dir() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("target")).unwrap();
fs::write(tmp.path().join("target/inner.txt"), "x").unwrap();
std::os::unix::fs::symlink(tmp.path().join("target"), tmp.path().join("link")).unwrap();
bush()
.arg(tmp.path())
.arg("--follow-symlinks")
.assert()
.success()
.stdout(predicate::str::contains("inner.txt"));
}
#[cfg(unix)]
#[test]
fn default_does_not_follow_symlinks() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("target")).unwrap();
fs::write(tmp.path().join("target/inner.txt"), "x").unwrap();
std::os::unix::fs::symlink(tmp.path().join("target"), tmp.path().join("link")).unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("link"))
.stdout(predicate::str::contains("inner.txt").count(1));
}
#[test]
fn output_overwrites_existing_file() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
let out = tmp.path().join("tree.txt");
fs::write(&out, "STALE OLD CONTENT").unwrap();
bush()
.arg(tmp.path())
.args(["-o", out.to_str().unwrap()])
.assert()
.success();
let contents = fs::read_to_string(&out).unwrap();
assert!(!contents.contains("STALE OLD CONTENT"));
assert!(contents.contains("x"));
}
#[test]
fn output_to_dev_null_succeeds() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
bush()
.arg(tmp.path())
.args(["-o", "/dev/null"])
.assert()
.success()
.stdout(predicate::str::is_empty());
}
#[test]
fn output_to_nonexistent_parent_dir_errors() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
let out = tmp.path().join("does_not_exist/tree.txt");
bush()
.arg(tmp.path())
.args(["-o", out.to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::contains("creating output file"));
}
#[test]
fn output_file_path_with_spaces() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
let out = tmp.path().join("tree with spaces.txt");
bush()
.arg(tmp.path())
.args(["-o", out.to_str().unwrap()])
.assert()
.success();
assert!(fs::read_to_string(&out).unwrap().contains("x"));
}
#[test]
fn home_unset_works_no_crash() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("x"));
}
#[test]
fn home_nonexistent_dir_works() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
bush()
.env("HOME", "/_bush_test_no_home_dir_xyz_42")
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("x"));
}
#[test]
fn home_empty_string_works() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
bush()
.env("HOME", "")
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("x"));
}
#[test]
fn walk_up_does_not_pick_up_home_bush_as_local() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("fake_home");
let project = home.join("project");
fs::create_dir_all(&project).unwrap();
fs::write(project.join("noisy.log"), "x").unwrap();
fs::write(project.join(".gitignore"), "*.log\n").unwrap();
fs::write(home.join(".bush"), r#"{"ignore_files": []}"#).unwrap();
bush()
.env("HOME", &home)
.arg(&project)
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
bush().arg(&project).assert().success();
}
#[cfg(unix)]
#[test]
fn permission_denied_dir_emits_stderr_continues() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let denied = tmp.path().join("denied");
fs::create_dir(&denied).unwrap();
fs::write(denied.join("inside.txt"), "x").unwrap();
fs::write(tmp.path().join("visible.txt"), "x").unwrap();
fs::set_permissions(&denied, fs::Permissions::from_mode(0o000)).unwrap();
let perms_enforced = fs::read_dir(&denied).is_err();
let result = bush().arg(tmp.path()).assert().success();
fs::set_permissions(&denied, fs::Permissions::from_mode(0o755)).unwrap();
let stdout = String::from_utf8_lossy(&result.get_output().stdout).to_string();
assert!(stdout.contains("visible.txt"));
assert!(stdout.contains("denied"));
if perms_enforced {
assert!(
!stdout.contains("inside.txt"),
"perm denied dir should not be entered"
);
}
}
#[test]
fn comments_and_blanks_in_gitignore_via_cli() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".gitignore"),
"# comment\n\nignored\n\n# another\n",
)
.unwrap();
fs::write(tmp.path().join("ignored"), "x").unwrap();
fs::write(tmp.path().join("keep"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("keep"))
.stdout(predicate::str::contains("ignored").not());
}
#[test]
fn negation_pattern_via_cli() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n!keep.log\n").unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join("keep.log"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("keep.log"))
.stdout(predicate::str::contains("noisy.log").not());
}
#[test]
fn many_files_render_correctly_via_cli() {
let tmp = TempDir::new().unwrap();
for i in 0..50 {
fs::write(tmp.path().join(format!("f{i:02}")), "x").unwrap();
}
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("50 files"));
}
#[test]
fn deep_nesting_via_cli() {
let tmp = TempDir::new().unwrap();
let mut path = tmp.path().to_path_buf();
for i in 0..10 {
path = path.join(format!("d{i}"));
}
fs::create_dir_all(&path).unwrap();
fs::write(path.join("leaf.txt"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("leaf.txt"))
.stdout(predicate::str::contains("10 directories, 1 file"));
}
#[test]
fn unicode_filename_via_cli() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("café.txt"), "x").unwrap();
fs::write(tmp.path().join("日本語.md"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("café.txt"))
.stdout(predicate::str::contains("日本語.md"));
}
#[test]
fn target_can_be_a_file() {
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("solo.txt");
fs::write(&f, "x").unwrap();
bush()
.arg(&f)
.assert()
.success()
.stdout(predicate::str::contains("solo.txt"));
}
#[test]
fn no_ignore_plus_hidden_shows_all() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("node_modules")).unwrap();
fs::write(tmp.path().join("node_modules/dep.js"), "x").unwrap();
fs::write(tmp.path().join("visible.js"), "x").unwrap();
fs::write(tmp.path().join(".env"), "secret=1").unwrap();
fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
bush()
.arg(tmp.path())
.args(["--no-ignore", "--hidden"])
.assert()
.success()
.stdout(predicate::str::contains("node_modules"))
.stdout(predicate::str::contains(".env"))
.stdout(predicate::str::contains(".gitignore"));
}
#[test]
fn explicit_config_relative_path_resolves_against_cwd() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
fs::write(tmp.path().join("custom.json"), r#"{"ignore_files": []}"#).unwrap();
bush()
.current_dir(tmp.path())
.args(["--config", "custom.json", "."])
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[cfg(unix)]
#[test]
fn target_can_be_symlink_to_dir() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("real")).unwrap();
fs::write(tmp.path().join("real/inside.txt"), "x").unwrap();
std::os::unix::fs::symlink(tmp.path().join("real"), tmp.path().join("link")).unwrap();
bush()
.arg(tmp.path().join("link"))
.assert()
.success()
.stdout(predicate::str::contains("inside.txt"));
}
#[cfg(unix)]
#[test]
fn output_target_is_a_symlink_writes_through() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
fs::write(tmp.path().join("real.txt"), "STALE").unwrap();
std::os::unix::fs::symlink(tmp.path().join("real.txt"), tmp.path().join("link.txt")).unwrap();
bush()
.arg(tmp.path())
.args(["-o", tmp.path().join("link.txt").to_str().unwrap()])
.assert()
.success();
let content = fs::read_to_string(tmp.path().join("real.txt")).unwrap();
assert!(content.contains("x"));
assert!(!content.contains("STALE"));
}
#[cfg(unix)]
#[test]
fn local_bush_as_symlink_is_followed() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
let real = tmp.path().join("real_config.json");
fs::write(&real, r#"{"ignore_files": []}"#).unwrap();
std::os::unix::fs::symlink(&real, tmp.path().join(".bush")).unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn bush_config_env_used_as_explicit_config() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
let cfg = tmp.path().join("via_env.json");
fs::write(&cfg, r#"{"ignore_files": []}"#).unwrap();
bush()
.env("BUSH_CONFIG", &cfg)
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn cli_config_wins_over_bush_config_env() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
let cli_cfg = tmp.path().join("cli.json");
fs::write(&cli_cfg, r#"{"ignore_files": []}"#).unwrap();
let env_cfg = tmp.path().join("env.json");
fs::write(&env_cfg, r#"{"ignore_files": [".gitignore"]}"#).unwrap();
bush()
.env("BUSH_CONFIG", &env_cfg)
.arg(tmp.path())
.args(["--config", cli_cfg.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn xdg_config_home_loaded() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
fs::create_dir_all(xdg.join("bush")).unwrap();
fs::write(xdg.join("bush/config.json"), r#"{"ignore_files": []}"#).unwrap();
let project = tmp.path().join("project");
fs::create_dir_all(&project).unwrap();
fs::write(project.join("noisy.log"), "x").unwrap();
fs::write(project.join(".gitignore"), "*.log\n").unwrap();
bush()
.env("XDG_CONFIG_HOME", &xdg)
.arg(&project)
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn dot_bush_json_local_file_works() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
fs::write(tmp.path().join(".bush.json"), r#"{"ignore_files": []}"#).unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("noisy.log"));
}
#[test]
fn format_json_produces_valid_json() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a.txt"), "x").unwrap();
fs::write(tmp.path().join("b.txt"), "x").unwrap();
let output = bush()
.arg(tmp.path())
.args(["--format", "json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8(output).unwrap();
let v: serde_json::Value = serde_json::from_str(&s).expect("output must be valid JSON");
assert!(v["root"].is_string());
assert!(v["children"].is_array());
assert_eq!(v["report"]["files"], 2);
}
#[test]
fn format_html_starts_with_doctype() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x.txt"), "y").unwrap();
bush()
.arg(tmp.path())
.args(["--format", "html"])
.assert()
.success()
.stdout(predicate::str::starts_with("<!DOCTYPE html>"))
.stdout(predicate::str::contains("x.txt"))
.stdout(predicate::str::contains("</body>"));
}
#[test]
fn format_xml_starts_with_declaration() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x.txt"), "y").unwrap();
bush()
.arg(tmp.path())
.args(["--format", "xml"])
.assert()
.success()
.stdout(predicate::str::starts_with("<?xml"))
.stdout(predicate::str::contains("<file name=\"x.txt\""))
.stdout(predicate::str::contains("</tree>"));
}
#[test]
fn format_json_respects_ignore_files() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("node_modules")).unwrap();
fs::write(tmp.path().join("node_modules/x"), "y").unwrap();
fs::write(tmp.path().join("main.rs"), "x").unwrap();
fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
let output = bush()
.arg(tmp.path())
.args(["--format", "json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8(output).unwrap();
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let names: Vec<String> = v["children"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap().to_string())
.collect();
assert!(names.contains(&"main.rs".to_string()));
assert!(!names.contains(&"node_modules".to_string()));
}
#[test]
fn format_json_includes_no_ansi_codes() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("dir")).unwrap();
fs::write(tmp.path().join("dir/x"), "y").unwrap();
bush()
.arg(tmp.path())
.args(["--format", "json", "--color", "always"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[").not());
}
#[cfg(unix)]
#[test]
fn symlink_target_display_via_cli() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("real.txt"), "x").unwrap();
std::os::unix::fs::symlink(tmp.path().join("real.txt"), tmp.path().join("link.txt")).unwrap();
bush()
.arg(tmp.path())
.arg("-l")
.assert()
.success()
.stdout(predicate::str::contains("link.txt -> "));
}
#[cfg(unix)]
#[test]
fn permissions_display_via_cli() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("script.sh");
fs::write(&f, "x").unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap();
bush()
.arg(tmp.path())
.arg("-p")
.assert()
.success()
.stdout(predicate::str::contains("[rwxr-xr-x]"));
}
#[cfg(unix)]
#[test]
fn permissions_and_sizes_combined() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("data");
fs::write(&f, vec![0u8; 200]).unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o644)).unwrap();
bush()
.arg(tmp.path())
.args(["-p", "-s"])
.assert()
.success()
.stdout(predicate::str::contains("[rw-r--r--]"))
.stdout(predicate::str::contains("[200]"));
}
#[test]
fn include_only_matching_files() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), "x").unwrap();
fs::write(tmp.path().join("src/util.rs"), "x").unwrap();
fs::write(tmp.path().join("README.md"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["--include", "*.rs"])
.assert()
.success()
.stdout(predicate::str::contains("main.rs"))
.stdout(predicate::str::contains("util.rs"))
.stdout(predicate::str::contains("README.md").not());
}
#[test]
fn exclude_drops_matching_paths() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("noisy.log"), "x").unwrap();
fs::write(tmp.path().join("keep.txt"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["--exclude", "*.log"])
.assert()
.success()
.stdout(predicate::str::contains("keep.txt"))
.stdout(predicate::str::contains("noisy.log").not());
}
#[test]
fn exclude_directory_by_name() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("vendor")).unwrap();
fs::write(tmp.path().join("vendor/lib.go"), "x").unwrap();
fs::write(tmp.path().join("main.go"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["--exclude", "vendor"])
.assert()
.success()
.stdout(predicate::str::contains("main.go"))
.stdout(predicate::str::contains("vendor").not());
}
#[test]
fn include_prunes_empty_dirs() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("empty_after_filter")).unwrap();
fs::write(tmp.path().join("empty_after_filter/x.txt"), "x").unwrap();
fs::write(tmp.path().join("main.rs"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["--include", "*.rs"])
.assert()
.success()
.stdout(predicate::str::contains("main.rs"))
.stdout(predicate::str::contains("empty_after_filter").not());
}
#[test]
fn include_and_exclude_combined() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a.rs"), "x").unwrap();
fs::write(tmp.path().join("b.rs"), "x").unwrap();
fs::write(tmp.path().join("test_b.rs"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["--include", "*.rs", "--exclude", "test_*.rs"])
.assert()
.success()
.stdout(predicate::str::contains("a.rs"))
.stdout(predicate::str::contains("b.rs"))
.stdout(predicate::str::contains("test_b.rs").not());
}
#[test]
fn invalid_glob_errors() {
let tmp = TempDir::new().unwrap();
bush()
.arg(tmp.path())
.args(["--include", "[unclosed"])
.assert()
.failure()
.stderr(predicate::str::contains("--include"));
}
#[test]
fn noreport_suppresses_footer() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
bush()
.arg(tmp.path())
.arg("--noreport")
.assert()
.success()
.stdout(predicate::str::contains("directories,").not());
}
#[test]
fn footer_present_by_default() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("0 directories, 1 file"));
}
#[test]
fn sort_by_size_via_cli() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("big"), vec![0u8; 1000]).unwrap();
fs::write(tmp.path().join("mid"), vec![0u8; 500]).unwrap();
fs::write(tmp.path().join("small"), vec![0u8; 10]).unwrap();
let output = bush()
.arg(tmp.path())
.args(["--sort", "size"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8(output).unwrap();
let small = s.find("small").unwrap();
let mid = s.find("mid").unwrap();
let big = s.find("big").unwrap();
assert!(small < mid && mid < big, "expected size-ASC, got: {s}");
}
#[test]
fn sort_reverse_flag() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("alpha"), "x").unwrap();
fs::write(tmp.path().join("zeta"), "x").unwrap();
let output = bush()
.arg(tmp.path())
.args(["-r"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8(output).unwrap();
let alpha = s.find("alpha").unwrap();
let zeta = s.find("zeta").unwrap();
assert!(zeta < alpha, "expected Z before A under -r, got: {s}");
}
#[test]
fn sort_none_via_cli() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("z"), "x").unwrap();
fs::write(tmp.path().join("a"), "x").unwrap();
bush()
.arg(tmp.path())
.args(["--sort", "none"])
.assert()
.success()
.stdout(predicate::str::contains("a"))
.stdout(predicate::str::contains("z"));
}
#[test]
fn show_sizes_flag_includes_bracketed_size() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("data.bin"), vec![0u8; 2048]).unwrap();
bush()
.arg(tmp.path())
.arg("-s")
.assert()
.success()
.stdout(predicate::str::contains("[2.0K]"))
.stdout(predicate::str::contains("data.bin"));
}
#[test]
fn show_mtime_flag_includes_iso_date() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
bush()
.arg(tmp.path())
.arg("-D")
.assert()
.success()
.stdout(predicate::str::is_match(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]").unwrap());
}
#[test]
fn sizes_and_mtime_combined() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), vec![0u8; 100]).unwrap();
bush()
.arg(tmp.path())
.args(["-s", "-D"])
.assert()
.success()
.stdout(predicate::str::contains("[100]"))
.stdout(predicate::str::is_match(r"\[\d{4}-\d{2}-\d{2}").unwrap());
}
#[test]
fn no_sizes_or_mtime_by_default() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("[").not())
.stdout(predicate::str::contains("]").not());
}
#[test]
fn color_always_emits_ansi() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("subdir")).unwrap();
fs::write(tmp.path().join("subdir/x"), "y").unwrap();
bush()
.arg(tmp.path())
.args(["--color", "always"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b["));
}
#[test]
fn color_never_emits_no_ansi() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("subdir")).unwrap();
bush()
.arg(tmp.path())
.args(["--color", "never"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[").not());
}
#[test]
fn color_auto_disabled_when_stdout_redirected() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("subdir")).unwrap();
bush()
.arg(tmp.path())
.args(["--color", "auto"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[").not());
}
#[test]
fn color_default_is_auto_disabled_in_test_env() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("subdir")).unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[").not());
}
#[test]
fn local_bush_as_directory_is_silently_skipped() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".bush")).unwrap();
fs::write(tmp.path().join("normal.txt"), "x").unwrap();
bush()
.arg(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("normal.txt"));
}