use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use crate::copy::EntryKind;
use crate::manifest::{
DriftStatus, Manifest, ManifestEntry, ManifestError, detect_drift, hash_file,
};
use crate::paths::{ResolveError, Resolver};
#[derive(Debug, Error)]
pub enum AdoptError {
#[error("dst does not exist: {0:?}")]
DstMissing(PathBuf),
#[error("dst {dst:?} is outside $HOME; provide --src <rel> to name the repo-relative path")]
OutsideHome {
dst: PathBuf,
},
#[error(
"repo already has {src:?}; use --force to overwrite, or --src to pick a different name"
)]
RepoCollision {
src: PathBuf,
},
#[error("io: {0}")]
Io(#[from] io::Error),
#[error(transparent)]
Manifest(#[from] Box<ManifestError>),
#[error(transparent)]
Resolve(#[from] Box<ResolveError>),
}
pub struct AdoptOpts {
pub dst: PathBuf,
pub src_override: Option<PathBuf>,
pub repo_path: PathBuf,
pub manifest_path: PathBuf,
pub force: bool,
pub dry_run: bool,
pub resolver: Resolver,
}
#[derive(Debug)]
pub struct AdoptReport {
pub src: PathBuf,
pub dst: PathBuf,
pub link_suggestion: String,
}
pub fn adopt(opts: &AdoptOpts) -> Result<AdoptReport, AdoptError> {
if !opts.dst.exists() {
return Err(AdoptError::DstMissing(opts.dst.clone()));
}
let src_rel: PathBuf = match &opts.src_override {
Some(r) => r.clone(),
None => derive_src(&opts.dst, &opts.resolver)?,
};
let repo_target = opts.repo_path.join(&src_rel);
if !opts.force && repo_target.exists() {
return Err(AdoptError::RepoCollision {
src: src_rel.clone(),
});
}
let link_suggestion = build_link_suggestion(&src_rel, &opts.dst);
if opts.dry_run {
return Ok(AdoptReport {
src: src_rel,
dst: opts.dst.clone(),
link_suggestion,
});
}
copy_atomic_simple(&opts.dst, &repo_target)?;
let hash = hash_file(&repo_target).map_err(AdoptError::Io)?;
let now = now_unix();
let mut manifest = Manifest::load(&opts.manifest_path)
.map_err(|e| AdoptError::Manifest(Box::new(e)))?
.unwrap_or_else(|| Manifest::new(opts.repo_path.clone()));
manifest.record(ManifestEntry {
src: src_rel.clone(),
dst: opts.dst.clone(),
kind: EntryKind::Link,
hash_src: hash.clone(),
hash_dst: hash,
deployed_at: now,
});
manifest
.save(&opts.manifest_path)
.map_err(|e| AdoptError::Manifest(Box::new(e)))?;
Ok(AdoptReport {
src: src_rel,
dst: opts.dst.clone(),
link_suggestion,
})
}
pub struct AdoptEditsOpts {
pub manifest_path: PathBuf,
pub repo_path: PathBuf,
pub dry_run: bool,
}
#[derive(Debug)]
pub struct AdoptEditsReport {
pub adopted: usize,
pub clean: usize,
pub missing: usize,
}
pub fn adopt_edits(opts: &AdoptEditsOpts) -> Result<AdoptEditsReport, AdoptError> {
let Some(mut manifest) =
Manifest::load(&opts.manifest_path).map_err(|e| AdoptError::Manifest(Box::new(e)))?
else {
return Ok(AdoptEditsReport {
adopted: 0,
clean: 0,
missing: 0,
});
};
let drift = detect_drift(&manifest);
let mut report = AdoptEditsReport {
adopted: 0,
clean: 0,
missing: 0,
};
let mut updated: Vec<ManifestEntry> = Vec::new();
for record in drift {
match record.status {
DriftStatus::Clean => {
report.clean += 1;
}
DriftStatus::DstMissing => {
report.missing += 1;
eprintln!(
"warning: dst missing: {:?}, leaving manifest entry alone",
record.dst
);
}
DriftStatus::Drifted => {
let repo_src = opts.repo_path.join(&record.src);
if !opts.dry_run {
copy_atomic_simple(&record.dst, &repo_src)?;
}
let hash = if opts.dry_run {
record
.current_hash
.unwrap_or_else(|| record.recorded_hash.clone())
} else {
hash_file(&repo_src).map_err(AdoptError::Io)?
};
updated.push(ManifestEntry {
src: record.src,
dst: record.dst,
kind: record.kind,
hash_src: hash.clone(),
hash_dst: hash,
deployed_at: now_unix(),
});
report.adopted += 1;
}
}
}
if !opts.dry_run {
for entry in updated {
manifest.record(entry);
}
manifest
.save(&opts.manifest_path)
.map_err(|e| AdoptError::Manifest(Box::new(e)))?;
}
Ok(report)
}
fn derive_src(dst: &Path, resolver: &Resolver) -> Result<PathBuf, AdoptError> {
let home_str = resolver
.resolve_var("HOME")
.map_err(|e| AdoptError::Resolve(Box::new(e)))?;
let home = PathBuf::from(&home_str);
dst.strip_prefix(&home)
.map(|rel| rel.to_path_buf())
.map_err(|_| AdoptError::OutsideHome {
dst: dst.to_path_buf(),
})
}
fn build_link_suggestion(src_rel: &Path, dst: &Path) -> String {
let src_display = src_rel.to_string_lossy().replace('\\', "/");
let dst_display = format!("${{HOME}}/{src_display}");
let dst_str = dst.to_string_lossy().replace('\\', "/");
let src_str_fwd = src_rel.to_string_lossy().replace('\\', "/");
let suggestion_dst = if dst_str.ends_with(&src_str_fwd) && dst_str.len() > src_str_fwd.len() {
dst_display
} else {
dst_str
};
format!(
"Add this to .krypt.toml:\n\n[[link]]\nsrc = \"{src_str_fwd}\"\ndst = \"{suggestion_dst}\""
)
}
fn copy_atomic_simple(src: &Path, dst: &Path) -> Result<(), io::Error> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
let mut tmp_name = dst.file_name().unwrap_or_default().to_os_string();
tmp_name.push(format!(".krypt-tmp-{}", std::process::id()));
let tmp = dst.with_file_name(tmp_name);
let _ = fs::remove_file(&tmp);
fs::copy(src, &tmp)?;
fs::rename(&tmp, dst)?;
Ok(())
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::Platform;
use std::collections::HashMap;
use tempfile::tempdir;
fn linux_resolver(home: &Path) -> Resolver {
let mut env = HashMap::new();
env.insert("HOME".into(), home.to_string_lossy().into_owned());
Resolver::for_platform(Platform::Linux).with_env(env)
}
#[test]
fn adopt_file_under_home() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join(".foo");
fs::write(&dst, b"cfg content").unwrap();
let manifest_path = state.path().join("manifest.json");
let report = adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: manifest_path.clone(),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap();
assert_eq!(report.src, PathBuf::from(".foo"));
assert_eq!(report.dst, dst);
let repo_file = repo.path().join(".foo");
assert!(repo_file.exists());
assert_eq!(fs::read(&repo_file).unwrap(), b"cfg content");
assert!(dst.exists());
let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
assert_eq!(manifest.entries.len(), 1);
let entry = &manifest.entries[&dst];
assert_eq!(entry.src, PathBuf::from(".foo"));
assert_eq!(entry.dst, dst);
assert_eq!(entry.hash_src, entry.hash_dst);
assert!(entry.hash_src.starts_with("sha256:"));
assert!(report.link_suggestion.contains("src = \".foo\""));
assert!(report.link_suggestion.contains("dst = \"${HOME}/.foo\""));
}
#[test]
fn adopt_with_src_override_outside_home() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let outside = tempdir().unwrap();
let dst = outside.path().join("some.conf");
fs::write(&dst, b"data").unwrap();
let report = adopt(&AdoptOpts {
dst: dst.clone(),
src_override: Some(PathBuf::from("some.conf")),
repo_path: repo.path().to_path_buf(),
manifest_path: state.path().join("manifest.json"),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap();
assert_eq!(report.src, PathBuf::from("some.conf"));
assert!(repo.path().join("some.conf").exists());
}
#[test]
fn adopt_outside_home_no_override_errors() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let outside = tempdir().unwrap();
let dst = outside.path().join("file.txt");
fs::write(&dst, b"x").unwrap();
let err = adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: state.path().join("manifest.json"),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap_err();
assert!(matches!(err, AdoptError::OutsideHome { .. }));
}
#[test]
fn adopt_repo_collision_without_force_errors() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join(".bar");
fs::write(&dst, b"new").unwrap();
fs::write(repo.path().join(".bar"), b"old").unwrap();
let err = adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: state.path().join("manifest.json"),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap_err();
assert!(matches!(err, AdoptError::RepoCollision { .. }));
}
#[test]
fn adopt_repo_collision_with_force_succeeds() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join(".bar");
fs::write(&dst, b"new content").unwrap();
fs::write(repo.path().join(".bar"), b"old content").unwrap();
adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: state.path().join("manifest.json"),
force: true,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap();
assert_eq!(fs::read(repo.path().join(".bar")).unwrap(), b"new content");
}
#[test]
fn adopt_missing_dst_errors() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join("nonexistent.cfg");
let err = adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: state.path().join("manifest.json"),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap_err();
assert!(matches!(err, AdoptError::DstMissing(_)));
}
#[test]
fn adopt_dry_run_no_disk_writes() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join(".cfg");
fs::write(&dst, b"data").unwrap();
let manifest_path = state.path().join("manifest.json");
let report = adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: manifest_path.clone(),
force: false,
dry_run: true,
resolver: linux_resolver(home.path()),
})
.unwrap();
assert!(report.link_suggestion.contains("src = \".cfg\""));
assert!(!repo.path().join(".cfg").exists());
assert!(!manifest_path.exists());
}
#[test]
fn adopt_edits_syncs_drifted_entries() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join(".zshrc");
fs::write(&dst, b"original").unwrap();
let manifest_path = state.path().join("manifest.json");
adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: manifest_path.clone(),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap();
fs::write(&dst, b"edited content").unwrap();
let report = adopt_edits(&AdoptEditsOpts {
manifest_path: manifest_path.clone(),
repo_path: repo.path().to_path_buf(),
dry_run: false,
})
.unwrap();
assert_eq!(report.adopted, 1);
assert_eq!(report.clean, 0);
assert_eq!(report.missing, 0);
assert_eq!(
fs::read(repo.path().join(".zshrc")).unwrap(),
b"edited content"
);
let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
let entry = &manifest.entries[&dst];
assert_eq!(entry.hash_src, entry.hash_dst);
let expected_hash = hash_file(&dst).unwrap();
assert_eq!(entry.hash_src, expected_hash);
}
#[test]
fn adopt_edits_no_drift_returns_zero_adopted() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join(".tmux.conf");
fs::write(&dst, b"clean").unwrap();
let manifest_path = state.path().join("manifest.json");
adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: manifest_path.clone(),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap();
let report = adopt_edits(&AdoptEditsOpts {
manifest_path: manifest_path.clone(),
repo_path: repo.path().to_path_buf(),
dry_run: false,
})
.unwrap();
assert_eq!(report.adopted, 0);
assert_eq!(report.clean, 1);
assert_eq!(report.missing, 0);
assert_eq!(fs::read(repo.path().join(".tmux.conf")).unwrap(), b"clean");
}
#[test]
fn adopt_edits_dry_run_no_changes() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join(".vimrc");
fs::write(&dst, b"original").unwrap();
let manifest_path = state.path().join("manifest.json");
adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: manifest_path.clone(),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap();
fs::write(&dst, b"drifted").unwrap();
let report = adopt_edits(&AdoptEditsOpts {
manifest_path: manifest_path.clone(),
repo_path: repo.path().to_path_buf(),
dry_run: true,
})
.unwrap();
assert_eq!(report.adopted, 1);
assert_eq!(fs::read(repo.path().join(".vimrc")).unwrap(), b"original");
let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
let entry = &manifest.entries[&dst];
assert_eq!(
entry.hash_src,
hash_file(&repo.path().join(".vimrc")).unwrap()
);
}
#[test]
fn adopt_edits_missing_dst_counted_and_warned() {
let home = tempdir().unwrap();
let repo = tempdir().unwrap();
let state = tempdir().unwrap();
let dst = home.path().join(".missing");
fs::write(&dst, b"data").unwrap();
let manifest_path = state.path().join("manifest.json");
adopt(&AdoptOpts {
dst: dst.clone(),
src_override: None,
repo_path: repo.path().to_path_buf(),
manifest_path: manifest_path.clone(),
force: false,
dry_run: false,
resolver: linux_resolver(home.path()),
})
.unwrap();
fs::remove_file(&dst).unwrap();
let report = adopt_edits(&AdoptEditsOpts {
manifest_path: manifest_path.clone(),
repo_path: repo.path().to_path_buf(),
dry_run: false,
})
.unwrap();
assert_eq!(report.missing, 1);
assert_eq!(report.adopted, 0);
let manifest = Manifest::load(&manifest_path).unwrap().unwrap();
assert_eq!(manifest.entries.len(), 1);
}
}