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,
}
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;
}
for e in WalkDir::new(&abs) {
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_ignored(rel) {
return Ok(());
}
let src = root.join(rel);
let dest = home.join(rel);
let state = 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 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 current == src => State::Linked,
Ok(current) => State::Wrong { current },
Err(_) => State::Occupied,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
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");
std::os::unix::fs::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");
std::os::unix::fs::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");
std::os::unix::fs::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());
}
}