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}