use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
const BINARY: &str = env!("CARGO_BIN_EXE_regex-le");
static COUNTER: AtomicUsize = AtomicUsize::new(0);
const FINDING: &str = "const email = /(\\w+)+@/g;\n";
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!(
"regex-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);
}
}
struct Run {
code: Option<i32>,
stdout: String,
stderr: String,
}
fn run(args: &[&str]) -> Run {
run_with_timezone(args, Some("UTC"))
}
fn run_with_timezone(args: &[&str], timezone: Option<&str>) -> Run {
let mut command = Command::new(BINARY);
command.args(args).stdin(Stdio::null());
match timezone {
Some(value) => command.env("TZ", value),
None => command.env_remove("TZ"),
};
let output = command.output().expect("the binary runs");
Run {
code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
fn reports(outcome: &Run) -> Vec<serde_json::Value> {
outcome
.stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("stdout carries only JSON"))
.collect()
}
fn files(outcome: &Run) -> Vec<String> {
reports(outcome)
.iter()
.map(|report| {
report["file"]
.as_str()
.expect("a report names its file")
.to_string()
})
.collect()
}
fn location(line: &str) -> &str {
line.split(" ").next().unwrap_or(line)
}
#[test]
fn every_path_in_the_report_uses_a_forward_slash() {
let tree = Tree::new("separators");
tree.write("src/deep/validate.js", FINDING);
tree.write("src/parse.ts", FINDING);
tree.write("README.md", "nothing here\n");
let root = tree.path().to_string_lossy().to_string();
let separator = std::path::MAIN_SEPARATOR;
assert!(
root.contains(separator),
"the tree has no separator in it, so this test asserts nothing"
);
if separator == '\\' {
assert!(
root.contains('\\'),
"the input path carries no backslash, so the rewrite is untested here"
);
} else {
eprintln!(
"NOTE: this platform separates with {separator:?}, so the rewrite is exercised \
by scan.rs's own unit test rather than here"
);
}
let outcome = run(&["--all", &root]);
let named = files(&outcome);
assert_eq!(named.len(), 3, "{}", outcome.stderr);
for file in &named {
assert!(!file.contains('\\'), "{file} carries a backslash");
assert!(file.contains('/'), "{file} is not spelled with separators");
}
assert!(
named
.iter()
.any(|file| file.ends_with("src/deep/validate.js")),
"{named:?}"
);
for line in outcome.stderr.lines() {
assert!(
!location(line).contains('\\'),
"a human line spells its path with a backslash: {line}"
);
}
assert!(
outcome.stderr.contains("src/deep/validate.js:1:"),
"the human line names the file:\n{}",
outcome.stderr
);
let missing = run(&["--all", &format!("{root}/nope")]);
assert_eq!(missing.code, Some(2));
for line in missing.stderr.lines() {
assert!(
!location(line).contains('\\'),
"a refusal spells its path with a backslash: {line}"
);
}
}
#[test]
fn the_answer_does_not_depend_on_the_timezone() {
let tree = Tree::new("timezone");
tree.write("src/a.js", FINDING);
tree.write("src/b.py", "P = re.compile(r\"(?P<w>\\w+)+@\")\n");
let root = tree.path().to_string_lossy().to_string();
let utc = run_with_timezone(&["--all", &root], Some("UTC"));
let tokyo = run_with_timezone(&["--all", &root], Some("Asia/Tokyo"));
let absent = run_with_timezone(&["--all", &root], None);
assert_eq!(utc.stdout, absent.stdout, "TZ unset changed the report");
assert_eq!(utc.stdout, tokyo.stdout, "a timezone changed the report");
assert_eq!(utc.code, absent.code);
assert_eq!(utc.code, tokyo.code);
}
#[test]
fn a_case_insensitive_filesystem_does_not_report_one_file_twice() {
let tree = Tree::new("case");
tree.write("README.md", FINDING);
tree.write("readme.md", FINDING);
let entries = std::fs::read_dir(tree.path())
.expect("the tree is readable")
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
.count();
assert!(
(1..=2).contains(&entries),
"one entry on a case-insensitive filesystem, two on a case-sensitive one"
);
let outcome = run(&["--all", &tree.path().to_string_lossy()]);
let mut named = files(&outcome);
assert_eq!(named.len(), entries, "one report line per file on disk");
named.sort();
let before = named.len();
named.dedup();
assert_eq!(named.len(), before, "a file was reported twice");
}
#[test]
fn reserved_windows_filenames_do_not_stop_the_walk() {
let tree = Tree::new("reserved");
tree.write("ordinary.js", FINDING);
let mut created = Vec::new();
for name in ["CON", "PRN", "AUX", "NUL", "COM1"] {
match std::fs::write(tree.path().join(name), FINDING) {
Ok(()) => created.push(name),
Err(error) => eprintln!("SKIPPED {name}: this platform refuses the name ({error})"),
}
}
let outcome = run(&["--all", &tree.path().to_string_lossy()]);
assert_eq!(outcome.code, Some(1), "{}", outcome.stderr);
let named = files(&outcome);
assert!(
named.iter().any(|file| file.ends_with("ordinary.js")),
"the ordinary file was lost: {named:?}"
);
assert_eq!(
named.len(),
created.len() + 1,
"every name that was created is reported: {named:?}"
);
}
#[test]
fn a_child_that_refuses_immediately_is_judged_by_its_exit_code() {
let tree = Tree::new("stdin-race");
let file = tree.write("a.js", FINDING);
let mut child = Command::new(BINARY)
.args(["--stdin", &file.to_string_lossy()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
let mut stdin = child.stdin.take().expect("stdin is piped");
let _ = stdin.write_all(&vec![b'x'; 1024 * 1024]);
drop(stdin);
let output = child.wait_with_output().expect("finishes");
assert_eq!(output.status.code(), Some(2));
assert!(
String::from_utf8_lossy(&output.stderr).contains("stdin"),
"the refusal names what it refused"
);
}