mod audit;
mod detect;
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();
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 manifest = Manifest::load(&root.join("sennit.toml"))?;
let plan = Plan::build(&root, &home, &manifest)?;
match cli.command {
Command::Apply {
dry_run,
no_backup,
secrets,
} => {
render_all(&root, &manifest, secrets)?;
let plan = Plan::build(&root, &home, &manifest)?;
apply(&plan, &root, &home, &manifest, dry_run, !no_backup)
}
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),
Command::Sync { dry_run } => sync::sync(&root, dry_run),
Command::Verify { export } => verify::verify(&root, export),
Command::Compare { a, b } => verify::compare(&a, &b),
Command::Audit { history } => audit::audit(&root, history),
Command::List { changed } => {
print_list(&plan, changed);
Ok(())
}
}
}
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) -> Result<()> {
if manifest.render.is_empty() {
return Ok(());
}
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 mut cache = render::SecretCache::default();
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 template = std::fs::read_to_string(&tmpl_path)
.with_context(|| format!("failed to read template {}", tmpl_path.display()))?;
if !secrets && render::needs_secrets(&template) {
deferred.push(out_rel.clone());
continue;
}
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()) {
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let declared = manifest.mode_for(Path::new(out_rel));
write_rendered(
&out_path,
&rendered,
render::needs_secrets(&template),
declared,
)
.with_context(|| format!("failed to write {}", out_path.display()))?;
println!(" \x1b[33mrendered\x1b[0m {out_rel}");
}
if !deferred.is_empty() {
for out_rel in &deferred {
println!(" \x1b[36mdeferred\x1b[0m {out_rel} (needs 1Password)");
}
println!(
"{} template(s) not rendered; run `sennit apply --secrets` once 1Password is unlocked",
deferred.len()
);
}
Ok(())
}
fn write_rendered(path: &Path, contents: &str, secret: bool, declared: Option<u32>) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, contents)?;
if let Some(mode) = declared.or(if secret { Some(0o600) } else { None }) {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
}
Ok(())
}
fn apply(
plan: &Plan,
root: &Path,
home: &Path,
manifest: &Manifest,
dry_run: bool,
backup: bool,
) -> Result<()> {
let previous = state::State::load(home)?;
let current: Vec<PathBuf> = plan.entries.iter().map(|e| e.rel.clone()).collect();
let stale = previous.stale(¤t);
let changes: Vec<_> = plan.changes().collect();
let nothing_to_link = changes.is_empty() && stale.is_empty();
let mut backups = Vec::new();
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;
}
println!(" {:>8} {}", "prune", rel.display());
if !dry_run {
std::fs::remove_file(&dest)
.with_context(|| format!("failed to remove {}", dest.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;
}
if let Some(parent) = e.dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
if let Some(kept) = remove_dest(&e.dest, &e.state, backup)? {
backups.push(state::Backup {
dest: e.dest.clone(),
kept_at: kept,
});
}
std::os::unix::fs::symlink(&e.src, &e.dest)
.with_context(|| format!("failed to link {}", e.dest.display()))?;
}
let hooks = hooks::run_all(root, &manifest.hooks, &previous.hooks, dry_run)?;
if dry_run {
println!(
"\n{} change(s), {} prune(s), nothing written (--dry-run)",
changes.len(),
stale.len()
);
return Ok(());
}
if nothing_to_link && hooks == previous.hooks {
println!("already up to date ({} links)", plan.entries.len());
return Ok(());
}
state::State {
links: current,
backups: backups.clone(),
hooks,
}
.save(home)?;
println!(
"\n{} link(s) updated, {} pruned",
changes.len(),
stale.len()
);
if !backups.is_empty() {
println!(
"{} file(s) moved aside; `sennit rollback` puts them back",
backups.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(());
}
for b in &st.backups {
println!(" {:>8} {}", "restore", b.dest.display());
if dry_run {
continue;
}
if !b.kept_at.exists() {
println!(" the backup is gone; skipped");
continue;
}
if let Ok(meta) = std::fs::symlink_metadata(&b.dest) {
if meta.file_type().is_symlink() {
std::fs::remove_file(&b.dest)?;
}
}
std::fs::rename(&b.kept_at, &b.dest)
.with_context(|| format!("failed to restore {}", b.dest.display()))?;
}
if dry_run {
println!(
"\n{} file(s) would be restored (--dry-run)",
st.backups.len()
);
return Ok(());
}
let n = st.backups.len();
st.backups.clear();
st.save(home)?;
println!("\n{n} file(s) restored");
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() {
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 => println!(
"\x1b[31m!\x1b[0m {} (not a symlink; would be replaced)",
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");
}