use crate::manifest::Manifest;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct Entry {
pub rel: PathBuf,
pub src: PathBuf,
pub dest: PathBuf,
pub state: State,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
Linked,
Missing,
Wrong { current: PathBuf },
Occupied,
Ancestor { link: PathBuf },
}
impl State {
pub fn needs_change(&self) -> bool {
!matches!(self, State::Linked)
}
}
pub struct Plan {
pub entries: Vec<Entry>,
}
impl Plan {
pub fn build(root: &Path, home: &Path, manifest: &Manifest) -> Result<Self> {
let mut entries = Vec::new();
for target in manifest.targets() {
let abs = root.join(target);
match std::fs::metadata(&abs) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(err) => {
return Err(anyhow::Error::new(err))
.with_context(|| format!("failed to read {}", abs.display()))
}
}
if abs.is_file() {
Self::push(&mut entries, root, home, manifest, Path::new(target))?;
continue;
}
let walker = WalkDir::new(&abs).into_iter().filter_entry(|e| {
e.path()
.strip_prefix(root)
.map(|rel| !manifest.is_ignored(rel))
.unwrap_or(true)
});
for e in walker {
let e = e.with_context(|| format!("failed to read {}", abs.display()))?;
if !e.file_type().is_file() {
continue;
}
let rel = e
.path()
.strip_prefix(root)
.context("path escaped repository root")?;
Self::push(&mut entries, root, home, manifest, rel)?;
}
}
for out in manifest.render.keys().chain(manifest.encrypted.keys()) {
let rel = Path::new(out);
if entries.iter().any(|e| e.rel == rel) {
continue;
}
if !manifest.targets().iter().any(|t| {
let t = Path::new(t);
rel == t || rel.starts_with(t)
}) {
continue;
}
Self::push(&mut entries, root, home, manifest, rel)?;
}
entries.sort_by(|a, b| a.rel.cmp(&b.rel));
Ok(Plan { entries })
}
fn push(
entries: &mut Vec<Entry>,
root: &Path,
home: &Path,
manifest: &Manifest,
rel: &Path,
) -> Result<()> {
if entries.iter().any(|e| e.rel == rel) {
return Ok(());
}
if manifest.is_template(rel)
|| manifest.is_ciphertext(rel)
|| manifest.is_merge_source(rel)
|| manifest.is_ignored(rel)
{
return Ok(());
}
if manifest.is_merge_output(rel) {
anyhow::bail!(
"`{}` is a [merge] output and also a file under [link]; \
remove it from the repository or from [link]",
rel.display()
);
}
let src = crate::platform::native(&root.join(rel));
let dest = crate::platform::native(&home.join(rel));
let state = match ancestor_link(root, home, rel, manifest) {
Some(link) => State::Ancestor { link },
None => inspect(&src, &dest),
};
entries.push(Entry {
rel: rel.to_path_buf(),
src,
dest,
state,
});
Ok(())
}
pub fn changes(&self) -> impl Iterator<Item = &Entry> {
self.entries.iter().filter(|e| e.state.needs_change())
}
}
fn first_symlink(home: &Path, rel: &Path) -> Option<PathBuf> {
let mut cur = home.to_path_buf();
let comps: Vec<_> = rel.components().collect();
for c in comps.iter().take(comps.len().saturating_sub(1)) {
cur.push(c);
let Ok(meta) = std::fs::symlink_metadata(&cur) else {
return None;
};
if meta.file_type().is_symlink() {
return Some(cur);
}
}
None
}
pub fn ancestor_link(root: &Path, home: &Path, rel: &Path, manifest: &Manifest) -> Option<PathBuf> {
let link = first_symlink(home, rel)?;
if link_into_repo(root, &link) {
return Some(link);
}
let at = link.strip_prefix(home).ok()?;
manifest
.targets()
.iter()
.any(|t| at.starts_with(t) && at != Path::new(t))
.then_some(link)
}
pub fn outside_top_levels(
root: &Path,
home: &Path,
manifest: &Manifest,
) -> Vec<(PathBuf, PathBuf)> {
let mut out = Vec::new();
for t in manifest.targets() {
let path = home.join(t);
let is_link = std::fs::symlink_metadata(&path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if is_link && !link_into_repo(root, &path) {
if let Ok(to) = std::fs::read_link(&path) {
out.push((PathBuf::from(t), to));
}
}
}
out
}
pub fn note_outside_top_levels(root: &Path, home: &Path, manifest: &Manifest) {
for (rel, to) in outside_top_levels(root, home, manifest) {
println!(
"note: ~/{} points to {}, outside the repository; files are placed inside it",
rel.display(),
to.display()
);
}
}
pub fn link_into_repo(root: &Path, link: &Path) -> bool {
let Ok(target) = std::fs::read_link(link) else {
return false;
};
let target = if target.is_absolute() {
target
} else {
match link.parent() {
Some(p) => p.join(target),
None => return false,
}
};
match crate::platform::canonicalize(&target) {
Ok(real) => crate::platform::path_starts_with(&real, root),
Err(_) => crate::platform::path_starts_with(&collapse(&target), root),
}
}
fn collapse(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
other => out.push(other),
}
}
out
}
pub fn repo_ancestor_link(root: &Path, home: &Path, rel: &Path) -> Option<PathBuf> {
first_symlink(home, rel).filter(|l| link_into_repo(root, l))
}
fn inspect(src: &Path, dest: &Path) -> State {
match std::fs::symlink_metadata(dest) {
Err(_) => State::Missing,
Ok(meta) => {
if !meta.file_type().is_symlink() {
return State::Occupied;
}
match std::fs::read_link(dest) {
Ok(current) if crate::platform::same_path(¤t, src) => State::Linked,
Ok(current) => State::Wrong { current },
Err(_) => State::Occupied,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::symlink;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("sennit-plan-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn write(path: &Path, body: &str) {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).unwrap();
}
std::fs::write(path, body).unwrap();
}
#[test]
fn inspect_tells_the_four_states_apart() {
let d = scratch("inspect");
let src = d.join("src.conf");
write(&src, "x");
let missing = d.join("missing.conf");
assert_eq!(inspect(&src, &missing), State::Missing);
let occupied = d.join("occupied.conf");
write(&occupied, "mine");
assert_eq!(inspect(&src, &occupied), State::Occupied);
let linked = d.join("linked.conf");
symlink(&src, &linked).unwrap();
assert_eq!(inspect(&src, &linked), State::Linked);
let elsewhere = d.join("elsewhere.conf");
write(&elsewhere, "y");
let wrong = d.join("wrong.conf");
symlink(&elsewhere, &wrong).unwrap();
assert_eq!(inspect(&src, &wrong), State::Wrong { current: elsewhere });
}
#[test]
fn a_link_to_a_deleted_file_is_still_a_link() {
let d = scratch("broken");
let src = d.join("src.conf");
write(&src, "x");
let dest = d.join("dest.conf");
symlink(&src, &dest).unwrap();
std::fs::remove_file(&src).unwrap();
assert_eq!(inspect(&src, &dest), State::Linked);
}
fn manifest_from(toml: &str) -> Manifest {
toml::from_str(toml).unwrap()
}
#[test]
fn a_declared_output_is_planned_before_it_exists() {
let d = scratch("planned-output");
let root = d.join("repo");
let home = d.join("home");
std::fs::create_dir_all(&home).unwrap();
write(&root.join("conf/app.conf.tmpl"), "x = {{ ui.bg }}\n");
let m = manifest_from(
r#"
[link]
common = ["conf"]
ignore = ["*.tmpl"]
[render]
"conf/app.conf" = "conf/app.conf.tmpl"
"#,
);
let plan = Plan::build(&root, &home, &m).unwrap();
let rels: Vec<_> = plan.entries.iter().map(|e| e.rel.clone()).collect();
assert_eq!(rels, vec![PathBuf::from("conf/app.conf")]);
assert_eq!(plan.entries[0].state, State::Missing);
}
#[test]
fn an_output_outside_the_link_targets_is_not_planned() {
let d = scratch("planned-outside");
let root = d.join("repo");
let home = d.join("home");
std::fs::create_dir_all(&home).unwrap();
write(&root.join("elsewhere/app.conf.tmpl"), "x\n");
let m = manifest_from(
r#"
[link]
common = ["conf"]
[render]
"elsewhere/app.conf" = "elsewhere/app.conf.tmpl"
"#,
);
let plan = Plan::build(&root, &home, &m).unwrap();
assert!(plan.entries.is_empty());
}
#[cfg(unix)]
#[test]
fn an_ignored_directory_is_not_walked() {
use std::os::unix::fs::PermissionsExt;
let d = scratch("pruned-walk");
let root = d.join("repo");
let home = d.join("home");
std::fs::create_dir_all(&home).unwrap();
write(&root.join("conf/a.conf"), "a\n");
write(&root.join("conf/worktrees/x/secret.txt"), "x\n");
write(&root.join("conf/hooks/node_modules/dep/index.js"), "x\n");
write(&root.join("conf/hooks/a.test.ts"), "x\n");
let locked = root.join("conf/worktrees");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let m = manifest_from(
r#"
[link]
common = ["conf"]
ignore = ["conf/worktrees", "**/node_modules", "*.test.ts"]
"#,
);
let plan = Plan::build(&root, &home, &m);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
let plan = plan.unwrap();
let rels: Vec<_> = plan.entries.iter().map(|e| e.rel.clone()).collect();
assert_eq!(rels, vec![PathBuf::from("conf/a.conf")]);
}
#[test]
fn a_link_into_the_repository_above_the_destination_is_found() {
let d = scratch("ancestor");
let root = d.join("repo");
let home = d.join("home");
write(&root.join("skills/gog/SKILL.md"), "x\n");
std::fs::create_dir_all(home.join("skills")).unwrap();
symlink(root.join("skills/gog"), home.join("skills/gog")).unwrap();
let rel = Path::new("skills/gog/SKILL.md");
let m = manifest_from("[link]\ncommon = [\"skills\"]\n");
assert_eq!(
ancestor_link(&root, &home, rel, &m),
Some(home.join("skills/gog"))
);
assert_eq!(
ancestor_link(&root, &home, Path::new("skills/other/a.md"), &m),
None
);
}
#[test]
fn a_relative_link_into_the_repository_is_found_too() {
let d = scratch("ancestor-relative");
let root = d.join("repo");
let home = d.join("repo/../home");
write(&root.join("skills/gog/SKILL.md"), "x\n");
std::fs::create_dir_all(home.join("skills")).unwrap();
symlink("../../repo/skills/gog", home.join("skills/gog")).unwrap();
let root = crate::platform::canonicalize(&root).unwrap();
assert!(repo_ancestor_link(&root, &home, Path::new("skills/gog/SKILL.md")).is_some());
}
#[test]
fn a_link_outside_the_repository_is_found_but_is_not_a_repository_link() {
let d = scratch("ancestor-outside");
let root = d.join("repo");
let home = d.join("home");
let elsewhere = d.join("life/skills/gog");
write(&root.join("skills/gog/SKILL.md"), "x\n");
write(&elsewhere.join("SKILL.md"), "old\n");
std::fs::create_dir_all(home.join("skills")).unwrap();
symlink(&elsewhere, home.join("skills/gog")).unwrap();
let rel = Path::new("skills/gog/SKILL.md");
assert!(!link_into_repo(&root, &home.join("skills/gog")));
assert_eq!(repo_ancestor_link(&root, &home, rel), None);
let m = manifest_from("[link]\ncommon = [\"skills\"]\n");
assert_eq!(
ancestor_link(&root, &home, rel, &m),
Some(home.join("skills/gog"))
);
let m = manifest_from("[link]\ncommon = [\"skills/gog\"]\n");
assert_eq!(ancestor_link(&root, &home, rel, &m), None);
let m = manifest_from("[link]\ncommon = [\"skills/gog/SKILL.md\"]\n");
assert_eq!(ancestor_link(&root, &home, rel, &m), None);
}
#[test]
fn a_target_that_climbs_out_of_the_repository_is_outside() {
let d = scratch("dotdot");
let root = crate::platform::canonicalize(&d).unwrap().join("repo");
let home = d.join("home");
write(&root.join("a"), "x\n");
write(&d.join("other/p"), "x\n");
std::fs::create_dir_all(&home).unwrap();
let to_existing = root.join("../other/p");
symlink(&to_existing, home.join("existing")).unwrap();
assert!(!link_into_repo(&root, &home.join("existing")));
let to_missing = root.join("../nowhere/p");
symlink(&to_missing, home.join("dangling")).unwrap();
assert!(!link_into_repo(&root, &home.join("dangling")));
let inside = root.join("sub/../a");
symlink(&inside, home.join("inside")).unwrap();
assert!(link_into_repo(&root, &home.join("inside")));
let gone = root.join("sub/../gone");
symlink(&gone, home.join("inside-gone")).unwrap();
assert!(link_into_repo(&root, &home.join("inside-gone")));
}
}