Skip to main content

dotm/
state.rs

1use crate::hash;
2use crate::scanner::EntryKind;
3use anyhow::{Context, Result};
4use fs2::FileExt;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
10pub struct FileStatus {
11    pub exists: bool,
12    pub content_modified: bool,
13    pub owner_changed: bool,
14    pub group_changed: bool,
15    pub mode_changed: bool,
16}
17
18impl FileStatus {
19    pub fn ok() -> Self {
20        Self {
21            exists: true,
22            content_modified: false,
23            owner_changed: false,
24            group_changed: false,
25            mode_changed: false,
26        }
27    }
28
29    pub fn missing() -> Self {
30        Self {
31            exists: false,
32            content_modified: false,
33            owner_changed: false,
34            group_changed: false,
35            mode_changed: false,
36        }
37    }
38
39    pub fn is_ok(&self) -> bool {
40        self.exists
41            && !self.content_modified
42            && !self.owner_changed
43            && !self.group_changed
44            && !self.mode_changed
45    }
46
47    pub fn is_missing(&self) -> bool {
48        !self.exists
49    }
50
51    pub fn is_modified(&self) -> bool {
52        self.content_modified
53    }
54
55    pub fn has_metadata_drift(&self) -> bool {
56        self.owner_changed || self.group_changed || self.mode_changed
57    }
58}
59
60const STATE_FILE: &str = "dotm-state.json";
61const CURRENT_VERSION: u32 = 3;
62
63#[derive(Debug, Default, Serialize, Deserialize)]
64pub struct DeployState {
65    #[serde(default)]
66    version: u32,
67    #[serde(skip)]
68    state_dir: PathBuf,
69    #[serde(skip)]
70    lock: Option<std::fs::File>,
71    entries: Vec<DeployEntry>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DeployEntry {
76    pub target: PathBuf,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub staged: Option<PathBuf>,
79    pub source: PathBuf,
80    pub content_hash: String,
81    #[serde(default)]
82    pub original_hash: Option<String>,
83    pub kind: EntryKind,
84    pub package: String,
85    #[serde(default)]
86    pub owner: Option<String>,
87    #[serde(default)]
88    pub group: Option<String>,
89    #[serde(default)]
90    pub mode: Option<String>,
91    #[serde(default)]
92    pub original_owner: Option<String>,
93    #[serde(default)]
94    pub original_group: Option<String>,
95    #[serde(default)]
96    pub original_mode: Option<String>,
97}
98
99impl DeployState {
100    pub fn new(state_dir: &Path) -> Self {
101        Self {
102            version: CURRENT_VERSION,
103            state_dir: state_dir.to_path_buf(),
104            ..Default::default()
105        }
106    }
107
108    /// Acquire an exclusive file lock without loading existing state.
109    pub fn lock(&mut self) -> Result<()> {
110        std::fs::create_dir_all(&self.state_dir).with_context(|| {
111            format!(
112                "failed to create state directory: {}",
113                self.state_dir.display()
114            )
115        })?;
116        let lock_path = self.state_dir.join("dotm.lock");
117        let lock_file = std::fs::OpenOptions::new()
118            .create(true)
119            .write(true)
120            .truncate(false)
121            .open(&lock_path)
122            .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
123
124        lock_file.try_lock_exclusive().map_err(|_| {
125            anyhow::anyhow!(
126                "another dotm process is running (could not acquire lock on {})",
127                lock_path.display()
128            )
129        })?;
130
131        self.lock = Some(lock_file);
132        Ok(())
133    }
134
135    pub fn load(state_dir: &Path) -> Result<Self> {
136        // Only run v1->v2 storage migration for legacy state dirs (not ~/.dotm/)
137        let is_legacy = state_dir.file_name().map(|n| n != ".dotm").unwrap_or(true);
138        if is_legacy {
139            Self::migrate_storage(state_dir)?;
140        }
141        let path = state_dir.join(STATE_FILE);
142        if !path.exists() {
143            return Ok(Self::new(state_dir));
144        }
145        let content = std::fs::read_to_string(&path)
146            .with_context(|| format!("failed to read state file: {}", path.display()))?;
147        let mut state: DeployState = serde_json::from_str(&content)
148            .with_context(|| format!("failed to parse state file: {}", path.display()))?;
149        if state.version > CURRENT_VERSION {
150            anyhow::bail!(
151                "state file was created by a newer version of dotm (state version {}, max supported {})",
152                state.version,
153                CURRENT_VERSION
154            );
155        }
156        if state.version < CURRENT_VERSION {
157            state.version = CURRENT_VERSION;
158        }
159        state.state_dir = state_dir.to_path_buf();
160        Ok(state)
161    }
162
163    /// Load state with an exclusive file lock to prevent concurrent access.
164    /// The lock is held until the DeployState is dropped.
165    pub fn load_locked(state_dir: &Path) -> Result<Self> {
166        std::fs::create_dir_all(state_dir).with_context(|| {
167            format!("failed to create state directory: {}", state_dir.display())
168        })?;
169        let lock_path = state_dir.join("dotm.lock");
170        let lock_file = std::fs::OpenOptions::new()
171            .create(true)
172            .write(true)
173            .truncate(false)
174            .open(&lock_path)
175            .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
176
177        lock_file.try_lock_exclusive().map_err(|_| {
178            anyhow::anyhow!(
179                "another dotm process is running (could not acquire lock on {})",
180                lock_path.display()
181            )
182        })?;
183
184        let mut state = Self::load(state_dir)?;
185        state.lock = Some(lock_file);
186        Ok(state)
187    }
188
189    pub fn save(&self) -> Result<()> {
190        std::fs::create_dir_all(&self.state_dir).with_context(|| {
191            format!(
192                "failed to create state directory: {}",
193                self.state_dir.display()
194            )
195        })?;
196        let path = self.state_dir.join(STATE_FILE);
197        let tmp_path = self.state_dir.join(".dotm-state.json.tmp");
198        let content = serde_json::to_string_pretty(self)?;
199        std::fs::write(&tmp_path, &content)
200            .with_context(|| format!("failed to write temp state file: {}", tmp_path.display()))?;
201        std::fs::rename(&tmp_path, &path).with_context(|| {
202            format!(
203                "failed to rename temp state file: {} -> {}",
204                tmp_path.display(),
205                path.display()
206            )
207        })?;
208        Ok(())
209    }
210
211    pub fn record(&mut self, entry: DeployEntry) {
212        self.entries.push(entry);
213    }
214
215    pub fn entries(&self) -> &[DeployEntry] {
216        &self.entries
217    }
218
219    pub fn remove_targets(&mut self, targets: &[PathBuf]) {
220        let target_set: std::collections::HashSet<&PathBuf> = targets.iter().collect();
221        self.entries.retain(|e| !target_set.contains(&e.target));
222    }
223
224    pub fn update_entry_hash(&mut self, index: usize, new_hash: String) {
225        if let Some(entry) = self.entries.get_mut(index) {
226            entry.content_hash = new_hash;
227        }
228    }
229
230    pub fn check_entry_status(&self, entry: &DeployEntry) -> FileStatus {
231        if !entry.target.exists() && !entry.target.is_symlink() {
232            return FileStatus::missing();
233        }
234
235        let mut status = FileStatus::ok();
236
237        if entry.target.is_symlink() {
238            // Symlink path: check that it points to the expected source
239            let link_dest = match std::fs::read_link(&entry.target) {
240                Ok(dest) => dest,
241                Err(_) => return FileStatus::missing(),
242            };
243
244            let canon_link = std::fs::canonicalize(&link_dest)
245                .or_else(|_| std::fs::canonicalize(&entry.target))
246                .unwrap_or(link_dest.clone());
247            let canon_source =
248                std::fs::canonicalize(&entry.source).unwrap_or_else(|_| entry.source.clone());
249
250            if canon_link == canon_source {
251                // Direct symlink to source — no content drift possible
252            } else if let Some(ref staged) = entry.staged {
253                // Transitional v2 compatibility: symlink points to staged path
254                let canon_staged = std::fs::canonicalize(staged).unwrap_or_else(|_| staged.clone());
255                if canon_link == canon_staged {
256                    // v2 staged symlink — check hash for drift
257                    if let Ok(current_hash) = hash::hash_file(staged)
258                        && current_hash != entry.content_hash
259                    {
260                        status.content_modified = true;
261                    }
262                } else {
263                    return FileStatus::missing();
264                }
265            } else {
266                return FileStatus::missing();
267            }
268        } else {
269            // Regular file (copy/template): hash the target
270            if let Ok(current_hash) = hash::hash_file(&entry.target)
271                && current_hash != entry.content_hash
272            {
273                status.content_modified = true;
274            }
275        }
276
277        // Metadata checks (only if we recorded what we set)
278        if let Ok((current_owner, current_group, current_mode)) =
279            crate::metadata::read_file_metadata(&entry.target)
280        {
281            if let Some(ref expected_owner) = entry.owner {
282                if current_owner != *expected_owner {
283                    status.owner_changed = true;
284                }
285            }
286            if let Some(ref expected_group) = entry.group {
287                if current_group != *expected_group {
288                    status.group_changed = true;
289                }
290            }
291            if let Some(ref expected_mode) = entry.mode {
292                if current_mode != *expected_mode {
293                    status.mode_changed = true;
294                }
295            }
296        }
297
298        status
299    }
300
301    pub fn originals_dir(&self) -> PathBuf {
302        self.state_dir.join("originals")
303    }
304
305    pub fn store_original(&self, content_hash: &str, content: &[u8]) -> Result<()> {
306        let dir = self.originals_dir();
307        std::fs::create_dir_all(&dir)
308            .with_context(|| format!("failed to create originals directory: {}", dir.display()))?;
309        let path = dir.join(content_hash);
310        if !path.exists() {
311            std::fs::write(&path, content)
312                .with_context(|| format!("failed to store original: {}", path.display()))?;
313        }
314        Ok(())
315    }
316
317    pub fn load_original(&self, content_hash: &str) -> Result<Vec<u8>> {
318        let path = self.originals_dir().join(content_hash);
319        std::fs::read(&path)
320            .with_context(|| format!("failed to load original content: {}", path.display()))
321    }
322
323    pub fn migrate_storage(state_dir: &Path) -> Result<()> {
324        let originals = state_dir.join("originals");
325        let deployed = state_dir.join("deployed");
326        if originals.is_dir() && !deployed.exists() {
327            std::fs::rename(&originals, &deployed)
328                .with_context(|| "failed to migrate originals/ to deployed/")?;
329        }
330        Ok(())
331    }
332
333    /// Restore files to their pre-dotm state.
334    /// Files with original_hash get their original content written back with original metadata.
335    /// Files without original_hash (dotm created them) get removed.
336    /// Returns the count of restored files.
337    pub fn restore(&mut self, package_filter: Option<&str>) -> Result<usize> {
338        let mut restored = 0;
339        let mut remaining = Vec::new();
340        let mut restore_error: Option<anyhow::Error> = None;
341
342        for entry in &self.entries {
343            if let Some(filter) = package_filter {
344                if entry.package != filter {
345                    remaining.push(entry.clone());
346                    continue;
347                }
348            }
349
350            // On error, keep unprocessed entries in remaining so state stays consistent
351            if restore_error.is_some() {
352                remaining.push(entry.clone());
353                continue;
354            }
355
356            let result: Result<()> = (|| {
357                if let Some(ref orig_hash) = entry.original_hash {
358                    let original_content = self.load_original(orig_hash)?;
359                    std::fs::write(&entry.target, &original_content).with_context(|| {
360                        format!("failed to restore: {}", entry.target.display())
361                    })?;
362
363                    if entry.original_owner.is_some() || entry.original_group.is_some() {
364                        let _ = crate::metadata::apply_ownership(
365                            &entry.target,
366                            entry.original_owner.as_deref(),
367                            entry.original_group.as_deref(),
368                        );
369                    }
370                    if let Some(ref orig_mode) = entry.original_mode {
371                        let _ =
372                            crate::deployer::apply_permission_override(&entry.target, orig_mode);
373                    }
374                } else if entry.target.exists() || entry.target.is_symlink() {
375                    std::fs::remove_file(&entry.target)
376                        .with_context(|| format!("failed to remove: {}", entry.target.display()))?;
377                    cleanup_empty_parents(&entry.target);
378                }
379                Ok(())
380            })();
381
382            match result {
383                Ok(()) => restored += 1,
384                Err(e) => {
385                    remaining.push(entry.clone());
386                    restore_error = Some(e);
387                }
388            }
389        }
390
391        if package_filter.is_some() {
392            self.entries = remaining;
393            if let Err(save_err) = self.save() {
394                eprintln!("warning: failed to save state after partial restore: {save_err}");
395            }
396        } else if restore_error.is_none() {
397            let originals = self.originals_dir();
398            if originals.is_dir() {
399                let _ = std::fs::remove_dir_all(&originals);
400            }
401            let state_path = self.state_dir.join(STATE_FILE);
402            if state_path.exists() {
403                std::fs::remove_file(&state_path)?;
404            }
405        } else {
406            // Save partial progress — remove successfully-restored entries
407            self.entries = remaining;
408            if let Err(save_err) = self.save() {
409                eprintln!("warning: failed to save state after partial restore: {save_err}");
410            }
411        }
412
413        if let Some(e) = restore_error {
414            return Err(e);
415        }
416
417        Ok(restored)
418    }
419
420    /// Remove managed files for a single package and save updated state.
421    ///
422    /// `cwd` is the working directory for hook execution. Note: deploy hooks
423    /// run from the package's target directory, but undeploy hooks run from
424    /// whatever `cwd` the caller provides (typically the dotfiles source dir),
425    /// since the target directory may be partially torn down during undeploy.
426    pub fn undeploy_package(
427        &mut self,
428        package: &str,
429        packages: Option<&HashMap<String, crate::config::PackageConfig>>,
430        cwd: &Path,
431    ) -> Result<usize> {
432        // Run pre_undeploy hook
433        if let Some(pkgs) = packages {
434            if let Some(pkg_config) = pkgs.get(package) {
435                if let Some(ref cmd) = pkg_config.pre_undeploy {
436                    crate::hooks::run_hook(cmd, cwd, package, "undeploy")?;
437                }
438            }
439        }
440
441        let mut removed = 0;
442        let mut remaining = Vec::new();
443
444        for entry in &self.entries {
445            if entry.package == package {
446                if entry.target.is_symlink() || entry.target.exists() {
447                    std::fs::remove_file(&entry.target).with_context(|| {
448                        format!("failed to remove target: {}", entry.target.display())
449                    })?;
450                    cleanup_empty_parents(&entry.target);
451                    removed += 1;
452                }
453            } else {
454                remaining.push(entry.clone());
455            }
456        }
457
458        self.entries = remaining;
459        self.save()?;
460
461        // Run post_undeploy hook
462        if let Some(pkgs) = packages {
463            if let Some(pkg_config) = pkgs.get(package) {
464                if let Some(ref cmd) = pkg_config.post_undeploy {
465                    if let Err(e) = crate::hooks::run_hook(cmd, cwd, package, "undeploy") {
466                        eprintln!("warning: {e}");
467                    }
468                }
469            }
470        }
471
472        Ok(removed)
473    }
474
475    /// Remove all managed files and return a count of removed files.
476    ///
477    /// `cwd` is the working directory for hook execution (see
478    /// [`undeploy_package`](Self::undeploy_package) for details on the
479    /// asymmetry with deploy hooks).
480    pub fn undeploy(
481        &self,
482        packages: Option<&HashMap<String, crate::config::PackageConfig>>,
483        cwd: &Path,
484    ) -> Result<usize> {
485        // Collect unique packages (preserving first-seen order). Entries may not
486        // be contiguous per-package (e.g. after state merges), so hooks are run
487        // in two separate passes rather than tracked during removal.
488        let mut unique_packages: Vec<&str> = Vec::new();
489        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
490        for entry in &self.entries {
491            if seen.insert(&entry.package) {
492                unique_packages.push(&entry.package);
493            }
494        }
495
496        // Run all pre_undeploy hooks (propagate errors — abort if any hook fails)
497        if let Some(pkgs) = packages {
498            for pkg_name in &unique_packages {
499                if let Some(pkg_config) = pkgs.get(*pkg_name) {
500                    if let Some(ref cmd) = pkg_config.pre_undeploy {
501                        crate::hooks::run_hook(cmd, cwd, pkg_name, "undeploy")?;
502                    }
503                }
504            }
505        }
506
507        let mut removed = 0;
508
509        for entry in &self.entries {
510            if entry.target.is_symlink() || entry.target.exists() {
511                std::fs::remove_file(&entry.target).with_context(|| {
512                    format!("failed to remove target: {}", entry.target.display())
513                })?;
514                cleanup_empty_parents(&entry.target);
515                removed += 1;
516            }
517        }
518
519        // Clean up originals directory
520        let originals = self.originals_dir();
521        if originals.is_dir() {
522            let _ = std::fs::remove_dir_all(&originals);
523        }
524
525        // Remove the state file itself
526        let state_path = self.state_dir.join(STATE_FILE);
527        if state_path.exists() {
528            std::fs::remove_file(&state_path)?;
529        }
530
531        // Run all post_undeploy hooks (warn on error — files already removed)
532        if let Some(pkgs) = packages {
533            for pkg_name in &unique_packages {
534                if let Some(pkg_config) = pkgs.get(*pkg_name) {
535                    if let Some(ref cmd) = pkg_config.post_undeploy {
536                        if let Err(e) = crate::hooks::run_hook(cmd, cwd, pkg_name, "undeploy") {
537                            eprintln!("warning: {e}");
538                        }
539                    }
540                }
541            }
542        }
543
544        Ok(removed)
545    }
546}
547
548pub fn cleanup_empty_parents(path: &Path) {
549    let mut current = path.parent();
550    while let Some(parent) = current {
551        if parent == Path::new("") || parent == Path::new("/") {
552            break;
553        }
554        match std::fs::read_dir(parent) {
555            Ok(mut entries) => {
556                if entries.next().is_none() {
557                    let _ = std::fs::remove_dir(parent);
558                    current = parent.parent();
559                } else {
560                    break;
561                }
562            }
563            Err(_) => break,
564        }
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use tempfile::TempDir;
572
573    #[test]
574    fn store_and_load_original_content() {
575        let dir = TempDir::new().unwrap();
576        let state = DeployState::new(dir.path());
577        state
578            .store_original("orig456", b"original pre-existing content")
579            .unwrap();
580        let loaded = state.load_original("orig456").unwrap();
581        assert_eq!(loaded, b"original pre-existing content");
582    }
583
584    #[test]
585    fn migrate_renames_originals_to_deployed() {
586        let dir = TempDir::new().unwrap();
587        let originals = dir.path().join("originals");
588        std::fs::create_dir_all(&originals).unwrap();
589        std::fs::write(originals.join("hash1"), "content1").unwrap();
590
591        DeployState::migrate_storage(dir.path()).unwrap();
592
593        assert!(!originals.exists());
594        let deployed = dir.path().join("deployed");
595        assert!(deployed.exists());
596        assert_eq!(
597            std::fs::read_to_string(deployed.join("hash1")).unwrap(),
598            "content1"
599        );
600    }
601
602    #[test]
603    fn migrate_noop_if_deployed_exists() {
604        let dir = TempDir::new().unwrap();
605        let deployed = dir.path().join("deployed");
606        std::fs::create_dir_all(&deployed).unwrap();
607        std::fs::write(deployed.join("hash1"), "existing").unwrap();
608
609        let originals = dir.path().join("originals");
610        std::fs::create_dir_all(&originals).unwrap();
611        std::fs::write(originals.join("hash1"), "should not replace").unwrap();
612
613        DeployState::migrate_storage(dir.path()).unwrap();
614
615        assert_eq!(
616            std::fs::read_to_string(deployed.join("hash1")).unwrap(),
617            "existing"
618        );
619    }
620
621    #[test]
622    fn concurrent_lock_fails() {
623        use fs2::FileExt;
624        let dir = TempDir::new().unwrap();
625        std::fs::create_dir_all(dir.path()).unwrap();
626        let lock_path = dir.path().join("dotm.lock");
627        std::fs::write(&lock_path, "").unwrap();
628
629        // Hold the lock
630        let f = std::fs::File::open(&lock_path).unwrap();
631        f.lock_exclusive().unwrap();
632
633        // Try to acquire from DeployState — should fail
634        let result = DeployState::load_locked(dir.path());
635        assert!(result.is_err());
636        assert!(
637            result
638                .unwrap_err()
639                .to_string()
640                .contains("another dotm process")
641        );
642    }
643}