use std::io::Write as _;
use std::path::{Path, PathBuf};
pub(crate) const PROJECT_EXTENSION: &str = "oxigis.json";
pub(crate) const MAX_PROJECT_BYTES: u64 = 128 * 1024 * 1024;
const MAX_STEM_BYTES: usize = 96;
pub(crate) fn read_project_text(path: &Path) -> Result<String, String> {
let name = path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into(),
);
let bytes = crate::dataset_read::read_capped(path, &name, MAX_PROJECT_BYTES)?;
String::from_utf8(bytes)
.map_err(|_| format!("{name} is not a UTF-8 text document, so it is not a project file."))
}
pub(crate) fn write_project_atomically(path: &Path, content: &str) -> Result<(), String> {
write_atomically(path, content.as_bytes())
}
pub(crate) fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
let Some(directory) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return Err(format!("{} has no folder to write into.", path.display()));
};
let temporary = directory.join(temp_file_name(path));
let outcome = write_all_synced(&temporary, bytes).and_then(|()| {
std::fs::rename(&temporary, path)
.map_err(|error| format!("could not replace {}: {error}", path.display()))
});
if outcome.is_err() {
let _removed = std::fs::remove_file(&temporary);
}
outcome
}
fn write_all_synced(path: &Path, bytes: &[u8]) -> Result<(), String> {
let mut file = std::fs::File::create(path)
.map_err(|error| format!("could not write {}: {error}", path.display()))?;
file.write_all(bytes)
.map_err(|error| format!("could not write {}: {error}", path.display()))?;
file.sync_all()
.map_err(|error| format!("could not flush {}: {error}", path.display()))?;
Ok(())
}
fn temp_file_name(destination: &Path) -> String {
static SAVE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let stem = destination
.file_name()
.map_or_else(|| "project".to_string(), |n| n.to_string_lossy().into());
let sequence = SAVE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!(".{stem}.oxigis-save-{}-{sequence}", std::process::id())
}
pub(crate) fn with_project_extension(path: &Path) -> PathBuf {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
return path.to_path_buf();
};
if name.to_ascii_lowercase().ends_with(".json") {
return path.to_path_buf();
}
path.with_file_name(format!("{name}.{PROJECT_EXTENSION}"))
}
pub(crate) fn with_pdf_extension(path: &Path) -> PathBuf {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
return path.to_path_buf();
};
if name.to_ascii_lowercase().ends_with(".pdf") {
return path.to_path_buf();
}
path.with_file_name(format!("{name}.pdf"))
}
pub(crate) fn suggested_file_name(project_name: &str) -> String {
let mut stem = String::new();
let mut pending_separator = false;
for character in project_name.chars() {
let safe = match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '-',
c if c.is_control() => '-',
c if c.is_whitespace() => '-',
c => c,
};
if safe == '-' {
pending_separator = !stem.is_empty();
continue;
}
let width = character.len_utf8() + usize::from(pending_separator);
if stem.len() + width > MAX_STEM_BYTES {
break;
}
if pending_separator {
stem.push('-');
pending_separator = false;
}
stem.push(safe);
}
let stem = stem.trim_matches('.');
if stem.is_empty() {
return format!("project.{PROJECT_EXTENSION}");
}
format!("{stem}.{PROJECT_EXTENSION}")
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_dir(label: &str) -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let path = std::env::temp_dir().join(format!("oxigis-project-{label}-{stamp}"));
std::fs::create_dir_all(&path).expect("the scratch directory is creatable");
path
}
#[test]
fn a_project_round_trips_through_the_atomic_write() {
let dir = scratch_dir("roundtrip");
let path = dir.join("city.oxigis.json");
write_project_atomically(&path, "{\"name\":\"city\"}").expect("a fresh write succeeds");
assert_eq!(
read_project_text(&path).expect("the file reads back"),
"{\"name\":\"city\"}",
);
write_project_atomically(&path, "{\"name\":\"city2\"}").expect("an overwrite succeeds");
assert_eq!(
read_project_text(&path).expect("the file reads back"),
"{\"name\":\"city2\"}",
);
let leftovers: Vec<String> = std::fs::read_dir(&dir)
.expect("the directory lists")
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name != "city.oxigis.json")
.collect();
assert!(
leftovers.is_empty(),
"scratch files were left: {leftovers:?}"
);
let _removed = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_write_leaves_the_previous_save_intact() {
let dir = scratch_dir("failed");
let path = dir.join("city.oxigis.json");
write_project_atomically(&path, "{\"keep\":true}").expect("the first write succeeds");
let occupied = dir.join("occupied.oxigis.json");
std::fs::create_dir(&occupied).expect("the directory is creatable");
std::fs::write(occupied.join("inside"), b"x").expect("the child is writable");
let error = write_project_atomically(&occupied, "{\"new\":true}")
.expect_err("a non-empty directory cannot be replaced by a file");
assert!(error.contains("occupied.oxigis.json"), "{error}");
assert!(
occupied.join("inside").is_file(),
"the existing entry survived the failure",
);
assert_eq!(
read_project_text(&path).expect("the file reads back"),
"{\"keep\":true}",
);
let scratch: Vec<String> = std::fs::read_dir(&dir)
.expect("the directory lists")
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains("oxigis-save-"))
.collect();
assert!(scratch.is_empty(), "a failed write left {scratch:?}");
let _removed = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_read_is_capped_and_a_non_utf8_document_is_refused() {
let dir = scratch_dir("bounds");
let big = dir.join("big.oxigis.json");
std::fs::write(&big, vec![b'x'; 512]).expect("the fixture is writable");
let refusal = crate::dataset_read::read_capped(&big, "big.oxigis.json", 128)
.expect_err("512 bytes do not fit a 128-byte cap");
assert!(refusal.contains("big.oxigis.json"), "{refusal}");
let binary = dir.join("binary.oxigis.json");
std::fs::write(&binary, [0xff_u8, 0xfe, 0xfd]).expect("the fixture is writable");
let error = read_project_text(&binary).expect_err("invalid UTF-8 is not a project");
assert!(error.contains("UTF-8"), "{error}");
let _removed = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_extension_is_added_only_when_one_is_not_already_there() {
assert_eq!(
with_project_extension(Path::new("/data/city")),
PathBuf::from("/data/city.oxigis.json"),
);
assert_eq!(
with_project_extension(Path::new("/data/city.oxigis.json")),
PathBuf::from("/data/city.oxigis.json"),
);
assert_eq!(
with_project_extension(Path::new("/data/city.JSON")),
PathBuf::from("/data/city.JSON"),
);
assert_eq!(
with_project_extension(Path::new("/data/city.geojson")),
PathBuf::from("/data/city.geojson.oxigis.json"),
);
assert_eq!(
with_project_extension(Path::new("/data/tokyo.v2")),
PathBuf::from("/data/tokyo.v2.oxigis.json"),
);
assert_eq!(with_project_extension(Path::new("/")), PathBuf::from("/"),);
}
#[test]
fn a_suggested_name_is_a_usable_file_name_on_every_platform() {
assert_eq!(
suggested_file_name("Tokyo wards"),
"Tokyo-wards.oxigis.json"
);
assert_eq!(
suggested_file_name("a/b:c*d?e\"f<g>h|i"),
"a-b-c-d-e-f-g-h-i.oxigis.json",
"every reserved character is replaced, and runs collapse",
);
assert_eq!(suggested_file_name(" "), "project.oxigis.json");
assert_eq!(suggested_file_name(".."), "project.oxigis.json");
assert_eq!(suggested_file_name(""), "project.oxigis.json");
assert_eq!(suggested_file_name("東京都"), "東京都.oxigis.json");
let long = suggested_file_name(&"x".repeat(4096));
assert_eq!(long.len(), MAX_STEM_BYTES + PROJECT_EXTENSION.len() + 1);
let wide = suggested_file_name(&"東".repeat(4096));
assert!(wide.starts_with('東'), "{wide}");
assert!(wide.ends_with(PROJECT_EXTENSION), "{wide}");
}
#[test]
fn a_scratch_name_is_hidden_and_names_its_destination() {
let name = temp_file_name(Path::new("/data/city.oxigis.json"));
assert!(name.starts_with(".city.oxigis.json"), "{name}");
assert!(name.contains("oxigis-save-"), "{name}");
assert_ne!(
name,
temp_file_name(Path::new("/data/city.oxigis.json")),
"two concurrent saves must not collide",
);
}
}