1use std::fs::{File, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Utc};
7use fs2::FileExt;
8use serde::{Deserialize, Serialize};
9
10use crate::session_meta::{SessionMeta, fingerprint_from_root};
11
12const CATALOG_FILENAME: &str = "project-catalog.json";
13const LOCK_FILENAME: &str = ".project-catalog.lock";
14const SCHEMA_VERSION: u8 = 1;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct ProjectRecord {
18 pub fingerprint: String,
19 pub root: PathBuf,
20 pub display_name: String,
21 #[serde(default)]
22 pub pinned: bool,
23 #[serde(default)]
24 pub archived: bool,
25 pub first_seen: DateTime<Utc>,
26 pub last_opened: DateTime<Utc>,
27}
28
29impl ProjectRecord {
30 pub fn path_available(&self) -> bool {
31 self.root.is_dir()
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct ProjectCatalog {
37 #[serde(default = "schema_version")]
38 pub schema_version: u8,
39 #[serde(default)]
40 pub projects: Vec<ProjectRecord>,
41}
42
43impl Default for ProjectCatalog {
44 fn default() -> Self {
45 Self {
46 schema_version: SCHEMA_VERSION,
47 projects: Vec::new(),
48 }
49 }
50}
51
52pub struct ProjectCatalogStore {
53 data_dir: PathBuf,
54}
55
56impl ProjectCatalogStore {
57 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
58 Self {
59 data_dir: data_dir.into(),
60 }
61 }
62
63 pub fn load(&self) -> Result<ProjectCatalog> {
64 load_catalog(&self.catalog_path())
65 }
66
67 pub fn register(&self, root: &Path, opened_at: DateTime<Utc>) -> Result<ProjectRecord> {
68 let root = canonical_root(root);
69 let fingerprint = fingerprint_from_root(&root);
70 self.mutate(|catalog| {
71 if let Some(project) = catalog
72 .projects
73 .iter_mut()
74 .find(|project| project.fingerprint == fingerprint)
75 {
76 project.last_opened = project.last_opened.max(opened_at);
77 return Ok(project.clone());
78 }
79 let project = ProjectRecord {
80 fingerprint,
81 display_name: display_name(&root),
82 root,
83 pinned: false,
84 archived: false,
85 first_seen: opened_at,
86 last_opened: opened_at,
87 };
88 catalog.projects.push(project.clone());
89 sort_projects(&mut catalog.projects);
90 Ok(project)
91 })
92 }
93
94 pub fn reconcile_sessions(&self) -> Result<ProjectCatalog> {
95 let sessions_dir = self.data_dir.join("sessions");
96 self.mutate(|catalog| {
97 let Ok(entries) = std::fs::read_dir(&sessions_dir) else {
98 return Ok(catalog.clone());
99 };
100 for entry in entries.flatten() {
101 let session_dir = entry.path();
102 if !session_dir.is_dir() {
103 continue;
104 }
105 let Some(meta) = SessionMeta::load(&session_dir) else {
106 continue;
107 };
108 let (Some(root), Some(fingerprint), Some(opened_at)) =
109 (meta.project_root, meta.project_fingerprint, meta.created_at)
110 else {
111 continue;
112 };
113 if let Some(project) = catalog
114 .projects
115 .iter_mut()
116 .find(|project| project.fingerprint == fingerprint)
117 {
118 project.first_seen = project.first_seen.min(opened_at);
119 project.last_opened = project.last_opened.max(opened_at);
120 continue;
121 }
122 let root = canonical_root(&root);
123 catalog.projects.push(ProjectRecord {
124 fingerprint,
125 display_name: display_name(&root),
126 root,
127 pinned: false,
128 archived: false,
129 first_seen: opened_at,
130 last_opened: opened_at,
131 });
132 }
133 sort_projects(&mut catalog.projects);
134 Ok(catalog.clone())
135 })
136 }
137
138 pub fn set_pinned(&self, fingerprint: &str, pinned: bool) -> Result<bool> {
139 self.set_flag(fingerprint, |project| project.pinned = pinned)
140 }
141
142 pub fn set_archived(&self, fingerprint: &str, archived: bool) -> Result<bool> {
143 self.set_flag(fingerprint, |project| project.archived = archived)
144 }
145
146 pub fn delete_archived(
147 &self,
148 fingerprint: &str,
149 active_project_fingerprint: Option<&str>,
150 ) -> Result<ProjectCatalog> {
151 self.mutate(|catalog| {
152 let index = catalog
153 .projects
154 .iter()
155 .position(|project| project.fingerprint == fingerprint)
156 .with_context(|| format!("project {fingerprint} is not registered"))?;
157 let project = catalog.projects[index].clone();
158 anyhow::ensure!(project.archived, "archive this project before deleting it");
159 anyhow::ensure!(
160 fingerprint_from_root(&canonical_root(&project.root)) == project.fingerprint,
161 "project identity does not match its catalog root"
162 );
163 anyhow::ensure!(
164 active_project_fingerprint != Some(fingerprint),
165 "cannot delete active session project"
166 );
167 purge_project_data(&self.data_dir, &project)?;
168 catalog.projects.remove(index);
169 Ok(catalog.clone())
170 })
171 }
172
173 fn set_flag(&self, fingerprint: &str, update: impl FnOnce(&mut ProjectRecord)) -> Result<bool> {
174 self.mutate(|catalog| {
175 let Some(project) = catalog
176 .projects
177 .iter_mut()
178 .find(|project| project.fingerprint == fingerprint)
179 else {
180 return Ok(false);
181 };
182 update(project);
183 sort_projects(&mut catalog.projects);
184 Ok(true)
185 })
186 }
187
188 fn mutate<T>(&self, update: impl FnOnce(&mut ProjectCatalog) -> Result<T>) -> Result<T> {
189 std::fs::create_dir_all(&self.data_dir)
190 .with_context(|| format!("create {}", self.data_dir.display()))?;
191 let lock = OpenOptions::new()
192 .create(true)
193 .read(true)
194 .write(true)
195 .truncate(false)
196 .open(self.data_dir.join(LOCK_FILENAME))?;
197 lock.lock_exclusive()?;
198 let mut catalog = load_catalog(&self.catalog_path())?;
199 let result = update(&mut catalog)?;
200 catalog.schema_version = SCHEMA_VERSION;
201 save_catalog(&self.catalog_path(), &catalog)?;
202 FileExt::unlock(&lock)?;
203 Ok(result)
204 }
205
206 fn catalog_path(&self) -> PathBuf {
207 self.data_dir.join(CATALOG_FILENAME)
208 }
209}
210
211fn purge_project_data(data_dir: &Path, project: &ProjectRecord) -> Result<()> {
212 purge_project_sessions(data_dir, &project.fingerprint)?;
213 remove_preview_registration(data_dir, &project.root)?;
214 remove_dir_if_exists(&data_dir.join("projects").join(&project.fingerprint))?;
215 purge_local_project_data(&project.root.join(".atman"))?;
216 Ok(())
217}
218
219fn purge_project_sessions(data_dir: &Path, fingerprint: &str) -> Result<()> {
220 let sessions_dir = data_dir.join("sessions");
221 let entries = match std::fs::read_dir(&sessions_dir) {
222 Ok(entries) => entries,
223 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
224 Err(error) => return Err(error).context(format!("read {}", sessions_dir.display())),
225 };
226 for entry in entries {
227 let entry = entry.with_context(|| format!("read {} entry", sessions_dir.display()))?;
228 let path = entry.path();
229 if path.is_dir()
230 && SessionMeta::load(&path)
231 .and_then(|meta| meta.project_fingerprint)
232 .as_deref()
233 == Some(fingerprint)
234 {
235 std::fs::remove_dir_all(&path)
236 .with_context(|| format!("delete project session {}", path.display()))?;
237 }
238 }
239 Ok(())
240}
241
242fn purge_local_project_data(atman_dir: &Path) -> Result<()> {
243 for name in ["confessions", "specs", "preview"] {
244 remove_dir_if_exists(&atman_dir.join(name))?;
245 }
246 for name in [
247 "index.db",
248 "index.db-wal",
249 "index.db-shm",
250 "index.db.recovered",
251 "restore-index.sh",
252 ] {
253 remove_file_if_exists(&atman_dir.join(name))?;
254 }
255 let entries = match std::fs::read_dir(atman_dir) {
256 Ok(entries) => entries,
257 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
258 Err(error) => return Err(error).context(format!("read {}", atman_dir.display())),
259 };
260 for entry in entries {
261 let entry = entry.with_context(|| format!("read {} entry", atman_dir.display()))?;
262 if entry
263 .file_name()
264 .to_str()
265 .is_some_and(|name| name.starts_with(".corrupt-backup-"))
266 {
267 remove_dir_if_exists(&entry.path())?;
268 }
269 }
270 Ok(())
271}
272
273fn remove_preview_registration(data_dir: &Path, project_root: &Path) -> Result<()> {
274 let path = data_dir.join("preview/projects.json");
275 let bytes = match std::fs::read(&path) {
276 Ok(bytes) => bytes,
277 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
278 Err(error) => return Err(error).context(format!("read {}", path.display())),
279 };
280 let mut projects: Vec<serde_json::Value> =
281 serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
282 let before = projects.len();
283 projects.retain(|preview| {
284 preview
285 .get("path")
286 .and_then(serde_json::Value::as_str)
287 .map(Path::new)
288 .map(canonical_root)
289 .is_none_or(|path| path != project_root)
290 });
291 if projects.len() != before {
292 save_json(&path, &projects)?;
293 }
294 Ok(())
295}
296
297fn remove_dir_if_exists(path: &Path) -> Result<()> {
298 match std::fs::remove_dir_all(path) {
299 Ok(()) => Ok(()),
300 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
301 Err(error) => Err(error).context(format!("delete {}", path.display())),
302 }
303}
304
305fn remove_file_if_exists(path: &Path) -> Result<()> {
306 match std::fs::remove_file(path) {
307 Ok(()) => Ok(()),
308 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
309 Err(error) => Err(error).context(format!("delete {}", path.display())),
310 }
311}
312
313fn load_catalog(path: &Path) -> Result<ProjectCatalog> {
314 let Ok(bytes) = std::fs::read(path) else {
315 return Ok(ProjectCatalog::default());
316 };
317 serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))
318}
319
320fn save_catalog(path: &Path, catalog: &ProjectCatalog) -> Result<()> {
321 save_json(path, catalog)
322}
323
324fn save_json(path: &Path, value: &impl Serialize) -> Result<()> {
325 let parent = path.parent().context("JSON file has no parent")?;
326 let name = path
327 .file_name()
328 .and_then(|name| name.to_str())
329 .unwrap_or(CATALOG_FILENAME);
330 let temp_path = parent.join(format!(".{name}.{}.tmp", uuid::Uuid::new_v4()));
331 let write_result = (|| -> Result<()> {
332 let mut temp = OpenOptions::new()
333 .create_new(true)
334 .write(true)
335 .open(&temp_path)?;
336 serde_json::to_writer_pretty(&mut temp, value)?;
337 temp.write_all(b"\n")?;
338 temp.sync_all()?;
339 std::fs::rename(&temp_path, path).with_context(|| format!("replace {}", path.display()))?;
340 Ok(())
341 })();
342 if write_result.is_err() {
343 let _ = std::fs::remove_file(&temp_path);
344 }
345 write_result?;
346 sync_parent(parent)?;
347 Ok(())
348}
349
350fn sync_parent(parent: &Path) -> Result<()> {
351 #[cfg(unix)]
352 File::open(parent)?.sync_all()?;
353 Ok(())
354}
355
356fn canonical_root(root: &Path) -> PathBuf {
357 root.canonicalize().unwrap_or_else(|_| {
358 if root.is_absolute() {
359 root.to_path_buf()
360 } else {
361 std::env::current_dir()
362 .map(|cwd| cwd.join(root))
363 .unwrap_or_else(|_| root.to_path_buf())
364 }
365 })
366}
367
368fn display_name(root: &Path) -> String {
369 root.file_name()
370 .and_then(|name| name.to_str())
371 .filter(|name| !name.is_empty())
372 .map(str::to_owned)
373 .unwrap_or_else(|| root.to_string_lossy().into_owned())
374}
375
376fn sort_projects(projects: &mut [ProjectRecord]) {
377 projects.sort_by(|a, b| {
378 a.archived
379 .cmp(&b.archived)
380 .then_with(|| b.pinned.cmp(&a.pinned))
381 .then_with(|| b.last_opened.cmp(&a.last_opened))
382 .then_with(|| a.display_name.cmp(&b.display_name))
383 });
384}
385
386fn schema_version() -> u8 {
387 SCHEMA_VERSION
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use chrono::TimeZone;
394 use tempfile::TempDir;
395
396 fn at(seconds: i64) -> DateTime<Utc> {
397 Utc.timestamp_opt(seconds, 0).unwrap()
398 }
399
400 #[test]
401 fn register_persists_zero_session_project_and_flags() {
402 let data = TempDir::new().unwrap();
403 let project = TempDir::new().unwrap();
404 let store = ProjectCatalogStore::new(data.path());
405
406 let record = store.register(project.path(), at(10)).unwrap();
407 assert!(store.set_pinned(&record.fingerprint, true).unwrap());
408 assert!(store.set_archived(&record.fingerprint, true).unwrap());
409
410 let catalog = store.load().unwrap();
411 assert_eq!(catalog.projects.len(), 1);
412 assert!(catalog.projects[0].pinned);
413 assert!(catalog.projects[0].archived);
414 assert!(catalog.projects[0].path_available());
415 }
416
417 #[test]
418 fn reconciliation_imports_sessions_and_preserves_user_flags() {
419 let data = TempDir::new().unwrap();
420 let project = TempDir::new().unwrap();
421 let store = ProjectCatalogStore::new(data.path());
422 let record = store.register(project.path(), at(20)).unwrap();
423 store.set_pinned(&record.fingerprint, true).unwrap();
424
425 let session_dir = data.path().join("sessions/session-a");
426 std::fs::create_dir_all(&session_dir).unwrap();
427 SessionMeta {
428 project_root: Some(project.path().to_path_buf()),
429 project_fingerprint: Some(record.fingerprint.clone()),
430 created_at: Some(at(30)),
431 ..Default::default()
432 }
433 .save(&session_dir)
434 .unwrap();
435
436 let catalog = store.reconcile_sessions().unwrap();
437 assert_eq!(catalog.projects.len(), 1);
438 assert!(catalog.projects[0].pinned);
439 assert_eq!(catalog.projects[0].first_seen, at(20));
440 assert_eq!(catalog.projects[0].last_opened, at(30));
441 }
442
443 #[test]
444 fn deleting_project_requires_archive_and_rejects_active_project() {
445 let data = TempDir::new().unwrap();
446 let project = TempDir::new().unwrap();
447 let store = ProjectCatalogStore::new(data.path());
448 let record = store.register(project.path(), at(10)).unwrap();
449
450 let error = store
451 .delete_archived(&record.fingerprint, None)
452 .unwrap_err();
453 assert!(error.to_string().contains("archive this project"));
454
455 store.set_archived(&record.fingerprint, true).unwrap();
456 let error = store
457 .delete_archived(&record.fingerprint, Some(&record.fingerprint))
458 .unwrap_err();
459 assert!(error.to_string().contains("active session"));
460 assert_eq!(store.load().unwrap().projects.len(), 1);
461 }
462
463 #[test]
464 fn deleting_project_rejects_a_catalog_identity_mismatch() {
465 let data = TempDir::new().unwrap();
466 let project = TempDir::new().unwrap();
467 let store = ProjectCatalogStore::new(data.path());
468 let record = store.register(project.path(), at(10)).unwrap();
469 store.set_archived(&record.fingerprint, true).unwrap();
470 let mut catalog = store.load().unwrap();
471 catalog.projects[0].fingerprint = "../outside".into();
472 save_catalog(&store.catalog_path(), &catalog).unwrap();
473
474 let error = store.delete_archived("../outside", None).unwrap_err();
475
476 assert!(error.to_string().contains("identity"));
477 assert!(project.path().exists());
478 }
479
480 #[test]
481 fn deleting_archived_project_purges_atman_data_and_preserves_source_config() {
482 let data = TempDir::new().unwrap();
483 let project = TempDir::new().unwrap();
484 let other_project = TempDir::new().unwrap();
485 let store = ProjectCatalogStore::new(data.path());
486 let record = store.register(project.path(), at(10)).unwrap();
487 let other = store.register(other_project.path(), at(20)).unwrap();
488 store.set_archived(&record.fingerprint, true).unwrap();
489
490 for (session_id, fingerprint, root) in [
491 ("delete-me", &record.fingerprint, project.path()),
492 ("keep-me", &other.fingerprint, other_project.path()),
493 ] {
494 let session_dir = data.path().join("sessions").join(session_id);
495 std::fs::create_dir_all(&session_dir).unwrap();
496 SessionMeta {
497 project_root: Some(root.to_path_buf()),
498 project_fingerprint: Some(fingerprint.clone()),
499 created_at: Some(at(30)),
500 ..Default::default()
501 }
502 .save(&session_dir)
503 .unwrap();
504 }
505
506 let global_scope = data.path().join("projects").join(&record.fingerprint);
507 std::fs::create_dir_all(global_scope.join("specs")).unwrap();
508 std::fs::write(global_scope.join("index.db"), "index").unwrap();
509
510 let local_scope = project.path().join(".atman");
511 for name in ["confessions", "specs", "preview", ".corrupt-backup-old"] {
512 std::fs::create_dir_all(local_scope.join(name)).unwrap();
513 }
514 std::fs::create_dir_all(local_scope.join("commands")).unwrap();
515 std::fs::write(local_scope.join("index.db"), "index").unwrap();
516 std::fs::write(
517 local_scope.join("config.toml"),
518 "[storage]\nscope='local'\n",
519 )
520 .unwrap();
521 std::fs::write(local_scope.join("commands/agent.at"), "flow agent() {}\n").unwrap();
522
523 let preview_dir = data.path().join("preview");
524 std::fs::create_dir_all(&preview_dir).unwrap();
525 std::fs::write(
526 preview_dir.join("projects.json"),
527 serde_json::to_vec(&serde_json::json!([
528 {"id":"delete","name":"Delete","path":record.root,"scope":global_scope},
529 {"id":"keep","name":"Keep","path":other.root,"scope":"/tmp/keep"}
530 ]))
531 .unwrap(),
532 )
533 .unwrap();
534
535 let catalog = store.delete_archived(&record.fingerprint, None).unwrap();
536
537 assert_eq!(catalog.projects.len(), 1);
538 assert_eq!(catalog.projects[0].fingerprint, other.fingerprint);
539 assert!(!data.path().join("sessions/delete-me").exists());
540 assert!(data.path().join("sessions/keep-me").exists());
541 assert!(
542 !data
543 .path()
544 .join("projects")
545 .join(&record.fingerprint)
546 .exists()
547 );
548 assert!(!local_scope.join("confessions").exists());
549 assert!(!local_scope.join("specs").exists());
550 assert!(!local_scope.join("preview").exists());
551 assert!(!local_scope.join(".corrupt-backup-old").exists());
552 assert!(!local_scope.join("index.db").exists());
553 assert!(local_scope.join("config.toml").exists());
554 assert!(local_scope.join("commands/agent.at").exists());
555 let previews: Vec<serde_json::Value> =
556 serde_json::from_slice(&std::fs::read(preview_dir.join("projects.json")).unwrap())
557 .unwrap();
558 assert_eq!(previews.len(), 1);
559 assert_eq!(previews[0]["id"], "keep");
560 }
561}