atomcode_core/setup/
install.rs1use 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)] const 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)] 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 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 pub fn rollback(mut self) -> RollbackOutcome {
87 let outcome = self.rollback_in_place();
88 self.finalized = true;
89 outcome
90 }
91
92 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 pub fn commit(mut self) -> InstalledSummary {
125 self.finalized = true;
126 let _ = std::fs::remove_dir_all(&self.backup_dir);
128 let summary = InstalledSummary::default();
129 std::mem::forget(self); 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()); 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 } 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 } 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}