use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process;
pub fn replace(directory: &Path, files: &[(String, String)]) -> io::Result<()> {
let staged = stage(directory, files)?;
swap(&staged, directory).inspect_err(|_| discard(&staged))
}
fn stage(directory: &Path, files: &[(String, String)]) -> io::Result<PathBuf> {
let staged = beside(directory, "new");
remove(&staged)?;
let written = files.iter().try_for_each(|(path, text)| {
let at = staged.join(path);
fs::create_dir_all(at.parent().expect("a file lies in a directory"))?;
fs::write(at, text)
});
written
.map(|()| staged.clone())
.inspect_err(|_| discard(&staged))
}
fn swap(staged: &Path, directory: &Path) -> io::Result<()> {
if !directory.exists() {
return fs::rename(staged, directory);
}
let old = beside(directory, "old");
remove(&old)?;
fs::rename(directory, &old)?;
if let Err(error) = fs::rename(staged, directory) {
fs::rename(&old, directory).ok();
return Err(error);
}
fs::remove_dir_all(&old)
}
fn beside(directory: &Path, purpose: &str) -> PathBuf {
let name = directory
.file_name()
.expect("a skill directory has a name")
.to_string_lossy();
directory.with_file_name(format!(".{name}.{purpose}-{}", process::id()))
}
fn remove(directory: &Path) -> io::Result<()> {
match fs::remove_dir_all(directory) {
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
_ => Ok(()),
}
}
fn discard(staged: &Path) {
remove(staged).ok();
}
#[cfg(test)]
mod tests {
use super::{stage, swap};
use std::collections::BTreeMap;
use std::path::Path;
use std::{env, fs, process};
fn files(root: &Path, dir: &Path, found: &mut BTreeMap<String, String>) {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
files(root, &path, found);
continue;
}
let relative = path.strip_prefix(root).unwrap().display().to_string();
found.insert(relative, fs::read_to_string(&path).unwrap());
}
}
fn listed(dir: &Path) -> BTreeMap<String, String> {
let mut found = BTreeMap::new();
files(dir, dir, &mut found);
found
}
#[test]
fn the_package_is_staged_whole_beside_the_skill_directory_which_is_left_untouched() {
let root = env::temp_dir().join(format!("loft-cli-stage-{}", process::id()));
let directory = root.join("skills/loft-spec");
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join("mine.md"), "kept").unwrap();
let package = [
("SKILL.md".to_owned(), "skill".to_owned()),
("references/format.md".to_owned(), "format".to_owned()),
];
let staged = stage(&directory, &package).unwrap();
assert_eq!(staged.parent(), directory.parent());
assert_ne!(staged, directory);
assert_eq!(listed(&staged), BTreeMap::from(package));
assert_eq!(
listed(&directory),
BTreeMap::from([("mine.md".to_owned(), "kept".to_owned())])
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_swap_that_fails_puts_the_replaced_directory_back_and_leaves_nothing_beside_it() {
let root = env::temp_dir().join(format!("loft-cli-swap-{}", process::id()));
let directory = root.join("skills/loft-spec");
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join("mine.md"), "kept").unwrap();
let missing = root.join("skills/.loft-spec.never-staged");
assert!(swap(&missing, &directory).is_err());
assert_eq!(
listed(&directory),
BTreeMap::from([("mine.md".to_owned(), "kept".to_owned())])
);
let names: Vec<String> = fs::read_dir(root.join("skills"))
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(names, ["loft-spec"]);
fs::remove_dir_all(root).unwrap();
}
}