Skip to main content

krypt_core/
copy.rs

1//! Copy engine — deploy files from the repo to `$HOME`.
2//!
3//! The engine works in two phases:
4//!
5//! 1. **Plan** — pure, no I/O for writes. Walks a [`Config`]'s
6//!    `[[link]]` and `[[template]]` entries, expands globs, applies
7//!    `${VAR}` resolution to destinations, and skips entries that
8//!    don't match the current platform. Returns a [`Plan`] which is a
9//!    `Vec<Action>` of [`Copy`][`Action::Copy`] / [`Conflict`][`Action::Conflict`]
10//!    items the caller can inspect or print.
11//! 2. **Execute** — performs the planned copies atomically, preserving
12//!    the source's mtime + (on Unix) file mode.
13//!
14//! What's deferred:
15//!
16//! - **Manifest-aware idempotency** — the executor records hashes via
17//!   [`crate::manifest`] but the planner still classifies any existing
18//!   destination as a [`Action::Conflict`]. Issue #15 (`krypt link`)
19//!   will compare against the manifest to narrow safe re-deploys.
20//! - **Interactive prompts** for conflicts — issue #15 wires the CLI
21//!   on top.
22
23use std::fs::{self, File};
24use std::path::{Path, PathBuf};
25use std::time::SystemTime;
26
27use serde::{Deserialize, Serialize};
28use thiserror::Error;
29
30use crate::config::{Config, Link, Template};
31use crate::paths::{ResolveError, Resolver};
32
33// ─── Errors ─────────────────────────────────────────────────────────────────
34
35/// Errors building a [`Plan`] from a Config.
36#[derive(Debug, Error)]
37pub enum PlanError {
38    /// Failed to resolve `${VAR}` in a destination path.
39    #[error("resolve dst {dst:?}: {source}")]
40    Resolve {
41        /// The unresolved destination string.
42        dst: String,
43        /// Underlying resolver error.
44        #[source]
45        source: ResolveError,
46    },
47
48    /// Glob pattern was syntactically invalid.
49    #[error("invalid glob pattern {pattern:?}: {reason}")]
50    Glob {
51        /// The pattern from the config.
52        pattern: String,
53        /// Error from the `glob` crate.
54        reason: String,
55    },
56
57    /// A platform string in the config wasn't one of `linux` / `macos`
58    /// / `windows`. The parser also catches this, but the planner
59    /// double-checks for callers who hand-build Configs.
60    #[error("unknown platform string {value:?}")]
61    UnknownPlatform {
62        /// The bad string.
63        value: String,
64    },
65}
66
67/// Errors executing a [`Plan`].
68#[derive(Debug, Error)]
69pub enum ExecError {
70    /// I/O failure during copy.
71    #[error("copy {src:?} -> {dst:?}: {source}")]
72    Io {
73        /// Source path.
74        src: PathBuf,
75        /// Destination path.
76        dst: PathBuf,
77        /// Underlying error.
78        #[source]
79        source: std::io::Error,
80    },
81
82    /// Source path didn't exist when we tried to copy it.
83    #[error("source missing: {0:?}")]
84    SourceMissing(PathBuf),
85}
86
87// ─── Plan + Action ──────────────────────────────────────────────────────────
88
89/// Which schema section an action came from.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum EntryKind {
93    /// `[[link]]`.
94    Link,
95    /// `[[template]]`.
96    Template,
97}
98
99/// What the engine intends to do for one (src, dst) pair.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum Action {
102    /// Destination does not exist — safe to write.
103    Copy {
104        /// Absolute source path.
105        src: PathBuf,
106        /// Absolute destination path.
107        dst: PathBuf,
108        /// Whether this came from a `[[link]]` or `[[template]]`.
109        kind: EntryKind,
110    },
111
112    /// Destination already exists. The caller decides what to do —
113    /// issue #15 (`krypt link`) will consult [`crate::manifest`] to
114    /// narrow content-matches into safe re-deploys.
115    Conflict {
116        /// Absolute source path.
117        src: PathBuf,
118        /// Absolute destination path.
119        dst: PathBuf,
120        /// Section the entry came from.
121        kind: EntryKind,
122    },
123}
124
125impl Action {
126    /// Source path for any variant.
127    pub fn src(&self) -> &Path {
128        match self {
129            Action::Copy { src, .. } | Action::Conflict { src, .. } => src,
130        }
131    }
132
133    /// Destination path for any variant.
134    pub fn dst(&self) -> &Path {
135        match self {
136            Action::Copy { dst, .. } | Action::Conflict { dst, .. } => dst,
137        }
138    }
139
140    /// Section the entry came from.
141    pub fn kind(&self) -> EntryKind {
142        match self {
143            Action::Copy { kind, .. } | Action::Conflict { kind, .. } => *kind,
144        }
145    }
146}
147
148/// A list of [`Action`]s to perform (or print, in dry-run mode).
149#[derive(Debug, Default, Clone)]
150pub struct Plan {
151    /// Ordered actions, in the order discovered from the config.
152    pub actions: Vec<Action>,
153}
154
155impl Plan {
156    /// Number of [`Action::Copy`] entries.
157    pub fn copy_count(&self) -> usize {
158        self.actions
159            .iter()
160            .filter(|a| matches!(a, Action::Copy { .. }))
161            .count()
162    }
163
164    /// Number of [`Action::Conflict`] entries.
165    pub fn conflict_count(&self) -> usize {
166        self.actions
167            .iter()
168            .filter(|a| matches!(a, Action::Conflict { .. }))
169            .count()
170    }
171}
172
173// ─── Planner ────────────────────────────────────────────────────────────────
174
175/// Build a [`Plan`] from a parsed (+ include-expanded) [`Config`].
176///
177/// `repo_root` is the directory the dotfiles repo lives in — `src` and
178/// `src_glob` fields are joined under it. `resolver` expands `${VAR}` in
179/// destination paths.
180pub fn plan(cfg: &Config, repo_root: &Path, resolver: &Resolver) -> Result<Plan, PlanError> {
181    let mut actions = Vec::new();
182    let current_platform = current_platform_str();
183
184    for link in &cfg.links {
185        if !platform_matches(&link.platform, current_platform)? {
186            continue;
187        }
188        plan_link(link, repo_root, resolver, &mut actions)?;
189    }
190    for tmpl in &cfg.templates {
191        if !platform_matches(&tmpl.platform, current_platform)? {
192            continue;
193        }
194        plan_template(tmpl, repo_root, resolver, &mut actions)?;
195    }
196    Ok(Plan { actions })
197}
198
199fn plan_link(
200    link: &Link,
201    repo_root: &Path,
202    resolver: &Resolver,
203    out: &mut Vec<Action>,
204) -> Result<(), PlanError> {
205    let dst_str = resolver
206        .resolve(&link.dst)
207        .map_err(|e| PlanError::Resolve {
208            dst: link.dst.clone(),
209            source: e,
210        })?;
211    let dst_base = PathBuf::from(dst_str);
212
213    if let Some(src) = &link.src {
214        let src_path = repo_root.join(src);
215        let action = build_action(&src_path, &dst_base, EntryKind::Link);
216        out.push(action);
217        return Ok(());
218    }
219
220    if let Some(src_glob) = &link.src_glob {
221        let full_pattern = repo_root.join(src_glob).to_string_lossy().into_owned();
222        let matches = glob::glob(&full_pattern).map_err(|e| PlanError::Glob {
223            pattern: full_pattern.clone(),
224            reason: e.to_string(),
225        })?;
226        let glob_prefix = glob_prefix_of(src_glob);
227        let strip_root = repo_root.join(&glob_prefix);
228        let mut paths: Vec<PathBuf> = matches.filter_map(|r| r.ok()).collect();
229        paths.sort();
230        for src_path in paths {
231            // Glob can match directories; skip those, copy only files.
232            if !src_path.is_file() {
233                continue;
234            }
235            let rel = src_path
236                .strip_prefix(&strip_root)
237                .unwrap_or(&src_path)
238                .to_path_buf();
239            let dst_path = dst_base.join(rel);
240            out.push(build_action(&src_path, &dst_path, EntryKind::Link));
241        }
242        return Ok(());
243    }
244
245    // The parser refuses to load a [[link]] with neither src nor src_glob.
246    // If a caller hand-builds a Config and bypasses validation, that's their
247    // problem — we just skip the entry.
248    Ok(())
249}
250
251fn plan_template(
252    tmpl: &Template,
253    repo_root: &Path,
254    resolver: &Resolver,
255    out: &mut Vec<Action>,
256) -> Result<(), PlanError> {
257    let dst_str = resolver
258        .resolve(&tmpl.dst)
259        .map_err(|e| PlanError::Resolve {
260            dst: tmpl.dst.clone(),
261            source: e,
262        })?;
263    let dst_path = PathBuf::from(dst_str);
264    let src_path = repo_root.join(&tmpl.src);
265    out.push(build_action(&src_path, &dst_path, EntryKind::Template));
266    Ok(())
267}
268
269fn build_action(src: &Path, dst: &Path, kind: EntryKind) -> Action {
270    if dst.exists() {
271        Action::Conflict {
272            src: src.to_path_buf(),
273            dst: dst.to_path_buf(),
274            kind,
275        }
276    } else {
277        Action::Copy {
278            src: src.to_path_buf(),
279            dst: dst.to_path_buf(),
280            kind,
281        }
282    }
283}
284
285/// Leading directory component of a glob pattern (everything before the
286/// first segment that contains a glob meta-character). Used to compute
287/// the relative-path for src→dst mapping.
288fn glob_prefix_of(pattern: &str) -> PathBuf {
289    let mut prefix = PathBuf::new();
290    for part in Path::new(pattern).components() {
291        let s = part.as_os_str().to_string_lossy();
292        if s.contains(['*', '?', '[']) {
293            break;
294        }
295        prefix.push(part.as_os_str());
296    }
297    prefix
298}
299
300fn current_platform_str() -> &'static str {
301    if cfg!(target_os = "windows") {
302        "windows"
303    } else if cfg!(target_os = "macos") {
304        "macos"
305    } else {
306        "linux"
307    }
308}
309
310fn platform_matches(entry_platform: &Option<String>, current: &str) -> Result<bool, PlanError> {
311    let Some(p) = entry_platform else {
312        return Ok(true);
313    };
314    match p.as_str() {
315        "linux" | "macos" | "windows" => Ok(p == current),
316        other => Err(PlanError::UnknownPlatform {
317            value: other.to_string(),
318        }),
319    }
320}
321
322// ─── Executor ───────────────────────────────────────────────────────────────
323
324/// Knobs for [`execute`].
325#[derive(Debug, Default, Clone, Copy)]
326pub struct ExecOpts {
327    /// If true, print what would be done without touching disk.
328    pub dry_run: bool,
329    /// If true, overwrite [`Action::Conflict`] destinations. Default
330    /// false — conflicts are surfaced as `skipped` in the report.
331    pub overwrite_conflicts: bool,
332}
333
334/// One file the executor wrote — surfaced so callers can update the
335/// deployment manifest (see [`crate::manifest`]).
336#[derive(Debug, Clone)]
337pub struct Written {
338    /// Absolute source path.
339    pub src: PathBuf,
340    /// Absolute destination path.
341    pub dst: PathBuf,
342    /// Section the entry came from.
343    pub kind: EntryKind,
344    /// `sha256:<hex>` of the source, computed at write time. `None` on
345    /// dry-runs.
346    pub hash_src: Option<String>,
347    /// `sha256:<hex>` of the destination after the copy. `None` on
348    /// dry-runs.
349    pub hash_dst: Option<String>,
350}
351
352/// What [`execute`] actually did.
353#[derive(Debug, Default, Clone)]
354pub struct Report {
355    /// Files actually written, with hashes. Empty in dry-run mode aside
356    /// from the path/kind triples.
357    pub written: Vec<Written>,
358    /// Conflict entries that were skipped (caller didn't opt into overwrite).
359    pub skipped_conflicts: usize,
360}
361
362impl Report {
363    /// Convenience — count of written entries (back-compat with the
364    /// pre-#13 `usize` field, used in tests + CLI summaries).
365    pub fn written_count(&self) -> usize {
366        self.written.len()
367    }
368}
369
370/// Run a [`Plan`] against the filesystem.
371pub fn execute(plan: &Plan, opts: ExecOpts) -> Result<Report, ExecError> {
372    let mut report = Report::default();
373    for action in &plan.actions {
374        match action {
375            Action::Copy { src, dst, kind } => {
376                let written = do_copy(src, dst, *kind, opts)?;
377                report.written.push(written);
378            }
379            Action::Conflict { src, dst, kind } => {
380                if opts.overwrite_conflicts {
381                    let written = do_copy(src, dst, *kind, opts)?;
382                    report.written.push(written);
383                } else {
384                    report.skipped_conflicts += 1;
385                }
386            }
387        }
388    }
389    Ok(report)
390}
391
392fn do_copy(src: &Path, dst: &Path, kind: EntryKind, opts: ExecOpts) -> Result<Written, ExecError> {
393    if opts.dry_run {
394        return Ok(Written {
395            src: src.to_path_buf(),
396            dst: dst.to_path_buf(),
397            kind,
398            hash_src: None,
399            hash_dst: None,
400        });
401    }
402    copy_atomic(src, dst)?;
403    let hash_src = crate::manifest::hash_file(src).ok();
404    let hash_dst = crate::manifest::hash_file(dst).ok();
405    Ok(Written {
406        src: src.to_path_buf(),
407        dst: dst.to_path_buf(),
408        kind,
409        hash_src,
410        hash_dst,
411    })
412}
413
414/// Atomically copy `src` -> `dst`, preserving mtime (and, on Unix,
415/// permission bits). Creates parent directories as needed.
416fn copy_atomic(src: &Path, dst: &Path) -> Result<(), ExecError> {
417    let mk_err = |e: std::io::Error| ExecError::Io {
418        src: src.to_path_buf(),
419        dst: dst.to_path_buf(),
420        source: e,
421    };
422
423    if !src.exists() {
424        return Err(ExecError::SourceMissing(src.to_path_buf()));
425    }
426    if let Some(parent) = dst.parent() {
427        fs::create_dir_all(parent).map_err(mk_err)?;
428    }
429    let tmp = tmp_sibling(dst);
430    // Remove a leftover from a previous failed run so the copy doesn't
431    // fail-fast with EEXIST on systems where copy is strict.
432    let _ = fs::remove_file(&tmp);
433
434    // fs::copy preserves mode on Unix; on Windows there's no concept to
435    // preserve for our purposes.
436    fs::copy(src, &tmp).map_err(mk_err)?;
437
438    // Preserve mtime. Read from source metadata; ignore failures for
439    // platforms where it isn't supported.
440    if let Ok(meta) = fs::metadata(src) {
441        if let Ok(modified) = meta.modified()
442            && let Ok(f) = File::options().write(true).open(&tmp)
443        {
444            let _ = f.set_modified(modified);
445        }
446    } else {
447        // Even if we couldn't read mtime, the copy itself succeeded.
448        let _: SystemTime = SystemTime::now(); // keep `SystemTime` import live
449    }
450
451    fs::rename(&tmp, dst).map_err(mk_err)?;
452    Ok(())
453}
454
455fn tmp_sibling(dst: &Path) -> PathBuf {
456    let mut name = dst.file_name().unwrap_or_default().to_os_string();
457    name.push(format!(".krypt-tmp-{}", std::process::id()));
458    dst.with_file_name(name)
459}
460
461// ─── Unit tests ─────────────────────────────────────────────────────────────
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn glob_prefix_strips_at_first_wildcard() {
469        assert_eq!(
470            glob_prefix_of(".config/nvim/**/*"),
471            PathBuf::from(".config/nvim")
472        );
473        assert_eq!(glob_prefix_of("**/*"), PathBuf::new());
474        assert_eq!(glob_prefix_of("a/b/c"), PathBuf::from("a/b/c"));
475        assert_eq!(glob_prefix_of("foo/*.toml"), PathBuf::from("foo"));
476    }
477
478    #[test]
479    fn platform_match_accepts_omitted() {
480        assert!(platform_matches(&None, "linux").unwrap());
481    }
482
483    #[test]
484    fn platform_match_filters_other_os() {
485        assert!(platform_matches(&Some("linux".into()), "linux").unwrap());
486        assert!(!platform_matches(&Some("macos".into()), "linux").unwrap());
487        assert!(!platform_matches(&Some("windows".into()), "linux").unwrap());
488    }
489
490    #[test]
491    fn platform_match_rejects_unknown() {
492        assert!(matches!(
493            platform_matches(&Some("freebsd".into()), "linux"),
494            Err(PlanError::UnknownPlatform { .. })
495        ));
496    }
497
498    #[test]
499    fn tmp_sibling_lives_next_to_dst() {
500        let dst = PathBuf::from("/some/where/file.conf");
501        let tmp = tmp_sibling(&dst);
502        assert_eq!(tmp.parent(), dst.parent());
503        let name = tmp.file_name().unwrap().to_string_lossy().to_string();
504        assert!(name.starts_with("file.conf.krypt-tmp-"));
505    }
506
507    #[test]
508    fn plan_counts_match_actions() {
509        let actions = vec![
510            Action::Copy {
511                src: "/a".into(),
512                dst: "/b".into(),
513                kind: EntryKind::Link,
514            },
515            Action::Conflict {
516                src: "/c".into(),
517                dst: "/d".into(),
518                kind: EntryKind::Template,
519            },
520            Action::Copy {
521                src: "/e".into(),
522                dst: "/f".into(),
523                kind: EntryKind::Link,
524            },
525        ];
526        let p = Plan { actions };
527        assert_eq!(p.copy_count(), 2);
528        assert_eq!(p.conflict_count(), 1);
529    }
530}