Skip to main content

dotr_dear/cli/
mod.rs

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    /// Run as if invoked from this directory instead of the current one.
28    #[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/// Shells `dotr completions` can generate a script for, plus the Carapace spec format.
48#[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 spec (YAML), not a shell script — see the `carapace` case below.
58    Carapace,
59}
60
61/// The Carapace spec YAML, kept in sync by hand alongside the `Cli` definition
62/// (unlike the other shells, which `clap_complete` generates automatically).
63const 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    /// Shell (or `carapace` for a Carapace spec) to generate completions for.
83    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    /// Packages to remove.
96    #[arg(num_args(0..))]
97    pub packages: Option<Vec<String>>,
98
99    /// Remove even if another package or profile still depends on it.
100    #[arg(short, long, default_value_t = false)]
101    pub force: bool,
102
103    /// Also remove dependencies left unreferenced after this removal.
104    #[arg(long, default_value_t = false)]
105    pub remove_orphans: bool,
106
107    /// Preview what would be removed without making changes.
108    #[arg(long, default_value_t = false)]
109    pub dry_run: bool,
110
111    /// Profile context used to resolve dependencies.
112    #[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    /// Resolve variables for this profile instead of the default.
135    #[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    /// Resolve variables for this profile instead of the default.
153    #[arg(short, long)]
154    pub profile: Option<String>,
155
156    /// Only resolve prompts relevant to these packages. Omit to use the
157    /// active profile's packages.
158    #[arg(num_args(0..), long)]
159    pub packages: Option<Vec<String>>,
160
161    /// Write the dump to this file instead of stdout.
162    #[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    /// Path to the file or directory to import.
177    #[arg(value_name = "IMPORT_PATH")]
178    pub path: String,
179
180    /// Deploy as a symlink instead of a copy, and deploy immediately.
181    #[arg(short, long, default_value_t = false)]
182    pub symlink: bool,
183
184    /// Override the auto-derived package name.
185    #[arg(short, long)]
186    pub name: Option<String>,
187
188    /// Add the package to this profile's dependencies instead of default.
189    #[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    /// Diff only these packages. Omit to diff the active profile's packages.
202    #[arg(num_args(0..), short, long)]
203    pub packages: Option<Vec<String>>,
204
205    /// Use this profile instead of the resolved default.
206    #[arg(short = 'P', long)]
207    pub profile: Option<String>,
208
209    /// Keep diffing remaining packages if one fails.
210    #[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    /// Update only these packages. Omit to update the active profile's packages.
224    #[arg(num_args(0..), short, long)]
225    pub packages: Option<Vec<String>>,
226
227    /// Use this profile instead of the resolved default.
228    #[arg(short = 'P', long)]
229    pub profile: Option<String>,
230
231    /// Keep updating remaining packages if one fails.
232    #[arg(long, default_value_t = false)]
233    pub ignore_errors: bool,
234
235    /// Override the clean mode setting for this run.
236    #[arg(long)]
237    pub clean: Option<bool>,
238
239    /// Preview without changing anything.
240    #[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    /// Deploy only these packages (plus dependencies, unless
254    /// --ignore-dependencies). Omit to deploy the active profile's packages.
255    #[arg(num_args(0..), short, long)]
256    pub packages: Option<Vec<String>>,
257
258    /// Use this profile instead of the resolved default.
259    #[arg(short = 'P', long)]
260    pub profile: Option<String>,
261
262    /// Keep deploying remaining packages if one fails.
263    #[arg(long, default_value_t = false)]
264    pub ignore_errors: bool,
265
266    /// Override the clean mode setting for this run.
267    #[arg(long)]
268    pub clean: Option<bool>,
269
270    /// Preview without changing anything.
271    #[arg(long, default_value_t = false)]
272    pub dry_run: bool,
273
274    /// Skip both pre- and post-actions.
275    #[arg(long, default_value_t = false)]
276    pub skip_actions: bool,
277
278    /// Skip only pre-actions.
279    #[arg(long, default_value_t = false)]
280    pub skip_pre_actions: bool,
281
282    /// Skip only post-actions.
283    #[arg(long, default_value_t = false)]
284    pub skip_post_actions: bool,
285
286    /// Deploy only the selected packages, without pulling in dependencies.
287    #[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    /// Profile context for the packages subcommand.
300    #[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    /// Show detailed information for each package.
327    #[arg(short, long, default_value_t = false)]
328    pub verbose: bool,
329
330    /// Print only package names, one per line, with no other output.
331    #[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    /// Name of the profile to remove.
365    #[arg(value_name = "PROFILE_NAME")]
366    pub name: String,
367
368    /// Preview what would be removed without making changes.
369    #[arg(long, default_value_t = false)]
370    pub dry_run: bool,
371
372    /// Also remove packages that were only referenced by this profile.
373    #[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    /// Show detailed information for each profile.
387    #[arg(short, long, default_value_t = false)]
388    pub verbose: bool,
389
390    /// Print only profile names, one per line, with no other output.
391    #[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    /// Name for the new profile.
404    #[arg(value_name = "PROFILE_NAME")]
405    pub name: String,
406
407    /// Save this profile as the default (writes DOTR_PROFILE to .uservariables.toml).
408    #[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}