Skip to main content

outpost_core/
registry.rs

1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::{OutpostError, OutpostResult, RemoteName, SourceRepo};
9
10const REGISTRY_VERSION: u32 = 1;
11const OUTPOST_IGNORE_LINE: &str = ".outpost/";
12
13#[derive(Debug, Clone)]
14pub struct Registry {
15    path: PathBuf,
16    exclude_path: PathBuf,
17    version: u32,
18    entries: Vec<RegistryEntry>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RegistryEntry {
23    pub path: PathBuf,
24    pub created_at: DateTime<Utc>,
25    pub remote_name: RemoteName,
26    pub locked: bool,
27    pub lock_reason: Option<String>,
28    pub locked_at: Option<DateTime<Utc>>,
29}
30
31#[must_use = "RegistryMut changes are persisted only on save()"]
32pub struct RegistryMut<'src> {
33    _source: &'src SourceRepo,
34    inner: Registry,
35    dirty: bool,
36    saved: bool,
37}
38
39impl Registry {
40    pub fn load(source: &SourceRepo) -> OutpostResult<Self> {
41        let path = source.registry_path();
42        let exclude_path = source.local_exclude_path();
43        let contents = match fs::read_to_string(&path) {
44            Ok(contents) => contents,
45            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
46                return Ok(Self {
47                    path,
48                    exclude_path,
49                    version: REGISTRY_VERSION,
50                    entries: Vec::new(),
51                });
52            }
53            Err(source) => {
54                return Err(OutpostError::IoAt { path, source });
55            }
56        };
57
58        let file = serde_json::from_str::<RegistryFile>(&contents).map_err(|source| {
59            OutpostError::BadRegistry {
60                path: path.clone(),
61                reason: source.to_string(),
62            }
63        })?;
64        if file.version != REGISTRY_VERSION {
65            return Err(OutpostError::BadRegistry {
66                path,
67                reason: format!("unsupported registry version {}", file.version),
68            });
69        }
70
71        let entries = file
72            .outposts
73            .into_iter()
74            .map(|entry| entry.try_into_entry(&path))
75            .collect::<OutpostResult<Vec<_>>>()?;
76
77        Ok(Self {
78            path,
79            exclude_path,
80            version: REGISTRY_VERSION,
81            entries,
82        })
83    }
84
85    pub fn entries(&self) -> &[RegistryEntry] {
86        &self.entries
87    }
88
89    pub fn save(&self) -> OutpostResult<()> {
90        let parent = self.path.parent().ok_or_else(|| OutpostError::IoAt {
91            path: self.path.clone(),
92            source: std::io::Error::new(
93                std::io::ErrorKind::InvalidInput,
94                "registry path has no parent",
95            ),
96        })?;
97        fs::create_dir_all(parent).map_err(|source| OutpostError::IoAt {
98            path: parent.to_path_buf(),
99            source,
100        })?;
101        ensure_local_ignore(&self.exclude_path)?;
102
103        let file = RegistryFile::from_registry(self);
104        let mut temp =
105            tempfile::NamedTempFile::new_in(parent).map_err(|source| OutpostError::IoAt {
106                path: parent.to_path_buf(),
107                source,
108            })?;
109        serde_json::to_writer_pretty(temp.as_file_mut(), &file).map_err(|source| {
110            OutpostError::IoAt {
111                path: self.path.clone(),
112                source: std::io::Error::new(std::io::ErrorKind::Other, source),
113            }
114        })?;
115        writeln!(temp.as_file_mut()).map_err(|source| OutpostError::IoAt {
116            path: self.path.clone(),
117            source,
118        })?;
119        temp.persist(&self.path)
120            .map_err(|source| OutpostError::IoAt {
121                path: self.path.clone(),
122                source: source.error,
123            })?;
124
125        Ok(())
126    }
127
128    fn find(&self, path: &Path) -> Option<usize> {
129        self.entries.iter().position(|entry| entry.path == path)
130    }
131
132    fn find_existing_or_recorded(&self, path: &Path) -> OutpostResult<(PathBuf, Option<usize>)> {
133        match canonicalize_path(path) {
134            Ok(canonical) => {
135                let index = self.find(&canonical);
136                Ok((canonical, index))
137            }
138            Err(canonicalize_err) => {
139                if let Some(index) = self.entries.iter().position(|entry| entry.path == path) {
140                    Ok((path.to_path_buf(), Some(index)))
141                } else {
142                    Err(canonicalize_err)
143                }
144            }
145        }
146    }
147}
148
149impl RegistryEntry {
150    pub fn new(path: PathBuf, remote_name: RemoteName) -> OutpostResult<Self> {
151        let path = canonicalize_path(&path)?;
152        let created_at = Utc::now();
153        Ok(Self {
154            path,
155            created_at,
156            remote_name,
157            locked: false,
158            lock_reason: None,
159            locked_at: None,
160        })
161    }
162}
163
164impl<'src> RegistryMut<'src> {
165    pub(crate) fn load(source: &'src SourceRepo) -> OutpostResult<Self> {
166        Ok(Self {
167            _source: source,
168            inner: Registry::load(source)?,
169            dirty: false,
170            saved: false,
171        })
172    }
173
174    pub fn add(&mut self, mut entry: RegistryEntry) -> OutpostResult<()> {
175        entry.path = canonicalize_path(&entry.path)?;
176        if let Some(index) = self.inner.find(&entry.path) {
177            let old = &self.inner.entries[index];
178            if old.locked && !entry.locked {
179                entry.locked = true;
180                entry.lock_reason = old.lock_reason.clone();
181                entry.locked_at = old.locked_at;
182            }
183            self.inner.entries[index] = entry;
184        } else {
185            self.inner.entries.push(entry);
186        }
187        self.dirty = true;
188        Ok(())
189    }
190
191    pub fn update_path(&mut self, old: &Path, new: PathBuf) -> OutpostResult<()> {
192        let (old, index) = self.inner.find_existing_or_recorded(old)?;
193        let new = canonicalize_path(&new)?;
194        let index = index.ok_or_else(|| OutpostError::RegistryEntryNotFound(old.clone()))?;
195        self.inner.entries[index].path = new;
196        self.dirty = true;
197        Ok(())
198    }
199
200    pub fn lock(&mut self, path: &Path, reason: Option<String>) -> OutpostResult<()> {
201        let path = canonicalize_path(path)?;
202        let index = self
203            .inner
204            .find(&path)
205            .ok_or_else(|| OutpostError::RegistryEntryNotFound(path.clone()))?;
206        let entry = &mut self.inner.entries[index];
207        entry.locked = true;
208        entry.lock_reason = reason;
209        entry.locked_at = Some(Utc::now());
210        self.dirty = true;
211        Ok(())
212    }
213
214    pub fn unlock(&mut self, path: &Path) -> OutpostResult<()> {
215        let path = canonicalize_path(path)?;
216        let index = self
217            .inner
218            .find(&path)
219            .ok_or_else(|| OutpostError::RegistryEntryNotFound(path.clone()))?;
220        let entry = &mut self.inner.entries[index];
221        entry.locked = false;
222        entry.lock_reason = None;
223        entry.locked_at = None;
224        self.dirty = true;
225        Ok(())
226    }
227
228    pub fn remove_by_path(&mut self, path: &Path) -> OutpostResult<bool> {
229        let (_path, index) = self.inner.find_existing_or_recorded(path)?;
230        if let Some(index) = index {
231            self.inner.entries.remove(index);
232            self.dirty = true;
233            Ok(true)
234        } else {
235            Ok(false)
236        }
237    }
238
239    pub fn entries(&self) -> &[RegistryEntry] {
240        self.inner.entries()
241    }
242
243    pub fn save(mut self) -> OutpostResult<()> {
244        self.saved = true;
245        let result = self.inner.save();
246        if result.is_ok() {
247            self.dirty = false;
248        }
249        result
250    }
251}
252
253impl<'src> Drop for RegistryMut<'src> {
254    fn drop(&mut self) {
255        if self.dirty && !self.saved {
256            debug_assert!(false, "RegistryMut dropped with unsaved changes");
257            eprintln!("warning: registry changes dropped without save");
258        }
259    }
260}
261
262#[derive(Debug, Serialize, Deserialize)]
263struct RegistryFile {
264    version: u32,
265    outposts: Vec<RegistryEntryFile>,
266}
267
268#[derive(Debug, Serialize, Deserialize)]
269struct RegistryEntryFile {
270    path: PathBuf,
271    created_at: DateTime<Utc>,
272    remote_name: String,
273    locked: bool,
274    lock_reason: Option<String>,
275    locked_at: Option<DateTime<Utc>>,
276}
277
278impl RegistryFile {
279    fn from_registry(registry: &Registry) -> Self {
280        Self {
281            version: registry.version,
282            outposts: registry
283                .entries
284                .iter()
285                .map(RegistryEntryFile::from_entry)
286                .collect(),
287        }
288    }
289}
290
291impl RegistryEntryFile {
292    fn from_entry(entry: &RegistryEntry) -> Self {
293        Self {
294            path: entry.path.clone(),
295            created_at: entry.created_at,
296            remote_name: entry.remote_name.as_str().to_owned(),
297            locked: entry.locked,
298            lock_reason: entry.lock_reason.clone(),
299            locked_at: entry.locked_at,
300        }
301    }
302
303    fn try_into_entry(self, registry_path: &Path) -> OutpostResult<RegistryEntry> {
304        let remote_name = RemoteName::parse(self.remote_name.clone()).map_err(|source| {
305            OutpostError::BadRegistry {
306                path: registry_path.to_path_buf(),
307                reason: source.to_string(),
308            }
309        })?;
310        Ok(RegistryEntry {
311            path: self.path,
312            created_at: self.created_at,
313            remote_name,
314            locked: self.locked,
315            lock_reason: self.lock_reason,
316            locked_at: self.locked_at,
317        })
318    }
319}
320
321fn ensure_local_ignore(exclude_path: &Path) -> OutpostResult<()> {
322    let parent = exclude_path.parent().ok_or_else(|| OutpostError::IoAt {
323        path: exclude_path.to_path_buf(),
324        source: std::io::Error::new(
325            std::io::ErrorKind::InvalidInput,
326            "exclude path has no parent",
327        ),
328    })?;
329    fs::create_dir_all(parent).map_err(|source| OutpostError::IoAt {
330        path: parent.to_path_buf(),
331        source,
332    })?;
333
334    let mut contents = match fs::read_to_string(exclude_path) {
335        Ok(contents) => contents,
336        Err(source) if source.kind() == std::io::ErrorKind::NotFound => String::new(),
337        Err(source) => {
338            return Err(OutpostError::IoAt {
339                path: exclude_path.to_path_buf(),
340                source,
341            });
342        }
343    };
344
345    if contents
346        .lines()
347        .any(|line| line.trim() == OUTPOST_IGNORE_LINE)
348    {
349        return Ok(());
350    }
351    if !contents.is_empty() && !contents.ends_with('\n') {
352        contents.push('\n');
353    }
354    contents.push_str(OUTPOST_IGNORE_LINE);
355    contents.push('\n');
356    fs::write(exclude_path, contents).map_err(|source| OutpostError::IoAt {
357        path: exclude_path.to_path_buf(),
358        source,
359    })
360}
361
362fn canonicalize_path(path: &Path) -> OutpostResult<PathBuf> {
363    fs::canonicalize(path).map_err(|source| OutpostError::IoAt {
364        path: path.to_path_buf(),
365        source,
366    })
367}
368
369#[cfg(test)]
370mod tests {
371    use std::panic::{AssertUnwindSafe, catch_unwind};
372
373    use super::*;
374    use crate::GitInvoker;
375    use chrono::TimeZone;
376
377    #[test]
378    fn empty_registry_serializes_to_expected_json_and_round_trips() {
379        let (_temp, source) = init_source_repo();
380        let registry = Registry::load(&source).expect("missing registry loads");
381
382        registry.save().expect("save empty registry");
383
384        let value: serde_json::Value = serde_json::from_str(
385            &fs::read_to_string(source.registry_path()).expect("registry file"),
386        )
387        .expect("registry json parses");
388        let expected: serde_json::Value =
389            serde_json::from_str(r#"{ "version": 1, "outposts": [] }"#).expect("expected json");
390        assert_eq!(value, expected);
391        assert!(
392            fs::read_to_string(source.local_exclude_path())
393                .expect("local exclude")
394                .lines()
395                .any(|line| line == OUTPOST_IGNORE_LINE)
396        );
397
398        let loaded = Registry::load(&source).expect("registry reloads");
399        assert!(loaded.entries().is_empty());
400    }
401
402    #[test]
403    fn add_readd_remove_and_add_round_trips_by_canonical_path() {
404        let (temp, source) = init_source_repo();
405        let outpost = temp.path().join("C");
406        let other = temp.path().join("D");
407        fs::create_dir_all(&outpost).expect("outpost dir");
408        fs::create_dir_all(&other).expect("other dir");
409
410        let mut registry = source.registry_mut().expect("registry mut");
411        registry
412            .add(entry_at(&outpost, "local", 1))
413            .expect("add local");
414        registry
415            .lock(&outpost, Some("keep".to_owned()))
416            .expect("lock");
417        registry
418            .add(entry_at(&outpost, "custom", 2))
419            .expect("re-add same path");
420        assert_eq!(registry.entries().len(), 1);
421        assert_eq!(registry.entries()[0].remote_name.as_str(), "custom");
422        assert!(registry.entries()[0].locked);
423        assert_eq!(registry.entries()[0].lock_reason.as_deref(), Some("keep"));
424
425        assert!(registry.remove_by_path(&outpost).expect("remove existing"));
426        assert!(!registry.remove_by_path(&outpost).expect("remove absent"));
427        registry
428            .add(entry_at(&other, "local", 3))
429            .expect("add other");
430        registry.save().expect("save");
431
432        let loaded = Registry::load(&source).expect("reload");
433        assert_eq!(loaded.entries().len(), 1);
434        assert_eq!(loaded.entries()[0].path, fs::canonicalize(&other).unwrap());
435        assert_eq!(loaded.entries()[0].remote_name.as_str(), "local");
436    }
437
438    #[test]
439    fn load_missing_registry_returns_empty_registry() {
440        let (_temp, source) = init_source_repo();
441        let registry = Registry::load(&source).expect("missing registry loads");
442
443        assert!(registry.entries().is_empty());
444        assert!(!source.registry_path().exists());
445    }
446
447    #[test]
448    fn update_path_handles_registered_old_path_after_rename() {
449        let (temp, source) = init_source_repo();
450        let old = temp.path().join("C");
451        let new = temp.path().join("D");
452        fs::create_dir_all(&old).expect("old outpost dir");
453        let canonical_old = fs::canonicalize(&old).expect("canonical old path");
454
455        let mut registry = source.registry_mut().expect("registry mut");
456        registry
457            .add(entry_at(&old, "local", 1))
458            .expect("add old path");
459        fs::rename(&old, &new).expect("rename outpost");
460
461        registry
462            .update_path(&canonical_old, new.clone())
463            .expect("update renamed path");
464        registry.save().expect("save");
465
466        let loaded = Registry::load(&source).expect("reload");
467        assert_eq!(loaded.entries().len(), 1);
468        assert_eq!(loaded.entries()[0].path, fs::canonicalize(&new).unwrap());
469    }
470
471    #[test]
472    fn remove_by_path_handles_registered_missing_path() {
473        let (temp, source) = init_source_repo();
474        let outpost = temp.path().join("C");
475        fs::create_dir_all(&outpost).expect("outpost dir");
476        let canonical_outpost = fs::canonicalize(&outpost).expect("canonical outpost");
477
478        let mut registry = source.registry_mut().expect("registry mut");
479        registry
480            .add(entry_at(&outpost, "local", 1))
481            .expect("add path");
482        fs::remove_dir(&outpost).expect("remove outpost dir");
483
484        assert!(
485            registry
486                .remove_by_path(&canonical_outpost)
487                .expect("remove missing registered path")
488        );
489        registry.save().expect("save");
490
491        assert!(
492            Registry::load(&source)
493                .expect("reload")
494                .entries()
495                .is_empty()
496        );
497    }
498
499    #[test]
500    fn load_malformed_json_returns_bad_registry() {
501        let (_temp, source) = init_source_repo();
502        fs::create_dir_all(source.registry_path().parent().unwrap()).expect("registry dir");
503        fs::write(source.registry_path(), "{not json").expect("write bad json");
504
505        let err = Registry::load(&source).expect_err("bad json should fail");
506        assert!(matches!(
507            err,
508            OutpostError::BadRegistry { path, .. } if path == source.registry_path()
509        ));
510    }
511
512    #[test]
513    #[cfg(debug_assertions)]
514    fn dropping_dirty_registry_mut_trips_debug_drop_guard() {
515        let (temp, source) = init_source_repo();
516        let outpost = temp.path().join("C");
517        fs::create_dir_all(&outpost).expect("outpost dir");
518
519        let result = catch_unwind(AssertUnwindSafe(|| {
520            let mut registry = source.registry_mut().expect("registry mut");
521            registry
522                .add(entry_at(&outpost, "local", 1))
523                .expect("add entry");
524        }));
525
526        assert!(result.is_err());
527    }
528
529    #[test]
530    #[cfg(debug_assertions)]
531    fn failed_save_returns_error_without_drop_guard_panic() {
532        let temp = tempfile::tempdir().expect("tempdir");
533        let work_tree = temp.path().join("source");
534        let git_dir = temp.path().join("git-file");
535        let outpost = temp.path().join("C");
536        fs::create_dir_all(&work_tree).expect("source dir");
537        fs::write(&git_dir, "not a dir").expect("git file");
538        fs::create_dir_all(&outpost).expect("outpost dir");
539        let source =
540            SourceRepo::from_storage_paths(&work_tree, &git_dir).expect("source repo storage");
541
542        let result = catch_unwind(AssertUnwindSafe(|| {
543            let mut registry = source.registry_mut().expect("registry mut");
544            registry
545                .add(entry_at(&outpost, "local", 1))
546                .expect("add entry");
547            registry.save()
548        }));
549
550        let save_result = result.expect("save error should not panic");
551        assert!(matches!(save_result, Err(OutpostError::IoAt { .. })));
552    }
553
554    #[test]
555    #[cfg(not(debug_assertions))]
556    fn dropping_dirty_registry_mut_does_not_panic_in_release_builds() {
557        let (temp, source) = init_source_repo();
558        let outpost = temp.path().join("C");
559        fs::create_dir_all(&outpost).expect("outpost dir");
560
561        let mut registry = source.registry_mut().expect("registry mut");
562        registry
563            .add(entry_at(&outpost, "local", 1))
564            .expect("add entry");
565    }
566
567    fn init_source_repo() -> (tempfile::TempDir, SourceRepo) {
568        let temp = tempfile::tempdir().expect("tempdir");
569        GitInvoker::at(temp.path())
570            .run_check(["init", "--initial-branch=main"])
571            .expect("init source");
572        let source = SourceRepo::from_storage_paths(temp.path(), &temp.path().join(".git"))
573            .expect("source repo");
574        (temp, source)
575    }
576
577    fn entry_at(path: &Path, remote: &str, seconds: i64) -> RegistryEntry {
578        let created_at = Utc.timestamp_opt(seconds, 0).single().unwrap();
579        let remote_name = RemoteName::parse(remote).expect("remote parses");
580        RegistryEntry {
581            path: path.to_path_buf(),
582            created_at,
583            remote_name,
584            locked: false,
585            lock_reason: None,
586            locked_at: None,
587        }
588    }
589}