Skip to main content

krypt_core/
adopt.rs

1//! `krypt adopt` and `krypt adopt-edits` — import existing dotfiles into the repo.
2//!
3//! `adopt` copies a file that already lives at its deployed location (`dst`)
4//! into the repo at the derived or user-supplied `src` path, then records a
5//! manifest entry.  The original file at `dst` is left untouched.
6//!
7//! `adopt_edits` scans every manifest entry for drift and, for each drifted
8//! entry, copies the current `dst` bytes back into `<repo>/<src>` and refreshes
9//! the manifest hashes.  This is the "I edited my deployed dotfiles in-place;
10//! sync those edits back to the repo" workflow.
11
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use thiserror::Error;
18
19use crate::copy::EntryKind;
20use crate::manifest::{
21    DriftStatus, Manifest, ManifestEntry, ManifestError, detect_drift, hash_file,
22};
23use crate::paths::{ResolveError, Resolver};
24
25// ─── Errors ─────────────────────────────────────────────────────────────────
26
27/// Errors from [`adopt`] or [`adopt_edits`].
28#[derive(Debug, Error)]
29pub enum AdoptError {
30    /// The file at `dst` does not exist.
31    #[error("dst does not exist: {0:?}")]
32    DstMissing(PathBuf),
33
34    /// `dst` is not under `$HOME` and no `--src` override was supplied.
35    #[error("dst {dst:?} is outside $HOME; provide --src <rel> to name the repo-relative path")]
36    OutsideHome {
37        /// The destination path that is outside `$HOME`.
38        dst: PathBuf,
39    },
40
41    /// A file already exists at `<repo>/<src>` and `--force` was not set.
42    #[error(
43        "repo already has {src:?}; use --force to overwrite, or --src to pick a different name"
44    )]
45    RepoCollision {
46        /// The repo-relative path that already exists.
47        src: PathBuf,
48    },
49
50    /// I/O failure.
51    #[error("io: {0}")]
52    Io(#[from] io::Error),
53
54    /// Manifest read/write failure (boxed to keep enum ≤ 128 bytes).
55    #[error(transparent)]
56    Manifest(#[from] Box<ManifestError>),
57
58    /// Path variable resolution failure (boxed to keep enum ≤ 128 bytes).
59    #[error(transparent)]
60    Resolve(#[from] Box<ResolveError>),
61}
62
63// ─── adopt ──────────────────────────────────────────────────────────────────
64
65/// Options for [`adopt`].
66pub struct AdoptOpts {
67    /// Absolute path to the file currently on disk (the "deployed" location).
68    pub dst: PathBuf,
69    /// Override the auto-derived repo-relative source path.
70    pub src_override: Option<PathBuf>,
71    /// Absolute path to the dotfiles repo root.
72    pub repo_path: PathBuf,
73    /// Absolute path to the manifest JSON file.
74    pub manifest_path: PathBuf,
75    /// Overwrite an existing file in the repo without erroring.
76    pub force: bool,
77    /// Print what would happen without touching disk or writing the manifest.
78    pub dry_run: bool,
79    /// Resolver used to discover `$HOME` for auto-deriving `src`.
80    pub resolver: Resolver,
81}
82
83/// Result of a successful [`adopt`] call.
84#[derive(Debug)]
85pub struct AdoptReport {
86    /// Repo-relative source path that was written.
87    pub src: PathBuf,
88    /// Absolute destination path that was adopted.
89    pub dst: PathBuf,
90    /// Copy-pasteable `[[link]]` block the user should add to `.krypt.toml`.
91    pub link_suggestion: String,
92}
93
94/// Import `opts.dst` into the repo and record it in the manifest.
95///
96/// The file at `dst` is copied (not moved) to `<repo_path>/<src>`.  A manifest
97/// entry is created with `hash_src = hash_dst = sha256(file)`.  The original
98/// file at `dst` is left in place.
99///
100/// Returns an [`AdoptReport`] with a ready-to-paste `[[link]]` block.
101pub fn adopt(opts: &AdoptOpts) -> Result<AdoptReport, AdoptError> {
102    if !opts.dst.exists() {
103        return Err(AdoptError::DstMissing(opts.dst.clone()));
104    }
105
106    let src_rel: PathBuf = match &opts.src_override {
107        Some(r) => r.clone(),
108        None => derive_src(&opts.dst, &opts.resolver)?,
109    };
110
111    let repo_target = opts.repo_path.join(&src_rel);
112
113    if !opts.force && repo_target.exists() {
114        return Err(AdoptError::RepoCollision {
115            src: src_rel.clone(),
116        });
117    }
118
119    let link_suggestion = build_link_suggestion(&src_rel, &opts.dst);
120
121    if opts.dry_run {
122        return Ok(AdoptReport {
123            src: src_rel,
124            dst: opts.dst.clone(),
125            link_suggestion,
126        });
127    }
128
129    copy_atomic_simple(&opts.dst, &repo_target)?;
130
131    let hash = hash_file(&repo_target).map_err(AdoptError::Io)?;
132    let now = now_unix();
133
134    let mut manifest = Manifest::load(&opts.manifest_path)
135        .map_err(|e| AdoptError::Manifest(Box::new(e)))?
136        .unwrap_or_else(|| Manifest::new(opts.repo_path.clone()));
137
138    manifest.record(ManifestEntry {
139        src: src_rel.clone(),
140        dst: opts.dst.clone(),
141        kind: EntryKind::Link,
142        hash_src: hash.clone(),
143        hash_dst: hash,
144        deployed_at: now,
145    });
146    manifest
147        .save(&opts.manifest_path)
148        .map_err(|e| AdoptError::Manifest(Box::new(e)))?;
149
150    Ok(AdoptReport {
151        src: src_rel,
152        dst: opts.dst.clone(),
153        link_suggestion,
154    })
155}
156
157// ─── adopt_edits ─────────────────────────────────────────────────────────────
158
159/// Options for [`adopt_edits`].
160pub struct AdoptEditsOpts {
161    /// Absolute path to the manifest JSON file.
162    pub manifest_path: PathBuf,
163    /// Absolute path to the dotfiles repo root (used to resolve `<repo>/<src>`).
164    pub repo_path: PathBuf,
165    /// Print what would happen without touching disk or saving the manifest.
166    pub dry_run: bool,
167}
168
169/// Result of a successful [`adopt_edits`] call.
170#[derive(Debug)]
171pub struct AdoptEditsReport {
172    /// Number of drifted entries whose edits were adopted.
173    pub adopted: usize,
174    /// Number of entries that were already clean.
175    pub clean: usize,
176    /// Number of entries whose `dst` was missing (skipped with a warning).
177    pub missing: usize,
178}
179
180/// For every drifted manifest entry, copy `dst` back into `<repo>/<src>` and
181/// refresh the manifest hashes.
182///
183/// Clean entries are silently skipped.  Missing-dst entries are skipped with a
184/// warning to stderr.  After processing, the manifest is saved atomically
185/// (unless `dry_run` is set).
186pub fn adopt_edits(opts: &AdoptEditsOpts) -> Result<AdoptEditsReport, AdoptError> {
187    let Some(mut manifest) =
188        Manifest::load(&opts.manifest_path).map_err(|e| AdoptError::Manifest(Box::new(e)))?
189    else {
190        return Ok(AdoptEditsReport {
191            adopted: 0,
192            clean: 0,
193            missing: 0,
194        });
195    };
196
197    let drift = detect_drift(&manifest);
198    let mut report = AdoptEditsReport {
199        adopted: 0,
200        clean: 0,
201        missing: 0,
202    };
203
204    let mut updated: Vec<ManifestEntry> = Vec::new();
205
206    for record in drift {
207        match record.status {
208            DriftStatus::Clean => {
209                report.clean += 1;
210            }
211            DriftStatus::DstMissing => {
212                report.missing += 1;
213                eprintln!(
214                    "warning: dst missing: {:?}, leaving manifest entry alone",
215                    record.dst
216                );
217            }
218            DriftStatus::Drifted => {
219                let repo_src = opts.repo_path.join(&record.src);
220                if !opts.dry_run {
221                    copy_atomic_simple(&record.dst, &repo_src)?;
222                }
223                let hash = if opts.dry_run {
224                    record
225                        .current_hash
226                        .unwrap_or_else(|| record.recorded_hash.clone())
227                } else {
228                    hash_file(&repo_src).map_err(AdoptError::Io)?
229                };
230                updated.push(ManifestEntry {
231                    src: record.src,
232                    dst: record.dst,
233                    kind: record.kind,
234                    hash_src: hash.clone(),
235                    hash_dst: hash,
236                    deployed_at: now_unix(),
237                });
238                report.adopted += 1;
239            }
240        }
241    }
242
243    if !opts.dry_run {
244        for entry in updated {
245            manifest.record(entry);
246        }
247        manifest
248            .save(&opts.manifest_path)
249            .map_err(|e| AdoptError::Manifest(Box::new(e)))?;
250    }
251
252    Ok(report)
253}
254
255// ─── Internals ───────────────────────────────────────────────────────────────
256
257/// Derive the repo-relative `src` path by stripping the `$HOME` prefix from
258/// `dst`.  Returns [`AdoptError::OutsideHome`] when `dst` is not under `HOME`.
259fn derive_src(dst: &Path, resolver: &Resolver) -> Result<PathBuf, AdoptError> {
260    let home_str = resolver
261        .resolve_var("HOME")
262        .map_err(|e| AdoptError::Resolve(Box::new(e)))?;
263    let home = PathBuf::from(&home_str);
264    dst.strip_prefix(&home)
265        .map(|rel| rel.to_path_buf())
266        .map_err(|_| AdoptError::OutsideHome {
267            dst: dst.to_path_buf(),
268        })
269}
270
271/// Build the `[[link]]` block the user should paste into `.krypt.toml`.
272///
273/// The `dst` string always uses forward slashes and `${HOME}/…` — that is the
274/// krypt convention regardless of host OS.  The resolver translates it at
275/// deploy time.
276fn build_link_suggestion(src_rel: &Path, dst: &Path) -> String {
277    // src display: forward slashes, no leading ./
278    let src_display = src_rel.to_string_lossy().replace('\\', "/");
279
280    // dst: express as ${HOME}/... with forward slashes.
281    // We don't have the home prefix here, so we just display the absolute dst
282    // with forward slashes.  For files *under* home the caller has already
283    // stripped the prefix into src_rel; we reconstruct the canonical form.
284    let dst_display = format!("${{HOME}}/{src_display}");
285
286    // Use the actual dst path for non-home cases (--src override outside home).
287    // If dst ends with the same relative portion as src_rel, use ${HOME}/...
288    // Otherwise, use the raw absolute path with forward slashes.
289    let dst_str = dst.to_string_lossy().replace('\\', "/");
290    let src_str_fwd = src_rel.to_string_lossy().replace('\\', "/");
291    let suggestion_dst = if dst_str.ends_with(&src_str_fwd) && dst_str.len() > src_str_fwd.len() {
292        dst_display
293    } else {
294        dst_str
295    };
296
297    format!(
298        "Add this to .krypt.toml:\n\n[[link]]\nsrc = \"{src_str_fwd}\"\ndst = \"{suggestion_dst}\""
299    )
300}
301
302/// Atomically copy `src` → `dst`, creating parent directories as needed.
303fn copy_atomic_simple(src: &Path, dst: &Path) -> Result<(), io::Error> {
304    if let Some(parent) = dst.parent() {
305        fs::create_dir_all(parent)?;
306    }
307    let mut tmp_name = dst.file_name().unwrap_or_default().to_os_string();
308    tmp_name.push(format!(".krypt-tmp-{}", std::process::id()));
309    let tmp = dst.with_file_name(tmp_name);
310    let _ = fs::remove_file(&tmp);
311    fs::copy(src, &tmp)?;
312    fs::rename(&tmp, dst)?;
313    Ok(())
314}
315
316fn now_unix() -> u64 {
317    SystemTime::now()
318        .duration_since(UNIX_EPOCH)
319        .map(|d| d.as_secs())
320        .unwrap_or(0)
321}
322
323// ─── Tests ───────────────────────────────────────────────────────────────────
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::paths::Platform;
329    use std::collections::HashMap;
330    use tempfile::tempdir;
331
332    fn linux_resolver(home: &Path) -> Resolver {
333        let mut env = HashMap::new();
334        env.insert("HOME".into(), home.to_string_lossy().into_owned());
335        Resolver::for_platform(Platform::Linux).with_env(env)
336    }
337
338    // ── adopt ───────────────────────────────────────────────────────────────
339
340    #[test]
341    fn adopt_file_under_home() {
342        let home = tempdir().unwrap();
343        let repo = tempdir().unwrap();
344        let state = tempdir().unwrap();
345
346        let dst = home.path().join(".foo");
347        fs::write(&dst, b"cfg content").unwrap();
348
349        let manifest_path = state.path().join("manifest.json");
350
351        let report = adopt(&AdoptOpts {
352            dst: dst.clone(),
353            src_override: None,
354            repo_path: repo.path().to_path_buf(),
355            manifest_path: manifest_path.clone(),
356            force: false,
357            dry_run: false,
358            resolver: linux_resolver(home.path()),
359        })
360        .unwrap();
361
362        // Derived src should be ".foo"
363        assert_eq!(report.src, PathBuf::from(".foo"));
364        assert_eq!(report.dst, dst);
365
366        // Repo now has the file.
367        let repo_file = repo.path().join(".foo");
368        assert!(repo_file.exists());
369        assert_eq!(fs::read(&repo_file).unwrap(), b"cfg content");
370
371        // Original at dst still exists.
372        assert!(dst.exists());
373
374        // Manifest has one entry.
375        let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
376        assert_eq!(manifest.entries.len(), 1);
377        let entry = &manifest.entries[&dst];
378        assert_eq!(entry.src, PathBuf::from(".foo"));
379        assert_eq!(entry.dst, dst);
380        assert_eq!(entry.hash_src, entry.hash_dst);
381        assert!(entry.hash_src.starts_with("sha256:"));
382
383        // Suggestion contains src and dst.
384        assert!(report.link_suggestion.contains("src = \".foo\""));
385        assert!(report.link_suggestion.contains("dst = \"${HOME}/.foo\""));
386    }
387
388    #[test]
389    fn adopt_with_src_override_outside_home() {
390        let home = tempdir().unwrap();
391        let repo = tempdir().unwrap();
392        let state = tempdir().unwrap();
393        let outside = tempdir().unwrap();
394
395        let dst = outside.path().join("some.conf");
396        fs::write(&dst, b"data").unwrap();
397
398        let report = adopt(&AdoptOpts {
399            dst: dst.clone(),
400            src_override: Some(PathBuf::from("some.conf")),
401            repo_path: repo.path().to_path_buf(),
402            manifest_path: state.path().join("manifest.json"),
403            force: false,
404            dry_run: false,
405            resolver: linux_resolver(home.path()),
406        })
407        .unwrap();
408
409        assert_eq!(report.src, PathBuf::from("some.conf"));
410        assert!(repo.path().join("some.conf").exists());
411    }
412
413    #[test]
414    fn adopt_outside_home_no_override_errors() {
415        let home = tempdir().unwrap();
416        let repo = tempdir().unwrap();
417        let state = tempdir().unwrap();
418        let outside = tempdir().unwrap();
419
420        let dst = outside.path().join("file.txt");
421        fs::write(&dst, b"x").unwrap();
422
423        let err = adopt(&AdoptOpts {
424            dst: dst.clone(),
425            src_override: None,
426            repo_path: repo.path().to_path_buf(),
427            manifest_path: state.path().join("manifest.json"),
428            force: false,
429            dry_run: false,
430            resolver: linux_resolver(home.path()),
431        })
432        .unwrap_err();
433
434        assert!(matches!(err, AdoptError::OutsideHome { .. }));
435    }
436
437    #[test]
438    fn adopt_repo_collision_without_force_errors() {
439        let home = tempdir().unwrap();
440        let repo = tempdir().unwrap();
441        let state = tempdir().unwrap();
442
443        let dst = home.path().join(".bar");
444        fs::write(&dst, b"new").unwrap();
445        // Pre-existing file in repo.
446        fs::write(repo.path().join(".bar"), b"old").unwrap();
447
448        let err = adopt(&AdoptOpts {
449            dst: dst.clone(),
450            src_override: None,
451            repo_path: repo.path().to_path_buf(),
452            manifest_path: state.path().join("manifest.json"),
453            force: false,
454            dry_run: false,
455            resolver: linux_resolver(home.path()),
456        })
457        .unwrap_err();
458
459        assert!(matches!(err, AdoptError::RepoCollision { .. }));
460    }
461
462    #[test]
463    fn adopt_repo_collision_with_force_succeeds() {
464        let home = tempdir().unwrap();
465        let repo = tempdir().unwrap();
466        let state = tempdir().unwrap();
467
468        let dst = home.path().join(".bar");
469        fs::write(&dst, b"new content").unwrap();
470        fs::write(repo.path().join(".bar"), b"old content").unwrap();
471
472        adopt(&AdoptOpts {
473            dst: dst.clone(),
474            src_override: None,
475            repo_path: repo.path().to_path_buf(),
476            manifest_path: state.path().join("manifest.json"),
477            force: true,
478            dry_run: false,
479            resolver: linux_resolver(home.path()),
480        })
481        .unwrap();
482
483        assert_eq!(fs::read(repo.path().join(".bar")).unwrap(), b"new content");
484    }
485
486    #[test]
487    fn adopt_missing_dst_errors() {
488        let home = tempdir().unwrap();
489        let repo = tempdir().unwrap();
490        let state = tempdir().unwrap();
491
492        let dst = home.path().join("nonexistent.cfg");
493
494        let err = adopt(&AdoptOpts {
495            dst: dst.clone(),
496            src_override: None,
497            repo_path: repo.path().to_path_buf(),
498            manifest_path: state.path().join("manifest.json"),
499            force: false,
500            dry_run: false,
501            resolver: linux_resolver(home.path()),
502        })
503        .unwrap_err();
504
505        assert!(matches!(err, AdoptError::DstMissing(_)));
506    }
507
508    #[test]
509    fn adopt_dry_run_no_disk_writes() {
510        let home = tempdir().unwrap();
511        let repo = tempdir().unwrap();
512        let state = tempdir().unwrap();
513
514        let dst = home.path().join(".cfg");
515        fs::write(&dst, b"data").unwrap();
516        let manifest_path = state.path().join("manifest.json");
517
518        let report = adopt(&AdoptOpts {
519            dst: dst.clone(),
520            src_override: None,
521            repo_path: repo.path().to_path_buf(),
522            manifest_path: manifest_path.clone(),
523            force: false,
524            dry_run: true,
525            resolver: linux_resolver(home.path()),
526        })
527        .unwrap();
528
529        // Suggestion is still returned.
530        assert!(report.link_suggestion.contains("src = \".cfg\""));
531
532        // Nothing written to repo or manifest.
533        assert!(!repo.path().join(".cfg").exists());
534        assert!(!manifest_path.exists());
535    }
536
537    // ── adopt_edits ──────────────────────────────────────────────────────────
538
539    #[test]
540    fn adopt_edits_syncs_drifted_entries() {
541        let home = tempdir().unwrap();
542        let repo = tempdir().unwrap();
543        let state = tempdir().unwrap();
544
545        let dst = home.path().join(".zshrc");
546        fs::write(&dst, b"original").unwrap();
547        let manifest_path = state.path().join("manifest.json");
548
549        // First, adopt the file to get it into the manifest.
550        adopt(&AdoptOpts {
551            dst: dst.clone(),
552            src_override: None,
553            repo_path: repo.path().to_path_buf(),
554            manifest_path: manifest_path.clone(),
555            force: false,
556            dry_run: false,
557            resolver: linux_resolver(home.path()),
558        })
559        .unwrap();
560
561        // User edits dst in place.
562        fs::write(&dst, b"edited content").unwrap();
563
564        let report = adopt_edits(&AdoptEditsOpts {
565            manifest_path: manifest_path.clone(),
566            repo_path: repo.path().to_path_buf(),
567            dry_run: false,
568        })
569        .unwrap();
570
571        assert_eq!(report.adopted, 1);
572        assert_eq!(report.clean, 0);
573        assert_eq!(report.missing, 0);
574
575        // Repo file now has the edited content.
576        assert_eq!(
577            fs::read(repo.path().join(".zshrc")).unwrap(),
578            b"edited content"
579        );
580
581        // Manifest hashes updated.
582        let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
583        let entry = &manifest.entries[&dst];
584        assert_eq!(entry.hash_src, entry.hash_dst);
585        let expected_hash = hash_file(&dst).unwrap();
586        assert_eq!(entry.hash_src, expected_hash);
587    }
588
589    #[test]
590    fn adopt_edits_no_drift_returns_zero_adopted() {
591        let home = tempdir().unwrap();
592        let repo = tempdir().unwrap();
593        let state = tempdir().unwrap();
594
595        let dst = home.path().join(".tmux.conf");
596        fs::write(&dst, b"clean").unwrap();
597        let manifest_path = state.path().join("manifest.json");
598
599        adopt(&AdoptOpts {
600            dst: dst.clone(),
601            src_override: None,
602            repo_path: repo.path().to_path_buf(),
603            manifest_path: manifest_path.clone(),
604            force: false,
605            dry_run: false,
606            resolver: linux_resolver(home.path()),
607        })
608        .unwrap();
609
610        let report = adopt_edits(&AdoptEditsOpts {
611            manifest_path: manifest_path.clone(),
612            repo_path: repo.path().to_path_buf(),
613            dry_run: false,
614        })
615        .unwrap();
616
617        assert_eq!(report.adopted, 0);
618        assert_eq!(report.clean, 1);
619        assert_eq!(report.missing, 0);
620
621        // Repo file unchanged.
622        assert_eq!(fs::read(repo.path().join(".tmux.conf")).unwrap(), b"clean");
623    }
624
625    #[test]
626    fn adopt_edits_dry_run_no_changes() {
627        let home = tempdir().unwrap();
628        let repo = tempdir().unwrap();
629        let state = tempdir().unwrap();
630
631        let dst = home.path().join(".vimrc");
632        fs::write(&dst, b"original").unwrap();
633        let manifest_path = state.path().join("manifest.json");
634
635        adopt(&AdoptOpts {
636            dst: dst.clone(),
637            src_override: None,
638            repo_path: repo.path().to_path_buf(),
639            manifest_path: manifest_path.clone(),
640            force: false,
641            dry_run: false,
642            resolver: linux_resolver(home.path()),
643        })
644        .unwrap();
645
646        // Drift the dst.
647        fs::write(&dst, b"drifted").unwrap();
648
649        let report = adopt_edits(&AdoptEditsOpts {
650            manifest_path: manifest_path.clone(),
651            repo_path: repo.path().to_path_buf(),
652            dry_run: true,
653        })
654        .unwrap();
655
656        assert_eq!(report.adopted, 1);
657
658        // Repo file still has original content.
659        assert_eq!(fs::read(repo.path().join(".vimrc")).unwrap(), b"original");
660
661        // Manifest hashes not updated.
662        let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
663        let entry = &manifest.entries[&dst];
664        assert_eq!(
665            entry.hash_src,
666            hash_file(&repo.path().join(".vimrc")).unwrap()
667        );
668    }
669
670    #[test]
671    fn adopt_edits_missing_dst_counted_and_warned() {
672        let home = tempdir().unwrap();
673        let repo = tempdir().unwrap();
674        let state = tempdir().unwrap();
675
676        let dst = home.path().join(".missing");
677        fs::write(&dst, b"data").unwrap();
678        let manifest_path = state.path().join("manifest.json");
679
680        adopt(&AdoptOpts {
681            dst: dst.clone(),
682            src_override: None,
683            repo_path: repo.path().to_path_buf(),
684            manifest_path: manifest_path.clone(),
685            force: false,
686            dry_run: false,
687            resolver: linux_resolver(home.path()),
688        })
689        .unwrap();
690
691        // Remove the dst to simulate DstMissing.
692        fs::remove_file(&dst).unwrap();
693
694        let report = adopt_edits(&AdoptEditsOpts {
695            manifest_path: manifest_path.clone(),
696            repo_path: repo.path().to_path_buf(),
697            dry_run: false,
698        })
699        .unwrap();
700
701        assert_eq!(report.missing, 1);
702        assert_eq!(report.adopted, 0);
703
704        // Manifest entry preserved.
705        let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
706        assert_eq!(manifest.entries.len(), 1);
707    }
708}