1use 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#[derive(Debug, Error)]
43pub enum DeployError {
44 #[error(transparent)]
46 Config(#[from] ConfigError),
47 #[error("include expansion: {0}")]
49 Include(String),
50 #[error(transparent)]
52 Plan(#[from] PlanError),
53 #[error(transparent)]
55 Exec(#[from] ExecError),
56 #[error(transparent)]
58 Manifest(#[from] ManifestError),
59 #[error("io: {0}")]
61 Io(#[from] std::io::Error),
62}
63
64#[derive(Debug, Clone)]
68pub struct DeployOpts {
69 pub config_path: PathBuf,
73 pub manifest_path: PathBuf,
76 pub platform: Option<Platform>,
78 pub dry_run: bool,
81 pub force: bool,
86}
87
88#[derive(Debug, Default, Clone)]
92pub struct LinkReport {
93 pub written: usize,
95 pub conflicts_skipped: usize,
97 pub idempotent_rewrites: usize,
101 pub platform_skipped: usize,
105}
106
107#[derive(Debug, Default, Clone)]
109pub struct UnlinkReport {
110 pub removed: usize,
112 pub drift_skipped: usize,
115 pub already_missing: usize,
117}
118
119pub 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 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
181pub fn unlink(opts: &DeployOpts) -> Result<UnlinkReport, DeployError> {
184 let Some(mut manifest) = Manifest::load(&opts.manifest_path)? else {
185 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
230pub fn relink(opts: &DeployOpts) -> Result<(UnlinkReport, LinkReport), DeployError> {
233 let u = unlink(opts)?;
234 let l = link(opts)?;
235 Ok((u, l))
236}
237
238fn 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#[allow(dead_code)]
318fn _ensure_report_in_scope(_: Report) {}
319
320#[cfg(test)]
323mod tests {
324 use super::*;
325 use tempfile::tempdir;
326
327 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 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 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 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 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 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 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(&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 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 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}