use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{OutpostError, OutpostResult, RemoteName, SourceRepo};
const REGISTRY_VERSION: u32 = 1;
const OUTPOST_IGNORE_LINE: &str = ".outpost/";
#[derive(Debug, Clone)]
pub struct Registry {
path: PathBuf,
exclude_path: PathBuf,
version: u32,
entries: Vec<RegistryEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryEntry {
pub path: PathBuf,
pub created_at: DateTime<Utc>,
pub remote_name: RemoteName,
pub locked: bool,
pub lock_reason: Option<String>,
pub locked_at: Option<DateTime<Utc>>,
}
#[must_use = "RegistryMut changes are persisted only on save()"]
pub struct RegistryMut<'src> {
_source: &'src SourceRepo,
inner: Registry,
dirty: bool,
saved: bool,
}
impl Registry {
pub fn load(source: &SourceRepo) -> OutpostResult<Self> {
let path = source.registry_path();
let exclude_path = source.local_exclude_path();
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self {
path,
exclude_path,
version: REGISTRY_VERSION,
entries: Vec::new(),
});
}
Err(source) => {
return Err(OutpostError::IoAt { path, source });
}
};
let file = serde_json::from_str::<RegistryFile>(&contents).map_err(|source| {
OutpostError::BadRegistry {
path: path.clone(),
reason: source.to_string(),
}
})?;
if file.version != REGISTRY_VERSION {
return Err(OutpostError::BadRegistry {
path,
reason: format!("unsupported registry version {}", file.version),
});
}
let entries = file
.outposts
.into_iter()
.map(|entry| entry.try_into_entry(&path))
.collect::<OutpostResult<Vec<_>>>()?;
Ok(Self {
path,
exclude_path,
version: REGISTRY_VERSION,
entries,
})
}
pub fn entries(&self) -> &[RegistryEntry] {
&self.entries
}
pub fn save(&self) -> OutpostResult<()> {
let parent = self.path.parent().ok_or_else(|| OutpostError::IoAt {
path: self.path.clone(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"registry path has no parent",
),
})?;
fs::create_dir_all(parent).map_err(|source| OutpostError::IoAt {
path: parent.to_path_buf(),
source,
})?;
ensure_local_ignore(&self.exclude_path)?;
let file = RegistryFile::from_registry(self);
let mut temp =
tempfile::NamedTempFile::new_in(parent).map_err(|source| OutpostError::IoAt {
path: parent.to_path_buf(),
source,
})?;
serde_json::to_writer_pretty(temp.as_file_mut(), &file).map_err(|source| {
OutpostError::IoAt {
path: self.path.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, source),
}
})?;
writeln!(temp.as_file_mut()).map_err(|source| OutpostError::IoAt {
path: self.path.clone(),
source,
})?;
temp.persist(&self.path)
.map_err(|source| OutpostError::IoAt {
path: self.path.clone(),
source: source.error,
})?;
Ok(())
}
fn find(&self, path: &Path) -> Option<usize> {
self.entries.iter().position(|entry| entry.path == path)
}
fn find_existing_or_recorded(&self, path: &Path) -> OutpostResult<(PathBuf, Option<usize>)> {
match canonicalize_path(path) {
Ok(canonical) => {
let index = self.find(&canonical);
Ok((canonical, index))
}
Err(canonicalize_err) => {
if let Some(index) = self.entries.iter().position(|entry| entry.path == path) {
Ok((path.to_path_buf(), Some(index)))
} else {
Err(canonicalize_err)
}
}
}
}
}
impl RegistryEntry {
pub fn new(path: PathBuf, remote_name: RemoteName) -> OutpostResult<Self> {
let path = canonicalize_path(&path)?;
let created_at = Utc::now();
Ok(Self {
path,
created_at,
remote_name,
locked: false,
lock_reason: None,
locked_at: None,
})
}
}
impl<'src> RegistryMut<'src> {
pub(crate) fn load(source: &'src SourceRepo) -> OutpostResult<Self> {
Ok(Self {
_source: source,
inner: Registry::load(source)?,
dirty: false,
saved: false,
})
}
pub fn add(&mut self, mut entry: RegistryEntry) -> OutpostResult<()> {
entry.path = canonicalize_path(&entry.path)?;
if let Some(index) = self.inner.find(&entry.path) {
let old = &self.inner.entries[index];
if old.locked && !entry.locked {
entry.locked = true;
entry.lock_reason = old.lock_reason.clone();
entry.locked_at = old.locked_at;
}
self.inner.entries[index] = entry;
} else {
self.inner.entries.push(entry);
}
self.dirty = true;
Ok(())
}
pub fn update_path(&mut self, old: &Path, new: PathBuf) -> OutpostResult<()> {
let (old, index) = self.inner.find_existing_or_recorded(old)?;
let new = canonicalize_path(&new)?;
let index = index.ok_or_else(|| OutpostError::RegistryEntryNotFound(old.clone()))?;
self.inner.entries[index].path = new;
self.dirty = true;
Ok(())
}
pub fn lock(&mut self, path: &Path, reason: Option<String>) -> OutpostResult<()> {
let path = canonicalize_path(path)?;
let index = self
.inner
.find(&path)
.ok_or_else(|| OutpostError::RegistryEntryNotFound(path.clone()))?;
let entry = &mut self.inner.entries[index];
entry.locked = true;
entry.lock_reason = reason;
entry.locked_at = Some(Utc::now());
self.dirty = true;
Ok(())
}
pub fn unlock(&mut self, path: &Path) -> OutpostResult<()> {
let path = canonicalize_path(path)?;
let index = self
.inner
.find(&path)
.ok_or_else(|| OutpostError::RegistryEntryNotFound(path.clone()))?;
let entry = &mut self.inner.entries[index];
entry.locked = false;
entry.lock_reason = None;
entry.locked_at = None;
self.dirty = true;
Ok(())
}
pub fn remove_by_path(&mut self, path: &Path) -> OutpostResult<bool> {
let (_path, index) = self.inner.find_existing_or_recorded(path)?;
if let Some(index) = index {
self.inner.entries.remove(index);
self.dirty = true;
Ok(true)
} else {
Ok(false)
}
}
pub fn entries(&self) -> &[RegistryEntry] {
self.inner.entries()
}
pub fn save(mut self) -> OutpostResult<()> {
self.saved = true;
let result = self.inner.save();
if result.is_ok() {
self.dirty = false;
}
result
}
}
impl<'src> Drop for RegistryMut<'src> {
fn drop(&mut self) {
if self.dirty && !self.saved {
debug_assert!(false, "RegistryMut dropped with unsaved changes");
eprintln!("warning: registry changes dropped without save");
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct RegistryFile {
version: u32,
outposts: Vec<RegistryEntryFile>,
}
#[derive(Debug, Serialize, Deserialize)]
struct RegistryEntryFile {
path: PathBuf,
created_at: DateTime<Utc>,
remote_name: String,
locked: bool,
lock_reason: Option<String>,
locked_at: Option<DateTime<Utc>>,
}
impl RegistryFile {
fn from_registry(registry: &Registry) -> Self {
Self {
version: registry.version,
outposts: registry
.entries
.iter()
.map(RegistryEntryFile::from_entry)
.collect(),
}
}
}
impl RegistryEntryFile {
fn from_entry(entry: &RegistryEntry) -> Self {
Self {
path: entry.path.clone(),
created_at: entry.created_at,
remote_name: entry.remote_name.as_str().to_owned(),
locked: entry.locked,
lock_reason: entry.lock_reason.clone(),
locked_at: entry.locked_at,
}
}
fn try_into_entry(self, registry_path: &Path) -> OutpostResult<RegistryEntry> {
let remote_name = RemoteName::parse(self.remote_name.clone()).map_err(|source| {
OutpostError::BadRegistry {
path: registry_path.to_path_buf(),
reason: source.to_string(),
}
})?;
Ok(RegistryEntry {
path: self.path,
created_at: self.created_at,
remote_name,
locked: self.locked,
lock_reason: self.lock_reason,
locked_at: self.locked_at,
})
}
}
fn ensure_local_ignore(exclude_path: &Path) -> OutpostResult<()> {
let parent = exclude_path.parent().ok_or_else(|| OutpostError::IoAt {
path: exclude_path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"exclude path has no parent",
),
})?;
fs::create_dir_all(parent).map_err(|source| OutpostError::IoAt {
path: parent.to_path_buf(),
source,
})?;
let mut contents = match fs::read_to_string(exclude_path) {
Ok(contents) => contents,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(source) => {
return Err(OutpostError::IoAt {
path: exclude_path.to_path_buf(),
source,
});
}
};
if contents
.lines()
.any(|line| line.trim() == OUTPOST_IGNORE_LINE)
{
return Ok(());
}
if !contents.is_empty() && !contents.ends_with('\n') {
contents.push('\n');
}
contents.push_str(OUTPOST_IGNORE_LINE);
contents.push('\n');
fs::write(exclude_path, contents).map_err(|source| OutpostError::IoAt {
path: exclude_path.to_path_buf(),
source,
})
}
fn canonicalize_path(path: &Path) -> OutpostResult<PathBuf> {
fs::canonicalize(path).map_err(|source| OutpostError::IoAt {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use std::panic::{AssertUnwindSafe, catch_unwind};
use super::*;
use crate::GitInvoker;
use chrono::TimeZone;
#[test]
fn empty_registry_serializes_to_expected_json_and_round_trips() {
let (_temp, source) = init_source_repo();
let registry = Registry::load(&source).expect("missing registry loads");
registry.save().expect("save empty registry");
let value: serde_json::Value = serde_json::from_str(
&fs::read_to_string(source.registry_path()).expect("registry file"),
)
.expect("registry json parses");
let expected: serde_json::Value =
serde_json::from_str(r#"{ "version": 1, "outposts": [] }"#).expect("expected json");
assert_eq!(value, expected);
assert!(
fs::read_to_string(source.local_exclude_path())
.expect("local exclude")
.lines()
.any(|line| line == OUTPOST_IGNORE_LINE)
);
let loaded = Registry::load(&source).expect("registry reloads");
assert!(loaded.entries().is_empty());
}
#[test]
fn add_readd_remove_and_add_round_trips_by_canonical_path() {
let (temp, source) = init_source_repo();
let outpost = temp.path().join("C");
let other = temp.path().join("D");
fs::create_dir_all(&outpost).expect("outpost dir");
fs::create_dir_all(&other).expect("other dir");
let mut registry = source.registry_mut().expect("registry mut");
registry
.add(entry_at(&outpost, "local", 1))
.expect("add local");
registry
.lock(&outpost, Some("keep".to_owned()))
.expect("lock");
registry
.add(entry_at(&outpost, "custom", 2))
.expect("re-add same path");
assert_eq!(registry.entries().len(), 1);
assert_eq!(registry.entries()[0].remote_name.as_str(), "custom");
assert!(registry.entries()[0].locked);
assert_eq!(registry.entries()[0].lock_reason.as_deref(), Some("keep"));
assert!(registry.remove_by_path(&outpost).expect("remove existing"));
assert!(!registry.remove_by_path(&outpost).expect("remove absent"));
registry
.add(entry_at(&other, "local", 3))
.expect("add other");
registry.save().expect("save");
let loaded = Registry::load(&source).expect("reload");
assert_eq!(loaded.entries().len(), 1);
assert_eq!(loaded.entries()[0].path, fs::canonicalize(&other).unwrap());
assert_eq!(loaded.entries()[0].remote_name.as_str(), "local");
}
#[test]
fn load_missing_registry_returns_empty_registry() {
let (_temp, source) = init_source_repo();
let registry = Registry::load(&source).expect("missing registry loads");
assert!(registry.entries().is_empty());
assert!(!source.registry_path().exists());
}
#[test]
fn update_path_handles_registered_old_path_after_rename() {
let (temp, source) = init_source_repo();
let old = temp.path().join("C");
let new = temp.path().join("D");
fs::create_dir_all(&old).expect("old outpost dir");
let canonical_old = fs::canonicalize(&old).expect("canonical old path");
let mut registry = source.registry_mut().expect("registry mut");
registry
.add(entry_at(&old, "local", 1))
.expect("add old path");
fs::rename(&old, &new).expect("rename outpost");
registry
.update_path(&canonical_old, new.clone())
.expect("update renamed path");
registry.save().expect("save");
let loaded = Registry::load(&source).expect("reload");
assert_eq!(loaded.entries().len(), 1);
assert_eq!(loaded.entries()[0].path, fs::canonicalize(&new).unwrap());
}
#[test]
fn remove_by_path_handles_registered_missing_path() {
let (temp, source) = init_source_repo();
let outpost = temp.path().join("C");
fs::create_dir_all(&outpost).expect("outpost dir");
let canonical_outpost = fs::canonicalize(&outpost).expect("canonical outpost");
let mut registry = source.registry_mut().expect("registry mut");
registry
.add(entry_at(&outpost, "local", 1))
.expect("add path");
fs::remove_dir(&outpost).expect("remove outpost dir");
assert!(
registry
.remove_by_path(&canonical_outpost)
.expect("remove missing registered path")
);
registry.save().expect("save");
assert!(
Registry::load(&source)
.expect("reload")
.entries()
.is_empty()
);
}
#[test]
fn load_malformed_json_returns_bad_registry() {
let (_temp, source) = init_source_repo();
fs::create_dir_all(source.registry_path().parent().unwrap()).expect("registry dir");
fs::write(source.registry_path(), "{not json").expect("write bad json");
let err = Registry::load(&source).expect_err("bad json should fail");
assert!(matches!(
err,
OutpostError::BadRegistry { path, .. } if path == source.registry_path()
));
}
#[test]
#[cfg(debug_assertions)]
fn dropping_dirty_registry_mut_trips_debug_drop_guard() {
let (temp, source) = init_source_repo();
let outpost = temp.path().join("C");
fs::create_dir_all(&outpost).expect("outpost dir");
let result = catch_unwind(AssertUnwindSafe(|| {
let mut registry = source.registry_mut().expect("registry mut");
registry
.add(entry_at(&outpost, "local", 1))
.expect("add entry");
}));
assert!(result.is_err());
}
#[test]
#[cfg(debug_assertions)]
fn failed_save_returns_error_without_drop_guard_panic() {
let temp = tempfile::tempdir().expect("tempdir");
let work_tree = temp.path().join("source");
let git_dir = temp.path().join("git-file");
let outpost = temp.path().join("C");
fs::create_dir_all(&work_tree).expect("source dir");
fs::write(&git_dir, "not a dir").expect("git file");
fs::create_dir_all(&outpost).expect("outpost dir");
let source =
SourceRepo::from_storage_paths(&work_tree, &git_dir).expect("source repo storage");
let result = catch_unwind(AssertUnwindSafe(|| {
let mut registry = source.registry_mut().expect("registry mut");
registry
.add(entry_at(&outpost, "local", 1))
.expect("add entry");
registry.save()
}));
let save_result = result.expect("save error should not panic");
assert!(matches!(save_result, Err(OutpostError::IoAt { .. })));
}
#[test]
#[cfg(not(debug_assertions))]
fn dropping_dirty_registry_mut_does_not_panic_in_release_builds() {
let (temp, source) = init_source_repo();
let outpost = temp.path().join("C");
fs::create_dir_all(&outpost).expect("outpost dir");
let mut registry = source.registry_mut().expect("registry mut");
registry
.add(entry_at(&outpost, "local", 1))
.expect("add entry");
}
fn init_source_repo() -> (tempfile::TempDir, SourceRepo) {
let temp = tempfile::tempdir().expect("tempdir");
GitInvoker::at(temp.path())
.run_check(["init", "--initial-branch=main"])
.expect("init source");
let source = SourceRepo::from_storage_paths(temp.path(), &temp.path().join(".git"))
.expect("source repo");
(temp, source)
}
fn entry_at(path: &Path, remote: &str, seconds: i64) -> RegistryEntry {
let created_at = Utc.timestamp_opt(seconds, 0).single().unwrap();
let remote_name = RemoteName::parse(remote).expect("remote parses");
RegistryEntry {
path: path.to_path_buf(),
created_at,
remote_name,
locked: false,
lock_reason: None,
locked_at: None,
}
}
}