use std::ffi::OsString;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::{Error, Result};
pub fn mirror_path(mirror_root: &Path, division: &str, relative_source: &Path) -> PathBuf {
let mut mapped = mirror_root.join(division).join(relative_source);
let mut name = mapped.file_name().map(OsString::from).unwrap_or_default();
name.push(".txt");
mapped.set_file_name(name);
mapped
}
pub fn recorded_text_path(division: &str, source_path: &str) -> String {
format!("{division}/{source_path}.txt")
}
pub fn resolve_recorded_path(mirror_root: &Path, recorded: &str) -> Result<PathBuf> {
let segments: Vec<&str> = recorded.split('/').collect();
let clean = segments.len() >= 2
&& !recorded.contains(['\\', ':'])
&& segments
.iter()
.all(|segment| !segment.is_empty() && *segment != "." && *segment != "..");
if !clean {
return Err(Error::io(
"resolve",
Path::new(recorded),
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"recorded path is not a clean division-qualified mirror path",
),
));
}
Ok(mirror_root.join(recorded))
}
pub fn write_atomic(dest: &Path, text: &str) -> Result<()> {
let dir = dest.parent().ok_or_else(|| {
Error::io(
"write",
dest,
std::io::Error::new(std::io::ErrorKind::InvalidInput, "no parent directory"),
)
})?;
fs::create_dir_all(dir).map_err(|e| Error::io("create_dir", dir, e))?;
let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| Error::io("write", dest, e))?;
tmp.as_file_mut()
.write_all(text.as_bytes())
.map_err(|e| Error::io("write", dest, e))?;
tmp.persist(dest)
.map_err(|e| Error::io("persist", dest, e.error))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mirror_path_is_division_namespaced() {
let mapped = mirror_path(
Path::new("/mirror"),
"emea",
Path::new("a/b/Q3 Budget.xlsx"),
);
assert_eq!(mapped, PathBuf::from("/mirror/emea/a/b/Q3 Budget.xlsx.txt"));
}
#[test]
fn mirror_path_keeps_extensionless_names_distinct() {
let with = mirror_path(Path::new("/m"), "d", Path::new("readme.md"));
let without = mirror_path(Path::new("/m"), "d", Path::new("readme"));
assert_eq!(with, PathBuf::from("/m/d/readme.md.txt"));
assert_eq!(without, PathBuf::from("/m/d/readme.txt"));
assert_ne!(with, without);
}
#[test]
fn same_source_path_in_two_divisions_cannot_collide() {
let emea = recorded_text_path("emea", "reports/q3.docx");
let apac = recorded_text_path("apac", "reports/q3.docx");
assert_eq!(emea, "emea/reports/q3.docx.txt");
assert_eq!(apac, "apac/reports/q3.docx.txt");
assert_ne!(emea, apac);
assert_ne!(
mirror_path(Path::new("/m"), "emea", Path::new("reports/q3.docx")),
mirror_path(Path::new("/m"), "apac", Path::new("reports/q3.docx"))
);
}
#[test]
fn recorded_paths_resolve_and_escapes_are_refused() {
let resolved = resolve_recorded_path(Path::new("/m"), "emea/a.txt.txt").unwrap();
assert_eq!(resolved, PathBuf::from("/m/emea/a.txt.txt"));
assert!(resolve_recorded_path(Path::new("/m"), "a.txt.txt").is_err());
assert!(resolve_recorded_path(Path::new("/m"), "emea/../evil").is_err());
assert!(resolve_recorded_path(Path::new("/m"), "emea//x").is_err());
assert!(resolve_recorded_path(Path::new("/m"), "emea/").is_err());
assert!(resolve_recorded_path(Path::new("/m"), "").is_err());
}
#[test]
fn write_atomic_creates_directories_and_content() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("deep/nested/file.txt");
write_atomic(&dest, "content\n").unwrap();
assert_eq!(fs::read_to_string(&dest).unwrap(), "content\n");
}
#[test]
fn write_atomic_replaces_existing_content() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("file.txt");
write_atomic(&dest, "old\n").unwrap();
write_atomic(&dest, "new\n").unwrap();
assert_eq!(fs::read_to_string(&dest).unwrap(), "new\n");
}
}