Skip to main content

atman_runtime/
project_catalog.rs

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}