use std::path::{Path, PathBuf};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use crate::{
config::{self, Config},
context::Context,
};
#[derive(Debug, Parser)]
#[command(
name = "dotr",
version,
about,
long_about = "DotR keeps a repository of your dotfiles separate from where they're \
actually used, and manages copying or symlinking them into place.\n\n\
Packages describe individual files or directories with a source and \
destination; profiles let the same repository describe different \
machines (work, home, server) with different packages, variables, and \
destinations. Config files can embed Tera template syntax compiled at \
deploy time, and pre/post shell hooks can run around each deployment."
)]
pub struct Cli {
#[clap(subcommand)]
pub command: Option<Command>,
#[clap(short, long, global = true)]
pub working_dir: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init(InitArgs),
Import(ImportArgs),
Deploy(DeployArgs),
Update(UpdateArgs),
Diff(DiffArgs),
Remove(RemovePackageArgs),
PrintVars(PrintVarsArgs),
Packages(PackagesArgs),
Profiles(ProfilesArgs),
Completions(CompletionsArgs),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CompletionShell {
Bash,
Zsh,
Fish,
Elvish,
#[value(name = "powershell")]
PowerShell,
Nushell,
Carapace,
}
const CARAPACE_SPEC: &str = include_str!("../../completions/carapace/dotr.yaml");
#[derive(Debug, Args)]
#[command(
name = "completions",
about = "Print a shell completion script to stdout.",
long_about = "Prints a completion script for the given shell to stdout. Redirect it \
into your shell's completion directory, e.g.:\n\n\
dotr completions bash > ~/.local/share/bash-completion/completions/dotr\n\n\
dotr completions zsh > ~/.zfunc/_dotr\n\n\
dotr completions fish > ~/.config/fish/completions/dotr.fish\n\n\
dotr completions nushell > ~/.config/nushell/completions/dotr.nu\n\n\
`carapace` is not a shell but a spec file for the Carapace completion \
engine (https://carapace.sh), which adds dynamic completion of real \
package/profile names from config.toml on top of the static commands and \
flags below:\n\n\
dotr completions carapace > ~/.config/carapace/specs/dotr.yaml"
)]
pub struct CompletionsArgs {
pub shell: CompletionShell,
}
#[derive(Debug, Args, Default)]
#[command(
name = "remove",
about = "Remove a managed package.",
long_about = "Deletes a package's entry from config.toml and its files from \
dotfiles/. Refuses to remove a package that another package or profile still depends \
on unless --force is given."
)]
pub struct RemovePackageArgs {
#[arg(num_args(0..))]
pub packages: Option<Vec<String>>,
#[arg(short, long, default_value_t = false)]
pub force: bool,
#[arg(long, default_value_t = false)]
pub remove_orphans: bool,
#[arg(long, default_value_t = false)]
pub dry_run: bool,
#[arg(short = 'P', long)]
pub profile: Option<String>,
}
#[derive(Debug, Args)]
#[command(
name = "init",
about = "Initialize dotfiles repository.",
long_about = "Creates config.toml, a dotfiles/ directory, and a .gitignore in the \
working directory. Safe to re-run - if config.toml already exists, it's left untouched."
)]
pub struct InitArgs {}
#[derive(Debug, Args, Default)]
#[command(
name = "print-vars",
about = "Print all user variables.",
long_about = "Prints every variable resolved for the given (or default) profile - \
config, environment, package, profile, and prompt-sourced - useful for debugging why a \
template rendered the way it did."
)]
pub struct PrintVarsArgs {
#[arg(short, long)]
pub profile: Option<String>,
}
#[derive(Debug, Args)]
#[command(
name = "import",
about = "Import dotfile and update configuration.",
long_about = "Copies a file or directory into the repository and registers it as a \
package in config.toml, with a source and destination. Use --symlink to also deploy it \
immediately as a symlink instead of a copy."
)]
#[derive(Default)]
pub struct ImportArgs {
#[arg(value_name = "IMPORT_PATH")]
pub path: String,
#[arg(short, long, default_value_t = false)]
pub symlink: bool,
#[arg(short, long)]
pub name: Option<String>,
#[arg(short, long)]
pub profile: Option<String>,
}
#[derive(Debug, Args, Default)]
#[command(
name = "diff",
about = "Show differences between dotfiles.",
long_about = "Shows a colored, line-by-line diff between what's in the repository \
and what's currently deployed, without changing anything."
)]
pub struct DiffArgs {
#[arg(num_args(0..), short, long)]
pub packages: Option<Vec<String>>,
#[arg(short = 'P', long)]
pub profile: Option<String>,
#[arg(long, default_value_t = false)]
pub ignore_errors: bool,
}
#[derive(Debug, Args, Default)]
#[command(
name = "update",
about = "Update dotfiles from deployed versions.",
long_about = "Copies changes made to deployed files back into the repository, so \
dotfiles/ keeps reflecting what's actually deployed. Templated packages are skipped, \
since the template file is the source of truth."
)]
pub struct UpdateArgs {
#[arg(num_args(0..), short, long)]
pub packages: Option<Vec<String>>,
#[arg(short = 'P', long)]
pub profile: Option<String>,
#[arg(long, default_value_t = false)]
pub ignore_errors: bool,
#[arg(long)]
pub clean: Option<bool>,
#[arg(long, default_value_t = false)]
pub dry_run: bool,
}
#[derive(Debug, Args, Default)]
#[command(
name = "deploy",
about = "Deploy dotfiles from repository.",
long_about = "Copies (or symlinks) every selected package from the repository to \
its destination. Selects the active profile's packages by default, or a specific set \
via --packages."
)]
pub struct DeployArgs {
#[arg(num_args(0..), short, long)]
pub packages: Option<Vec<String>>,
#[arg(short = 'P', long)]
pub profile: Option<String>,
#[arg(long, default_value_t = false)]
pub ignore_errors: bool,
#[arg(long)]
pub clean: Option<bool>,
#[arg(long, default_value_t = false)]
pub dry_run: bool,
#[arg(long, default_value_t = false)]
pub skip_actions: bool,
#[arg(long, default_value_t = false)]
pub skip_pre_actions: bool,
#[arg(long, default_value_t = false)]
pub skip_post_actions: bool,
#[arg(long, default_value_t = false)]
pub ignore_dependencies: bool,
}
#[derive(Debug, Args, Default)]
#[command(
name = "packages",
about = "Manage packages (list, import, deploy, update, remove, diff).",
long_about = "Groups package-scoped commands under one namespace. Each subcommand \
behaves the same as its top-level equivalent, plus list for viewing all managed packages."
)]
pub struct PackagesArgs {
#[arg(short = 'P', long)]
pub profile: Option<String>,
#[clap(subcommand)]
pub command: Option<PackagesCommand>,
}
#[derive(Debug, Subcommand)]
pub enum PackagesCommand {
List(PackagesListArgs),
Import(ImportArgs),
Deploy(DeployArgs),
Update(UpdateArgs),
Remove(RemovePackageArgs),
Diff(DiffArgs),
}
#[derive(Debug, Args, Default)]
#[command(
name = "list",
about = "List all managed packages.",
long_about = "Lists every package currently tracked in config.toml. Use --verbose \
to also show each package's source, destination, and other fields. Use --plain for a bare, \
newline-separated list of names with no other output, suitable for scripting or shell \
completion."
)]
pub struct PackagesListArgs {
#[arg(short, long, default_value_t = false)]
pub verbose: bool,
#[arg(long, default_value_t = false, conflicts_with = "verbose")]
pub plain: bool,
}
#[derive(Debug, Args, Default)]
#[command(
name = "profiles",
about = "Manage profiles (list, add, remove).",
long_about = "Profiles describe an environment (work, home, a server) as a set of \
packages, variables, and prompts, so the same repository can deploy differently \
depending on which profile is active."
)]
pub struct ProfilesArgs {
#[clap(subcommand)]
pub command: Option<ProfilesCommand>,
}
#[derive(Debug, Subcommand)]
pub enum ProfilesCommand {
Add(ProfilesAddArgs),
List(ProfilesListArgs),
Remove(ProfileRemoveArgs),
}
#[derive(Debug, Args, Default)]
#[command(
name = "remove",
about = "Remove a profile.",
long_about = "Deletes a profile from config.toml. The default profile cannot be \
removed. Use --remove-orphans to also clean up packages that were only referenced by \
this profile."
)]
pub struct ProfileRemoveArgs {
#[arg(value_name = "PROFILE_NAME")]
pub name: String,
#[arg(long, default_value_t = false)]
pub dry_run: bool,
#[arg(long, default_value_t = false)]
pub remove_orphans: bool,
}
#[derive(Debug, Args, Default)]
#[command(
name = "list",
about = "List all profiles.",
long_about = "Lists every profile defined in config.toml. Use --verbose to also \
show each profile's dependencies and variables. Use --plain for a bare, newline-separated \
list of names with no other output, suitable for scripting or shell completion."
)]
pub struct ProfilesListArgs {
#[arg(short, long, default_value_t = false)]
pub verbose: bool,
#[arg(long, default_value_t = false, conflicts_with = "verbose")]
pub plain: bool,
}
#[derive(Debug, Args, Default)]
#[command(
name = "add",
about = "Add a new profile.",
long_about = "Creates a new, empty profile in config.toml. Use --set-as-current to \
make it the default on this machine by writing DOTR_PROFILE into .uservariables.toml."
)]
pub struct ProfilesAddArgs {
#[arg(value_name = "PROFILE_NAME")]
pub name: String,
#[arg(long, default_value_t = false)]
pub set_as_current: bool,
}
const BANNER: &str = r#"
██████╗ ██████╗ ████████╗██████╗
██╔══██╗██╔═══██╗╚══██╔══╝██╔══██╗
██║ ██║██║ ██║ ██║ ██████╔╝
██║ ██║██║ ██║ ██║ ██╔══██╗
██████╔╝╚██████╔╝ ██║ ██║ ██║
╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
"#;
pub fn run_cli(args: Cli) -> Result<(), anyhow::Error> {
let mut working_dir = std::env::current_dir()?;
if let Some(wd) = args.working_dir {
working_dir = PathBuf::from(wd);
}
if !working_dir.exists() {
anyhow::bail!("The specified working directory does not exist");
}
working_dir = working_dir.canonicalize()?;
match args.command {
Some(Command::Init(_)) => {
println!("Initializing configuration...");
Config::init(&working_dir)?;
println!("Configuration initialized successfully.");
}
Some(Command::Import(args)) => {
let (mut conf, ctx) = init_config(&working_dir, &args.profile, true, true)?;
conf.import_package(&args, &ctx)?;
}
Some(Command::Deploy(args)) => {
let (conf, mut ctx) = init_config(&working_dir, &args.profile, false, true)?;
ctx.get_prompted_variables(&conf, &args.packages)?;
conf.deploy_packages(&ctx, &args)?;
}
Some(Command::Update(args)) => {
let (conf, mut ctx) = init_config(&working_dir, &args.profile, false, true)?;
ctx.get_prompted_variables(&conf, &args.packages)?;
conf.backup_packages(&ctx, &args)?;
}
Some(Command::Diff(args)) => {
let (conf, mut ctx) = init_config(&working_dir, &args.profile, false, true)?;
ctx.get_prompted_variables(&conf, &args.packages)?;
conf.diff_packages(&ctx, &args)?;
}
Some(Command::PrintVars(args)) => {
let (_, ctx) = init_config(&working_dir, &args.profile, false, true)?;
ctx.print_variables();
}
Some(Command::Remove(args)) => {
let (mut conf, ctx) = init_config(&working_dir, &args.profile, false, true)?;
conf.remove_packages(&args, &ctx)?;
}
Some(Command::Packages(args)) => {
let show_banner = !matches!(&args.command, Some(PackagesCommand::List(a)) if a.plain);
let (mut conf, mut ctx) = init_config(&working_dir, &args.profile, false, show_banner)?;
match args.command {
Some(PackagesCommand::List(args)) => {
conf.list_packages(&ctx, &args)?;
}
Some(PackagesCommand::Import(import_args)) => {
conf.import_package(&import_args, &ctx)?;
}
Some(PackagesCommand::Deploy(deploy_args)) => {
ctx.get_prompted_variables(&conf, &deploy_args.packages)?;
conf.deploy_packages(&ctx, &deploy_args)?;
}
Some(PackagesCommand::Update(update_args)) => {
ctx.get_prompted_variables(&conf, &update_args.packages)?;
conf.backup_packages(&ctx, &update_args)?;
}
Some(PackagesCommand::Diff(diff_args)) => {
ctx.get_prompted_variables(&conf, &diff_args.packages)?;
conf.diff_packages(&ctx, &diff_args)?;
}
Some(PackagesCommand::Remove(remove_args)) => {
conf.remove_packages(&remove_args, &ctx)?;
}
None => {
println!("No packages command provided. Use --help for more information.");
}
}
}
Some(Command::Profiles(args)) => {
let show_banner = !matches!(&args.command, Some(ProfilesCommand::List(a)) if a.plain);
let (mut conf, mut ctx) = init_config(&working_dir, &None, false, show_banner)?;
match args.command {
Some(ProfilesCommand::List(list_args)) => {
conf.list_profiles(&list_args)?;
}
Some(ProfilesCommand::Add(add_args)) => {
conf.add_profile(&add_args, &mut ctx)?;
}
Some(ProfilesCommand::Remove(remove_args)) => {
conf.remove_profile(&remove_args, &ctx)?;
}
None => {
println!("No profiles command provided. Use --help for more information.");
}
}
}
Some(Command::Completions(args)) => {
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
let mut stdout = std::io::stdout();
match args.shell {
CompletionShell::Bash => clap_complete::generate(
clap_complete::Shell::Bash,
&mut cmd,
bin_name,
&mut stdout,
),
CompletionShell::Zsh => clap_complete::generate(
clap_complete::Shell::Zsh,
&mut cmd,
bin_name,
&mut stdout,
),
CompletionShell::Fish => clap_complete::generate(
clap_complete::Shell::Fish,
&mut cmd,
bin_name,
&mut stdout,
),
CompletionShell::Elvish => clap_complete::generate(
clap_complete::Shell::Elvish,
&mut cmd,
bin_name,
&mut stdout,
),
CompletionShell::PowerShell => clap_complete::generate(
clap_complete::Shell::PowerShell,
&mut cmd,
bin_name,
&mut stdout,
),
CompletionShell::Nushell => clap_complete::generate(
clap_complete_nushell::Nushell,
&mut cmd,
bin_name,
&mut stdout,
),
CompletionShell::Carapace => print!("{}", CARAPACE_SPEC),
}
}
None => {
println!("No command provided. Use --help for more information.");
}
}
Ok(())
}
fn init_config(
working_dir: &Path,
profile: &Option<String>,
create_if_missing: bool,
show_banner: bool,
) -> anyhow::Result<(Config, Context)> {
let mut conf = config::Config::from_path(working_dir)?;
if conf.banner && show_banner {
println!("{}", BANNER);
}
let (ctx, profile_created) = Context::new(working_dir, &conf, profile, create_if_missing)?;
if profile_created {
conf.update_profiles(&ctx.profile, &ctx)?;
}
Ok((conf, ctx))
}