Skip to main content

dotm/
state.rs

1use crate::hash;
2use crate::scanner::EntryKind;
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
8pub struct FileStatus {
9    pub exists: bool,
10    pub content_modified: bool,
11    pub owner_changed: bool,
12    pub group_changed: bool,
13    pub mode_changed: bool,
14}
15
16impl FileStatus {
17    pub fn ok() -> Self {
18        Self {
19            exists: true,
20            content_modified: false,
21            owner_changed: false,
22            group_changed: false,
23            mode_changed: false,
24        }
25    }
26
27    pub fn missing() -> Self {
28        Self {
29            exists: false,
30            content_modified: false,
31            owner_changed: false,
32            group_changed: false,
33            mode_changed: false,
34        }
35    }
36
37    pub fn is_ok(&self) -> bool {
38        self.exists
39            && !self.content_modified
40            && !self.owner_changed
41            && !self.group_changed
42            && !self.mode_changed
43    }
44
45    pub fn is_missing(&self) -> bool {
46        !self.exists
47    }
48
49    pub fn is_modified(&self) -> bool {
50        self.content_modified
51    }
52
53    pub fn has_metadata_drift(&self) -> bool {
54        self.owner_changed || self.group_changed || self.mode_changed
55    }
56}
57
58const STATE_FILE: &str = "dotm-state.json";
59
60#[derive(Debug, Default, Serialize, Deserialize)]
61pub struct DeployState {
62    #[serde(skip)]
63    state_dir: PathBuf,
64    entries: Vec<DeployEntry>,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68pub struct DeployEntry {
69    pub target: PathBuf,
70    pub staged: PathBuf,
71    pub source: PathBuf,
72    pub content_hash: String,
73    #[serde(default)]
74    pub original_hash: Option<String>,
75    pub kind: EntryKind,
76    pub package: String,
77    #[serde(default)]
78    pub owner: Option<String>,
79    #[serde(default)]
80    pub group: Option<String>,
81    #[serde(default)]
82    pub mode: Option<String>,
83    #[serde(default)]
84    pub original_owner: Option<String>,
85    #[serde(default)]
86    pub original_group: Option<String>,
87    #[serde(default)]
88    pub original_mode: Option<String>,
89}
90
91impl DeployState {
92    pub fn new(state_dir: &Path) -> Self {
93        Self {
94            state_dir: state_dir.to_path_buf(),
95            ..Default::default()
96        }
97    }
98
99    pub fn load(state_dir: &Path) -> Result<Self> {
100        Self::migrate_storage(state_dir)?;
101        let path = state_dir.join(STATE_FILE);
102        if !path.exists() {
103            return Ok(Self::new(state_dir));
104        }
105        let content = std::fs::read_to_string(&path)
106            .with_context(|| format!("failed to read state file: {}", path.display()))?;
107        let mut state: DeployState = serde_json::from_str(&content)
108            .with_context(|| format!("failed to parse state file: {}", path.display()))?;
109        state.state_dir = state_dir.to_path_buf();
110        Ok(state)
111    }
112
113    pub fn save(&self) -> Result<()> {
114        std::fs::create_dir_all(&self.state_dir)
115            .with_context(|| format!("failed to create state directory: {}", self.state_dir.display()))?;
116        let path = self.state_dir.join(STATE_FILE);
117        let content = serde_json::to_string_pretty(self)?;
118        std::fs::write(&path, content)
119            .with_context(|| format!("failed to write state file: {}", path.display()))?;
120        Ok(())
121    }
122
123    pub fn record(&mut self, entry: DeployEntry) {
124        self.entries.push(entry);
125    }
126
127    pub fn entries(&self) -> &[DeployEntry] {
128        &self.entries
129    }
130
131    pub fn check_entry_status(&self, entry: &DeployEntry) -> FileStatus {
132        if !entry.target.exists() && !entry.target.is_symlink() {
133            return FileStatus::missing();
134        }
135
136        let mut status = FileStatus::ok();
137
138        if entry.staged.exists() {
139            if let Ok(current_hash) = hash::hash_file(&entry.staged)
140                && current_hash != entry.content_hash
141            {
142                status.content_modified = true;
143            }
144        } else {
145            return FileStatus::missing();
146        }
147
148        // Metadata checks (only if we recorded what we set)
149        if let Ok((current_owner, current_group, current_mode)) =
150            crate::metadata::read_file_metadata(&entry.target)
151        {
152            if let Some(ref expected_owner) = entry.owner {
153                if current_owner != *expected_owner {
154                    status.owner_changed = true;
155                }
156            }
157            if let Some(ref expected_group) = entry.group {
158                if current_group != *expected_group {
159                    status.group_changed = true;
160                }
161            }
162            if let Some(ref expected_mode) = entry.mode {
163                if current_mode != *expected_mode {
164                    status.mode_changed = true;
165                }
166            }
167        }
168
169        status
170    }
171
172    pub fn originals_dir(&self) -> PathBuf {
173        self.state_dir.join("originals")
174    }
175
176    pub fn store_original(&self, content_hash: &str, content: &[u8]) -> Result<()> {
177        let dir = self.originals_dir();
178        std::fs::create_dir_all(&dir)
179            .with_context(|| format!("failed to create originals directory: {}", dir.display()))?;
180        let path = dir.join(content_hash);
181        if !path.exists() {
182            std::fs::write(&path, content)
183                .with_context(|| format!("failed to store original: {}", path.display()))?;
184        }
185        Ok(())
186    }
187
188    pub fn load_original(&self, content_hash: &str) -> Result<Vec<u8>> {
189        let path = self.originals_dir().join(content_hash);
190        std::fs::read(&path)
191            .with_context(|| format!("failed to load original content: {}", path.display()))
192    }
193
194    pub fn deployed_dir(&self) -> PathBuf {
195        self.state_dir.join("deployed")
196    }
197
198    pub fn store_deployed(&self, content_hash: &str, content: &[u8]) -> Result<()> {
199        let dir = self.deployed_dir();
200        std::fs::create_dir_all(&dir)
201            .with_context(|| format!("failed to create deployed directory: {}", dir.display()))?;
202        let path = dir.join(content_hash);
203        if !path.exists() {
204            std::fs::write(&path, content)
205                .with_context(|| format!("failed to store deployed content: {}", path.display()))?;
206        }
207        Ok(())
208    }
209
210    pub fn load_deployed(&self, content_hash: &str) -> Result<Vec<u8>> {
211        let path = self.deployed_dir().join(content_hash);
212        std::fs::read(&path)
213            .with_context(|| format!("failed to load deployed content: {}", path.display()))
214    }
215
216    pub fn migrate_storage(state_dir: &Path) -> Result<()> {
217        let originals = state_dir.join("originals");
218        let deployed = state_dir.join("deployed");
219        if originals.is_dir() && !deployed.exists() {
220            std::fs::rename(&originals, &deployed)
221                .with_context(|| "failed to migrate originals/ to deployed/")?;
222        }
223        Ok(())
224    }
225
226    /// Restore files to their pre-dotm state.
227    /// Files with original_hash get their original content written back with original metadata.
228    /// Files without original_hash (dotm created them) get removed.
229    /// Returns the count of restored files.
230    pub fn restore(&self, package_filter: Option<&str>) -> Result<usize> {
231        let mut restored = 0;
232
233        for entry in &self.entries {
234            if let Some(filter) = package_filter {
235                if entry.package != filter {
236                    continue;
237                }
238            }
239
240            if let Some(ref orig_hash) = entry.original_hash {
241                // Restore original content
242                let original_content = self.load_original(orig_hash)?;
243                std::fs::write(&entry.target, &original_content)
244                    .with_context(|| format!("failed to restore: {}", entry.target.display()))?;
245
246                // Restore original metadata if recorded
247                if entry.original_owner.is_some() || entry.original_group.is_some() {
248                    let _ = crate::metadata::apply_ownership(
249                        &entry.target,
250                        entry.original_owner.as_deref(),
251                        entry.original_group.as_deref(),
252                    );
253                }
254                if let Some(ref orig_mode) = entry.original_mode {
255                    let _ = crate::deployer::apply_permission_override(&entry.target, orig_mode);
256                }
257
258                restored += 1;
259            } else {
260                // No original — file was created by dotm, remove it
261                if entry.target.exists() || entry.target.is_symlink() {
262                    std::fs::remove_file(&entry.target)
263                        .with_context(|| format!("failed to remove: {}", entry.target.display()))?;
264                    cleanup_empty_parents(&entry.target);
265                    restored += 1;
266                }
267            }
268
269            // Clean up staged file if separate from target
270            if entry.staged != entry.target && entry.staged.exists() {
271                std::fs::remove_file(&entry.staged)
272                    .with_context(|| format!("failed to remove staged: {}", entry.staged.display()))?;
273                cleanup_empty_parents(&entry.staged);
274            }
275        }
276
277        // Clean up state directories if restoring everything (no package filter)
278        if package_filter.is_none() {
279            let deployed = self.deployed_dir();
280            if deployed.is_dir() {
281                let _ = std::fs::remove_dir_all(&deployed);
282            }
283            let originals = self.originals_dir();
284            if originals.is_dir() {
285                let _ = std::fs::remove_dir_all(&originals);
286            }
287            let state_path = self.state_dir.join(STATE_FILE);
288            if state_path.exists() {
289                std::fs::remove_file(&state_path)?;
290            }
291        }
292
293        Ok(restored)
294    }
295
296    /// Remove all managed files and return a count of removed files.
297    pub fn undeploy(&self) -> Result<usize> {
298        let mut removed = 0;
299
300        for entry in &self.entries {
301            if entry.target.is_symlink() || entry.target.exists() {
302                std::fs::remove_file(&entry.target)
303                    .with_context(|| format!("failed to remove target: {}", entry.target.display()))?;
304                cleanup_empty_parents(&entry.target);
305                removed += 1;
306            }
307
308            if entry.staged.exists() {
309                std::fs::remove_file(&entry.staged)
310                    .with_context(|| format!("failed to remove staged file: {}", entry.staged.display()))?;
311                cleanup_empty_parents(&entry.staged);
312            }
313        }
314
315        // Clean up originals directory
316        let originals = self.originals_dir();
317        if originals.is_dir() {
318            let _ = std::fs::remove_dir_all(&originals);
319        }
320
321        // Clean up deployed directory
322        let deployed = self.deployed_dir();
323        if deployed.is_dir() {
324            let _ = std::fs::remove_dir_all(&deployed);
325        }
326
327        // Remove the state file itself
328        let state_path = self.state_dir.join(STATE_FILE);
329        if state_path.exists() {
330            std::fs::remove_file(&state_path)?;
331        }
332
333        Ok(removed)
334    }
335}
336
337fn cleanup_empty_parents(path: &Path) {
338    let mut current = path.parent();
339    while let Some(parent) = current {
340        if parent == Path::new("") || parent == Path::new("/") {
341            break;
342        }
343        match std::fs::read_dir(parent) {
344            Ok(mut entries) => {
345                if entries.next().is_none() {
346                    let _ = std::fs::remove_dir(parent);
347                    current = parent.parent();
348                } else {
349                    break;
350                }
351            }
352            Err(_) => break,
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use tempfile::TempDir;
361
362    #[test]
363    fn deployed_dir_is_separate_from_originals() {
364        let dir = TempDir::new().unwrap();
365        let state = DeployState::new(dir.path());
366        assert_ne!(state.originals_dir(), state.deployed_dir());
367        assert!(state.originals_dir().ends_with("originals"));
368        assert!(state.deployed_dir().ends_with("deployed"));
369    }
370
371    #[test]
372    fn store_and_load_deployed_content() {
373        let dir = TempDir::new().unwrap();
374        let state = DeployState::new(dir.path());
375        state.store_deployed("abc123", b"deployed file content").unwrap();
376        let loaded = state.load_deployed("abc123").unwrap();
377        assert_eq!(loaded, b"deployed file content");
378    }
379
380    #[test]
381    fn store_and_load_original_content() {
382        let dir = TempDir::new().unwrap();
383        let state = DeployState::new(dir.path());
384        state.store_original("orig456", b"original pre-existing content").unwrap();
385        let loaded = state.load_original("orig456").unwrap();
386        assert_eq!(loaded, b"original pre-existing content");
387    }
388
389    #[test]
390    fn migrate_renames_originals_to_deployed() {
391        let dir = TempDir::new().unwrap();
392        let originals = dir.path().join("originals");
393        std::fs::create_dir_all(&originals).unwrap();
394        std::fs::write(originals.join("hash1"), "content1").unwrap();
395
396        DeployState::migrate_storage(dir.path()).unwrap();
397
398        assert!(!originals.exists());
399        let deployed = dir.path().join("deployed");
400        assert!(deployed.exists());
401        assert_eq!(std::fs::read_to_string(deployed.join("hash1")).unwrap(), "content1");
402    }
403
404    #[test]
405    fn migrate_noop_if_deployed_exists() {
406        let dir = TempDir::new().unwrap();
407        let deployed = dir.path().join("deployed");
408        std::fs::create_dir_all(&deployed).unwrap();
409        std::fs::write(deployed.join("hash1"), "existing").unwrap();
410
411        let originals = dir.path().join("originals");
412        std::fs::create_dir_all(&originals).unwrap();
413        std::fs::write(originals.join("hash1"), "should not replace").unwrap();
414
415        DeployState::migrate_storage(dir.path()).unwrap();
416
417        assert_eq!(std::fs::read_to_string(deployed.join("hash1")).unwrap(), "existing");
418    }
419}