use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
const BINARY: &str = env!("CARGO_BIN_EXE_paths-le");
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn skip(case: &str, why: &str) {
eprintln!("SKIPPED {case}: {why}");
}
struct Tree {
root: PathBuf,
}
impl Tree {
fn new(name: &str) -> Self {
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"paths-le-platform-{name}-{}-{unique}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("a temporary directory");
Self {
root: std::fs::canonicalize(&root).expect("a canonical directory"),
}
}
fn path(&self) -> &Path {
&self.root
}
fn write(&self, relative: &str, contents: &str) -> PathBuf {
let target = self.root.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("a parent directory");
}
std::fs::write(&target, contents).expect("a file");
target
}
}
impl Drop for Tree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn stdout_of(args: &[&str], tz: Option<&str>) -> String {
let mut command = Command::new(BINARY);
command.args(args).stdin(Stdio::null());
match tz {
Some(value) => command.env("TZ", value),
None => command.env_remove("TZ"),
};
let output = command.output().expect("the binary runs");
assert!(
output.status.code().is_some(),
"paths-le {args:?} was killed by a signal: {}",
output.status
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn reports(args: &[&str]) -> Vec<serde_json::Value> {
stdout_of(args, Some("UTC"))
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("stdout carries only JSON"))
.collect()
}
fn separator_tree() -> Tree {
let tree = Tree::new("separators");
tree.write("src/nested/target.ts", "");
tree.write(
"src/nested/app.ts",
"import './target.ts';\nimport './gone.ts';\n",
);
#[cfg(unix)]
std::os::unix::fs::symlink("target.ts", tree.path().join("src/nested/link.ts"))
.expect("a symlink");
#[cfg(unix)]
tree.write("src/nested/linked.ts", "import './link.ts';\n");
tree
}
#[test]
fn every_reported_path_spells_its_separators_forward() {
let tree = separator_tree();
let reports = reports(&[&tree.path().to_string_lossy()]);
assert!(!reports.is_empty());
let mut saw_separator = false;
for report in &reports {
let file = report["file"].as_str().expect("a file");
assert!(!file.contains('\\'), "report path uses a backslash: {file}");
saw_separator |= file.contains('/');
for path in report["paths"].as_array().expect("paths") {
for field in ["canonical", "symlink"] {
let Some(value) = path["resolution"][field].as_str() else {
continue;
};
assert!(
!value.contains('\\'),
"resolution.{field} uses a backslash: {value}"
);
}
}
}
assert!(
saw_separator,
"no reported path carried a separator at all, so this asserted nothing"
);
}
#[test]
fn the_report_does_not_depend_on_the_time_zone() {
let tree = separator_tree();
let arguments = [tree.path().to_string_lossy().into_owned()];
let arguments: Vec<&str> = arguments.iter().map(String::as_str).collect();
let utc = stdout_of(&arguments, Some("UTC"));
let unset = stdout_of(&arguments, None);
let elsewhere = stdout_of(&arguments, Some("Pacific/Kiritimati"));
assert!(!utc.is_empty());
assert_eq!(utc, unset, "the report changed when TZ was unset");
assert_eq!(utc, elsewhere, "the report changed with the time zone");
}
#[test]
fn a_case_folding_filesystem_does_not_report_one_file_twice() {
let tree = Tree::new("case");
tree.write("README.md", "see ./a.txt\n");
let _ = std::fs::write(tree.path().join("readme.md"), "see ./b.txt\n");
let on_disk = std::fs::read_dir(tree.path())
.expect("the tree is readable")
.count();
let reports = reports(&[&tree.path().to_string_lossy()]);
let mut files: Vec<&str> = reports
.iter()
.map(|report| report["file"].as_str().expect("a file"))
.collect();
let seen = files.len();
files.sort_unstable();
files.dedup();
assert_eq!(files.len(), seen, "a file was reported twice: {files:?}");
assert_eq!(
seen, on_disk,
"the walk and the directory disagree on how many files there are"
);
}
#[test]
fn reserved_windows_device_names_do_not_break_the_walk() {
let tree = Tree::new("reserved");
tree.write("ordinary.json", "{\"a\":\"./t.txt\"}");
tree.write("t.txt", "");
let mut created = Vec::new();
for name in ["CON", "PRN", "AUX", "NUL", "COM1"] {
match std::fs::write(tree.path().join(name), "{\"a\":\"./t.txt\"}") {
Ok(()) => created.push(name),
Err(_) => skip(
"reserved_windows_device_names_do_not_break_the_walk",
&format!("{name} is a reserved device name on this platform"),
),
}
}
let reports = reports(&[&tree.path().to_string_lossy()]);
assert!(
reports.iter().any(|report| report["file"]
.as_str()
.is_some_and(|f| f.ends_with("ordinary.json"))),
"the ordinary file was lost alongside the reserved ones"
);
for name in created {
assert!(
reports
.iter()
.any(|report| report["file"].as_str().is_some_and(|f| f.ends_with(name))),
"{name} was created and then not examined"
);
}
}
#[test]
fn a_child_that_refuses_before_reading_stdin_still_exits_two() {
for arguments in [vec!["--stdin"], vec!["--stdin", "--format"]] {
let mut child = Command::new(BINARY)
.args(&arguments)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
let _ = child
.stdin
.as_mut()
.expect("stdin")
.write_all(&b"{}".repeat(4096));
drop(child.stdin.take());
let output = child.wait_with_output().expect("the run finishes");
assert_eq!(
output.status.code(),
Some(2),
"paths-le {arguments:?} must refuse a question it cannot answer"
);
}
}
#[test]
fn a_stdin_report_is_labelled_the_same_everywhere() {
let mut child = Command::new(BINARY)
.args(["--stdin", "--format", "json", "--no-resolve"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
let written = child
.stdin
.as_mut()
.expect("stdin")
.write_all(b"{\"a\":\"./x.txt\"}");
drop(child.stdin.take());
let output = child.wait_with_output().expect("the run finishes");
assert!(written.is_ok(), "the child closed stdin before reading it");
assert_eq!(output.status.code(), Some(0));
let report: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout carries JSON");
assert_eq!(report["file"], "<stdin>");
assert_eq!(report["paths"][0]["value"], "./x.txt");
}