Skip to main content

cli_ui/
lib.rs

1//! # cli-ui
2//!
3//! Styled CLI framework for Rust — typed argument parsing, consistent help,
4//! progress output, and summary blocks with zero boilerplate.
5//!
6//! ## Quick start — single command
7//!
8//! ```rust,no_run
9//! use std::path::PathBuf;
10//! use cli_ui::{CliOptions, header, phase, step, ok, summary};
11//! use cli_ui::styles::{paint, CYAN, YELLOW, DIM, OK};
12//! use cli_ui::progress::format_bytes;
13//!
14//! #[derive(CliOptions)]
15//! #[cli(about = "compress images", theme = "green")]
16//! struct Opt {
17//!     #[arg(positional, validate(exists, is_file, ext("png","jpg","webp")))]
18//!     input: PathBuf,
19//!
20//!     #[arg(positional, validate(is_dir), action(create_dir_all))]
21//!     output: PathBuf,
22//!
23//!     #[arg(section = "Quality", short = 'q', long = "quality",
24//!           default = 85, validate(range(1, 100)))]
25//!     quality: u32,
26//!
27//!     #[arg(section = "Quality", long = "format",
28//!           default = "webp", validate(one_of("webp","jpeg","png")))]
29//!     format: String,
30//!
31//!     #[arg(section = "Performance", short = 'j', long = "jobs",
32//!           default = env("JOBS", 4), validate(range(1, 256)))]
33//!     jobs: usize,
34//! }
35//!
36//! fn main() {
37//!     let opt = Opt::parse();
38//!     header!("imgopt", env!("CARGO_PKG_VERSION"), "compress images", "lossless by default");
39//!     phase!("init", "reading {}", opt.input.display());
40//!     summary! {
41//!         done: "Done",
42//!         "output" => paint(CYAN, &opt.output.display().to_string()),
43//!         section,
44//!         "time" => paint(DIM, "120ms"),
45//!     }
46//! }
47//! ```
48//!
49//! ## Quick start — subcommands
50//!
51//! ```rust,no_run
52//! use cli_ui::{CliCommand, CliOptions, Result};
53//!
54//! #[derive(CliOptions)]
55//! struct Global {
56//!     #[arg(short = 'v', long = "verbose", negatable)]
57//!     verbose: bool,
58//! }
59//!
60//! #[derive(CliCommand)]
61//! #[cli(name = "mytool", about = "...", theme = "cyan", global = Global)]
62//! enum Cmd {
63//!     /// Download a file
64//!     #[cli(alias = "dl")]
65//!     Download(DownloadOpt),
66//!     /// Show status
67//!     Status,
68//! }
69//!
70//! fn main() -> Result<()> {
71//!     match Cmd::parse()? {
72//!         Cmd::Download(opt) => download(Cmd::global(), opt),
73//!         Cmd::Status        => status(Cmd::global()),
74//!     }
75//!     Ok(())
76//! }
77//! # #[derive(cli_ui::CliOptions)] struct DownloadOpt {}
78//! # fn download(_: &Global, _: DownloadOpt) {}
79//! # fn status(_: &Global) {}
80//! ```
81//!
82//! ## Interactive prompts
83//!
84//! `cli-ui` also ships clack-style interactive prompts behind the
85//! `interactive` feature — text, select, multiselect, confirm, password,
86//! autocomplete, date, path, spinner, framed log lines, sequential tasks.
87//! Read the [`prompt`] module docs for the full mental model; here's the
88//! whole API surface in five lines:
89//!
90//! ```no_run
91//! use cli_ui::prompt::prelude::*;
92//!
93//! intro("Set up");
94//! let name = text("Your name").run().or_cancel("Bye.");
95//! let go   = confirm("Continue?").default(true).run().or_cancel("Bye.");
96//! outro(format!("Hi {name}, {}", if go { "going" } else { "stopping" }));
97//! ```
98
99pub mod complete;
100pub mod help;
101pub mod macros;
102pub mod progress;
103pub mod prompt;
104pub mod styles;
105pub mod summary;
106pub mod term;
107
108pub use cli_ui_derive::{CliCommand, CliOptions};
109pub use progress::{format_bytes, Progress};
110pub use summary::Summary;
111
112use styles::*;
113
114// ─────────────────────────────────────────────────────────────────────────────
115// Core error + result types
116// ─────────────────────────────────────────────────────────────────────────────
117
118/// Error returned by [`CliCommand::parse`] and [`CliOptions::parse_args`].
119#[derive(Debug)]
120pub struct CliError(pub String);
121
122impl std::fmt::Display for CliError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "{}", self.0)
125    }
126}
127
128impl std::error::Error for CliError {}
129
130/// `Result<T>` alias used throughout cli-ui.
131pub type Result<T> = std::result::Result<T, CliError>;
132
133// ─────────────────────────────────────────────────────────────────────────────
134// Traits
135// ─────────────────────────────────────────────────────────────────────────────
136
137/// Implemented by `#[derive(CliOptions)]` on structs.
138///
139/// Provides `parse_args(&[&str])` for use both as a standalone parser
140/// and as a subcommand option parser inside `CliCommand`.
141pub trait CliOptions: Sized {
142    #[doc(hidden)]
143    fn parse_args(args: &[&str]) -> std::result::Result<Self, String>;
144}
145
146impl CliOptions for () {
147    fn parse_args(_: &[&str]) -> std::result::Result<Self, String> {
148        Ok(())
149    }
150}
151
152/// Implemented by `#[derive(CliCommand)]` on enums.
153///
154/// # Note on `global()`
155///
156/// Global options are stored in a process-wide [`std::sync::OnceLock`]
157/// set during `parse()`. Safe for CLI utilities. For libraries or tests
158/// that call `parse()` multiple times in the same process, pass the global
159/// options explicitly instead of relying on `global()`.
160pub trait CliCommand: Sized {
161    /// Type of global options. `()` if `#[cli(global = ...)]` is not used.
162    type Global: 'static;
163
164    /// Parse subcommand + global options from `std::env::args()`.
165    fn parse() -> Result<Self>;
166
167    /// Return a reference to the parsed global options.
168    ///
169    /// # Panics
170    /// Panics if called before [`parse()`](Self::parse).
171    fn global() -> &'static Self::Global
172    where
173        Self::Global: Sized;
174
175    /// Print styled help to stderr.
176    fn help();
177}
178
179/// Internal trait — uniform dispatch for both `CliOptions` leaves
180/// and nested `CliCommand` enums.
181#[doc(hidden)]
182pub trait ParseInner: Sized {
183    fn parse_inner(args: &[&str]) -> Result<Self>;
184}
185
186/// Internal trait — print help for a specific subcommand type.
187/// Implemented by generated code for both CliOptions and CliCommand types.
188#[doc(hidden)]
189pub trait PrintHelp {
190    fn print_help();
191}
192
193/// Internal trait — nested completion support.
194/// Implemented by generated code for both CliOptions and CliCommand types.
195#[doc(hidden)]
196pub trait NestedCompletions {
197    fn print_completions(shell: &str);
198}
199
200// ─────────────────────────────────────────────────────────────────────────────
201// Global flag parsing (used by generated code)
202// ─────────────────────────────────────────────────────────────────────────────
203
204/// Split `args` into global flags (before the subcommand name) and the rest.
205///
206/// Called by generated `parse()` when `#[cli(global = T)]` is present.
207#[doc(hidden)]
208pub fn parse_global_flags<G: CliOptions>(args: &[String]) -> (G, Vec<String>) {
209    let split = args
210        .iter()
211        .position(|a| !a.starts_with('-'))
212        .unwrap_or(args.len());
213    let global_refs: Vec<&str> = args[..split].iter().map(|s| s.as_str()).collect();
214    let rest: Vec<String> = args[split..].to_vec();
215    let global = G::parse_args(&global_refs).unwrap_or_else(|e| {
216        print_error(&format!("invalid global flag: {e}"));
217        std::process::exit(1);
218    });
219    (global, rest)
220}
221
222// ─────────────────────────────────────────────────────────────────────────────
223// Print helpers (called from generated code)
224// ─────────────────────────────────────────────────────────────────────────────
225
226/// Print a themed version badge to stderr.
227pub fn print_version(name: &str, version: &str, badge: anstyle::Style) {
228    eprintln!();
229    eprintln!(
230        "  {}  {}",
231        paint(badge, &format!(" {name} ")),
232        paint(DIM, &format!("v{version}"))
233    );
234    eprintln!();
235}
236
237/// Print a styled error line to stderr.
238///
239/// ```text
240///  ✘  something went wrong
241/// ```
242pub fn print_error(msg: &str) {
243    eprintln!();
244    eprintln!("{}  {}", paint(ERR, CROSS), paint(BOLD, msg));
245    eprintln!();
246}
247
248/// Print a yellow warning line to stderr (non-fatal).
249///
250/// ```text
251///  ⚠  file already exists — may be overwritten
252/// ```
253pub fn print_warning(msg: &str) {
254    eprintln!();
255    eprintln!("{}  {}", paint(YELLOW, "⚠"), paint(YELLOW, msg));
256    eprintln!();
257}
258
259/// Print "missing required arguments" error.
260pub fn print_missing_args(name: &str, _about: &str, _tagline: &str) {
261    eprintln!();
262    eprintln!(
263        "{}  {}",
264        paint(ERR, CROSS),
265        paint(BOLD, "missing required arguments")
266    );
267    eprintln!(
268        "{}  {}",
269        paint(DIM, BAR),
270        paint(WHITE, &format!("{name} <INPUT> <OUTPUT>"))
271    );
272    eprintln!(
273        "{}  {}",
274        paint(DIM, ARROW),
275        paint(WHITE, &format!("run `{name} --help` for usage"))
276    );
277    eprintln!();
278}
279
280/// Print "unknown subcommand" error with did-you-mean suggestion.
281pub fn print_unknown_subcommand(app: &str, cmd: &str, known: &[&str]) {
282    eprintln!();
283    eprintln!(
284        "{}  {}",
285        paint(ERR, CROSS),
286        paint(BOLD, &format!("unknown command: `{cmd}`"))
287    );
288    if let Some(s) = did_you_mean(cmd, known) {
289        eprintln!(
290            "{}  {}",
291            paint(DIM, BAR),
292            paint(WHITE, &format!("did you mean `{s}`?"))
293        );
294    }
295    eprintln!(
296        "{}  {}",
297        paint(DIM, ARROW),
298        paint(
299            WHITE,
300            &format!("run `{app} --help` to see available commands")
301        )
302    );
303    eprintln!();
304}
305
306/// Print "missing subcommand" error.
307pub fn print_missing_subcommand(app: &str, known: &[&str]) {
308    eprintln!();
309    eprintln!("{}  {}", paint(ERR, CROSS), paint(BOLD, "missing command"));
310    eprintln!(
311        "{}  {}",
312        paint(DIM, BAR),
313        paint(WHITE, &format!("available: {}", known.join(", ")))
314    );
315    eprintln!(
316        "{}  {}",
317        paint(DIM, ARROW),
318        paint(WHITE, &format!("run `{app} --help` for usage"))
319    );
320    eprintln!();
321}
322
323/// Print "usage: app subcommand" header for subcommand help.
324pub fn print_sub_usage(app: &str, cmd: &str) {
325    let (_, accent) = styles::theme_styles("cyan");
326    eprintln!();
327    eprintln!(
328        "{} {} {}",
329        paint(accent, DIAMOND),
330        paint(BOLD, &format!("{app} {cmd}")),
331        paint(DIM, "— subcommand options"),
332    );
333}
334
335/// Print help for a unit variant (no options).
336pub fn print_unit_help(app: &str, cmd: &str, _opts: &[(&str, &str)]) {
337    let (_, accent) = styles::theme_styles("cyan");
338    eprintln!();
339    eprintln!(
340        "{} {} {}",
341        paint(accent, DIAMOND),
342        paint(BOLD, &format!("{app} {cmd}")),
343        paint(DIM, "— no options")
344    );
345    eprintln!(
346        "{}  {}",
347        paint(DIM, BAR),
348        paint(DIM, &format!("usage: {app} {cmd}"))
349    );
350    eprintln!();
351}
352
353// ─────────────────────────────────────────────────────────────────────────────
354// Glob matching (no regex dependency)
355// ─────────────────────────────────────────────────────────────────────────────
356
357/// Simple glob matching — supports `*`, `?`, `[abc]`.
358///
359/// Used by `validate(glob("*.csv"))`. Does not use the `regex` crate.
360///
361/// # Examples
362/// ```
363/// assert!(cli_ui::glob_match("*.csv", "data.csv"));
364/// assert!(cli_ui::glob_match("file_?.txt", "file_1.txt"));
365/// assert!(!cli_ui::glob_match("*.json", "data.csv"));
366/// ```
367pub fn glob_match(pattern: &str, input: &str) -> bool {
368    glob_match_inner(pattern.as_bytes(), input.as_bytes())
369}
370
371fn glob_match_inner(pat: &[u8], s: &[u8]) -> bool {
372    match (pat.first(), s.first()) {
373        (None, None) => true,
374        (Some(b'*'), _) => {
375            // * matches zero or more chars
376            glob_match_inner(&pat[1..], s) || (!s.is_empty() && glob_match_inner(pat, &s[1..]))
377        }
378        (Some(b'?'), Some(_)) => glob_match_inner(&pat[1..], &s[1..]),
379        (Some(b'['), _) => {
380            // find closing ]
381            let end = pat.iter().position(|&b| b == b']').unwrap_or(pat.len());
382            let class = &pat[1..end];
383            if s.is_empty() {
384                return false;
385            }
386            let matched = class.contains(&s[0]);
387            if matched {
388                glob_match_inner(&pat[end + 1..], &s[1..])
389            } else {
390                false
391            }
392        }
393        (Some(a), Some(b)) if a == b => glob_match_inner(&pat[1..], &s[1..]),
394        _ => false,
395    }
396}
397
398// ─────────────────────────────────────────────────────────────────────────────
399// Did-you-mean (Levenshtein distance ≤ 2)
400// ─────────────────────────────────────────────────────────────────────────────
401
402fn did_you_mean<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
403    candidates
404        .iter()
405        .filter_map(|c| {
406            let d = edit_distance(input, c);
407            if d <= 2 {
408                Some((d, *c))
409            } else {
410                None
411            }
412        })
413        .min_by_key(|(d, _)| *d)
414        .map(|(_, c)| c)
415}
416
417#[allow(clippy::needless_range_loop)]
418fn edit_distance(a: &str, b: &str) -> usize {
419    let a: Vec<char> = a.chars().collect();
420    let b: Vec<char> = b.chars().collect();
421    let (m, n) = (a.len(), b.len());
422    let mut dp = vec![vec![0usize; n + 1]; m + 1];
423    for i in 0..=m {
424        dp[i][0] = i;
425    }
426    for j in 0..=n {
427        dp[0][j] = j;
428    }
429    for i in 1..=m {
430        for j in 1..=n {
431            dp[i][j] = if a[i - 1] == b[j - 1] {
432                dp[i - 1][j - 1]
433            } else {
434                1 + dp[i - 1][j].min(dp[i][j - 1]).min(dp[i - 1][j - 1])
435            };
436        }
437    }
438    dp[m][n]
439}