Skip to main content

krypt_core/
deploy.rs

1//! High-level deploy orchestration — the engine behind
2//! `krypt link` / `krypt unlink` / `krypt relink`.
3//!
4//! Stages of a `link`:
5//!
6//! 1. Load `.krypt.toml` (with `include = [...]` expansion).
7//! 2. Build a [`Resolver`] (optionally pinned to a non-host
8//!    [`Platform`] for testing).
9//! 3. Apply `[paths]` overrides from the config.
10//! 4. Build a [`Plan`].
11//! 5. Load the existing [`Manifest`] (if any).
12//! 6. **Narrow conflicts**: for each [`Action::Conflict`] in the plan,
13//!    check the manifest — if the recorded `hash_dst` matches the
14//!    current file on disk, the destination is "ours" and a re-deploy
15//!    is safe; promote to a [`Action::Copy`]. If `force` is set,
16//!    promote everything.
17//! 7. Execute the plan.
18//! 8. Update the manifest with what was written, save atomically.
19//!
20//! `unlink` is the inverse: iterate the manifest, hash each `dst`,
21//! delete only entries that still match the recorded hash (i.e.
22//! haven't drifted). With `force`, delete regardless of drift.
23//!
24//! `relink` = unlink + link.
25
26use std::collections::BTreeMap;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30use thiserror::Error;
31
32use crate::config::{Config, ConfigError};
33use crate::copy::{Action, ExecError, ExecOpts, PlanError, Report, execute as execute_plan, plan};
34use crate::manifest::{
35    DriftStatus, Manifest, ManifestEntry, ManifestError, detect_drift, hash_file,
36};
37use crate::paths::{Platform, Resolver};
38
39// ─── Errors ─────────────────────────────────────────────────────────────────
40
41/// Anything that can go wrong during a deploy.
42#[derive(Debug, Error)]
43pub enum DeployError {
44    /// `.krypt.toml` failed to load or include-expand.
45    #[error(transparent)]
46    Config(#[from] ConfigError),
47    /// `include = [...]` expansion failed.
48    #[error("include expansion: {0}")]
49    Include(String),
50    /// Building the plan failed.
51    #[error(transparent)]
52    Plan(#[from] PlanError),
53    /// Executing the plan failed.
54    #[error(transparent)]
55    Exec(#[from] ExecError),
56    /// Reading or writing the manifest failed.
57    #[error(transparent)]
58    Manifest(#[from] ManifestError),
59    /// I/O failure outside the planner/executor path (e.g. unlink remove).
60    #[error("io: {0}")]
61    Io(#[from] std::io::Error),
62}
63
64// ─── Options ────────────────────────────────────────────────────────────────
65
66/// Knobs for [`link`] / [`unlink`] / [`relink`].
67#[derive(Debug, Clone)]
68pub struct DeployOpts {
69    /// Absolute path to the `.krypt.toml` file driving the deploy. The
70    /// directory it lives in is treated as `repo_root` for source-path
71    /// resolution.
72    pub config_path: PathBuf,
73    /// Where to read + persist the deployment manifest. Defaults to
74    /// `${XDG_STATE}/krypt/manifest.json`; tests pass an explicit path.
75    pub manifest_path: PathBuf,
76    /// Override the auto-detected platform. `None` = use `cfg!(target_os)`.
77    pub platform: Option<Platform>,
78    /// If true, no filesystem mutation occurs. The returned report still
79    /// describes what *would* have happened.
80    pub dry_run: bool,
81    /// On `link`, overwrite real conflicts (destinations with content
82    /// that doesn't match any prior manifest entry). On `unlink`,
83    /// delete destinations even if they've drifted from the recorded
84    /// hash.
85    pub force: bool,
86}
87
88// ─── Reports ────────────────────────────────────────────────────────────────
89
90/// Summary returned by [`link`].
91#[derive(Debug, Default, Clone)]
92pub struct LinkReport {
93    /// Files actually copied (count). Includes safe re-deploys + forced.
94    pub written: usize,
95    /// Conflicts surfaced + skipped because `force` was off.
96    pub conflicts_skipped: usize,
97    /// Manifest-tracked re-deploys whose dst hash matched the recorded
98    /// one (so we overwrote silently with the same bytes — true
99    /// idempotency).
100    pub idempotent_rewrites: usize,
101    /// Conflicts skipped because the user-supplied `--platform` filter
102    /// excluded them. Always 0 today (the planner already filters); kept
103    /// for forward compat with cross-platform planning.
104    pub platform_skipped: usize,
105}
106
107/// Summary returned by [`unlink`].
108#[derive(Debug, Default, Clone)]
109pub struct UnlinkReport {
110    /// Files removed from disk.
111    pub removed: usize,
112    /// Entries skipped because the destination drifted (use `force` to
113    /// delete anyway).
114    pub drift_skipped: usize,
115    /// Entries skipped because the destination is already gone.
116    pub already_missing: usize,
117}
118
119// ─── link / unlink / relink ────────────────────────────────────────────────
120
121/// Deploy every entry in the config. Idempotent — re-runs over a clean
122/// state make no changes.
123pub fn link(opts: &DeployOpts) -> Result<LinkReport, DeployError> {
124    let (cfg, repo_root) = load_config(&opts.config_path)?;
125    let resolver = build_resolver(opts.platform, &cfg);
126
127    let raw_plan = plan(&cfg, &repo_root, &resolver)?;
128    let mut manifest =
129        Manifest::load(&opts.manifest_path)?.unwrap_or_else(|| Manifest::new(repo_root.clone()));
130    manifest.repo_path = repo_root.clone();
131
132    let (narrowed, idempotent) = narrow_conflicts(&raw_plan, &manifest, opts.force);
133
134    let report = execute_plan(
135        &narrowed,
136        ExecOpts {
137            dry_run: opts.dry_run,
138            // narrow_conflicts already promoted everything we want to
139            // overwrite into Copy; anything still Conflict here is a
140            // real, unresolved conflict — leave it skipped.
141            overwrite_conflicts: false,
142        },
143    )?;
144
145    let mut conflicts_skipped = 0usize;
146    for a in &narrowed.actions {
147        if matches!(a, Action::Conflict { .. }) {
148            conflicts_skipped += 1;
149        }
150    }
151
152    if !opts.dry_run {
153        for w in &report.written {
154            if let (Some(hash_src), Some(hash_dst)) = (&w.hash_src, &w.hash_dst) {
155                let src_rel = w
156                    .src
157                    .strip_prefix(&repo_root)
158                    .map(|p| p.to_path_buf())
159                    .unwrap_or_else(|_| w.src.clone());
160                manifest.record(ManifestEntry {
161                    src: src_rel,
162                    dst: w.dst.clone(),
163                    kind: w.kind,
164                    hash_src: hash_src.clone(),
165                    hash_dst: hash_dst.clone(),
166                    deployed_at: now_unix(),
167                });
168            }
169        }
170        manifest.save(&opts.manifest_path)?;
171    }
172
173    Ok(LinkReport {
174        written: report.written.len(),
175        conflicts_skipped,
176        idempotent_rewrites: idempotent,
177        platform_skipped: 0,
178    })
179}
180
181/// Remove every entry recorded in the manifest. Drifted destinations are
182/// skipped unless `force` is set.
183pub fn unlink(opts: &DeployOpts) -> Result<UnlinkReport, DeployError> {
184    let Some(mut manifest) = Manifest::load(&opts.manifest_path)? else {
185        // Nothing to do; manifest doesn't exist yet.
186        return Ok(UnlinkReport::default());
187    };
188
189    let mut report = UnlinkReport::default();
190    let drift = detect_drift(&manifest);
191    let mut to_forget: Vec<PathBuf> = Vec::new();
192
193    for d in drift {
194        match d.status {
195            DriftStatus::Clean => {
196                if !opts.dry_run {
197                    fs::remove_file(&d.dst)?;
198                }
199                report.removed += 1;
200                to_forget.push(d.dst);
201            }
202            DriftStatus::DstMissing => {
203                report.already_missing += 1;
204                to_forget.push(d.dst);
205            }
206            DriftStatus::Drifted => {
207                if opts.force {
208                    if !opts.dry_run {
209                        fs::remove_file(&d.dst)?;
210                    }
211                    report.removed += 1;
212                    to_forget.push(d.dst);
213                } else {
214                    report.drift_skipped += 1;
215                }
216            }
217        }
218    }
219
220    if !opts.dry_run {
221        for dst in &to_forget {
222            manifest.forget(dst);
223        }
224        manifest.save(&opts.manifest_path)?;
225    }
226
227    Ok(report)
228}
229
230/// Convenience: [`unlink`] followed by [`link`]. Useful after large
231/// config edits where you want a clean redeploy.
232pub fn relink(opts: &DeployOpts) -> Result<(UnlinkReport, LinkReport), DeployError> {
233    let u = unlink(opts)?;
234    let l = link(opts)?;
235    Ok((u, l))
236}
237
238// ─── Internals ──────────────────────────────────────────────────────────────
239
240/// Walk `plan.actions` and, for each [`Action::Conflict`], either promote
241/// to [`Action::Copy`] (safe to overwrite) or leave it alone (real
242/// conflict). Returns `(narrowed_plan, idempotent_count)`.
243fn narrow_conflicts(
244    plan: &crate::copy::Plan,
245    manifest: &Manifest,
246    force: bool,
247) -> (crate::copy::Plan, usize) {
248    let mut out = Vec::with_capacity(plan.actions.len());
249    let mut idempotent = 0usize;
250    for action in &plan.actions {
251        match action {
252            Action::Copy { .. } => out.push(action.clone()),
253            Action::Conflict { src, dst, kind } => {
254                if force {
255                    out.push(Action::Copy {
256                        src: src.clone(),
257                        dst: dst.clone(),
258                        kind: *kind,
259                    });
260                    continue;
261                }
262                if let Some(entry) = manifest.entries.get(dst)
263                    && hash_matches_recorded(dst, &entry.hash_dst)
264                {
265                    out.push(Action::Copy {
266                        src: src.clone(),
267                        dst: dst.clone(),
268                        kind: *kind,
269                    });
270                    idempotent += 1;
271                    continue;
272                }
273                out.push(action.clone());
274            }
275        }
276    }
277    (crate::copy::Plan { actions: out }, idempotent)
278}
279
280fn hash_matches_recorded(dst: &Path, recorded: &str) -> bool {
281    match hash_file(dst) {
282        Ok(actual) => actual == recorded,
283        Err(_) => false,
284    }
285}
286
287fn load_config(config_path: &Path) -> Result<(Config, PathBuf), DeployError> {
288    let cfg = crate::include::load_with_includes(config_path).map_err(|e| match e {
289        crate::include::IncludeError::Config(c) => DeployError::Config(c),
290        other => DeployError::Include(other.to_string()),
291    })?;
292    let repo_root = config_path
293        .parent()
294        .map(|p| p.to_path_buf())
295        .unwrap_or_else(|| PathBuf::from("."));
296    Ok((cfg, repo_root))
297}
298
299fn build_resolver(platform: Option<Platform>, cfg: &Config) -> Resolver {
300    let r = match platform {
301        Some(p) => Resolver::for_platform(p),
302        None => Resolver::new(),
303    };
304    let overrides: BTreeMap<String, String> = cfg.paths.clone().into_iter().collect();
305    r.with_overrides(overrides)
306}
307
308fn now_unix() -> u64 {
309    use std::time::{SystemTime, UNIX_EPOCH};
310    SystemTime::now()
311        .duration_since(UNIX_EPOCH)
312        .map(|d| d.as_secs())
313        .unwrap_or(0)
314}
315
316// (Suppress dead-code lint on `Report` — used through `execute_plan`.)
317#[allow(dead_code)]
318fn _ensure_report_in_scope(_: Report) {}
319
320// ─── Tests ──────────────────────────────────────────────────────────────────
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use tempfile::tempdir;
326
327    /// Build a tiny synthetic repo + return its `.krypt.toml` path.
328    fn synth_repo(root: &Path, files: &[(&str, &[u8])]) -> PathBuf {
329        for (rel, bytes) in files {
330            let p = root.join(rel);
331            if let Some(parent) = p.parent() {
332                fs::create_dir_all(parent).unwrap();
333            }
334            fs::write(p, bytes).unwrap();
335        }
336        root.join(".krypt.toml")
337    }
338
339    /// Return the path as a forward-slash string safe to embed in a TOML
340    /// basic string. On Windows, `Path::display` emits backslashes which
341    /// TOML treats as escape sequences and rejects.
342    fn toml_path(p: &Path) -> String {
343        p.to_string_lossy().replace('\\', "/")
344    }
345
346    fn opts(cfg: PathBuf, manifest: PathBuf, force: bool) -> DeployOpts {
347        DeployOpts {
348            config_path: cfg,
349            manifest_path: manifest,
350            platform: Some(Platform::Linux),
351            dry_run: false,
352            force,
353        }
354    }
355
356    #[test]
357    fn link_writes_files_and_manifest() {
358        let repo = tempdir().unwrap();
359        let home = tempdir().unwrap();
360        let state = tempdir().unwrap();
361
362        let cfg_text = format!(
363            r#"
364[paths]
365HOME = "{home}"
366
367[[link]]
368src = "gitconfig"
369dst = "${{HOME}}/.gitconfig"
370"#,
371            home = toml_path(home.path())
372        );
373        let cfg_path = synth_repo(repo.path(), &[("gitconfig", b"[user]\n")]);
374        fs::write(&cfg_path, cfg_text).unwrap();
375
376        let manifest_path = state.path().join("manifest.json");
377        let r = link(&opts(cfg_path, manifest_path.clone(), false)).unwrap();
378        assert_eq!(r.written, 1);
379        assert!(home.path().join(".gitconfig").exists());
380
381        let m = Manifest::load(&manifest_path).unwrap().unwrap();
382        assert_eq!(m.entries.len(), 1);
383        let entry = &m.entries[&home.path().join(".gitconfig")];
384        assert_eq!(entry.src, PathBuf::from("gitconfig"));
385        assert!(entry.hash_dst.starts_with("sha256:"));
386    }
387
388    #[test]
389    fn link_idempotent_when_manifest_agrees() {
390        let repo = tempdir().unwrap();
391        let home = tempdir().unwrap();
392        let state = tempdir().unwrap();
393
394        let cfg_text = format!(
395            r#"
396[paths]
397HOME = "{home}"
398
399[[link]]
400src = "a"
401dst = "${{HOME}}/a"
402"#,
403            home = toml_path(home.path())
404        );
405        let cfg_path = synth_repo(repo.path(), &[("a", b"v1")]);
406        fs::write(&cfg_path, cfg_text).unwrap();
407        let manifest_path = state.path().join("manifest.json");
408
409        link(&opts(cfg_path.clone(), manifest_path.clone(), false)).unwrap();
410        let r = link(&opts(cfg_path, manifest_path, false)).unwrap();
411        // Plan sees dst exists → Conflict. Manifest matches → narrowed to
412        // Copy and silently rewritten.
413        assert_eq!(r.idempotent_rewrites, 1);
414        assert_eq!(r.conflicts_skipped, 0);
415        assert_eq!(r.written, 1);
416    }
417
418    #[test]
419    fn link_untracked_conflict_skipped_without_force() {
420        let repo = tempdir().unwrap();
421        let home = tempdir().unwrap();
422        let state = tempdir().unwrap();
423
424        // Pre-existing file at dst, NOT in our manifest.
425        fs::write(home.path().join("a"), b"user wrote this").unwrap();
426
427        let cfg_text = format!(
428            r#"
429[paths]
430HOME = "{home}"
431
432[[link]]
433src = "a"
434dst = "${{HOME}}/a"
435"#,
436            home = toml_path(home.path())
437        );
438        let cfg_path = synth_repo(repo.path(), &[("a", b"repo wrote this")]);
439        fs::write(&cfg_path, cfg_text).unwrap();
440
441        let manifest_path = state.path().join("manifest.json");
442        let r = link(&opts(cfg_path.clone(), manifest_path.clone(), false)).unwrap();
443        assert_eq!(r.conflicts_skipped, 1);
444        assert_eq!(r.written, 0);
445        assert_eq!(fs::read(home.path().join("a")).unwrap(), b"user wrote this");
446
447        // With force, it overwrites.
448        let r = link(&opts(cfg_path, manifest_path, true)).unwrap();
449        assert_eq!(r.written, 1);
450        assert_eq!(fs::read(home.path().join("a")).unwrap(), b"repo wrote this");
451    }
452
453    #[test]
454    fn unlink_removes_clean_entries_only() {
455        let repo = tempdir().unwrap();
456        let home = tempdir().unwrap();
457        let state = tempdir().unwrap();
458
459        let cfg_text = format!(
460            r#"
461[paths]
462HOME = "{home}"
463
464[[link]]
465src = "a"
466dst = "${{HOME}}/a"
467
468[[link]]
469src = "b"
470dst = "${{HOME}}/b"
471"#,
472            home = toml_path(home.path())
473        );
474        let cfg_path = synth_repo(repo.path(), &[("a", b"a1"), ("b", b"b1")]);
475        fs::write(&cfg_path, cfg_text).unwrap();
476        let manifest_path = state.path().join("manifest.json");
477
478        link(&opts(cfg_path, manifest_path.clone(), false)).unwrap();
479
480        // User modifies one deployed file.
481        fs::write(home.path().join("b"), b"USER EDITED").unwrap();
482
483        let r = unlink(&DeployOpts {
484            config_path: PathBuf::new(),
485            manifest_path: manifest_path.clone(),
486            platform: Some(Platform::Linux),
487            dry_run: false,
488            force: false,
489        })
490        .unwrap();
491        assert_eq!(r.removed, 1);
492        assert_eq!(r.drift_skipped, 1);
493        assert!(!home.path().join("a").exists());
494        assert!(home.path().join("b").exists(), "drifted file kept");
495
496        let m = Manifest::load(&manifest_path).unwrap().unwrap();
497        assert_eq!(m.entries.len(), 1, "drifted entry still tracked");
498    }
499
500    #[test]
501    fn unlink_force_removes_drifted() {
502        let repo = tempdir().unwrap();
503        let home = tempdir().unwrap();
504        let state = tempdir().unwrap();
505
506        let cfg_text = format!(
507            r#"
508[paths]
509HOME = "{home}"
510
511[[link]]
512src = "a"
513dst = "${{HOME}}/a"
514"#,
515            home = toml_path(home.path())
516        );
517        let cfg_path = synth_repo(repo.path(), &[("a", b"a1")]);
518        fs::write(&cfg_path, cfg_text).unwrap();
519        let manifest_path = state.path().join("manifest.json");
520
521        link(&opts(cfg_path, manifest_path.clone(), false)).unwrap();
522        fs::write(home.path().join("a"), b"DRIFT").unwrap();
523
524        let r = unlink(&DeployOpts {
525            config_path: PathBuf::new(),
526            manifest_path: manifest_path.clone(),
527            platform: Some(Platform::Linux),
528            dry_run: false,
529            force: true,
530        })
531        .unwrap();
532        assert_eq!(r.removed, 1);
533        assert!(!home.path().join("a").exists());
534    }
535
536    #[test]
537    fn link_unlink_link_round_trips() {
538        let repo = tempdir().unwrap();
539        let home = tempdir().unwrap();
540        let state = tempdir().unwrap();
541
542        let cfg_text = format!(
543            r#"
544[paths]
545HOME = "{home}"
546
547[[link]]
548src = "x"
549dst = "${{HOME}}/x"
550
551[[link]]
552src = "y/y"
553dst = "${{HOME}}/.config/y/y"
554"#,
555            home = toml_path(home.path())
556        );
557        let cfg_path = synth_repo(repo.path(), &[("x", b"X"), ("y/y", b"Y")]);
558        fs::write(&cfg_path, cfg_text).unwrap();
559        let manifest_path = state.path().join("manifest.json");
560
561        let dopts = opts(cfg_path, manifest_path.clone(), false);
562
563        // First link.
564        link(&dopts).unwrap();
565        let snapshot_x = fs::read(home.path().join("x")).unwrap();
566        let snapshot_y = fs::read(home.path().join(".config/y/y")).unwrap();
567        let snapshot_manifest =
568            serde_json::to_string(&Manifest::load(&manifest_path).unwrap()).unwrap();
569
570        // Unlink.
571        unlink(&dopts).unwrap();
572        assert!(!home.path().join("x").exists());
573        assert!(!home.path().join(".config/y/y").exists());
574        let m = Manifest::load(&manifest_path).unwrap().unwrap();
575        assert_eq!(m.entries.len(), 0);
576
577        // Re-link. State must match.
578        link(&dopts).unwrap();
579        assert_eq!(fs::read(home.path().join("x")).unwrap(), snapshot_x);
580        assert_eq!(
581            fs::read(home.path().join(".config/y/y")).unwrap(),
582            snapshot_y
583        );
584
585        let after = serde_json::to_string(&Manifest::load(&manifest_path).unwrap()).unwrap();
586        // Timestamps will differ but entries set must match.
587        let snap_m: Manifest = serde_json::from_str(&snapshot_manifest).unwrap();
588        let after_m: Manifest = serde_json::from_str(&after).unwrap();
589        assert_eq!(snap_m.entries.len(), after_m.entries.len());
590        for (k, snap_entry) in &snap_m.entries {
591            let after_entry = &after_m.entries[k];
592            assert_eq!(snap_entry.src, after_entry.src);
593            assert_eq!(snap_entry.hash_src, after_entry.hash_src);
594            assert_eq!(snap_entry.hash_dst, after_entry.hash_dst);
595            assert_eq!(snap_entry.kind, after_entry.kind);
596        }
597    }
598
599    #[test]
600    fn relink_runs_unlink_then_link() {
601        let repo = tempdir().unwrap();
602        let home = tempdir().unwrap();
603        let state = tempdir().unwrap();
604
605        let cfg_text = format!(
606            r#"
607[paths]
608HOME = "{home}"
609
610[[link]]
611src = "a"
612dst = "${{HOME}}/a"
613"#,
614            home = toml_path(home.path())
615        );
616        let cfg_path = synth_repo(repo.path(), &[("a", b"v1")]);
617        fs::write(&cfg_path, cfg_text).unwrap();
618        let manifest_path = state.path().join("manifest.json");
619
620        let dopts = opts(cfg_path, manifest_path, false);
621        link(&dopts).unwrap();
622        let (u, l) = relink(&dopts).unwrap();
623        assert_eq!(u.removed, 1);
624        assert_eq!(l.written, 1);
625        assert!(home.path().join("a").exists());
626    }
627
628    #[test]
629    fn dry_run_writes_nothing() {
630        let repo = tempdir().unwrap();
631        let home = tempdir().unwrap();
632        let state = tempdir().unwrap();
633
634        let cfg_text = format!(
635            r#"
636[paths]
637HOME = "{home}"
638
639[[link]]
640src = "a"
641dst = "${{HOME}}/a"
642"#,
643            home = toml_path(home.path())
644        );
645        let cfg_path = synth_repo(repo.path(), &[("a", b"v1")]);
646        fs::write(&cfg_path, cfg_text).unwrap();
647        let manifest_path = state.path().join("manifest.json");
648
649        let r = link(&DeployOpts {
650            config_path: cfg_path,
651            manifest_path: manifest_path.clone(),
652            platform: Some(Platform::Linux),
653            dry_run: true,
654            force: false,
655        })
656        .unwrap();
657        assert_eq!(r.written, 1);
658        assert!(!home.path().join("a").exists());
659        assert!(!manifest_path.exists());
660    }
661}