use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::Duration;
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_f00"))
}
fn temp_dir(label: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!(
"f00-smoke-{}-{}-{}",
label,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
fs::create_dir_all(&base).expect("mkdir");
base
}
fn empty_config(fixture: &Path) -> PathBuf {
let parent = fixture
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(std::env::temp_dir);
let p = parent.join(format!(
"f00-empty-cfg-{}-{}.toml",
std::process::id(),
fixture.file_name().and_then(|n| n.to_str()).unwrap_or("x")
));
fs::write(&p, "").unwrap();
p
}
fn f00(config: &Path) -> Command {
let mut c = Command::new(bin());
c.env("LC_ALL", "C");
c.args(["--color=never", "--git=false", "--config"]);
c.arg(config);
c
}
fn smoke_fixture(label: &str) -> (PathBuf, PathBuf) {
let dir = temp_dir(label);
fs::write(dir.join("alpha.txt"), b"hello").unwrap();
fs::write(dir.join("beta.rs"), b"fn main() {}").unwrap();
fs::write(dir.join(".hidden"), b"secret").unwrap();
fs::create_dir(dir.join("subdir")).unwrap();
fs::write(dir.join("subdir").join("nested.txt"), b"x").unwrap();
fs::write(dir.join("big.bin"), vec![0u8; 2048]).unwrap();
let cfg = empty_config(&dir);
(dir, cfg)
}
#[test]
fn smoke_one_per_line() {
let (dir, cfg) = smoke_fixture("1");
let out = f00(&cfg).args(["-1"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("alpha.txt"), "{stdout}");
assert!(stdout.contains("beta.rs"), "{stdout}");
assert!(
!stdout.contains(".hidden"),
"default hides dotfiles: {stdout}"
);
assert!(stdout.lines().filter(|l| !l.is_empty()).count() >= 3);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn smoke_long_all() {
let (dir, cfg) = smoke_fixture("la");
let out = f00(&cfg).args(["-la"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains(".hidden"), "-a part of -la: {stdout}");
assert!(stdout.contains("alpha.txt"), "{stdout}");
assert!(
stdout.lines().any(|l| {
let t = l.trim_start();
t.starts_with('-') || t.starts_with('d') || t.starts_with('l')
}),
"expected long listing: {stdout}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn smoke_almost_all() {
let (dir, cfg) = smoke_fixture("A");
let out = f00(&cfg).args(["-A", "-1"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains(".hidden"), "{stdout}");
for line in stdout.lines() {
let t = line.trim();
assert_ne!(t, ".");
assert_ne!(t, "..");
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn smoke_human_readable() {
let (dir, cfg) = smoke_fixture("h");
let out = f00(&cfg).args(["-l", "-h"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("big.bin"), "{stdout}");
let has_human = stdout.lines().any(|l| {
l.contains("big.bin")
&& l.split_whitespace().any(|tok| {
tok.ends_with('K')
|| tok.ends_with('M')
|| tok.ends_with('G')
|| tok.ends_with('k')
|| tok.contains('K')
|| tok.contains('.')
})
});
let raw_only = stdout
.lines()
.any(|l| l.contains("big.bin") && l.contains("2048"));
assert!(
has_human || !raw_only,
"expected human-readable size for big.bin: {stdout}"
);
assert!(
stdout
.lines()
.any(|l| l.contains("big.bin") && l.split_whitespace().count() >= 5),
"{stdout}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn smoke_recursive() {
let (dir, cfg) = smoke_fixture("R");
let out = f00(&cfg).args(["-R", "-1"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("alpha.txt"), "{stdout}");
assert!(
stdout.contains("nested.txt") || stdout.contains("subdir"),
"recursive should reach subdir: {stdout}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn smoke_sort_time() {
let (dir, cfg) = smoke_fixture("t");
let older = dir.join("older.txt");
let newer = dir.join("newer.txt");
fs::write(&older, b"old").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(&newer, b"new").unwrap();
let out = f00(&cfg).args(["-1", "-t"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let i_new = stdout.find("newer.txt").expect("newer");
let i_old = stdout.find("older.txt").expect("older");
assert!(i_new < i_old, "-t newest first: {stdout}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn smoke_json() {
let (dir, cfg) = smoke_fixture("json");
let out = f00(&cfg).args(["--json"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("json");
assert!(v.is_array(), "{stdout}");
let names: Vec<_> = v
.as_array()
.unwrap()
.iter()
.filter_map(|e| e.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"alpha.txt"), "names={names:?}");
let first = v.as_array().unwrap().first().unwrap();
assert!(first.get("inode").is_some(), "{first}");
assert!(first.get("permissions").is_some(), "{first}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn smoke_json_short_j() {
let (dir, cfg) = smoke_fixture("json-j");
let out = f00(&cfg).args(["-j"]).arg(&dir).output().unwrap();
assert_eq!(
out.status.code(),
Some(0),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("json from -j");
assert!(v.is_array(), "{stdout}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn smoke_tree() {
let (dir, cfg) = smoke_fixture("tree");
let out = f00(&cfg).args(["--tree"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("alpha.txt"), "{stdout}");
assert!(
stdout.contains('├')
|| stdout.contains('└')
|| stdout.contains("subdir")
|| stdout.contains("│"),
"expected tree-like output: {stdout}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn gnu_mode_has_no_git_chars() {
let dir = temp_dir("gnu-git");
fs::write(dir.join("tracked.txt"), b"clean\n").unwrap();
let cfg = empty_config(&dir);
let _ = Command::new("git")
.args(["init", "-q"])
.current_dir(&dir)
.status();
let _ = Command::new("git")
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"add",
"tracked.txt",
])
.current_dir(&dir)
.status();
let _ = Command::new("git")
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"c",
])
.current_dir(&dir)
.status();
fs::write(dir.join("tracked.txt"), b"dirty\n").unwrap();
let out = Command::new(bin())
.env("LC_ALL", "C")
.args(["--color=never", "--gnu", "-l", "--config"])
.arg(&cfg)
.arg(&dir)
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains(" M ") && !stdout.contains(" ? ") && !stdout.contains(" A "),
"--gnu must not show git status chars: {stdout}"
);
assert!(stdout.contains("tracked.txt"), "{stdout}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn git_false_no_annotation_spaces() {
let dir = temp_dir("git-false");
fs::write(dir.join("plain.txt"), b"x").unwrap();
let cfg = empty_config(&dir);
let out = f00(&cfg).args(["-l"]).arg(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let line = stdout
.lines()
.find(|l| l.contains("plain.txt"))
.expect("plain.txt line");
assert!(
!line.contains(" plain.txt"),
"--git=false must not leave git annotation padding: {line}"
);
assert!(
line.contains(" plain.txt") || line.ends_with("plain.txt"),
"filename present: {line}"
);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
fn running_as_root() -> bool {
extern "C" {
fn geteuid() -> u32;
}
unsafe { geteuid() == 0 }
}
#[test]
#[cfg(unix)]
fn recursive_unreadable_subdir_exits_1_continues() {
use std::os::unix::fs::PermissionsExt;
if running_as_root() {
return;
}
let dir = temp_dir("recurse-err");
fs::write(dir.join("visible.txt"), b"v").unwrap();
let ok_sib = dir.join("sibling");
fs::create_dir(&ok_sib).unwrap();
fs::write(ok_sib.join("sib.txt"), b"s").unwrap();
let locked = dir.join("locked");
fs::create_dir(&locked).unwrap();
fs::write(locked.join("hidden.txt"), b"h").unwrap();
let cfg = empty_config(&dir);
let mut perms = fs::metadata(&locked).unwrap().permissions();
perms.set_mode(0o000);
fs::set_permissions(&locked, perms).unwrap();
if fs::read_dir(&locked).is_ok() {
let mut perms = fs::metadata(&locked).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&locked, perms).unwrap();
let _ = fs::remove_dir_all(&dir);
return;
}
let out = f00(&cfg).args(["-R", "-1"]).arg(&dir).output().unwrap();
let mut perms = fs::metadata(&locked).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&locked, perms).unwrap();
let code = out.status.code();
assert_eq!(
code,
Some(1),
"expected exit 1; stderr={} stdout={}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("visible.txt"),
"should list root entries: {stdout}"
);
assert!(
stdout.contains("sib.txt") || stdout.contains("sibling"),
"should continue into readable siblings: {stdout}"
);
let _ = fs::remove_dir_all(&dir);
}