mod audit;
mod detect;
mod encrypted;
mod hooks;
mod manifest;
mod packages;
mod plan;
mod render;
mod state;
mod sync;
mod verify;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use manifest::Manifest;
use packages::Packages;
use plan::{Plan, State};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "sennit",
version,
about = "Dotfiles manager that keeps symlink semantics"
)]
struct Cli {
#[arg(long, global = true)]
root: Option<PathBuf>,
#[arg(long, global = true)]
home: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Apply {
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_backup: bool,
#[arg(long)]
secrets: bool,
},
Diff,
Check,
Audit {
#[arg(long)]
history: Option<PathBuf>,
},
Verify {
#[arg(long)]
export: Option<PathBuf>,
},
Compare { a: PathBuf, b: PathBuf },
Sync {
#[arg(long)]
dry_run: bool,
},
Render {
#[arg(long)]
secrets: bool,
},
Rollback {
#[arg(long)]
dry_run: bool,
},
List {
#[arg(long)]
changed: bool,
},
}
fn main() {
if let Err(e) = run() {
eprintln!("\x1b[31merror\x1b[0m: {e:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
if let Command::Compare { a, b } = &cli.command {
return verify::compare(a, b);
}
let root = match &cli.root {
Some(p) => p.clone(),
None => find_root().context("could not locate sennit.toml")?,
};
let home = match &cli.home {
Some(p) => p.clone(),
None => PathBuf::from(std::env::var("HOME").context("HOME is not set")?),
};
let root = absolute(&root).with_context(|| format!("bad --root: {}", root.display()))?;
let home = absolute(&home).with_context(|| format!("bad --home: {}", home.display()))?;
let manifest = Manifest::load(&root.join("sennit.toml"))?;
let plan = Plan::build(&root, &home, &manifest)?;
match cli.command {
Command::Apply {
dry_run,
no_backup,
secrets,
} => {
let produced = render_all(&root, &manifest, secrets, dry_run)?;
let plan = Plan::build(&root, &home, &manifest)?;
apply(
&plan, &root, &home, &manifest, dry_run, !no_backup, &produced,
)
}
Command::Rollback { dry_run } => rollback(&home, dry_run),
Command::Diff => {
print_diff(&plan);
Ok(())
}
Command::Check => check(&root),
Command::Render { secrets } => render_all(&root, &manifest, secrets, false).map(|_| ()),
Command::Sync { dry_run } => sync::sync(&root, dry_run),
Command::Verify { export } => verify::verify(&root, export),
Command::Compare { .. } => unreachable!(),
Command::Audit { history } => audit::audit(&root, history),
Command::List { changed } => {
print_list(&plan, changed);
Ok(())
}
}
}
fn absolute(p: &Path) -> Result<PathBuf> {
if let Ok(c) = std::fs::canonicalize(p) {
return Ok(c);
}
if p.is_absolute() {
return Ok(p.to_path_buf());
}
Ok(std::env::current_dir()?.join(p))
}
fn find_root() -> Result<PathBuf> {
let mut dir = std::env::current_dir()?;
loop {
if dir.join("sennit.toml").is_file() {
return Ok(dir);
}
if !dir.pop() {
bail!("sennit.toml not found in any parent directory");
}
}
}
fn check(root: &Path) -> Result<()> {
let packages = Packages::load(&root.join("packages.toml"))?;
let provided = packages.provided();
let required = detect::scan(root)?;
let mut missing: Vec<&detect::Requirement> = Vec::new();
let mut optional: Vec<&detect::Requirement> = Vec::new();
for req in &required {
match provided.get(&(req.kind, req.name.clone())) {
None => missing.push(req),
Some(true) => optional.push(req),
Some(false) => {}
}
}
println!(
"checked {} requirement(s) against {} declared name(s)",
required.len(),
provided.len()
);
let required_names: std::collections::HashSet<_> =
required.iter().map(|r| (r.kind, r.name.clone())).collect();
let mut unused: Vec<_> = provided
.keys()
.filter(|(k, _)| matches!(k, packages::Kind::Font | packages::Kind::Extension))
.filter(|key| !required_names.contains(*key))
.collect();
unused.sort();
for (kind, name) in &unused {
println!(
"\x1b[33munused\x1b[0m {:<9} {} (declared, referenced by nothing)",
kind.label(),
name
);
}
for o in &optional {
println!(
"\x1b[33moptional\x1b[0m {:<9} {} (declared, not installed by setup)",
o.kind.label(),
o.name
);
}
if missing.is_empty() {
println!("\x1b[32mok\x1b[0m no undeclared dependencies");
return Ok(());
}
println!();
for m in &missing {
println!(
"\x1b[31mundeclared\x1b[0m {:<9} {}\n required by {}",
m.kind.label(),
m.name,
m.source
);
}
bail!("{} undeclared dependency(ies)", missing.len());
}
fn render_all(
root: &Path,
manifest: &Manifest,
secrets: bool,
dry_run: bool,
) -> Result<std::collections::BTreeSet<PathBuf>> {
let mut produced = decrypt_all(root, manifest, dry_run)?;
if manifest.render.is_empty() {
return Ok(produced);
}
let data: Vec<PathBuf> = if manifest.data.is_empty() {
vec![root.join("theme.toml")]
} else {
manifest.data.iter().map(|d| root.join(d)).collect()
};
let vars = render::load_vars(&data)?;
let providers = if manifest.providers.is_empty() {
render::default_providers()
} else {
manifest.providers.clone()
};
let mut cache = render::SecretCache::with(providers);
let mut deferred = Vec::new();
for (out_rel, tmpl_rel) in &manifest.render {
let tmpl_path = root.join(tmpl_rel);
let out_path = root.join(out_rel);
let raw = std::fs::read_to_string(&tmpl_path)
.with_context(|| format!("failed to read template {}", tmpl_path.display()))?;
let template = render::resolve_conditionals(&raw, &vars, tmpl_rel)?;
if !secrets && render::needs_secrets(&template) {
deferred.push((out_rel.clone(), render::schemes_used(&template)));
continue;
}
if dry_run && render::needs_secrets(&template) {
println!(" \x1b[33mwould render\x1b[0m {out_rel} (reads secrets)");
produced.insert(PathBuf::from(out_rel));
continue;
}
produced.insert(PathBuf::from(out_rel));
let rendered = render::expand_with(&template, &vars, tmpl_rel, &mut cache)?;
if std::fs::read_to_string(&out_path).ok().as_deref() == Some(rendered.as_str()) {
if !dry_run {
enforce_mode(
root,
&out_path,
manifest.mode_for(Path::new(out_rel)),
render::needs_secrets(&template),
)?;
}
continue;
}
if dry_run {
println!(" \x1b[33mwould render\x1b[0m {out_rel}");
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mode =
manifest
.mode_for(Path::new(out_rel))
.unwrap_or(if render::needs_secrets(&template) {
0o400
} else {
0o444
});
write_generated(root, &out_path, rendered.as_bytes(), mode)
.with_context(|| format!("failed to write {}", out_path.display()))?;
println!(" \x1b[33mrendered\x1b[0m {out_rel}");
}
if !deferred.is_empty() {
for (out_rel, schemes) in &deferred {
let who = if schemes.is_empty() {
String::from("needs a secret provider")
} else {
format!("needs {}", schemes.join(", "))
};
println!(" \x1b[36mdeferred\x1b[0m {out_rel} ({who})");
}
println!(
"{} template(s) not rendered; run `sennit apply --secrets` once the provider is available",
deferred.len()
);
}
Ok(produced)
}
fn decrypt_all(
root: &Path,
manifest: &Manifest,
dry_run: bool,
) -> Result<std::collections::BTreeSet<PathBuf>> {
let mut produced = std::collections::BTreeSet::new();
if manifest.encrypted.is_empty() {
return Ok(produced);
}
let Some(enc) = &manifest.encryption else {
bail!("encrypted files are declared but [encryption] is not");
};
if !enc.ready() {
for out_rel in manifest.encrypted.keys() {
println!(" \x1b[36mdeferred\x1b[0m {out_rel} (no decryption key)");
}
if let Some(id) = enc.identity_path() {
println!(
"{} encrypted file(s) not decrypted; the key is expected at {id}",
manifest.encrypted.len()
);
}
return Ok(produced);
}
for (out_rel, src_rel) in &manifest.encrypted {
let src = root.join(src_rel);
let out_path = root.join(out_rel);
produced.insert(PathBuf::from(out_rel));
if dry_run {
println!(" \x1b[35mwould decrypt\x1b[0m {out_rel}");
continue;
}
let plain = enc.decrypt(&src)?;
if std::fs::read(&out_path).ok().as_deref() == Some(plain.as_slice()) {
enforce_mode(root, &out_path, manifest.mode_for(Path::new(out_rel)), true)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mode = manifest.mode_for(Path::new(out_rel)).unwrap_or(0o400);
write_generated(root, &out_path, &plain, mode)
.with_context(|| format!("failed to write {}", out_path.display()))?;
println!(" \x1b[35mdecrypted\x1b[0m {out_rel}");
}
Ok(produced)
}
fn enforce_mode(root: &Path, path: &Path, declared: Option<u32>, secret: bool) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let want = declared.unwrap_or(if secret { 0o400 } else { 0o444 });
must_be_inside(root, path)?;
if let Ok(m) = std::fs::symlink_metadata(path) {
if m.file_type().is_symlink() {
bail!(
"{} is a symlink; a generated file is written in place, not through a link",
path.display()
);
}
}
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => return Ok(()),
};
if meta.permissions().mode() & 0o777 != want {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(want))
.with_context(|| format!("failed to set mode on {}", path.display()))?;
}
Ok(())
}
fn must_be_inside(root: &Path, path: &Path) -> Result<()> {
let parent = path.parent().unwrap_or(root);
let real = std::fs::canonicalize(parent)
.with_context(|| format!("failed to resolve {}", parent.display()))?;
let real_root = std::fs::canonicalize(root)
.with_context(|| format!("failed to resolve {}", root.display()))?;
if !real.starts_with(&real_root) {
bail!(
"{} resolves to {}, which is outside the repository",
path.display(),
real.display()
);
}
Ok(())
}
fn write_generated(root: &Path, path: &Path, contents: &[u8], mode: u32) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
must_be_inside(root, path)?;
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => bail!(
"{} is a directory; a generated file cannot be written there",
path.display()
),
Ok(meta) if meta.file_type().is_symlink() => bail!(
"{} is a symlink; a generated file is written in place, not through a link",
path.display()
),
Ok(_) => {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).ok();
}
Err(_) => {}
}
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(mode)
.open(path)?;
f.write_all(contents)?;
f.flush()?;
drop(f);
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
Ok(())
}
fn enforce_modes(root: &Path, manifest: &Manifest, preview: bool) -> Result<usize> {
use std::os::unix::fs::PermissionsExt;
let mut fixed = 0usize;
for rel in manifest.modes.keys() {
let want = manifest
.mode_for(Path::new(rel))
.expect("validated when the manifest loaded");
let path = root.join(rel);
match std::fs::symlink_metadata(&path) {
Ok(m) if m.file_type().is_symlink() => bail!(
"`{}` is a symlink; a declared mode applies to the path itself, \
and following the link would change something else",
rel
),
_ => {}
}
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(err) => {
return Err(anyhow::Error::new(err))
.with_context(|| format!("failed to stat {}", path.display()))
}
};
must_be_inside(root, &path)?;
if meta.is_dir() && want & 0o500 != 0o500 {
bail!(
"mode {:o} on the directory `{}` would remove your own access to it",
want,
rel
);
}
if meta.permissions().mode() & 0o777 == want {
continue;
}
if preview {
println!(" {:>8} {} ({:o})", "would set", rel, want);
fixed += 1;
continue;
}
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(want))
.with_context(|| format!("failed to set mode on {}", path.display()))?;
let got = std::fs::metadata(&path)?.permissions().mode() & 0o777;
if got != want {
bail!(
"{}: asked for mode {:o} but the filesystem gave {:o}",
path.display(),
want,
got
);
}
println!(" {:>8} {} ({:o})", "mode", rel, want);
fixed += 1;
}
Ok(fixed)
}
fn apply(
plan: &Plan,
root: &Path,
home: &Path,
manifest: &Manifest,
dry_run: bool,
backup: bool,
produced: &std::collections::BTreeSet<PathBuf>,
) -> Result<()> {
let previous = state::State::load(home)?;
let placeable = |e: &plan::Entry| -> Result<bool> {
match std::fs::symlink_metadata(&e.src) {
Ok(_) => Ok(true),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Ok(dry_run && produced.contains(&e.rel))
}
Err(err) => Err(anyhow::Error::new(err))
.with_context(|| format!("failed to read {}", e.src.display())),
}
};
let mut current: Vec<PathBuf> = Vec::new();
for e in &plan.entries {
if placeable(e)? {
current.push(e.rel.clone());
}
}
let stale = previous.stale(¤t);
let mut changes: Vec<&plan::Entry> = Vec::new();
let mut not_yet: Vec<&plan::Entry> = Vec::new();
for e in plan.changes() {
if placeable(e)? {
changes.push(e);
} else {
not_yet.push(e);
}
}
let mut kept: Vec<state::Backup> = previous
.backups
.iter()
.filter(|b| b.kept_at.exists())
.cloned()
.collect();
let mut pruned = 0usize;
let mut placed: Vec<PathBuf> = current
.iter()
.filter(|rel| !plan.changes().any(|e| &&e.rel == rel))
.cloned()
.collect();
for rel in &stale {
let dest = home.join(rel);
let Ok(meta) = std::fs::symlink_metadata(&dest) else {
continue;
};
if !meta.file_type().is_symlink() {
continue;
}
match std::fs::read_link(&dest) {
Ok(target) if target.starts_with(root) => {}
_ => {
println!(
" {:>8} {} (points outside the repository; left alone)",
"keep",
rel.display()
);
continue;
}
}
println!(" {:>8} {}", "prune", rel.display());
pruned += 1;
if !dry_run {
std::fs::remove_file(&dest)
.with_context(|| format!("failed to remove {}", dest.display()))?;
}
}
for e in ¬_yet {
println!(" {:>8} {} (not generated yet)", "defer", e.rel.display());
}
for e in &changes {
let verb = match &e.state {
State::Missing => "link",
State::Wrong { .. } => "relink",
State::Occupied => {
if backup {
"backup"
} else {
"replace"
}
}
State::Linked => unreachable!(),
};
println!(" {:>8} {}", verb, e.rel.display());
if dry_run {
continue;
}
let step = (|| -> Result<()> {
if let Some(parent) = e.dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
if let Some(at) = remove_dest(&e.dest, &e.state, backup)? {
kept.push(state::Backup {
dest: e.dest.clone(),
kept_at: at,
});
state::State {
links: placed.clone(),
backups: kept.clone(),
hooks: previous.hooks.clone(),
}
.save(home)?;
}
std::os::unix::fs::symlink(&e.src, &e.dest)
.with_context(|| format!("failed to link {}", e.dest.display()))?;
Ok(())
})();
match step {
Ok(()) => placed.push(e.rel.clone()),
Err(err) => {
state::State {
links: placed.clone(),
backups: kept.clone(),
hooks: previous.hooks.clone(),
}
.save(home)?;
return Err(err);
}
}
}
if dry_run {
hooks::run_all(root, &manifest.hooks, &previous.hooks, true)?;
enforce_modes(root, manifest, true)?;
println!(
"\n{} change(s), {} prune(s), nothing written (--dry-run)",
changes.len() + not_yet.len(),
pruned
);
return Ok(());
}
state::State {
links: current.clone(),
backups: kept.clone(),
hooks: previous.hooks.clone(),
}
.save(home)?;
let fixed = enforce_modes(root, manifest, false)?;
let ran = hooks::run_all(root, &manifest.hooks, &previous.hooks, false)?;
let hooks = ran.fingerprints;
state::State {
links: current,
backups: kept.clone(),
hooks: hooks.clone(),
}
.save(home)?;
if changes.is_empty() && pruned == 0 && fixed == 0 && ran.count == 0 {
if not_yet.is_empty() {
println!("already up to date ({} links)", plan.entries.len());
} else {
println!(
"{} link(s) in place, {} not generated yet",
plan.entries.len() - not_yet.len(),
not_yet.len()
);
}
return Ok(());
}
println!("\n{} link(s) updated, {} pruned", changes.len(), pruned);
if !not_yet.is_empty() {
println!("{} not generated yet, so not linked", not_yet.len());
}
if !kept.is_empty() {
println!(
"{} file(s) moved aside; `sennit rollback` puts them back",
kept.len()
);
}
Ok(())
}
fn rollback(home: &Path, dry_run: bool) -> Result<()> {
let mut st = state::State::load(home)?;
if st.backups.is_empty() {
println!("nothing to roll back");
return Ok(());
}
let mut newest: Vec<&state::Backup> = Vec::new();
let mut shadowed: Vec<&state::Backup> = Vec::new();
for b in st.backups.iter().rev() {
if newest.iter().any(|n| n.dest == b.dest) {
shadowed.push(b);
} else {
newest.push(b);
}
}
newest.reverse();
let mut restored = 0usize;
let mut missing = 0usize;
for b in &newest {
println!(" {:>8} {}", "restore", b.dest.display());
if !b.kept_at.exists() {
println!(" the backup is gone; nothing to put back");
missing += 1;
continue;
}
if dry_run {
restored += 1;
continue;
}
match std::fs::symlink_metadata(&b.dest) {
Ok(meta) if meta.file_type().is_symlink() => {
std::fs::remove_file(&b.dest)?;
}
Ok(_) => {
let aside = backup_path(&b.dest)?;
std::fs::rename(&b.dest, &aside)
.with_context(|| format!("failed to move {} aside", b.dest.display()))?;
println!(" what was there is now at {}", aside.display());
}
Err(_) => {}
}
std::fs::rename(&b.kept_at, &b.dest)
.with_context(|| format!("failed to restore {}", b.dest.display()))?;
restored += 1;
}
for b in shadowed.iter().rev() {
println!(
" {:>8} {} (an older copy of {}; left where it is)",
"kept",
b.kept_at.display(),
b.dest.display()
);
}
if dry_run {
println!("\n{restored} file(s) would be restored (--dry-run)");
if missing > 0 {
println!("{missing} recorded backup(s) are no longer on disk");
}
return Ok(());
}
let n = restored;
let older = shadowed.len();
st.backups.clear();
st.save(home)?;
println!("\n{n} file(s) restored");
if missing > 0 {
println!("{missing} recorded backup(s) were already gone; nothing was put back for them");
}
if older > 0 {
println!("{older} older copy(ies) left on disk; move them back by hand if you want them");
}
Ok(())
}
fn remove_dest(dest: &Path, state: &State, backup: bool) -> Result<Option<PathBuf>> {
match state {
State::Missing | State::Linked => Ok(None),
State::Wrong { .. } => {
std::fs::remove_file(dest)
.with_context(|| format!("failed to remove symlink {}", dest.display()))?;
Ok(None)
}
State::Occupied => {
if backup {
let to = backup_path(dest)?;
std::fs::rename(dest, &to).with_context(|| {
format!(
"failed to move {} aside to {}",
dest.display(),
to.display()
)
})?;
println!(" kept the old file at {}", to.display());
return Ok(Some(to));
}
let meta = std::fs::symlink_metadata(dest)?;
if meta.is_dir() {
let n = walkdir::WalkDir::new(dest)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.count();
println!(" deleting the directory and its {n} file(s)");
std::fs::remove_dir_all(dest)
} else {
std::fs::remove_file(dest)
}
.with_context(|| format!("failed to remove {}", dest.display()))?;
Ok(None)
}
}
}
fn backup_path(dest: &Path) -> Result<PathBuf> {
let base = format!("{}.sennit-backup", dest.display());
let first = PathBuf::from(&base);
if !first.exists() {
return Ok(first);
}
for n in 1..1000 {
let p = PathBuf::from(format!("{base}.{n}"));
if !p.exists() {
return Ok(p);
}
}
bail!("too many backups next to {}", dest.display())
}
fn print_diff(plan: &Plan) {
let changes: Vec<_> = plan.changes().collect();
if changes.is_empty() {
println!("no changes ({} links already in place)", plan.entries.len());
return;
}
for e in &changes {
match &e.state {
State::Missing => println!("\x1b[32m+\x1b[0m {}", e.rel.display()),
State::Wrong { current } => println!(
"\x1b[33m~\x1b[0m {}\n now -> {}\n new -> {}",
e.rel.display(),
current.display(),
e.src.display()
),
State::Occupied => {
let what = match std::fs::symlink_metadata(&e.dest) {
Ok(m) if m.is_dir() => "a directory",
_ => "a real file",
};
println!(
"\x1b[31m!\x1b[0m {} ({what} is in the way; would be moved aside)",
e.rel.display()
)
}
State::Linked => {}
}
}
println!("\n{} change(s)", changes.len());
}
fn print_list(plan: &Plan, changed_only: bool) {
let width = plan
.entries
.iter()
.map(|e| e.rel.as_os_str().len())
.max()
.unwrap_or(6)
.max(6);
for e in &plan.entries {
if changed_only && !e.state.needs_change() {
continue;
}
let mark = match e.state {
State::Linked => "\x1b[32mok\x1b[0m",
State::Missing => "\x1b[33m--\x1b[0m",
State::Wrong { .. } => "\x1b[33m~~\x1b[0m",
State::Occupied => "\x1b[31m!!\x1b[0m",
};
println!(
"{} {:<width$} -> {}",
mark,
e.rel.display(),
e.dest.display(),
width = width
);
}
let n = plan.entries.len();
let c = plan.changes().count();
println!("\n{n} link(s), {c} need change");
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("sennit-main-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_relative_root_becomes_absolute() {
let p = absolute(Path::new(".")).unwrap();
assert!(p.is_absolute(), "{}", p.display());
}
#[test]
fn a_path_that_does_not_exist_yet_is_still_made_absolute() {
let p = absolute(Path::new("no/such/dir")).unwrap();
assert!(p.is_absolute());
assert!(p.ends_with("no/such/dir"));
}
#[test]
fn a_backup_does_not_overwrite_an_earlier_backup() {
let d = scratch("backup-path");
let dest = d.join("a.conf");
assert_eq!(backup_path(&dest).unwrap(), d.join("a.conf.sennit-backup"));
std::fs::write(d.join("a.conf.sennit-backup"), "first").unwrap();
assert_eq!(
backup_path(&dest).unwrap(),
d.join("a.conf.sennit-backup.1")
);
std::fs::write(d.join("a.conf.sennit-backup.1"), "second").unwrap();
assert_eq!(
backup_path(&dest).unwrap(),
d.join("a.conf.sennit-backup.2")
);
}
#[test]
fn a_real_file_in_the_way_is_moved_aside_not_deleted() {
let d = scratch("remove-dest-backup");
let dest = d.join("a.conf");
std::fs::write(&dest, "MINE").unwrap();
let kept = remove_dest(&dest, &State::Occupied, true).unwrap().unwrap();
assert!(!dest.exists());
assert_eq!(std::fs::read_to_string(&kept).unwrap(), "MINE");
}
#[test]
fn a_symlink_pointing_elsewhere_is_removed_without_a_backup() {
let d = scratch("remove-dest-wrong");
let other = d.join("other");
std::fs::write(&other, "x").unwrap();
let dest = d.join("a.conf");
std::os::unix::fs::symlink(&other, &dest).unwrap();
let kept = remove_dest(
&dest,
&State::Wrong {
current: other.clone(),
},
true,
)
.unwrap();
assert!(kept.is_none());
assert!(!dest.exists());
assert!(other.exists());
}
#[test]
fn no_backup_deletes_a_whole_directory() {
let d = scratch("remove-dest-dir");
let dest = d.join("nvim");
std::fs::create_dir_all(dest.join("lua")).unwrap();
std::fs::write(dest.join("init.lua"), "x").unwrap();
std::fs::write(dest.join("lua/plugins.lua"), "x").unwrap();
remove_dest(&dest, &State::Occupied, false).unwrap();
assert!(!dest.exists());
}
#[test]
fn a_directory_in_the_way_is_moved_aside_whole() {
let d = scratch("remove-dest-dir-backup");
let dest = d.join("nvim");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("init.lua"), "MINE").unwrap();
let kept = remove_dest(&dest, &State::Occupied, true).unwrap().unwrap();
assert!(!dest.exists());
assert_eq!(
std::fs::read_to_string(kept.join("init.lua")).unwrap(),
"MINE"
);
}
#[test]
fn a_generated_file_is_created_with_its_final_mode() {
let d = scratch("write-generated");
let p = d.join("out.conf");
write_generated(&d, &p, b"token", 0o400).unwrap();
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o400
);
assert_eq!(std::fs::read_to_string(&p).unwrap(), "token");
}
#[test]
fn a_read_only_generated_file_can_still_be_rewritten() {
let d = scratch("write-generated-again");
let p = d.join("out.conf");
write_generated(&d, &p, b"one", 0o444).unwrap();
write_generated(&d, &p, b"two", 0o444).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "two");
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o444
);
}
#[test]
fn enforce_mode_fixes_a_file_left_at_the_wrong_mode() {
let d = scratch("enforce-mode");
let p = d.join("out.conf");
std::fs::write(&p, "x").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
enforce_mode(&d, &p, None, true).unwrap();
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o400
);
}
}