Skip to main content

atomcode_core/setup/
install.rs

1//! Transactional installer. install.rs ONLY writes files; reload routing is
2//! the caller's job. Drop defaults to rollback (RAII); commit must take self
3//! and forget the txn to prevent the default rollback.
4
5use crate::setup::error::{SetupError, SetupResult};
6use crate::setup::fs_atomic::atomic_write;
7use crate::setup::types::*;
8use std::path::PathBuf;
9
10#[allow(dead_code)] // TODO: T20 uses this for explicit gitignore-marker presence checks.
11const GITIGNORE_MARKER: &str = ".atomcode/local/";
12const GITIGNORE_BLOCK: &str = "\n# AtomCode local-scope configs (machine-specific)\n.atomcode/local/\n";
13
14#[derive(Debug)]
15pub enum FileWrite {
16    AppendGitignore { path: PathBuf, backup_path: PathBuf },
17}
18
19#[derive(Debug)]
20pub struct InstalledTxn {
21    pub(crate) writes: Vec<FileWrite>,
22    pub(crate) backup_dir: PathBuf,
23    #[allow(dead_code)] // reserved for future reload routing
24    pub(crate) project_root: PathBuf,
25    pub(crate) finalized: bool,
26}
27
28impl InstalledTxn {
29    pub fn new(project_root: PathBuf) -> std::io::Result<Self> {
30        let ts = std::time::SystemTime::now()
31            .duration_since(std::time::UNIX_EPOCH)
32            .map(|d| d.as_secs())
33            .unwrap_or(0);
34        let backup_dir = project_root.join(".atomcode").join(format!(".setup-backup-{ts}"));
35        std::fs::create_dir_all(&backup_dir)?;
36        Ok(Self {
37            writes: vec![],
38            backup_dir,
39            project_root,
40            finalized: false,
41        })
42    }
43
44    fn backup_path_for(&self, path: &std::path::Path) -> PathBuf {
45        let safe = path
46            .file_name()
47            .map(|n| n.to_string_lossy().into_owned())
48            .unwrap_or_else(|| "unknown".into());
49        self.backup_dir.join(safe)
50    }
51
52    /// Append the AtomCode local-scope marker to `.gitignore`. Idempotent —
53    /// returns without writing if the marker (any of 4 syntactic variants) is
54    /// already present. Otherwise backs up existing file (if any) and appends.
55    pub fn append_gitignore(&mut self, project_root: &std::path::Path) -> SetupResult<()> {
56        let path = project_root.join(".gitignore");
57        let existing = std::fs::read_to_string(&path).unwrap_or_default();
58        let already = existing.lines().any(|l| {
59            let l = l.trim();
60            l == ".atomcode/local"
61                || l == ".atomcode/local/"
62                || l == "**/.atomcode/local"
63                || l == "**/.atomcode/local/"
64        });
65        if already {
66            return Ok(());
67        }
68        let backup_path = self.backup_path_for(&path);
69        if path.exists() {
70            std::fs::copy(&path, &backup_path).map_err(SetupError::Io)?;
71        }
72        let new_content = if existing.is_empty() {
73            GITIGNORE_BLOCK.trim_start().to_string()
74        } else {
75            format!("{existing}{GITIGNORE_BLOCK}")
76        };
77        atomic_write(&path, new_content.as_bytes(), 0o644).map_err(SetupError::Other)?;
78        self.writes
79            .push(FileWrite::AppendGitignore { path, backup_path });
80        Ok(())
81    }
82
83    /// Roll back all writes in LIFO order. Returns `Clean` on full success,
84    /// `Partial` otherwise. Consumes `self` — sets `finalized=true` so Drop
85    /// is a no-op.
86    pub fn rollback(mut self) -> RollbackOutcome {
87        let outcome = self.rollback_in_place();
88        self.finalized = true;
89        outcome
90    }
91
92    /// In-place rollback used by both the public `rollback` and `Drop`.
93    pub(crate) fn rollback_in_place(&mut self) -> RollbackOutcome {
94        let mut restored = vec![];
95        let mut failed = vec![];
96        for w in std::mem::take(&mut self.writes).into_iter().rev() {
97            match w {
98                FileWrite::AppendGitignore { path, backup_path } => {
99                    match std::fs::copy(&backup_path, &path) {
100                        Ok(_) => restored.push(path),
101                        Err(e) => failed.push((path, e.to_string())),
102                    }
103                }
104            }
105        }
106        if failed.is_empty() {
107            RollbackOutcome::Clean
108        } else {
109            let hint = format!(
110                "Some files were not restored. Backup remains at {} — restore manually with `cp -r`",
111                self.backup_dir.display()
112            );
113            RollbackOutcome::Partial {
114                restored,
115                failed,
116                manual_cleanup_hint: hint,
117            }
118        }
119    }
120
121    /// Commit the transaction: marks finalized, best-effort cleans backup_dir,
122    /// and `mem::forget`s self to prevent Drop's rollback. The returned
123    /// summary is currently empty — T24 (orchestrator) populates it.
124    pub fn commit(mut self) -> InstalledSummary {
125        self.finalized = true;
126        // Best-effort cleanup of backup_dir; failure is non-fatal.
127        let _ = std::fs::remove_dir_all(&self.backup_dir);
128        let summary = InstalledSummary::default();
129        std::mem::forget(self); // prevent Drop's rollback
130        summary
131    }
132}
133
134impl Drop for InstalledTxn {
135    fn drop(&mut self) {
136        if !self.finalized {
137            let _ = self.rollback_in_place();
138        }
139    }
140}
141
142#[derive(Debug)]
143pub enum RollbackOutcome {
144    Clean,
145    Partial {
146        restored: Vec<PathBuf>,
147        failed: Vec<(PathBuf, String)>,
148        manual_cleanup_hint: String,
149    },
150}
151
152#[derive(Debug, Default)]
153pub struct InstalledSummary {
154    pub installed: Vec<(RecId, PathBuf)>,
155    pub skipped: Vec<(RecId, SkipReason)>,
156    pub failed: Vec<(RecId, String)>,
157    pub reload_directives: std::collections::HashSet<ReloadDirective>,
158}
159
160#[derive(Debug, Clone)]
161pub enum SkipReason {
162    AlreadyInstalled,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166pub enum ReloadDirective {
167    Skill,
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn new_creates_backup_dir() {
176        let dir = tempfile::tempdir().unwrap();
177        let txn = InstalledTxn::new(dir.path().to_path_buf()).unwrap();
178        assert!(txn.backup_dir.exists());
179        assert!(txn.backup_dir.starts_with(dir.path().join(".atomcode")));
180        std::mem::forget(txn);
181    }
182
183    #[test]
184    fn append_gitignore_adds_local_marker() {
185        let dir = tempfile::tempdir().unwrap();
186        let mut txn = InstalledTxn::new(dir.path().to_path_buf()).unwrap();
187        txn.append_gitignore(dir.path()).unwrap();
188        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
189        assert!(content.contains(".atomcode/local/"));
190        std::mem::forget(txn);
191    }
192
193    #[test]
194    fn append_gitignore_idempotent() {
195        let dir = tempfile::tempdir().unwrap();
196        std::fs::write(dir.path().join(".gitignore"), "node_modules/\n.atomcode/local/\n").unwrap();
197        let mut txn = InstalledTxn::new(dir.path().to_path_buf()).unwrap();
198        txn.append_gitignore(dir.path()).unwrap();
199        assert!(txn.writes.is_empty()); // already present, nothing written
200        std::mem::forget(txn);
201    }
202
203    #[test]
204    fn rollback_restores_gitignore_backup() {
205        let dir = tempfile::tempdir().unwrap();
206        let gi = dir.path().join(".gitignore");
207        std::fs::write(&gi, "node_modules/\n").unwrap();
208        let mut txn = InstalledTxn::new(dir.path().to_path_buf()).unwrap();
209        txn.append_gitignore(dir.path()).unwrap();
210        assert!(std::fs::read_to_string(&gi).unwrap().contains(".atomcode/local/"));
211
212        let outcome = txn.rollback();
213        assert!(matches!(outcome, RollbackOutcome::Clean));
214        let restored = std::fs::read_to_string(&gi).unwrap();
215        assert!(!restored.contains(".atomcode/local/"));
216        assert!(restored.contains("node_modules/"));
217    }
218
219    #[test]
220    fn commit_prevents_drop_rollback() {
221        let dir = tempfile::tempdir().unwrap();
222        let gi = dir.path().join(".gitignore");
223        {
224            let mut txn = InstalledTxn::new(dir.path().to_path_buf()).unwrap();
225            txn.append_gitignore(dir.path()).unwrap();
226            let _summary = txn.commit();
227        } // Drop runs here; should NOT restore .gitignore.
228        assert!(std::fs::read_to_string(&gi).unwrap().contains(".atomcode/local/"));
229    }
230
231    #[test]
232    fn drop_without_commit_rolls_back() {
233        let dir = tempfile::tempdir().unwrap();
234        let gi = dir.path().join(".gitignore");
235        std::fs::write(&gi, "existing\n").unwrap();
236        {
237            let mut txn = InstalledTxn::new(dir.path().to_path_buf()).unwrap();
238            txn.append_gitignore(dir.path()).unwrap();
239        } // Drop without commit — rollback should fire.
240        let content = std::fs::read_to_string(&gi).unwrap();
241        assert!(
242            !content.contains(".atomcode/local/"),
243            "drop should have rolled back, .gitignore still has marker"
244        );
245    }
246}