use clap::{CommandFactory, Parser};
use dotm::orchestrator::Orchestrator;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "dotm", about = "Dotfile manager with composable roles", version)]
struct Cli {
#[arg(short, long, default_value = ".")]
dir: PathBuf,
#[command(subcommand)]
command: Commands,
}
#[derive(clap::Subcommand)]
enum Commands {
Deploy {
#[arg(long)]
host: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
system: bool,
#[arg(short, long)]
package: Option<String>,
},
Undeploy {
#[arg(long)]
system: bool,
#[arg(short, long)]
package: Option<String>,
},
Status {
#[arg(short, long)]
verbose: bool,
#[arg(short, long)]
short: bool,
#[arg(short, long)]
package: Option<String>,
#[arg(long)]
system: bool,
},
Diff {
path: Option<String>,
#[arg(long)]
host: Option<String>,
#[arg(long)]
system: bool,
},
Check {
#[arg(long)]
warn_suggestions: bool,
},
Init {
name: String,
},
Add {
package: String,
#[arg(required = true)]
files: Vec<std::path::PathBuf>,
#[arg(long)]
force: bool,
#[arg(long)]
system: bool,
},
List {
#[command(subcommand)]
what: ListWhat,
},
Commit {
#[arg(short, long)]
message: Option<String>,
},
Push,
Pull,
Completions {
shell: clap_complete::Shell,
},
Restore {
#[arg(long)]
system: bool,
#[arg(short, long)]
package: Option<String>,
#[arg(long)]
dry_run: bool,
},
Prune {
#[arg(long)]
host: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
system: bool,
},
Sync {
#[arg(long)]
host: Option<String>,
#[arg(long)]
no_push: bool,
#[arg(long)]
force: bool,
#[arg(long)]
system: bool,
},
}
#[derive(clap::Subcommand)]
enum ListWhat {
Packages {
#[arg(short, long)]
verbose: bool,
},
Roles {
#[arg(short, long)]
verbose: bool,
},
Hosts {
#[arg(short, long)]
verbose: bool,
#[arg(long)]
tree: bool,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Deploy {
host,
dry_run,
force,
system,
package,
} => {
let hostname = match host {
Some(h) => h,
None => hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| {
eprintln!("error: could not detect hostname, use --host to specify");
std::process::exit(1);
}),
};
let target_dir = dirs::home_dir().unwrap_or_else(|| {
eprintln!("error: could not determine home directory");
std::process::exit(1);
});
let state_dir = if system {
check_system_privileges();
system_state_dir()
} else {
dotm_state_dir()
};
let mut orch = Orchestrator::new(&cli.dir, &target_dir)?
.with_state_dir(&state_dir)
.with_system_mode(system)
.with_package_filter(package);
if system && !orch.loader().root().packages.values().any(|p| p.system) {
println!("no system packages configured");
return Ok(());
}
let report = orch.deploy(&hostname, dry_run, force)?;
if dry_run {
println!("Dry run — would deploy {} files:", report.dry_run_actions.len());
for path in &report.dry_run_actions {
println!(" {}", path.display());
}
} else {
if !report.created.is_empty() {
println!("Created {} files:", report.created.len());
for path in &report.created {
println!(" + {}", path.display());
}
}
if !report.updated.is_empty() {
println!("Updated {} files:", report.updated.len());
for path in &report.updated {
println!(" ~ {}", path.display());
}
}
if !report.conflicts.is_empty() {
eprintln!("Conflicts ({}):", report.conflicts.len());
for (path, msg) in &report.conflicts {
eprintln!(" ! {} — {}", path.display(), msg);
}
}
if !report.orphaned.is_empty() {
if report.pruned.is_empty() {
eprintln!("Warning: {} orphaned files (no longer managed):", report.orphaned.len());
for path in &report.orphaned {
eprintln!(" ? {}", path.display());
}
eprintln!("Run 'dotm prune' to clean up, or set auto_prune = true in dotm.toml.");
} else {
println!("Pruned {} orphaned files.", report.pruned.len());
}
}
}
if !report.conflicts.is_empty() {
std::process::exit(1);
}
}
Commands::Restore { system, package, dry_run } => {
let state_dir = if system {
check_system_privileges();
system_state_dir()
} else {
dotm_state_dir()
};
let state = dotm::state::DeployState::load_locked(&state_dir)?;
if state.entries().is_empty() {
println!("No files currently managed by dotm.");
return Ok(());
}
if dry_run {
let mut count = 0;
for entry in state.entries() {
if let Some(ref filter) = package {
if entry.package != *filter {
continue;
}
}
if entry.original_hash.is_some() {
println!(" restore {}", entry.target.display());
} else {
println!(" remove {}", entry.target.display());
}
count += 1;
}
println!("Dry run — would restore {} files.", count);
} else {
let restored = state.restore(package.as_deref())?;
println!("Restored {} files.", restored);
}
}
Commands::Undeploy { system, package } => {
let state_dir = if system {
check_system_privileges();
system_state_dir()
} else {
dotm_state_dir()
};
let mut state = dotm::state::DeployState::load_locked(&state_dir)?;
let removed = if let Some(ref pkg) = package {
state.undeploy_package(pkg)?
} else {
state.undeploy()?
};
println!("Removed {removed} managed files.");
}
Commands::Status { verbose, short, package, system } => {
let state_dir = if system {
check_system_privileges();
system_state_dir()
} else {
dotm_state_dir()
};
let state = dotm::state::DeployState::load(&state_dir)?;
let entries = state.entries();
if entries.is_empty() {
if !short {
println!("No files currently managed by dotm.");
}
return Ok(());
}
let statuses: Vec<dotm::state::FileStatus> = entries
.iter()
.map(|e| state.check_entry_status(e))
.collect();
let mut groups = dotm::status::group_by_package(entries, &statuses);
if let Some(ref pkg_name) = package {
groups.retain(|g| g.name == *pkg_name);
if groups.is_empty() {
eprintln!("error: no deployed package named '{pkg_name}'");
std::process::exit(1);
}
}
let total: usize = groups.iter().map(|g| g.total).sum();
let modified: usize = groups.iter().map(|g| g.modified).sum();
let missing: usize = groups.iter().map(|g| g.missing).sum();
let color = dotm::status::use_color();
if let Some(git_repo) = dotm::git::GitRepo::open(&cli.dir) {
match git_repo.summary() {
Ok(summary) => {
if !short {
dotm::status::print_git_summary(&summary, color);
}
}
Err(e) => {
if !short {
eprintln!("warning: failed to read git status: {e}");
}
}
}
}
if short {
dotm::status::print_short(total, modified, missing, color);
} else {
if verbose || package.is_some() {
dotm::status::print_status_verbose(&groups, color);
} else {
dotm::status::print_status_default(&groups, color);
}
println!();
dotm::status::print_footer(total, modified, missing, color);
if modified > 0 {
println!("Run 'dotm diff' to see changes, 'dotm deploy' to re-sync.");
}
}
if modified > 0 || missing > 0 {
std::process::exit(1);
}
}
Commands::Diff { path, host, system } => {
let state_dir = if system {
check_system_privileges();
system_state_dir()
} else {
dotm_state_dir()
};
let state = dotm::state::DeployState::load(&state_dir)?;
let mut found_diffs = false;
let config_context: Option<toml::map::Map<String, toml::Value>> = (|| {
let loader = dotm::loader::ConfigLoader::new(&cli.dir).ok()?;
let hostname = host.clone().or_else(|| {
hostname::get().ok().map(|h| h.to_string_lossy().to_string())
})?;
let host_config = loader.load_host(&hostname).ok()?;
let mut merged_vars = toml::map::Map::new();
for role_name in &host_config.roles {
if let Ok(role) = loader.load_role(role_name) {
merged_vars = dotm::vars::merge_vars(&merged_vars, &role.vars);
}
}
merged_vars = dotm::vars::merge_vars(&merged_vars, &host_config.vars);
Some(merged_vars)
})();
if config_context.is_none() && !state.entries().is_empty() {
eprintln!("warning: could not load dotfiles config; showing drift status only");
}
for entry in state.entries() {
if let Some(ref filter) = path {
if !entry.target.to_str().unwrap_or("").contains(filter) {
continue;
}
}
if entry.target.is_symlink() {
continue;
}
let status = state.check_entry_status(entry);
if !status.is_modified() {
continue;
}
found_diffs = true;
if let Some(ref vars) = config_context {
let expected = if entry.kind == dotm::scanner::EntryKind::Template {
std::fs::read_to_string(&entry.source)
.ok()
.and_then(|tmpl| dotm::template::render_template(&tmpl, vars).ok())
} else {
std::fs::read_to_string(&entry.source).ok()
};
let current = std::fs::read_to_string(&entry.target).unwrap_or_default();
if let Some(expected) = expected {
let label_a = format!("expected: {}", entry.target.display());
let label_b = format!("current: {}", entry.target.display());
print!("{}", dotm::diff::format_unified_diff(&expected, ¤t, &label_a, &label_b));
} else {
println!(" M {} (source unavailable)", entry.target.display());
}
} else {
println!(" M {}", entry.target.display());
}
}
if !found_diffs {
println!("No modified files.");
}
}
Commands::Check { warn_suggestions } => {
let loader = dotm::loader::ConfigLoader::new(&cli.dir)?;
let mut errors: Vec<String> = Vec::new();
let hosts_dir = cli.dir.join("hosts");
if hosts_dir.is_dir() {
for entry in std::fs::read_dir(&hosts_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.expect("invalid host filename");
match loader.load_host(stem) {
Ok(host) => {
for role_name in &host.roles {
if let Err(e) = loader.load_role(role_name) {
errors.push(format!(
"host '{}' references invalid role '{}': {}",
stem, role_name, e
));
}
}
}
Err(e) => {
errors.push(format!("invalid host config '{}': {}", stem, e));
}
}
}
}
}
let root = loader.root();
for (pkg_name, pkg_config) in &root.packages {
for dep in &pkg_config.depends {
if !root.packages.contains_key(dep) {
errors.push(format!(
"package '{}' depends on unknown package '{}'",
pkg_name, dep
));
}
}
if warn_suggestions {
for sug in &pkg_config.suggests {
if !root.packages.contains_key(sug) {
eprintln!(
"warning: package '{}' suggests unknown package '{}'",
pkg_name, sug
);
}
}
}
let pkg_dir = loader.packages_dir().join(pkg_name);
if !pkg_dir.is_dir() {
errors.push(format!(
"package '{}' declared but directory not found: {}",
pkg_name,
pkg_dir.display()
));
}
}
let all_pkgs: Vec<&str> = root.packages.keys().map(|s| s.as_str()).collect();
if let Err(e) = dotm::resolver::resolve_packages(root, &all_pkgs) {
errors.push(format!("dependency resolution error: {}", e));
}
errors.extend(dotm::config::validate_system_packages(root));
let dep_warnings = dotm::config::deprecated_strategy_warnings(loader.root());
for w in &dep_warnings {
eprintln!("{w}");
}
if errors.is_empty() {
println!("Configuration is valid.");
} else {
eprintln!("Configuration errors:");
for err in &errors {
eprintln!(" - {}", err);
}
std::process::exit(1);
}
}
Commands::Init { name } => {
let pkg_dir = cli.dir.join("packages").join(&name);
if pkg_dir.exists() {
eprintln!(
"error: package '{}' already exists at {}",
name,
pkg_dir.display()
);
std::process::exit(1);
}
std::fs::create_dir_all(&pkg_dir)?;
println!("Created package: {}", pkg_dir.display());
println!("Add files mirroring their home directory structure.");
}
Commands::Add {
package,
files,
force,
system: _,
} => {
let loader = dotm::loader::ConfigLoader::new(&cli.dir)?;
if !loader.root().packages.contains_key(&package) {
eprintln!("error: unknown package '{package}'");
std::process::exit(1);
}
let pkg_config = &loader.root().packages[&package];
let target_dir = if let Some(ref target) = pkg_config.target {
PathBuf::from(dotm::orchestrator::expand_path(
target,
Some(&format!("package '{package}'")),
)?)
} else {
dirs::home_dir().unwrap_or_else(|| {
eprintln!("error: could not determine home directory");
std::process::exit(1);
})
};
let packages_dir = loader.packages_dir();
let pkg_dir = packages_dir.join(&package);
let mut moved = 0;
for file in &files {
let abs_file = std::fs::canonicalize(file).unwrap_or_else(|_| {
eprintln!("error: file not found: {}", file.display());
std::process::exit(1);
});
let rel_path = abs_file.strip_prefix(&target_dir).unwrap_or_else(|_| {
eprintln!(
"error: {} is not under the package target directory ({})",
abs_file.display(),
target_dir.display()
);
std::process::exit(1);
});
let dest = pkg_dir.join(rel_path);
if dest.exists() && !force {
eprintln!(
"error: {} already exists in package (use --force to overwrite)",
dest.display()
);
std::process::exit(1);
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&abs_file, &dest)?;
println!(" {} → {}", abs_file.display(), dest.display());
moved += 1;
}
if moved > 0 {
println!("Added {} file(s) to package '{package}'.", moved);
println!("Run 'dotm deploy' to create symlinks.");
}
}
Commands::List { what } => {
let loader = dotm::loader::ConfigLoader::new(&cli.dir)?;
match what {
ListWhat::Packages { verbose } => {
print!("{}", dotm::list::render_packages(loader.root(), verbose));
}
ListWhat::Roles { verbose } => {
print!("{}", dotm::list::render_roles(&loader, verbose)?);
}
ListWhat::Hosts { verbose, tree } => {
if tree {
print!("{}", dotm::list::render_tree(&loader)?);
} else {
print!("{}", dotm::list::render_hosts(&loader, verbose)?);
}
}
}
}
Commands::Commit { message } => {
let git_repo = dotm::git::GitRepo::open(&cli.dir).ok_or_else(|| {
anyhow::anyhow!("dotfiles directory is not a git repository")
})?;
let msg = match message {
Some(m) => m,
None => {
let dirty = git_repo.dirty_files()?;
if dirty.is_empty() {
anyhow::bail!("nothing to commit — working tree is clean");
}
let mut body = format!("dotm: update {} files\n\n", dirty.len());
for f in &dirty {
body.push_str(&format!(" {}\n", f.path));
}
body
}
};
git_repo.commit_all(&msg)?;
println!("Committed changes.");
}
Commands::Push => {
let git_repo = dotm::git::GitRepo::open(&cli.dir).ok_or_else(|| {
anyhow::anyhow!("dotfiles directory is not a git repository")
})?;
match git_repo.push()? {
dotm::git::PushResult::Success => println!("Pushed successfully."),
dotm::git::PushResult::NoRemote => {
eprintln!("error: no remote configured");
std::process::exit(1);
}
dotm::git::PushResult::Rejected(msg) => {
eprintln!("Push rejected:\n{msg}");
std::process::exit(1);
}
dotm::git::PushResult::Error(msg) => {
eprintln!("Push failed:\n{msg}");
std::process::exit(1);
}
}
}
Commands::Pull => {
let git_repo = dotm::git::GitRepo::open(&cli.dir).ok_or_else(|| {
anyhow::anyhow!("dotfiles directory is not a git repository")
})?;
match git_repo.pull()? {
dotm::git::PullResult::Success => println!("Pulled successfully."),
dotm::git::PullResult::AlreadyUpToDate => println!("Already up to date."),
dotm::git::PullResult::NoRemote => {
eprintln!("error: no remote configured");
std::process::exit(1);
}
dotm::git::PullResult::Conflicts(files) => {
eprintln!("Pull resulted in conflicts:");
for f in &files {
eprintln!(" ! {f}");
}
eprintln!(
"\nResolve conflicts in the dotfiles repo, then run 'dotm deploy'."
);
std::process::exit(1);
}
dotm::git::PullResult::Error(msg) => {
eprintln!("Pull failed:\n{msg}");
std::process::exit(1);
}
}
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "dotm", &mut std::io::stdout());
}
Commands::Prune {
host,
dry_run,
system,
} => {
let hostname = match host {
Some(h) => h,
None => hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| {
eprintln!("error: could not detect hostname, use --host to specify");
std::process::exit(1);
}),
};
let target_dir = dirs::home_dir().unwrap_or_else(|| {
eprintln!("error: could not determine home directory");
std::process::exit(1);
});
let state_dir = if system {
check_system_privileges();
system_state_dir()
} else {
dotm_state_dir()
};
let existing_state = dotm::state::DeployState::load_locked(&state_dir)?;
if existing_state.entries().is_empty() {
println!("No files currently managed by dotm.");
return Ok(());
}
let mut orch = Orchestrator::new(&cli.dir, &target_dir)?
.with_state_dir(&state_dir)
.with_system_mode(system);
let report = orch.deploy(&hostname, true, false)?;
let new_targets: std::collections::HashSet<std::path::PathBuf> = report
.dry_run_actions
.iter()
.cloned()
.collect();
let mut pruned = 0;
for entry in existing_state.entries() {
if !new_targets.contains(&entry.target) {
if dry_run {
println!(" ? {}", entry.target.display());
} else {
if entry.target.is_symlink() || entry.target.exists() {
let _ = std::fs::remove_file(&entry.target);
dotm::state::cleanup_empty_parents(&entry.target);
}
println!(" - {}", entry.target.display());
}
pruned += 1;
}
}
if dry_run {
if pruned > 0 {
println!("Dry run — would prune {pruned} orphaned files.");
} else {
println!("No orphaned files to prune.");
}
} else if pruned > 0 {
drop(existing_state); let mut orch2 = Orchestrator::new(&cli.dir, &target_dir)?
.with_state_dir(&state_dir)
.with_system_mode(system);
orch2.deploy(&hostname, false, true)?;
println!("Pruned {pruned} orphaned files.");
} else {
println!("No orphaned files to prune.");
}
}
Commands::Sync {
host,
no_push,
force,
system,
} => {
let git_repo = dotm::git::GitRepo::open(&cli.dir).ok_or_else(|| {
anyhow::anyhow!("dotfiles directory is not a git repository")
})?;
println!("Pulling from remote...");
match git_repo.pull()? {
dotm::git::PullResult::Success => println!("Pulled successfully."),
dotm::git::PullResult::AlreadyUpToDate => println!("Already up to date."),
dotm::git::PullResult::NoRemote => {
eprintln!("warning: no remote configured, skipping pull");
}
dotm::git::PullResult::Conflicts(files) => {
eprintln!("Pull resulted in merge conflicts:");
for f in &files {
eprintln!(" ! {f}");
}
eprintln!(
"\nSync aborted. Resolve conflicts in the dotfiles repo, then retry."
);
std::process::exit(1);
}
dotm::git::PullResult::Error(msg) => {
eprintln!("Pull failed:\n{msg}");
eprintln!("Sync aborted.");
std::process::exit(1);
}
}
println!("Deploying...");
let hostname = match host {
Some(h) => h,
None => hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| {
eprintln!("error: could not detect hostname, use --host to specify");
std::process::exit(1);
}),
};
let target_dir = dirs::home_dir().unwrap_or_else(|| {
eprintln!("error: could not determine home directory");
std::process::exit(1);
});
let state_dir = if system {
check_system_privileges();
system_state_dir()
} else {
dotm_state_dir()
};
let mut orch = Orchestrator::new(&cli.dir, &target_dir)?
.with_state_dir(&state_dir)
.with_system_mode(system);
if system && !orch.loader().root().packages.values().any(|p| p.system) {
println!("no system packages configured");
return Ok(());
}
let report = orch.deploy(&hostname, false, force)?;
if !report.created.is_empty() {
println!("Created {} files.", report.created.len());
}
if !report.updated.is_empty() {
println!("Updated {} files.", report.updated.len());
}
if !report.conflicts.is_empty() {
eprintln!("Deploy conflicts ({}):", report.conflicts.len());
for (path, msg) in &report.conflicts {
eprintln!(" ! {} — {}", path.display(), msg);
}
}
if !no_push {
println!("Pushing to remote...");
match git_repo.push()? {
dotm::git::PushResult::Success => println!("Pushed successfully."),
dotm::git::PushResult::NoRemote => {
eprintln!("warning: no remote configured, skipping push");
}
dotm::git::PushResult::Rejected(msg) => {
eprintln!("Push rejected:\n{msg}");
std::process::exit(1);
}
dotm::git::PushResult::Error(msg) => {
eprintln!("Push failed:\n{msg}");
std::process::exit(1);
}
}
}
println!("Sync complete.");
}
}
Ok(())
}
fn dotm_state_dir() -> PathBuf {
let dotm_dir = dirs::home_dir()
.expect("could not determine home directory")
.join(".dotm");
if dotm_dir.join("dotm-state.json").exists() {
return dotm_dir;
}
let legacy = dirs::state_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".local/state")))
.expect("could not determine state directory")
.join("dotm");
if legacy.join("dotm-state.json").exists() {
match migrate_state_dir(&legacy, &dotm_dir) {
Ok(()) => {
eprintln!(
"note: migrated state from {} to {}",
legacy.display(),
dotm_dir.display()
);
return dotm_dir;
}
Err(e) => {
eprintln!("warning: could not migrate state to {}: {e}", dotm_dir.display());
return legacy;
}
}
}
dotm_dir
}
fn migrate_state_dir(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let dest = to.join(entry.file_name());
if !dest.exists() {
std::fs::rename(entry.path(), &dest)?;
}
}
let _ = std::fs::remove_dir(from);
Ok(())
}
fn system_state_dir() -> PathBuf {
PathBuf::from("/var/lib/dotm")
}
fn check_system_privileges() {
if nix::unistd::geteuid().as_raw() != 0 {
eprintln!("error: system packages require root privileges — run with sudo");
std::process::exit(1);
}
}