1use 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#[derive(Debug, Error)]
29pub enum AdoptError {
30 #[error("dst does not exist: {0:?}")]
32 DstMissing(PathBuf),
33
34 #[error("dst {dst:?} is outside $HOME; provide --src <rel> to name the repo-relative path")]
36 OutsideHome {
37 dst: PathBuf,
39 },
40
41 #[error(
43 "repo already has {src:?}; use --force to overwrite, or --src to pick a different name"
44 )]
45 RepoCollision {
46 src: PathBuf,
48 },
49
50 #[error("io: {0}")]
52 Io(#[from] io::Error),
53
54 #[error(transparent)]
56 Manifest(#[from] Box<ManifestError>),
57
58 #[error(transparent)]
60 Resolve(#[from] Box<ResolveError>),
61}
62
63pub struct AdoptOpts {
67 pub dst: PathBuf,
69 pub src_override: Option<PathBuf>,
71 pub repo_path: PathBuf,
73 pub manifest_path: PathBuf,
75 pub force: bool,
77 pub dry_run: bool,
79 pub resolver: Resolver,
81}
82
83#[derive(Debug)]
85pub struct AdoptReport {
86 pub src: PathBuf,
88 pub dst: PathBuf,
90 pub link_suggestion: String,
92}
93
94pub 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
157pub struct AdoptEditsOpts {
161 pub manifest_path: PathBuf,
163 pub repo_path: PathBuf,
165 pub dry_run: bool,
167}
168
169#[derive(Debug)]
171pub struct AdoptEditsReport {
172 pub adopted: usize,
174 pub clean: usize,
176 pub missing: usize,
178}
179
180pub 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
255fn 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
271fn build_link_suggestion(src_rel: &Path, dst: &Path) -> String {
277 let src_display = src_rel.to_string_lossy().replace('\\', "/");
279
280 let dst_display = format!("${{HOME}}/{src_display}");
285
286 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
302fn 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#[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 #[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 assert_eq!(report.src, PathBuf::from(".foo"));
364 assert_eq!(report.dst, dst);
365
366 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 assert!(dst.exists());
373
374 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 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 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 assert!(report.link_suggestion.contains("src = \".cfg\""));
531
532 assert!(!repo.path().join(".cfg").exists());
534 assert!(!manifest_path.exists());
535 }
536
537 #[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 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 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 assert_eq!(
577 fs::read(repo.path().join(".zshrc")).unwrap(),
578 b"edited content"
579 );
580
581 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 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 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 assert_eq!(fs::read(repo.path().join(".vimrc")).unwrap(), b"original");
660
661 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 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 let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
706 assert_eq!(manifest.entries.len(), 1);
707 }
708}