1use std::path::{Path, PathBuf};
2
3use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
4
5use crate::{
6 config::{self, Config},
7 context::Context,
8 utils::{LogLevel, cprintln},
9};
10
11#[derive(Debug, Parser)]
12#[command(
13 name = "dotr",
14 version,
15 about,
16 long_about = "DotR keeps a repository of your dotfiles separate from where they're \
17actually used, and manages copying or symlinking them into place.\n\n\
18Packages describe individual files or directories with a source and \
19destination; profiles let the same repository describe different \
20machines (work, home, server) with different packages, variables, and \
21destinations. Config files can embed Tera template syntax compiled at \
22deploy time, and pre/post shell hooks can run around each deployment."
23)]
24pub struct Cli {
25 #[clap(subcommand)]
26 pub command: Option<Command>,
27 #[clap(short, long, global = true)]
29 pub working_dir: Option<String>,
30}
31
32#[derive(Debug, Subcommand)]
33pub enum Command {
34 Init(InitArgs),
35 Import(ImportArgs),
36 Deploy(DeployArgs),
37 Update(UpdateArgs),
38 Diff(DiffArgs),
39 Remove(RemovePackageArgs),
40 PrintVars(PrintVarsArgs),
41 DumpUserVars(DumpUserVarsArgs),
42 Packages(PackagesArgs),
43 Profiles(ProfilesArgs),
44 Completions(CompletionsArgs),
45}
46
47#[derive(Debug, Clone, Copy, ValueEnum)]
49pub enum CompletionShell {
50 Bash,
51 Zsh,
52 Fish,
53 Elvish,
54 #[value(name = "powershell")]
55 PowerShell,
56 Nushell,
57 Carapace,
59}
60
61const CARAPACE_SPEC: &str = include_str!("../../completions/carapace/dotr.yaml");
64
65#[derive(Debug, Args)]
66#[command(
67 name = "completions",
68 about = "Print a shell completion script to stdout.",
69 long_about = "Prints a completion script for the given shell to stdout. Redirect it \
70into your shell's completion directory, e.g.:\n\n\
71dotr completions bash > ~/.local/share/bash-completion/completions/dotr\n\n\
72dotr completions zsh > ~/.zfunc/_dotr\n\n\
73dotr completions fish > ~/.config/fish/completions/dotr.fish\n\n\
74dotr completions nushell > ~/.config/nushell/completions/dotr.nu\n\n\
75`carapace` is not a shell but a spec file for the Carapace completion \
76engine (https://carapace.sh), which adds dynamic completion of real \
77package/profile names from config.toml on top of the static commands and \
78flags below:\n\n\
79dotr completions carapace > ~/.config/carapace/specs/dotr.yaml"
80)]
81pub struct CompletionsArgs {
82 pub shell: CompletionShell,
84}
85
86#[derive(Debug, Args, Default)]
87#[command(
88 name = "remove",
89 about = "Remove a managed package.",
90 long_about = "Deletes a package's entry from config.toml and its files from \
91dotfiles/. Refuses to remove a package that another package or profile still depends \
92on unless --force is given."
93)]
94pub struct RemovePackageArgs {
95 #[arg(num_args(0..))]
97 pub packages: Option<Vec<String>>,
98
99 #[arg(short, long, default_value_t = false)]
101 pub force: bool,
102
103 #[arg(long, default_value_t = false)]
105 pub remove_orphans: bool,
106
107 #[arg(long, default_value_t = false)]
109 pub dry_run: bool,
110
111 #[arg(short = 'P', long)]
113 pub profile: Option<String>,
114}
115
116#[derive(Debug, Args)]
117#[command(
118 name = "init",
119 about = "Initialize dotfiles repository.",
120 long_about = "Creates config.toml, a dotfiles/ directory, and a .gitignore in the \
121working directory. Safe to re-run - if config.toml already exists, it's left untouched."
122)]
123pub struct InitArgs {}
124
125#[derive(Debug, Args, Default)]
126#[command(
127 name = "print-vars",
128 about = "Print all user variables.",
129 long_about = "Prints every variable resolved for the given (or default) profile - \
130config, environment, package, profile, and prompt-sourced - useful for debugging why a \
131template rendered the way it did."
132)]
133pub struct PrintVarsArgs {
134 #[arg(short, long)]
136 pub profile: Option<String>,
137}
138
139#[derive(Debug, Args, Default)]
140#[command(
141 name = "dump-user-vars",
142 about = "Dump user variables to a TOML file.",
143 long_about = "Resolves every prompted (user) variable for the given (or default) \
144profile - prompting for anything not yet answered, through whichever backend is \
145configured (file, keychain, or Bitwarden, via prompt_backend) - and writes the full \
146set as TOML to stdout, or to --output if given. Unlike .uservariables.toml, this \
147includes keychain- and Bitwarden-backed values too, so it's an escape hatch for \
148backup or migrating a value from one backend to another: copy an entry into \
149.uservariables.toml and drop (or change) prompt_backend to move it there."
150)]
151pub struct DumpUserVarsArgs {
152 #[arg(short, long)]
154 pub profile: Option<String>,
155
156 #[arg(num_args(0..), long)]
159 pub packages: Option<Vec<String>>,
160
161 #[arg(short, long)]
163 pub output: Option<String>,
164}
165
166#[derive(Debug, Args)]
167#[command(
168 name = "import",
169 about = "Import dotfile and update configuration.",
170 long_about = "Copies a file or directory into the repository and registers it as a \
171package in config.toml, with a source and destination. Use --symlink to also deploy it \
172immediately as a symlink instead of a copy."
173)]
174#[derive(Default)]
175pub struct ImportArgs {
176 #[arg(value_name = "IMPORT_PATH")]
178 pub path: String,
179
180 #[arg(short, long, default_value_t = false)]
182 pub symlink: bool,
183
184 #[arg(short, long)]
186 pub name: Option<String>,
187
188 #[arg(short, long)]
190 pub profile: Option<String>,
191}
192
193#[derive(Debug, Args, Default)]
194#[command(
195 name = "diff",
196 about = "Show differences between dotfiles.",
197 long_about = "Shows a colored, line-by-line diff between what's in the repository \
198and what's currently deployed, without changing anything."
199)]
200pub struct DiffArgs {
201 #[arg(num_args(0..), short, long)]
203 pub packages: Option<Vec<String>>,
204
205 #[arg(short = 'P', long)]
207 pub profile: Option<String>,
208
209 #[arg(long, default_value_t = false)]
211 pub ignore_errors: bool,
212}
213
214#[derive(Debug, Args, Default)]
215#[command(
216 name = "update",
217 about = "Update dotfiles from deployed versions.",
218 long_about = "Copies changes made to deployed files back into the repository, so \
219dotfiles/ keeps reflecting what's actually deployed. Templated packages are skipped, \
220since the template file is the source of truth."
221)]
222pub struct UpdateArgs {
223 #[arg(num_args(0..), short, long)]
225 pub packages: Option<Vec<String>>,
226
227 #[arg(short = 'P', long)]
229 pub profile: Option<String>,
230
231 #[arg(long, default_value_t = false)]
233 pub ignore_errors: bool,
234
235 #[arg(long)]
237 pub clean: Option<bool>,
238
239 #[arg(long, default_value_t = false)]
241 pub dry_run: bool,
242}
243
244#[derive(Debug, Args, Default)]
245#[command(
246 name = "deploy",
247 about = "Deploy dotfiles from repository.",
248 long_about = "Copies (or symlinks) every selected package from the repository to \
249its destination. Selects the active profile's packages by default, or a specific set \
250via --packages."
251)]
252pub struct DeployArgs {
253 #[arg(num_args(0..), short, long)]
256 pub packages: Option<Vec<String>>,
257
258 #[arg(short = 'P', long)]
260 pub profile: Option<String>,
261
262 #[arg(long, default_value_t = false)]
264 pub ignore_errors: bool,
265
266 #[arg(long)]
268 pub clean: Option<bool>,
269
270 #[arg(long, default_value_t = false)]
272 pub dry_run: bool,
273
274 #[arg(long, default_value_t = false)]
276 pub skip_actions: bool,
277
278 #[arg(long, default_value_t = false)]
280 pub skip_pre_actions: bool,
281
282 #[arg(long, default_value_t = false)]
284 pub skip_post_actions: bool,
285
286 #[arg(long, default_value_t = false)]
288 pub ignore_dependencies: bool,
289}
290
291#[derive(Debug, Args, Default)]
292#[command(
293 name = "packages",
294 about = "Manage packages (list, import, deploy, update, remove, diff).",
295 long_about = "Groups package-scoped commands under one namespace. Each subcommand \
296behaves the same as its top-level equivalent, plus list for viewing all managed packages."
297)]
298pub struct PackagesArgs {
299 #[arg(short = 'P', long)]
301 pub profile: Option<String>,
302 #[clap(subcommand)]
303 pub command: Option<PackagesCommand>,
304}
305
306#[derive(Debug, Subcommand)]
307pub enum PackagesCommand {
308 List(PackagesListArgs),
309 Import(ImportArgs),
310 Deploy(DeployArgs),
311 Update(UpdateArgs),
312 Remove(RemovePackageArgs),
313 Diff(DiffArgs),
314}
315
316#[derive(Debug, Args, Default)]
317#[command(
318 name = "list",
319 about = "List all managed packages.",
320 long_about = "Lists every package currently tracked in config.toml. Use --verbose \
321to also show each package's source, destination, and other fields. Use --plain for a bare, \
322newline-separated list of names with no other output, suitable for scripting or shell \
323completion."
324)]
325pub struct PackagesListArgs {
326 #[arg(short, long, default_value_t = false)]
328 pub verbose: bool,
329
330 #[arg(long, default_value_t = false, conflicts_with = "verbose")]
332 pub plain: bool,
333}
334
335#[derive(Debug, Args, Default)]
336#[command(
337 name = "profiles",
338 about = "Manage profiles (list, add, remove).",
339 long_about = "Profiles describe an environment (work, home, a server) as a set of \
340packages, variables, and prompts, so the same repository can deploy differently \
341depending on which profile is active."
342)]
343pub struct ProfilesArgs {
344 #[clap(subcommand)]
345 pub command: Option<ProfilesCommand>,
346}
347
348#[derive(Debug, Subcommand)]
349pub enum ProfilesCommand {
350 Add(ProfilesAddArgs),
351 List(ProfilesListArgs),
352 Remove(ProfileRemoveArgs),
353}
354
355#[derive(Debug, Args, Default)]
356#[command(
357 name = "remove",
358 about = "Remove a profile.",
359 long_about = "Deletes a profile from config.toml. The default profile cannot be \
360removed. Use --remove-orphans to also clean up packages that were only referenced by \
361this profile."
362)]
363pub struct ProfileRemoveArgs {
364 #[arg(value_name = "PROFILE_NAME")]
366 pub name: String,
367
368 #[arg(long, default_value_t = false)]
370 pub dry_run: bool,
371
372 #[arg(long, default_value_t = false)]
374 pub remove_orphans: bool,
375}
376
377#[derive(Debug, Args, Default)]
378#[command(
379 name = "list",
380 about = "List all profiles.",
381 long_about = "Lists every profile defined in config.toml. Use --verbose to also \
382show each profile's dependencies and variables. Use --plain for a bare, newline-separated \
383list of names with no other output, suitable for scripting or shell completion."
384)]
385pub struct ProfilesListArgs {
386 #[arg(short, long, default_value_t = false)]
388 pub verbose: bool,
389
390 #[arg(long, default_value_t = false, conflicts_with = "verbose")]
392 pub plain: bool,
393}
394
395#[derive(Debug, Args, Default)]
396#[command(
397 name = "add",
398 about = "Add a new profile.",
399 long_about = "Creates a new, empty profile in config.toml. Use --set-as-current to \
400make it the default on this machine by writing DOTR_PROFILE into .uservariables.toml."
401)]
402pub struct ProfilesAddArgs {
403 #[arg(value_name = "PROFILE_NAME")]
405 pub name: String,
406
407 #[arg(long, default_value_t = false)]
409 pub set_as_current: bool,
410}
411
412const BANNER: &str = r#"
413██████╗ ██████╗ ████████╗██████╗
414██╔══██╗██╔═══██╗╚══██╔══╝██╔══██╗
415██║ ██║██║ ██║ ██║ ██████╔╝
416██║ ██║██║ ██║ ██║ ██╔══██╗
417██████╔╝╚██████╔╝ ██║ ██║ ██║
418╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
419"#;
420
421pub fn run_cli(args: Cli) -> Result<(), anyhow::Error> {
422 let mut working_dir = std::env::current_dir()?;
423 if let Some(wd) = args.working_dir {
424 working_dir = PathBuf::from(wd);
425 }
426
427 if !working_dir.exists() {
428 anyhow::bail!("The specified working directory does not exist");
429 }
430 working_dir = working_dir.canonicalize()?;
431
432 match args.command {
433 Some(Command::Init(_)) => {
434 println!("Initializing configuration...");
435 Config::init(&working_dir)?;
436 println!("Configuration initialized successfully.");
437 }
438 Some(Command::Import(args)) => {
439 let (mut conf, mut ctx) = init_config(&working_dir, &args.profile, true, true)?;
440 ctx.get_prompted_variables(&conf, &None)?;
441 conf.import_package(&args, &ctx)?;
442 }
443 Some(Command::Deploy(args)) => {
444 let (conf, mut ctx) = init_config(&working_dir, &args.profile, false, true)?;
445 ctx.get_prompted_variables(&conf, &args.packages)?;
446 conf.deploy_packages(&ctx, &args)?;
447 }
448 Some(Command::Update(args)) => {
449 let (conf, mut ctx) = init_config(&working_dir, &args.profile, false, true)?;
450 ctx.get_prompted_variables(&conf, &args.packages)?;
451 conf.backup_packages(&ctx, &args)?;
452 }
453 Some(Command::Diff(args)) => {
454 let (conf, mut ctx) = init_config(&working_dir, &args.profile, false, true)?;
455 ctx.get_prompted_variables(&conf, &args.packages)?;
456 conf.diff_packages(&ctx, &args)?;
457 }
458 Some(Command::PrintVars(args)) => {
459 let (conf, mut ctx) = init_config(&working_dir, &args.profile, false, true)?;
460 ctx.get_prompted_variables(&conf, &None)?;
461 ctx.print_variables();
462 }
463 Some(Command::DumpUserVars(args)) => {
464 let (conf, mut ctx) = init_config(&working_dir, &args.profile, false, true)?;
465 let resolved = ctx.get_prompted_variables(&conf, &args.packages)?;
466 let toml_string = toml::to_string_pretty(&resolved)?;
467 match &args.output {
468 Some(path) => {
469 std::fs::write(path, &toml_string)?;
470 cprintln(
471 &format!("Wrote {} prompted variable(s) to {}", resolved.len(), path),
472 &LogLevel::Info,
473 );
474 }
475 None => print!("{}", toml_string),
476 }
477 }
478 Some(Command::Remove(args)) => {
479 let (mut conf, ctx) = init_config(&working_dir, &args.profile, false, true)?;
480 conf.remove_packages(&args, &ctx)?;
481 }
482 Some(Command::Packages(args)) => {
483 let show_banner = !matches!(&args.command, Some(PackagesCommand::List(a)) if a.plain);
484 let (mut conf, mut ctx) = init_config(&working_dir, &args.profile, false, show_banner)?;
485 match args.command {
486 Some(PackagesCommand::List(args)) => {
487 ctx.get_prompted_variables(&conf, &None)?;
488 conf.list_packages(&ctx, &args)?;
489 }
490 Some(PackagesCommand::Import(import_args)) => {
491 ctx.get_prompted_variables(&conf, &None)?;
492 conf.import_package(&import_args, &ctx)?;
493 }
494 Some(PackagesCommand::Deploy(deploy_args)) => {
495 ctx.get_prompted_variables(&conf, &deploy_args.packages)?;
496 conf.deploy_packages(&ctx, &deploy_args)?;
497 }
498 Some(PackagesCommand::Update(update_args)) => {
499 ctx.get_prompted_variables(&conf, &update_args.packages)?;
500 conf.backup_packages(&ctx, &update_args)?;
501 }
502 Some(PackagesCommand::Diff(diff_args)) => {
503 ctx.get_prompted_variables(&conf, &diff_args.packages)?;
504 conf.diff_packages(&ctx, &diff_args)?;
505 }
506 Some(PackagesCommand::Remove(remove_args)) => {
507 conf.remove_packages(&remove_args, &ctx)?;
508 }
509 None => {
510 println!("No packages command provided. Use --help for more information.");
511 }
512 }
513 }
514 Some(Command::Profiles(args)) => {
515 let show_banner = !matches!(&args.command, Some(ProfilesCommand::List(a)) if a.plain);
516 let (mut conf, mut ctx) = init_config(&working_dir, &None, false, show_banner)?;
517 match args.command {
518 Some(ProfilesCommand::List(list_args)) => {
519 conf.list_profiles(&list_args)?;
520 }
521 Some(ProfilesCommand::Add(add_args)) => {
522 conf.add_profile(&add_args, &mut ctx)?;
523 }
524 Some(ProfilesCommand::Remove(remove_args)) => {
525 conf.remove_profile(&remove_args, &ctx)?;
526 }
527 None => {
528 println!("No profiles command provided. Use --help for more information.");
529 }
530 }
531 }
532 Some(Command::Completions(args)) => {
533 let mut cmd = Cli::command();
534 let bin_name = cmd.get_name().to_string();
535 let mut stdout = std::io::stdout();
536 match args.shell {
537 CompletionShell::Bash => clap_complete::generate(
538 clap_complete::Shell::Bash,
539 &mut cmd,
540 bin_name,
541 &mut stdout,
542 ),
543 CompletionShell::Zsh => clap_complete::generate(
544 clap_complete::Shell::Zsh,
545 &mut cmd,
546 bin_name,
547 &mut stdout,
548 ),
549 CompletionShell::Fish => clap_complete::generate(
550 clap_complete::Shell::Fish,
551 &mut cmd,
552 bin_name,
553 &mut stdout,
554 ),
555 CompletionShell::Elvish => clap_complete::generate(
556 clap_complete::Shell::Elvish,
557 &mut cmd,
558 bin_name,
559 &mut stdout,
560 ),
561 CompletionShell::PowerShell => clap_complete::generate(
562 clap_complete::Shell::PowerShell,
563 &mut cmd,
564 bin_name,
565 &mut stdout,
566 ),
567 CompletionShell::Nushell => clap_complete::generate(
568 clap_complete_nushell::Nushell,
569 &mut cmd,
570 bin_name,
571 &mut stdout,
572 ),
573 CompletionShell::Carapace => print!("{}", CARAPACE_SPEC),
574 }
575 }
576 None => {
577 println!("No command provided. Use --help for more information.");
578 }
579 }
580 Ok(())
581}
582
583fn init_config(
584 working_dir: &Path,
585 profile: &Option<String>,
586 create_if_missing: bool,
587 show_banner: bool,
588) -> anyhow::Result<(Config, Context)> {
589 let mut conf = config::Config::from_path(working_dir)?;
590 if conf.banner && show_banner {
591 println!("{}", BANNER);
592 }
593 let (ctx, profile_created) = Context::new(working_dir, &conf, profile, create_if_missing)?;
594 if profile_created {
595 conf.update_profiles(&ctx.profile, &ctx)?;
596 }
597 Ok((conf, ctx))
598}