Skip to main content

cli_ui/
help.rs

1//! Help renderer — called from generated `help()` method.
2//!
3//! You don't normally use this module directly; it is driven by
4//! the `#[derive(CliOptions)]` macro.
5
6use crate::styles::*;
7use anstyle::Style;
8
9/// A named section in the help output (e.g. "Arguments", "Assets").
10pub struct HelpSection {
11    /// Section title shown after the `◆` symbol.
12    pub title: &'static str,
13    /// Entries within the section.
14    pub entries: Vec<HelpEntry>,
15}
16
17/// A single line within a [`HelpSection`].
18pub enum HelpEntry {
19    /// A key/description pair, aligned in columns across the section.
20    Pair {
21        /// Left column — e.g. `--port <N>`.
22        key: String,
23        /// Right column description.
24        desc: String,
25    },
26    /// A free-form dimmed line.
27    Detail(String),
28}
29
30/// Render a complete help page to stderr.
31///
32/// Called by the generated `YourStruct::help()` method.
33/// All styling is controlled by `badge` and `accent` from the app's theme.
34#[allow(clippy::too_many_arguments)]
35pub fn render(
36    name: &str,
37    version: &str,
38    about: &str,
39    tagline: &str,
40    url: &str,
41    usage: &str,
42    sections: &[HelpSection],
43    examples: &[&str],
44    hint: &str,
45    badge: Style,
46    accent: Style,
47) {
48    let bar = paint(DIM, BAR);
49    let _ = badge; // theme already conveyed via accent color
50
51    // ── header ────────────────────────────────────────────────────────
52    eprintln!();
53    eprintln!(
54        "{} {} {}",
55        paint(accent, DIAMOND),
56        paint(WHITE_BOLD, name),
57        paint(accent, &format!("v{version}")),
58    );
59    let subtitle = match (about.is_empty(), tagline.is_empty()) {
60        (false, false) => format!("{about} · {tagline}"),
61        (false, true) => about.to_string(),
62        (true, false) => tagline.to_string(),
63        (true, true) => String::new(),
64    };
65    if !subtitle.is_empty() {
66        eprintln!("{}  {}", bar, paint(DIM, &subtitle));
67    }
68    eprintln!();
69
70    // ── usage ─────────────────────────────────────────────────────────
71    print_section_title(accent, "Usage");
72    eprintln!("{}  {}", bar, style_usage(usage, accent));
73    eprintln!();
74
75    // ── sections ──────────────────────────────────────────────────────
76    for section in sections {
77        print_section_title(accent, section.title);
78
79        let max_key = section
80            .entries
81            .iter()
82            .filter_map(|e| {
83                if let HelpEntry::Pair { key, .. } = e {
84                    Some(visible_width(key))
85                } else {
86                    None
87                }
88            })
89            .max()
90            .unwrap_or(0);
91
92        for entry in &section.entries {
93            match entry {
94                HelpEntry::Pair { key, desc } => {
95                    let pad = " ".repeat(max_key - visible_width(key) + 2);
96                    let styled_key = style_key(key, accent);
97                    let styled_desc = style_desc(desc);
98                    eprintln!("{}  {}{}{}", bar, styled_key, pad, styled_desc);
99                }
100                HelpEntry::Detail(line) => {
101                    eprintln!("{}  {}", bar, paint(DIM, line));
102                }
103            }
104        }
105        eprintln!();
106    }
107
108    // ── examples ──────────────────────────────────────────────────────
109    if !examples.is_empty() {
110        print_section_title(accent, "Examples");
111        for ex in examples {
112            eprintln!("{}  {} {}", bar, paint(DIM, "$"), paint(WHITE_BOLD, ex));
113        }
114        eprintln!();
115    }
116
117    // ── notes (hint) ──────────────────────────────────────────────────
118    if !hint.is_empty() {
119        print_section_title(accent, "Notes");
120        eprintln!("{}  {} {}", bar, paint(accent, ARROW), paint(WHITE, hint));
121        eprintln!();
122    }
123
124    // ── url ───────────────────────────────────────────────────────────
125    if !url.is_empty() {
126        let link = Style::new()
127            .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::BrightCyan)))
128            .underline();
129        print_section_title(accent, "Link");
130        eprintln!("{}  {}", bar, paint(link, url));
131        eprintln!();
132    }
133}
134
135// ── helpers ───────────────────────────────────────────────────────────
136
137fn print_section_title(accent: Style, title: &str) {
138    eprintln!("{} {}", paint(accent, DIAMOND), paint(accent, title));
139}
140
141/// Style a `key` cell.
142///
143/// Rules:
144/// - Positional `<NAME>` (whole key in angle brackets) → accent bold.
145/// - Regular flag key → BOLD white, `<VALUE>` placeholders DIM, `/` separator DIM.
146/// - Meta section → whole key DIM.
147fn style_key(key: &str, accent: Style) -> String {
148    let indent_end = key.len() - key.trim_start().len();
149    let (indent, body) = key.split_at(indent_end);
150
151    // Positional: `<INPUT>` — no spaces, wrapped in angle brackets.
152    if body.starts_with('<') && body.ends_with('>') && !body.contains(' ') {
153        return format!("{}{}", indent, paint(accent, body));
154    }
155
156    // Flag key: split by " / " (negatable pair), style each side.
157    let parts: Vec<String> = body.split(" / ").map(style_flag_part).collect();
158    let sep = format!(" {} ", paint(DIM, "/"));
159    format!("{}{}", indent, parts.join(&sep))
160}
161
162/// Style a single flag chunk like `-j, --jobs` or `    --format <STR>`.
163/// Angle-bracket runs → DIM, everything else → BOLD white.
164fn style_flag_part(part: &str) -> String {
165    let mut out = String::new();
166    let mut buf = String::new();
167    let mut chars = part.chars().peekable();
168    while let Some(c) = chars.next() {
169        if c == '<' {
170            if !buf.is_empty() {
171                out.push_str(&paint(WHITE_BOLD, &buf));
172                buf.clear();
173            }
174            let mut angle = String::from("<");
175            for c2 in chars.by_ref() {
176                angle.push(c2);
177                if c2 == '>' {
178                    break;
179                }
180            }
181            // consume trailing `...` (multi flag) into the dim segment
182            while chars.peek() == Some(&'.') {
183                angle.push(chars.next().unwrap());
184            }
185            out.push_str(&paint(DIM, &angle));
186        } else {
187            buf.push(c);
188        }
189    }
190    if !buf.is_empty() {
191        out.push_str(&paint(WHITE_BOLD, &buf));
192    }
193    out
194}
195
196/// Style the description cell. Detects trailing `[default: ...]` and dims it.
197fn style_desc(desc: &str) -> String {
198    if let Some(idx) = desc.rfind("[default:") {
199        if desc.trim_end().ends_with(']') {
200            let head = desc[..idx].trim_end();
201            let tail = &desc[idx..];
202            return format!("{}  {}", paint(WHITE, head), paint(DIM, tail));
203        }
204    }
205    paint(WHITE, desc)
206}
207
208/// Style the usage line: `name` bold, positional `<...>` accent, `[OPTIONS]` dim.
209fn style_usage(usage: &str, accent: Style) -> String {
210    let mut out = String::new();
211    let mut buf = String::new();
212    let mut chars = usage.chars().peekable();
213    let mut first_word = true;
214
215    let flush = |buf: &mut String, out: &mut String, first_word: &mut bool| {
216        if buf.is_empty() {
217            return;
218        }
219        // First word is the program name — BOLD white.
220        if *first_word {
221            let trimmed = buf.trim_end();
222            let trailing = &buf[trimmed.len()..].to_string();
223            out.push_str(&paint(WHITE_BOLD, trimmed));
224            out.push_str(trailing);
225            *first_word = false;
226        } else {
227            out.push_str(&paint(WHITE, buf));
228        }
229        buf.clear();
230    };
231
232    while let Some(c) = chars.next() {
233        match c {
234            '<' => {
235                flush(&mut buf, &mut out, &mut first_word);
236                let mut token = String::from("<");
237                for c2 in chars.by_ref() {
238                    token.push(c2);
239                    if c2 == '>' {
240                        break;
241                    }
242                }
243                out.push_str(&paint(accent, &token));
244            }
245            '[' => {
246                flush(&mut buf, &mut out, &mut first_word);
247                let mut token = String::from("[");
248                for c2 in chars.by_ref() {
249                    token.push(c2);
250                    if c2 == ']' {
251                        break;
252                    }
253                }
254                out.push_str(&paint(DIM, &token));
255            }
256            _ => buf.push(c),
257        }
258    }
259    flush(&mut buf, &mut out, &mut first_word);
260    out
261}
262
263/// Visible width of a raw (un-styled) help key. All keys are ASCII in practice,
264/// so `len()` is fine — kept as a function for symmetry with future non-ASCII keys.
265fn visible_width(s: &str) -> usize {
266    s.chars().count()
267}