yui/cli.rs
1use anyhow::Result;
2use camino::Utf8PathBuf;
3use clap::builder::styling::{AnsiColor, Effects, Styles};
4use clap::{Parser, Subcommand};
5
6use crate::cmd;
7use crate::config::IconsMode;
8
9/// Explicit colour palette for `--help` output. clap honours `NO_COLOR`
10/// and falls back to monochrome when stdout isn't a TTY, so this is
11/// safe to leave on unconditionally — the styled bytes are only ever
12/// emitted when a real terminal is reading them. The palette mirrors
13/// the bind-points / icon colours used in `yui list` / `yui status` so
14/// help, list, and status all feel like the same tool.
15const HELP_STYLES: Styles = Styles::styled()
16 // "Commands:" / "Options:" / etc. section headers.
17 .header(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
18 // The "Usage:" heading label itself (NOT the binary name — that
19 // falls under `literal` below).
20 .usage(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
21 // Binary name in the usage line + every subcommand / option
22 // literal (`init`, `--source`, …).
23 .literal(AnsiColor::Magenta.on_default().effects(Effects::BOLD))
24 // <PLACEHOLDER> values inside option signatures.
25 .placeholder(AnsiColor::Cyan.on_default())
26 .error(AnsiColor::Red.on_default().effects(Effects::BOLD))
27 .valid(AnsiColor::Green.on_default())
28 .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
29
30#[derive(Parser, Debug)]
31#[command(version, about, long_about = None, styles = HELP_STYLES)]
32pub struct Cli {
33 /// Path to dotfiles source repository ($DOTFILES)
34 #[arg(short, long, env = "YUI_SOURCE", global = true)]
35 pub source: Option<Utf8PathBuf>,
36
37 /// Increase log verbosity (-v, -vv, -vvv)
38 #[arg(short, long, action = clap::ArgAction::Count, global = true)]
39 pub verbose: u8,
40
41 #[command(subcommand)]
42 pub command: Command,
43}
44
45#[derive(Subcommand, Debug)]
46pub enum Command {
47 /// Initialize source repo skeleton
48 Init {
49 /// Install git pre-commit/pre-push hooks for render-drift check
50 #[arg(long)]
51 git_hooks: bool,
52 },
53
54 /// Render templates + link targets + auto-absorb (default workflow)
55 Apply {
56 #[arg(long)]
57 dry_run: bool,
58 },
59
60 /// Render templates only
61 Render {
62 /// Fail with non-zero exit if rendered output diverges (CI hook)
63 #[arg(long)]
64 check: bool,
65 #[arg(long)]
66 dry_run: bool,
67 },
68
69 /// Link / relink targets only
70 Link {
71 #[arg(long)]
72 dry_run: bool,
73 },
74
75 /// Unlink targets
76 Unlink { paths: Vec<Utf8PathBuf> },
77
78 /// Show drift status (link-broken / replaced / template-drift)
79 Status {
80 /// Override [ui] icons mode for this invocation
81 #[arg(long, value_name = "MODE")]
82 icons: Option<IconsMode>,
83 /// Disable color output (also respected via NO_COLOR env)
84 #[arg(long)]
85 no_color: bool,
86 },
87
88 /// List all src→dst link mappings (mount entries + .yuilink overrides)
89 List {
90 /// Include entries whose `when` evaluates false on the current host
91 #[arg(long)]
92 all: bool,
93 /// Override [ui] icons mode for this invocation
94 #[arg(long, value_name = "MODE")]
95 icons: Option<IconsMode>,
96 /// Disable color output (also respected via NO_COLOR env)
97 #[arg(long)]
98 no_color: bool,
99 },
100
101 /// Manually absorb a target into source (when auto-absorb skipped)
102 Absorb {
103 target: Utf8PathBuf,
104 #[arg(long)]
105 dry_run: bool,
106 },
107
108 /// Diagnose environment (symlink capability, source detection, etc)
109 Doctor {
110 /// Override [ui] icons mode for this invocation
111 #[arg(long, value_name = "MODE")]
112 icons: Option<IconsMode>,
113 /// Disable color output (also respected via NO_COLOR env)
114 #[arg(long)]
115 no_color: bool,
116 },
117
118 /// Garbage-collect old backups under `$DOTFILES/.yui/backup/`.
119 ///
120 /// With no `--older-than`, prints every parsed backup with its
121 /// age + size and exits without deleting (a survey).
122 /// With `--older-than DUR`, deletes entries whose timestamp
123 /// suffix is older than DUR. Backups whose name doesn't match
124 /// yui's `<stem>_<YYYYMMDD_HHMMSSfff>[.<ext>]` shape are left
125 /// alone — anything you dropped into `.yui/backup/` by hand
126 /// stays there.
127 GcBackup {
128 /// Age threshold; e.g. `30d`, `2w`, `12h`, `6mo`, `1y`.
129 /// Omit to run a non-destructive survey instead.
130 #[arg(long, value_name = "DUR")]
131 older_than: Option<String>,
132 /// Preview the deletion (no files removed). Only meaningful
133 /// when `--older-than` is also given.
134 #[arg(long)]
135 dry_run: bool,
136 /// Override [ui] icons mode for this invocation
137 #[arg(long, value_name = "MODE")]
138 icons: Option<IconsMode>,
139 /// Disable color output (also respected via NO_COLOR env)
140 #[arg(long)]
141 no_color: bool,
142 },
143
144 /// Manage `[[hook]]` scripts
145 Hooks {
146 #[command(subcommand)]
147 action: HookAction,
148 },
149}
150
151#[derive(Subcommand, Debug)]
152pub enum HookAction {
153 /// List configured hooks with their last-run state
154 List {
155 /// Override [ui] icons mode for this invocation
156 #[arg(long, value_name = "MODE")]
157 icons: Option<IconsMode>,
158 /// Disable color output (also respected via NO_COLOR env)
159 #[arg(long)]
160 no_color: bool,
161 },
162 /// Run a hook (or every hook). The `when` filter is always honored;
163 /// `--force` bypasses the `when_run` state check (so a `once` hook
164 /// can be re-run, an `onchange` hook re-runs even with matching
165 /// hash).
166 Run {
167 /// Hook name (omit to run every hook per its `when_run` rule)
168 name: Option<String>,
169 /// Bypass the `when_run` state check
170 #[arg(long)]
171 force: bool,
172 },
173}
174
175impl Cli {
176 pub fn run(self) -> Result<()> {
177 let source = self.source;
178 match self.command {
179 Command::Init { git_hooks } => cmd::init(source, git_hooks),
180 Command::Apply { dry_run } => cmd::apply(source, dry_run),
181 Command::Render { check, dry_run } => cmd::render(source, check, dry_run),
182 Command::Link { dry_run } => cmd::link(source, dry_run),
183 Command::Unlink { paths } => cmd::unlink(source, paths),
184 Command::Status { icons, no_color } => cmd::status(source, icons, no_color),
185 Command::List {
186 all,
187 icons,
188 no_color,
189 } => cmd::list(source, all, icons, no_color),
190 Command::Absorb { target, dry_run } => cmd::absorb(source, target, dry_run),
191 Command::Doctor { icons, no_color } => cmd::doctor(source, icons, no_color),
192 Command::GcBackup {
193 older_than,
194 dry_run,
195 icons,
196 no_color,
197 } => cmd::gc_backup(source, older_than, dry_run, icons, no_color),
198 Command::Hooks { action } => match action {
199 HookAction::List { icons, no_color } => cmd::hooks_list(source, icons, no_color),
200 HookAction::Run { name, force } => cmd::hooks_run(source, name, force),
201 },
202 }
203 }
204}