mod detect;
mod manifest;
mod packages;
mod plan;
mod render;
mod sync;
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,
},
Diff,
Check,
Sync {
#[arg(long)]
dry_run: bool,
},
Render {
#[arg(long)]
check: 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 } => apply(&plan, dry_run),
Command::Diff => {
print_diff(&plan);
Ok(())
}
Command::Check => check(&root),
Command::Render { check } => render_all(&root, &manifest, check),
Command::Sync { dry_run } => sync::sync(&root, dry_run),
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()
);
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, check_only: bool) -> Result<()> {
let vars = render::load_vars(&root.join("theme.toml"))?;
let mut stale = 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()))?;
let rendered = render::expand(&template, &vars, tmpl_rel)?;
let current = std::fs::read_to_string(&out_path).unwrap_or_default();
if current == rendered {
println!(" \x1b[32munchanged\x1b[0m {out_rel}");
continue;
}
if check_only {
println!(" \x1b[31mstale\x1b[0m {out_rel}");
stale.push(out_rel.clone());
} else {
std::fs::write(&out_path, &rendered)
.with_context(|| format!("failed to write {}", out_path.display()))?;
println!(" \x1b[33mrendered\x1b[0m {out_rel}");
}
}
if !stale.is_empty() {
bail!("{} file(s) out of date; run `sennit render`", stale.len());
}
Ok(())
}
fn apply(plan: &Plan, dry_run: bool) -> Result<()> {
let changes: Vec<_> = plan.changes().collect();
if changes.is_empty() {
println!("already up to date ({} links)", plan.entries.len());
return Ok(());
}
for e in &changes {
let verb = match &e.state {
State::Missing => "link",
State::Wrong { .. } => "relink",
State::Occupied => "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()))?;
}
remove_dest(&e.dest, &e.state)?;
std::os::unix::fs::symlink(&e.src, &e.dest)
.with_context(|| format!("failed to link {}", e.dest.display()))?;
}
if dry_run {
println!("\n{} change(s), nothing written (--dry-run)", changes.len());
} else {
println!("\n{} link(s) updated", changes.len());
}
Ok(())
}
fn remove_dest(dest: &Path, state: &State) -> Result<()> {
match state {
State::Missing => Ok(()),
State::Wrong { .. } => std::fs::remove_file(dest)
.with_context(|| format!("failed to remove symlink {}", dest.display())),
State::Occupied => {
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()))
}
State::Linked => Ok(()),
}
}
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");
}