use std::path::{Path, PathBuf};
use crate::error::{self, Error, Result};
pub const HOME_ENV: &str = "ONEVCS_HOME";
pub fn root() -> Result<PathBuf> {
match std::env::var_os(HOME_ENV) {
Some(value) if value.is_empty() => Err(Error::Invalid {
reason: format!("{HOME_ENV} is set but empty; unset it or give it a directory"),
}),
Some(value) => Ok(PathBuf::from(value)),
None => home_directory()
.map(|home| home.join(".onevcs"))
.ok_or_else(|| Error::Invalid {
reason: format!("cannot find a home directory; set {HOME_ENV} to a directory"),
}),
}
}
#[cfg(unix)]
fn home_directory() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
#[cfg(windows)]
fn home_directory() -> Option<PathBuf> {
std::env::var_os("USERPROFILE")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
pub fn registry_path() -> Result<PathBuf> {
Ok(root()?.join("registry.json"))
}
pub fn locks_dir() -> Result<PathBuf> {
Ok(root()?.join("locks"))
}
pub fn sessions_dir() -> Result<PathBuf> {
Ok(root()?.join("sessions"))
}
pub fn workspaces_dir() -> Result<PathBuf> {
Ok(root()?.join("workspaces"))
}
pub fn streams_dir() -> Result<PathBuf> {
Ok(root()?.join("streams"))
}
pub fn artifacts_dir() -> Result<PathBuf> {
Ok(root()?.join("artifacts"))
}
pub fn expand_tilde(value: &str) -> PathBuf {
match (value.strip_prefix("~/"), home_directory()) {
(Some(rest), Some(home)) => home.join(rest),
_ => PathBuf::from(value),
}
}
pub fn ensure_dir(path: &Path) -> Result<()> {
std::fs::create_dir_all(path).map_err(error::at("create", path))
}
pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
atomic_write_before_replace(path, contents, || {})
}
fn atomic_write_before_replace(
path: &Path,
contents: &str,
before_replace: impl FnOnce(),
) -> Result<()> {
let parent = path.parent().unwrap_or(Path::new("."));
ensure_dir(parent)?;
let temporary = parent.join(format!(".{}.{}", file_name(path), crate::ids::unique()));
std::fs::write(&temporary, contents).map_err(error::at("write", &temporary))?;
before_replace();
let replaced = std::fs::rename(&temporary, path).map_err(error::at("replace", path));
let _ = std::fs::remove_file(&temporary);
replaced
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_owned())
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
#[test]
fn a_reader_overlapping_replacement_sees_only_a_complete_document() {
let directory = tempfile::tempdir().expect("a temporary state directory");
let path = directory.path().join("session.json");
let old = r#"{"state":"open"}"#;
let new = r#"{"state":"closed"}"#;
std::fs::write(&path, old).expect("the old record");
let (ready_tx, ready_rx) = mpsc::channel();
let (continue_tx, continue_rx) = mpsc::channel();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
super::atomic_write_before_replace(&writer_path, new, || {
ready_tx.send(()).expect("the reader is waiting");
continue_rx.recv().expect("the reader completed");
})
.expect("the replacement succeeds");
});
ready_rx.recv().expect("the replacement is ready");
let during = std::fs::read_to_string(&path).expect("the record remains readable");
serde_json::from_str::<serde_json::Value>(&during).expect("the old record is complete");
assert_eq!(during, old);
continue_tx.send(()).expect("the writer is waiting");
writer.join().expect("the writer finishes");
let after = std::fs::read_to_string(&path).expect("the new record is readable");
serde_json::from_str::<serde_json::Value>(&after).expect("the new record is complete");
assert_eq!(after, new);
}
}