use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use reserve_core::{Finding, Status};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WriteSummary {
pub available: usize,
pub unavailable: usize,
pub unknown: usize,
pub files: Vec<PathBuf>,
}
const MAX_LIST_MIB: u64 = 8;
const MAX_LIST_BYTES: u64 = MAX_LIST_MIB * 1024 * 1024;
fn read_capped(path: &Path, limit: u64) -> Result<String, io::Error> {
use std::io::Read as _;
let file = fs::File::open(path)?;
let mut text = String::new();
let read = file.take(limit + 1).read_to_string(&mut text)?;
if read as u64 > limit {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} is larger than {MAX_LIST_MIB} MiB", path.display()),
));
}
Ok(text)
}
pub(crate) fn read_list(path: &Path) -> Result<Vec<String>, io::Error> {
let text = read_capped(path, MAX_LIST_BYTES)?;
Ok(parse_list(&text))
}
pub(crate) fn label_for(findings: &[&Finding]) -> Option<String> {
let mut names: Vec<&str> = Vec::new();
for finding in findings {
if !names.contains(&finding.name.as_str()) {
names.push(&finding.name);
}
}
let first = safe_label(names.first()?)?;
match names.len() {
1 => Some(first),
rest => Some(format!("{first}-and-{}-more", rest.saturating_sub(1))),
}
}
fn safe_label(name: &str) -> Option<String> {
const ROOM: usize = 48;
let mut label = String::new();
for letter in name.chars() {
if letter.is_alphanumeric() {
label.extend(letter.to_lowercase());
} else if !label.ends_with('-') {
label.push('-');
}
if label.len() >= ROOM {
break;
}
}
let trimmed = label.trim_matches('-');
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
fn file_name(label: Option<&str>, stem: &str, as_json: bool) -> String {
let extension = if as_json { "json" } else { "txt" };
match label {
Some(label) => format!("{label}-{stem}.{extension}"),
None => format!("{stem}.{extension}"),
}
}
pub(crate) fn write_results(
dir: &Path,
findings: &[&Finding],
as_json: bool,
append: bool,
) -> Result<WriteSummary, io::Error> {
fs::create_dir_all(dir)?;
let label = label_for(findings);
let label = label.as_deref();
if !append {
refuse_to_replace(dir, findings, label, as_json)?;
}
let mut available: Vec<&Finding> = Vec::new();
let mut unavailable: Vec<&Finding> = Vec::new();
let mut unknown: Vec<&Finding> = Vec::new();
for &finding in findings {
match finding.status {
Status::Available => available.push(finding),
Status::Taken => unavailable.push(finding),
Status::Unknown(_) => unknown.push(finding),
}
}
let mut written = WriteSummary {
available: 0,
unavailable: 0,
unknown: 0,
files: Vec::new(),
};
if let Some((count, path)) =
write_status_file(dir, label, "available", &available, as_json, append)?
{
written.available = count;
written.files.push(path);
}
if let Some((count, path)) =
write_status_file(dir, label, "unavailable", &unavailable, as_json, append)?
{
written.unavailable = count;
written.files.push(path);
}
if let Some((count, path)) =
write_status_file(dir, label, "unknown", &unknown, as_json, append)?
{
written.unknown = count;
written.files.push(path);
}
Ok(written)
}
fn parse_list(text: &str) -> Vec<String> {
let mut entries: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for line in text.lines() {
let body = line.split_once('#').map_or(line, |(before, _)| before);
for piece in body.split(',') {
let entry = piece.trim();
if entry.is_empty() {
continue;
}
if seen.insert(entry.to_owned()) {
entries.push(entry.to_owned());
}
}
}
entries
}
fn refuse_to_replace(
dir: &Path,
findings: &[&Finding],
label: Option<&str>,
as_json: bool,
) -> Result<(), io::Error> {
let mut occupied: Vec<String> = Vec::new();
for (stem, wanted) in [
(
"available",
findings.iter().any(|f| f.status == Status::Available),
),
(
"unavailable",
findings.iter().any(|f| f.status == Status::Taken),
),
(
"unknown",
findings
.iter()
.any(|f| matches!(f.status, Status::Unknown(_))),
),
] {
if !wanted {
continue;
}
let path = dir.join(file_name(label, stem, as_json));
if path.exists() {
occupied.push(path.display().to_string());
}
}
if occupied.is_empty() {
return Ok(());
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"{} already exists; pass --append to merge into it, or --out <dir> to write somewhere else",
occupied.join(", ")
),
))
}
fn write_status_file(
dir: &Path,
label: Option<&str>,
stem: &str,
findings: &[&Finding],
as_json: bool,
append: bool,
) -> Result<Option<(usize, PathBuf)>, io::Error> {
if findings.is_empty() {
return Ok(None);
}
let path = dir.join(file_name(label, stem, as_json));
let count = if as_json {
write_json(&path, findings, append)?
} else {
write_text(&path, findings, append)?
};
Ok(Some((count, path)))
}
fn write_text(path: &Path, findings: &[&Finding], append: bool) -> Result<usize, io::Error> {
let mut lines = if append {
read_existing_lines(path)?
} else {
Vec::new()
};
lines.extend(findings.iter().map(|finding| finding.domain.clone()));
lines.sort_unstable();
lines.dedup();
let mut body = lines.join("\n");
body.push('\n');
write_atomically(path, body.as_bytes())?;
Ok(lines.len())
}
fn write_json(path: &Path, findings: &[&Finding], append: bool) -> Result<usize, io::Error> {
let existing = if append {
read_existing_entries(path)?
} else {
Vec::new()
};
let mut fresh: Vec<Value> = Vec::with_capacity(findings.len());
for finding in findings {
fresh.push(serde_json::to_value(finding).map_err(invalid_json)?);
}
let merged = merge_by_domain(existing, fresh);
let count = merged.len();
let mut body = serde_json::to_string_pretty(&Value::Array(merged)).map_err(invalid_json)?;
body.push('\n');
write_atomically(path, body.as_bytes())?;
Ok(count)
}
fn merge_by_domain(existing: Vec<Value>, fresh: Vec<Value>) -> Vec<Value> {
let mut merged: Vec<Value> = Vec::with_capacity(existing.len().saturating_add(fresh.len()));
let mut placed: HashMap<String, usize> = HashMap::new();
for entry in existing.into_iter().chain(fresh) {
match domain_of(&entry) {
Some(domain) => match placed.get(&domain).copied() {
Some(index) => {
if let Some(slot) = merged.get_mut(index) {
*slot = entry;
}
}
None => {
placed.insert(domain, merged.len());
merged.push(entry);
}
},
None => merged.push(entry),
}
}
merged
}
fn domain_of(entry: &Value) -> Option<String> {
entry.get("domain")?.as_str().map(str::to_owned)
}
fn read_existing_lines(path: &Path) -> Result<Vec<String>, io::Error> {
match read_capped(path, MAX_LIST_BYTES) {
Ok(text) => Ok(text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error),
}
}
fn read_existing_entries(path: &Path) -> Result<Vec<Value>, io::Error> {
let text = match read_capped(path, MAX_LIST_BYTES) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error),
};
if text.trim().is_empty() {
return Ok(Vec::new());
}
let parsed: Value = serde_json::from_str(&text).map_err(invalid_json)?;
match parsed {
Value::Array(entries) => Ok(entries),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{} does not hold a JSON array", path.display()),
)),
}
}
fn write_atomically(target: &Path, body: &[u8]) -> Result<(), io::Error> {
let staging = staging_path(target);
if let Err(error) = write_and_sync(&staging, body) {
let _ = fs::remove_file(&staging);
return Err(error);
}
if let Err(error) = fs::rename(&staging, target) {
let _ = fs::remove_file(&staging);
return Err(error);
}
Ok(())
}
fn write_and_sync(path: &Path, body: &[u8]) -> Result<(), io::Error> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(body)?;
file.sync_all()
}
fn staging_path(target: &Path) -> PathBuf {
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
let stem = target
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("results");
let ticket = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
target.with_file_name(format!(
".{stem}.{}.{stamp}.{ticket}.tmp",
std::process::id()
))
}
fn invalid_json(error: serde_json::Error) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, error)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use reserve_core::{Reason, Source, Suffix};
use tempfile::tempdir;
use super::*;
fn suffix() -> Suffix {
Suffix::parse("com").expect("com parses")
}
fn finding(domain: &str, status: Status) -> Finding {
let name = domain.split('.').next().unwrap_or(domain).to_owned();
Finding {
domain: domain.to_owned(),
name,
suffix: suffix(),
status,
source: Some(Source::Registry),
elapsed: Duration::from_millis(12),
responder: None,
registration: None,
}
}
fn borrow(findings: &[Finding]) -> Vec<&Finding> {
findings.iter().collect()
}
fn list_file(dir: &Path, body: &str) -> PathBuf {
let path = dir.join("names.txt");
fs::write(&path, body).expect("input list written");
path
}
fn saved(dir: &Path, named: &str, stem: &str, extension: &str) -> PathBuf {
dir.join(format!("{named}-{stem}.{extension}"))
}
fn text_of(path: &Path) -> String {
fs::read_to_string(path).expect("file readable")
}
fn names_in(dir: &Path) -> Vec<String> {
let mut found: Vec<String> = fs::read_dir(dir)
.expect("directory readable")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
found.sort();
found
}
#[test]
fn a_comment_is_stripped_to_end_of_line() {
let dir = tempdir().expect("temp dir");
let path = list_file(dir.path(), "example # the one we want\nother\n");
let entries = read_list(&path).expect("list read");
assert_eq!(entries, vec!["example".to_owned(), "other".to_owned()]);
}
#[test]
fn a_line_that_is_only_a_comment_is_skipped() {
let dir = tempdir().expect("temp dir");
let path = list_file(dir.path(), "# shortlist for the launch\nexample\n");
let entries = read_list(&path).expect("list read");
assert_eq!(entries, vec!["example".to_owned()]);
}
#[test]
fn commas_separate_entries_on_one_line() {
let dir = tempdir().expect("temp dir");
let path = list_file(dir.path(), "one, two ,three\nfour\n");
let entries = read_list(&path).expect("list read");
assert_eq!(
entries,
vec![
"one".to_owned(),
"two".to_owned(),
"three".to_owned(),
"four".to_owned(),
]
);
}
#[test]
fn blank_and_whitespace_only_lines_are_skipped() {
let dir = tempdir().expect("temp dir");
let path = list_file(dir.path(), "one\n\n \n\t\ntwo\n,,\n");
let entries = read_list(&path).expect("list read");
assert_eq!(entries, vec!["one".to_owned(), "two".to_owned()]);
}
#[test]
fn duplicates_are_dropped_and_the_first_order_is_kept() {
let dir = tempdir().expect("temp dir");
let path = list_file(dir.path(), "zulu\nalpha\nzulu\n alpha \nmike\n");
let entries = read_list(&path).expect("list read");
assert_eq!(
entries,
vec!["zulu".to_owned(), "alpha".to_owned(), "mike".to_owned()]
);
}
#[test]
fn an_empty_file_reads_as_an_empty_list_rather_than_an_error() {
let dir = tempdir().expect("temp dir");
let path = list_file(dir.path(), "");
let entries = read_list(&path).expect("an empty list is not a failure");
assert!(entries.is_empty());
}
#[test]
fn a_file_of_only_comments_reads_as_an_empty_list() {
let dir = tempdir().expect("temp dir");
let path = list_file(dir.path(), "# names to try\n# example\n\n# later\n");
let entries = read_list(&path).expect("an empty list is not a failure");
assert!(entries.is_empty());
}
#[test]
fn a_missing_file_reports_the_error_rather_than_an_empty_list() {
let dir = tempdir().expect("temp dir");
let error = read_list(&dir.path().join("absent.txt")).expect_err("missing file fails");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn a_saved_file_is_named_after_what_was_searched() {
let one = vec![finding("sazzad.com", Status::Available)];
assert_eq!(label_for(&borrow(&one)).as_deref(), Some("sazzad"));
assert_eq!(
file_name(Some("sazzad"), "available", false),
"sazzad-available.txt"
);
assert_eq!(
file_name(Some("sazzad"), "unavailable", true),
"sazzad-unavailable.json"
);
}
#[test]
fn one_name_checked_across_many_extensions_still_names_the_file_once() {
let sweep = vec![
finding("sazzad.com", Status::Available),
finding("sazzad.net", Status::Taken),
finding("sazzad.org", Status::Available),
];
assert_eq!(
label_for(&borrow(&sweep)).as_deref(),
Some("sazzad"),
"the extension varies but the search does not"
);
}
#[test]
fn several_names_are_named_after_the_first_and_a_count() {
let many = vec![
finding("alpha.com", Status::Available),
finding("bravo.com", Status::Available),
finding("charlie.com", Status::Available),
];
assert_eq!(
label_for(&borrow(&many)).as_deref(),
Some("alpha-and-2-more")
);
}
#[test]
fn a_name_can_never_steer_the_write_out_of_the_chosen_folder() {
for hostile in [
"../../etc/passwd",
"/absolute/path",
"..",
"with space",
"UPPER.Case",
] {
let label = safe_label(hostile).unwrap_or_default();
assert!(
!label.contains('/') && !label.contains('\\'),
"{hostile} kept a separator: {label}"
);
assert!(
!label.contains(".."),
"{hostile} kept a parent step: {label}"
);
assert!(
!label.starts_with('-') && !label.ends_with('-'),
"{hostile} left a stray hyphen: {label}"
);
assert_eq!(label, label.to_lowercase(), "{hostile} kept capitals");
}
assert_eq!(
safe_label("../../etc/passwd").as_deref(),
Some("etc-passwd")
);
assert_eq!(safe_label("UPPER.Case").as_deref(), Some("upper-case"));
assert_eq!(safe_label("///").as_deref(), None, "nothing usable is left");
}
#[test]
fn a_name_with_nothing_usable_in_it_falls_back_to_the_plain_file_name() {
assert_eq!(file_name(None, "available", false), "available.txt");
}
#[test]
fn a_very_long_name_cannot_grow_the_file_name_without_end() {
let long = "a".repeat(500);
let label = safe_label(&long).expect("letters survive");
assert!(label.len() <= 48, "{} characters", label.len());
}
#[test]
fn a_name_in_another_script_keeps_its_own_letters() {
assert_eq!(
safe_label("বাংলা").as_deref(),
Some("বাংলা"),
"stripping these would name the file after a different search"
);
}
#[test]
fn text_results_land_in_one_sorted_file_for_each_class() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let findings = vec![
finding("zulu.com", Status::Available),
finding("alpha.com", Status::Available),
finding("taken.com", Status::Taken),
finding("late.com", Status::Unknown(Reason::TimedOut)),
];
let written =
write_results(&out, &borrow(&findings), false, false).expect("results written");
assert_eq!(
text_of(&saved(&out, "zulu-and-3-more", "available", "txt")),
"alpha.com\nzulu.com\n"
);
assert_eq!(
text_of(&saved(&out, "zulu-and-3-more", "unavailable", "txt")),
"taken.com\n"
);
assert_eq!(
text_of(&saved(&out, "zulu-and-3-more", "unknown", "txt")),
"late.com\n"
);
assert_eq!(written.available, 2);
assert_eq!(written.unavailable, 1);
assert_eq!(written.unknown, 1);
assert_eq!(
written.files,
vec![
saved(&out, "zulu-and-3-more", "available", "txt"),
saved(&out, "zulu-and-3-more", "unavailable", "txt"),
saved(&out, "zulu-and-3-more", "unknown", "txt"),
]
);
}
#[test]
fn the_output_directory_is_created_when_it_is_missing() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("deep").join("results");
let findings = vec![finding("alpha.com", Status::Available)];
write_results(&out, &borrow(&findings), false, false).expect("results written");
assert!(saved(&out, "alpha", "available", "txt").is_file());
}
#[test]
fn a_class_with_nothing_in_it_writes_no_file() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let findings = vec![finding("alpha.com", Status::Available)];
let written =
write_results(&out, &borrow(&findings), false, false).expect("results written");
assert_eq!(names_in(&out), vec!["alpha-available.txt".to_owned()]);
assert_eq!(written.unavailable, 0);
assert_eq!(written.unknown, 0);
}
#[test]
fn an_unanswered_lookup_always_gets_its_own_file_and_never_the_available_one() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let findings = vec![
finding("alpha.com", Status::Available),
finding("late.com", Status::Unknown(Reason::RateLimited)),
finding("quiet.com", Status::Unknown(Reason::Unreachable)),
];
let written =
write_results(&out, &borrow(&findings), false, false).expect("results written");
let unknown = text_of(&saved(&out, "alpha-and-2-more", "unknown", "txt"));
assert!(unknown.contains("late.com"), "{unknown}");
assert!(unknown.contains("quiet.com"), "{unknown}");
assert_eq!(written.unknown, 2);
let available = text_of(&saved(&out, "alpha-and-2-more", "available", "txt"));
assert!(!available.contains("late.com"), "{available}");
assert!(!available.contains("quiet.com"), "{available}");
}
#[test]
fn writing_leaves_no_temporary_file_behind() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let findings = vec![
finding("alpha.com", Status::Available),
finding("taken.com", Status::Taken),
finding("late.com", Status::Unknown(Reason::TimedOut)),
];
write_results(&out, &borrow(&findings), false, false).expect("results written");
assert_eq!(
names_in(&out),
vec![
"alpha-and-2-more-available.txt".to_owned(),
"alpha-and-2-more-unavailable.txt".to_owned(),
"alpha-and-2-more-unknown.txt".to_owned(),
]
);
}
#[test]
fn a_second_write_refuses_rather_than_destroying_the_first() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let first = vec![
finding("alpha.com", Status::Available),
finding("bravo.com", Status::Available),
finding("charlie.com", Status::Available),
];
write_results(&out, &borrow(&first), false, false).expect("first write");
let second = vec![
finding("alpha.com", Status::Available),
finding("bravo.com", Status::Available),
finding("charlie.com", Status::Available),
];
let refused = write_results(&out, &borrow(&second), false, false)
.expect_err("an existing result file is never replaced in silence");
assert_eq!(refused.kind(), std::io::ErrorKind::AlreadyExists);
assert!(
refused.to_string().contains("--append"),
"the refusal names the flag that would have merged instead"
);
assert_eq!(
text_of(&saved(&out, "alpha-and-2-more", "available", "txt")),
"alpha.com\nbravo.com\ncharlie.com\n",
"the first run's results are still there"
);
}
#[test]
fn appending_text_merges_the_two_runs_without_duplicates() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let first = vec![
finding("bravo.com", Status::Available),
finding("alpha.com", Status::Available),
];
write_results(&out, &borrow(&first), false, false).expect("first write");
let second = vec![
finding("bravo.com", Status::Available),
finding("alpha.net", Status::Available),
];
let written = write_results(&out, &borrow(&second), false, true).expect("appended write");
assert_eq!(
text_of(&saved(&out, "bravo-and-1-more", "available", "txt")),
"alpha.com\nalpha.net\nbravo.com\n",
"bravo.com was in both runs and is written once"
);
assert_eq!(written.available, 3);
}
#[test]
fn appending_to_a_file_that_is_not_there_yet_still_writes_it() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let findings = vec![finding("alpha.com", Status::Available)];
write_results(&out, &borrow(&findings), false, true).expect("appended write");
assert_eq!(
text_of(&saved(&out, "alpha", "available", "txt")),
"alpha.com\n"
);
}
#[test]
fn json_results_hold_the_whole_finding() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let findings = vec![finding("alpha.com", Status::Available)];
write_results(&out, &borrow(&findings), true, false).expect("results written");
let body = text_of(&saved(&out, "alpha", "available", "json"));
assert!(body.contains('\n'), "the array is pretty printed: {body}");
let parsed: Vec<Finding> =
serde_json::from_str(&body).expect("the file is one array of findings");
assert_eq!(parsed, findings);
}
#[test]
fn appending_json_keeps_the_file_one_valid_array() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let first = vec![finding("alpha.com", Status::Available)];
write_results(&out, &borrow(&first), true, false).expect("first write");
let second = vec![
finding("alpha.com", Status::Available),
finding("alpha.net", Status::Available),
];
let written = write_results(&out, &borrow(&second), true, true).expect("appended write");
let body = text_of(&saved(&out, "alpha", "available", "json"));
let parsed: Value = serde_json::from_str(&body).expect("one valid JSON document");
let entries = parsed.as_array().expect("the document is an array");
assert_eq!(entries.len(), 2);
assert_eq!(written.available, 2);
let domains: Vec<String> = entries.iter().filter_map(domain_of).collect();
assert_eq!(
domains,
vec!["alpha.com".to_owned(), "alpha.net".to_owned()]
);
}
#[test]
fn appending_json_keeps_the_newest_answer_for_a_domain() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let mut early = finding("alpha.com", Status::Available);
early.responder = Some("first".to_owned());
write_results(&out, &borrow(&[early]), true, false).expect("first write");
let mut later = finding("alpha.com", Status::Available);
later.responder = Some("second".to_owned());
write_results(&out, &borrow(&[later]), true, true).expect("appended write");
let body = text_of(&saved(&out, "alpha", "available", "json"));
let parsed: Vec<Finding> = serde_json::from_str(&body).expect("one array of findings");
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed.first().and_then(|entry| entry.responder.clone()),
Some("second".to_owned())
);
}
#[test]
fn appending_to_a_json_file_that_holds_something_else_is_reported() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
fs::create_dir_all(&out).expect("directory made");
fs::write(
saved(&out, "bravo", "available", "json"),
"{\"domain\":\"alpha.com\"}",
)
.expect("file seeded");
let findings = vec![finding("bravo.com", Status::Available)];
let error =
write_results(&out, &borrow(&findings), true, true).expect_err("a non-array fails");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn nothing_to_write_leaves_an_empty_directory_and_zero_counts() {
let dir = tempdir().expect("temp dir");
let out = dir.path().join("results");
let written = write_results(&out, &[], false, false).expect("results written");
assert!(names_in(&out).is_empty());
assert_eq!(
written,
WriteSummary {
available: 0,
unavailable: 0,
unknown: 0,
files: Vec::new(),
}
);
}
}