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>,
pub replaced: Vec<PathBuf>,
}
const MAX_LIST_MIB: u64 = 8;
const MAX_LIST_BYTES: u64 = MAX_LIST_MIB * 1024 * 1024;
pub(crate) fn read_list(path: &Path) -> Result<Vec<String>, io::Error> {
let size = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
if size > MAX_LIST_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("the list is larger than {MAX_LIST_MIB} MiB"),
));
}
let text = fs::read_to_string(path)?;
Ok(parse_list(&text))
}
pub(crate) fn write_results(
dir: &Path,
findings: &[&Finding],
as_json: bool,
append: bool,
) -> Result<WriteSummary, io::Error> {
fs::create_dir_all(dir)?;
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(),
replaced: Vec::new(),
};
if let Some((count, path, replaced)) =
write_status_file(dir, "available", &available, as_json, append)?
{
written.available = count;
if replaced {
written.replaced.push(path.clone());
}
written.files.push(path);
}
if let Some((count, path, replaced)) =
write_status_file(dir, "unavailable", &unavailable, as_json, append)?
{
written.unavailable = count;
if replaced {
written.replaced.push(path.clone());
}
written.files.push(path);
}
if let Some((count, path, replaced)) =
write_status_file(dir, "unknown", &unknown, as_json, append)?
{
written.unknown = count;
if replaced {
written.replaced.push(path.clone());
}
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 write_status_file(
dir: &Path,
stem: &str,
findings: &[&Finding],
as_json: bool,
append: bool,
) -> Result<Option<(usize, PathBuf, bool)>, io::Error> {
if findings.is_empty() {
return Ok(None);
}
let extension = if as_json { "json" } else { "txt" };
let path = dir.join(format!("{stem}.{extension}"));
let replaced = !append && path.exists();
let count = if as_json {
write_json(&path, findings, append)?
} else {
write_text(&path, findings, append)?
};
Ok(Some((count, path, replaced)))
}
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 fs::read_to_string(path) {
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 fs::read_to_string(path) {
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 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 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(&out.join("available.txt")), "alpha.com\nzulu.com\n");
assert_eq!(text_of(&out.join("unavailable.txt")), "taken.com\n");
assert_eq!(text_of(&out.join("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![
out.join("available.txt"),
out.join("unavailable.txt"),
out.join("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!(out.join("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!["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(&out.join("unknown.txt"));
assert!(unknown.contains("late.com"), "{unknown}");
assert!(unknown.contains("quiet.com"), "{unknown}");
assert_eq!(written.unknown, 2);
let available = text_of(&out.join("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![
"available.txt".to_owned(),
"unavailable.txt".to_owned(),
"unknown.txt".to_owned(),
]
);
}
#[test]
fn a_replacing_write_leaves_none_of_the_longer_file_it_replaced() {
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)];
write_results(&out, &borrow(&second), false, false).expect("second write");
assert_eq!(text_of(&out.join("available.txt")), "alpha.com\n");
}
#[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("alpha.com", Status::Available),
finding("charlie.com", Status::Available),
];
let written = write_results(&out, &borrow(&second), false, true).expect("appended write");
assert_eq!(
text_of(&out.join("available.txt")),
"alpha.com\nbravo.com\ncharlie.com\n"
);
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(&out.join("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(&out.join("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("bravo.com", Status::Available),
];
let written = write_results(&out, &borrow(&second), true, true).expect("appended write");
let body = text_of(&out.join("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(), "bravo.com".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(&out.join("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(out.join("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(),
replaced: Vec::new(),
}
);
}
}