yui/cli.rs
1use anyhow::Result;
2use camino::Utf8PathBuf;
3use clap::builder::styling::{AnsiColor, Effects, Styles};
4use clap::{CommandFactory, Parser, Subcommand};
5use clap_complete::Shell;
6
7use crate::cmd;
8use crate::config::IconsMode;
9
10/// Explicit colour palette for `--help` output. clap honours `NO_COLOR`
11/// and falls back to monochrome when stdout isn't a TTY, so this is
12/// safe to leave on unconditionally — the styled bytes are only ever
13/// emitted when a real terminal is reading them. The palette mirrors
14/// the bind-points / icon colours used in `yui list` / `yui status` so
15/// help, list, and status all feel like the same tool.
16const HELP_STYLES: Styles = Styles::styled()
17 // "Commands:" / "Options:" / etc. section headers.
18 .header(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
19 // The "Usage:" heading label itself (NOT the binary name — that
20 // falls under `literal` below).
21 .usage(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
22 // Binary name in the usage line + every subcommand / option
23 // literal (`init`, `--source`, …).
24 .literal(AnsiColor::Magenta.on_default().effects(Effects::BOLD))
25 // <PLACEHOLDER> values inside option signatures.
26 .placeholder(AnsiColor::Cyan.on_default())
27 .error(AnsiColor::Red.on_default().effects(Effects::BOLD))
28 .valid(AnsiColor::Green.on_default())
29 .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
30
31#[derive(Parser, Debug)]
32#[command(version, about, long_about = None, styles = HELP_STYLES)]
33pub struct Cli {
34 /// Path to dotfiles source repository ($DOTFILES)
35 #[arg(short, long, env = "YUI_SOURCE", global = true)]
36 pub source: Option<Utf8PathBuf>,
37
38 /// Increase log verbosity (-v, -vv, -vvv)
39 #[arg(short, long, action = clap::ArgAction::Count, global = true)]
40 pub verbose: u8,
41
42 #[command(subcommand)]
43 pub command: Command,
44}
45
46#[derive(Subcommand, Debug)]
47pub enum Command {
48 /// Initialize source repo skeleton
49 Init {
50 /// Install git pre-commit/pre-push hooks for render-drift check
51 #[arg(long)]
52 git_hooks: bool,
53 },
54
55 /// Render templates + link targets + auto-absorb (default workflow)
56 Apply {
57 #[arg(long)]
58 dry_run: bool,
59 },
60
61 /// Render templates only
62 Render {
63 /// Fail with non-zero exit if rendered output diverges (CI hook)
64 #[arg(long)]
65 check: bool,
66 #[arg(long)]
67 dry_run: bool,
68 },
69
70 /// Link / relink targets only
71 Link {
72 #[arg(long)]
73 dry_run: bool,
74 },
75
76 /// Unlink targets
77 Unlink { paths: Vec<Utf8PathBuf> },
78
79 /// Show drift status (link-broken / replaced / template-drift)
80 Status {
81 /// Override [ui] icons mode for this invocation
82 #[arg(long, value_name = "MODE")]
83 icons: Option<IconsMode>,
84 /// Disable color output (also respected via NO_COLOR env)
85 #[arg(long)]
86 no_color: bool,
87 },
88
89 /// List all src→dst link mappings (mount entries + .yuilink overrides)
90 List {
91 /// Include entries whose `when` evaluates false on the current host
92 #[arg(long)]
93 all: bool,
94 /// Override [ui] icons mode for this invocation
95 #[arg(long, value_name = "MODE")]
96 icons: Option<IconsMode>,
97 /// Disable color output (also respected via NO_COLOR env)
98 #[arg(long)]
99 no_color: bool,
100 },
101
102 /// Manually absorb a target into source (when auto-absorb skipped).
103 ///
104 /// Prints a unified diff (source vs target) on stderr. Without
105 /// `--yes`, prompts on a TTY before writing; off a TTY refuses
106 /// to act unless `--yes` is given. `--dry-run` only shows the
107 /// diff and exits.
108 Absorb {
109 target: Utf8PathBuf,
110 #[arg(long)]
111 dry_run: bool,
112 /// Skip the y/N prompt (still prints the diff).
113 #[arg(long)]
114 yes: bool,
115 },
116
117 /// Diagnose environment (symlink capability, source detection, etc)
118 Doctor {
119 /// Override [ui] icons mode for this invocation
120 #[arg(long, value_name = "MODE")]
121 icons: Option<IconsMode>,
122 /// Disable color output (also respected via NO_COLOR env)
123 #[arg(long)]
124 no_color: bool,
125 },
126
127 /// Garbage-collect old backups under `$DOTFILES/.yui/backup/`.
128 ///
129 /// With no `--older-than`, prints every parsed backup with its
130 /// age + size and exits without deleting (a survey).
131 /// With `--older-than DUR`, deletes entries whose timestamp
132 /// suffix is older than DUR. Backups whose name doesn't match
133 /// yui's `<stem>_<YYYYMMDD_HHMMSSfff>[.<ext>]` shape are left
134 /// alone — anything you dropped into `.yui/backup/` by hand
135 /// stays there.
136 GcBackup {
137 /// Age threshold; e.g. `30d`, `2w`, `12h`, `6mo`, `1y`.
138 /// Omit to run a non-destructive survey instead.
139 #[arg(long, value_name = "DUR")]
140 older_than: Option<String>,
141 /// Preview the deletion (no files removed). Only meaningful
142 /// when `--older-than` is also given.
143 #[arg(long)]
144 dry_run: bool,
145 /// Override [ui] icons mode for this invocation
146 #[arg(long, value_name = "MODE")]
147 icons: Option<IconsMode>,
148 /// Disable color output (also respected via NO_COLOR env)
149 #[arg(long)]
150 no_color: bool,
151 },
152
153 /// Manage `[[hook]]` scripts
154 Hooks {
155 #[command(subcommand)]
156 action: HookAction,
157 },
158
159 /// Pull source repo and re-apply (`git pull --ff-only` + `apply`).
160 ///
161 /// Refuses to run with a dirty source tree — pulling on top of
162 /// uncommitted changes mixes upstream work with the user's
163 /// in-progress edits in ways that are easy to get wrong. Commit
164 /// (or stash) first.
165 Update {
166 /// Render templates / link targets in dry-run after the pull.
167 #[arg(long)]
168 dry_run: bool,
169 },
170
171 /// List source files NOT claimed by any `[[mount.entry]]` — yui's
172 /// "what's just sitting in the repo unused?" report. Skips
173 /// `.yui/`, `.git/`, anything matched by `.yuiignore`, and the
174 /// repo's own meta files (`config*.toml`, `.yuilink`, `.gitignore`,
175 /// `.yuiignore`, `*.tera` template sources).
176 Unmanaged {
177 /// Override [ui] icons mode for this invocation
178 #[arg(long, value_name = "MODE")]
179 icons: Option<IconsMode>,
180 /// Disable color output (also respected via NO_COLOR env)
181 #[arg(long)]
182 no_color: bool,
183 },
184
185 /// Print a unified diff for every entry that's drifted from
186 /// source — like `status` but with full content. Render-drift
187 /// rows show the rendered file vs what the template would
188 /// produce now; link-drift rows show source vs target.
189 Diff {
190 /// Override [ui] icons mode for this invocation
191 #[arg(long, value_name = "MODE")]
192 icons: Option<IconsMode>,
193 /// Disable color output (also respected via NO_COLOR env)
194 #[arg(long)]
195 no_color: bool,
196 },
197
198 /// Generate shell completion script for `<shell>` to stdout.
199 ///
200 /// Pipe into the right place for your shell, e.g.
201 /// `yui completion bash > ~/.local/share/bash-completion/completions/yui`,
202 /// `yui completion zsh > "${fpath[1]}/_yui"`,
203 /// `yui completion pwsh | Out-String | Invoke-Expression`.
204 Completion {
205 /// Target shell (bash / zsh / fish / powershell / elvish).
206 shell: Shell,
207 },
208}
209
210#[derive(Subcommand, Debug)]
211pub enum HookAction {
212 /// List configured hooks with their last-run state
213 List {
214 /// Override [ui] icons mode for this invocation
215 #[arg(long, value_name = "MODE")]
216 icons: Option<IconsMode>,
217 /// Disable color output (also respected via NO_COLOR env)
218 #[arg(long)]
219 no_color: bool,
220 },
221 /// Run a hook (or every hook). The `when` filter is always honored;
222 /// `--force` bypasses the `when_run` state check (so a `once` hook
223 /// can be re-run, an `onchange` hook re-runs even with matching
224 /// hash).
225 Run {
226 /// Hook name (omit to run every hook per its `when_run` rule)
227 name: Option<String>,
228 /// Bypass the `when_run` state check
229 #[arg(long)]
230 force: bool,
231 },
232}
233
234impl Cli {
235 pub fn run(self) -> Result<()> {
236 let source = self.source;
237 match self.command {
238 Command::Init { git_hooks } => cmd::init(source, git_hooks),
239 Command::Apply { dry_run } => cmd::apply(source, dry_run),
240 Command::Render { check, dry_run } => cmd::render(source, check, dry_run),
241 Command::Link { dry_run } => cmd::link(source, dry_run),
242 Command::Unlink { paths } => cmd::unlink(source, paths),
243 Command::Status { icons, no_color } => cmd::status(source, icons, no_color),
244 Command::List {
245 all,
246 icons,
247 no_color,
248 } => cmd::list(source, all, icons, no_color),
249 Command::Absorb {
250 target,
251 dry_run,
252 yes,
253 } => cmd::absorb(source, target, dry_run, yes),
254 Command::Doctor { icons, no_color } => cmd::doctor(source, icons, no_color),
255 Command::GcBackup {
256 older_than,
257 dry_run,
258 icons,
259 no_color,
260 } => cmd::gc_backup(source, older_than, dry_run, icons, no_color),
261 Command::Hooks { action } => match action {
262 HookAction::List { icons, no_color } => cmd::hooks_list(source, icons, no_color),
263 HookAction::Run { name, force } => cmd::hooks_run(source, name, force),
264 },
265 Command::Update { dry_run } => cmd::update(source, dry_run),
266 Command::Unmanaged { icons, no_color } => cmd::unmanaged(source, icons, no_color),
267 Command::Diff { icons, no_color } => cmd::diff(source, icons, no_color),
268 Command::Completion { shell } => {
269 let mut cmd = Cli::command();
270 clap_complete::generate(shell, &mut cmd, "yui", &mut std::io::stdout());
271 Ok(())
272 }
273 }
274 }
275}