Skip to main content

runi_cli/launcher/
help.rs

1use crate::tint::{Tint, supports_color, supports_color_stdout};
2
3use super::schema::{CLArgument, CLOption, CommandSchema};
4
5/// Format help output for a schema. The result is ANSI-styled when the
6/// destination stream is a TTY and plain otherwise.
7pub struct HelpPrinter;
8
9impl HelpPrinter {
10    /// Produce help text sized for stderr (color when stderr is a TTY).
11    /// Prefer [`HelpPrinter::print`] / [`HelpPrinter::print_error`] in
12    /// production; this method is primarily here for tests.
13    pub fn format(schema: &CommandSchema) -> String {
14        Self::format_with_color(schema, supports_color())
15    }
16
17    fn format_with_color(schema: &CommandSchema, color: bool) -> String {
18        let mut out = String::new();
19
20        if !schema.description.is_empty() {
21            out.push_str(&schema.description);
22            out.push_str("\n\n");
23        }
24
25        out.push_str(&bold("Usage:", color));
26        out.push(' ');
27        out.push_str(&usage_line(schema));
28        out.push_str("\n\n");
29
30        if !schema.arguments.is_empty() {
31            out.push_str(&bold("Arguments:", color));
32            out.push('\n');
33            let rows: Vec<Row> = schema
34                .arguments
35                .iter()
36                .map(|a| argument_row(a, color))
37                .collect();
38            write_rows(&mut out, &rows);
39            out.push('\n');
40        }
41
42        out.push_str(&bold("Options:", color));
43        out.push('\n');
44        let mut option_rows: Vec<Row> = schema
45            .options
46            .iter()
47            .map(|o| option_row(o, color))
48            .collect();
49        option_rows.push(help_row(color));
50        write_rows(&mut out, &option_rows);
51
52        if !schema.subcommands.is_empty() {
53            out.push('\n');
54            out.push_str(&bold("Subcommands:", color));
55            out.push('\n');
56            let rows: Vec<Row> = schema
57                .subcommands
58                .iter()
59                .map(|s| subcommand_row(s, color))
60                .collect();
61            write_rows(&mut out, &rows);
62        }
63
64        out
65    }
66
67    /// Print help text to stdout. Use this for user-requested help
68    /// (`--help`) so output can be piped or redirected normally. Color is
69    /// keyed off stdout, so redirected stdout is always plain.
70    /// Flushes stdout before returning so callers that `process::exit`
71    /// immediately afterwards still see all bytes land.
72    pub fn print(schema: &CommandSchema) {
73        use std::io::Write;
74        let text = Self::format_with_color(schema, supports_color_stdout());
75        let stdout = std::io::stdout();
76        let mut lock = stdout.lock();
77        let _ = lock.write_all(text.as_bytes());
78        let _ = lock.flush();
79    }
80
81    /// Print help text to stderr. Use this alongside an error message so
82    /// both are grouped on the same stream.
83    pub fn print_error(schema: &CommandSchema) {
84        use std::io::Write;
85        let text = Self::format_with_color(schema, supports_color());
86        let stderr = std::io::stderr();
87        let mut lock = stderr.lock();
88        let _ = lock.write_all(text.as_bytes());
89        let _ = lock.flush();
90    }
91}
92
93fn usage_line(schema: &CommandSchema) -> String {
94    let mut parts: Vec<String> = vec![schema.name.clone()];
95    if !schema.options.is_empty() {
96        parts.push("[OPTIONS]".to_string());
97    }
98    // Match the parser: positionals bind before subcommand dispatch, so the
99    // usage line must present them in the same order the user types them.
100    for arg in &schema.arguments {
101        if arg.required {
102            parts.push(format!("<{}>", arg.name));
103        } else {
104            parts.push(format!("[{}]", arg.name));
105        }
106    }
107    if !schema.subcommands.is_empty() {
108        parts.push("<COMMAND>".to_string());
109    }
110    parts.join(" ")
111}
112
113/// One formatted row (argument, option, subcommand, or the `-h, --help`
114/// line). `head_plain` is kept alongside the styled `head` so the layout
115/// pass can align columns on visible width — ANSI escape sequences in
116/// `head` would otherwise inflate `.chars().count()`.
117struct Row {
118    head_plain: String,
119    head: String,
120    description: String,
121}
122
123fn option_row(opt: &CLOption, color: bool) -> Row {
124    let mut head = String::new();
125    match (&opt.short, &opt.long) {
126        (Some(s), Some(l)) => {
127            head.push_str(s);
128            head.push_str(", ");
129            head.push_str(l);
130        }
131        (Some(s), None) => head.push_str(s),
132        (None, Some(l)) => {
133            head.push_str("    ");
134            head.push_str(l);
135        }
136        (None, None) => {}
137    }
138    if opt.takes_value {
139        head.push_str(&format!(" <{}>", opt.value_name));
140    }
141    Row {
142        head_plain: head.clone(),
143        head: if color {
144            Tint::cyan().paint(&head)
145        } else {
146            head
147        },
148        description: dim(&opt.description, color),
149    }
150}
151
152fn argument_row(arg: &CLArgument, color: bool) -> Row {
153    let head_plain = if arg.required {
154        format!("<{}>", arg.name)
155    } else {
156        format!("[{}]", arg.name)
157    };
158    Row {
159        head: if color {
160            Tint::green().paint(&head_plain)
161        } else {
162            head_plain.clone()
163        },
164        head_plain,
165        description: dim(&arg.description, color),
166    }
167}
168
169fn subcommand_row(sub: &CommandSchema, color: bool) -> Row {
170    Row {
171        head_plain: sub.name.clone(),
172        head: if color {
173            Tint::cyan().paint(&sub.name)
174        } else {
175            sub.name.clone()
176        },
177        description: dim(&sub.description, color),
178    }
179}
180
181fn help_row(color: bool) -> Row {
182    let head_plain = "-h, --help".to_string();
183    Row {
184        head: if color {
185            Tint::cyan().paint(&head_plain)
186        } else {
187            head_plain.clone()
188        },
189        head_plain,
190        description: dim("Show this help message", color),
191    }
192}
193
194/// Align descriptions by padding each `head` to the longest plain-width in
195/// the section, plus a 4-space gutter. Rows without descriptions are
196/// emitted unpadded.
197fn write_rows(out: &mut String, rows: &[Row]) {
198    let max_head = rows
199        .iter()
200        .map(|r| r.head_plain.chars().count())
201        .max()
202        .unwrap_or(0);
203    for row in rows {
204        out.push_str("  ");
205        out.push_str(&row.head);
206        if !row.description.is_empty() {
207            let pad = max_head.saturating_sub(row.head_plain.chars().count()) + 4;
208            for _ in 0..pad {
209                out.push(' ');
210            }
211            out.push_str(&row.description);
212        }
213        out.push('\n');
214    }
215}
216
217fn bold(s: &str, color: bool) -> String {
218    if color {
219        Tint::white().bold().paint(s)
220    } else {
221        s.to_string()
222    }
223}
224
225fn dim(s: &str, color: bool) -> String {
226    if color {
227        Tint::white().dimmed().paint(s)
228    } else {
229        s.to_string()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use runi_test::pretty_assertions::assert_eq;
237
238    fn no_ansi(s: &str) -> String {
239        // Strip simple CSI sequences for readable assertions.
240        let bytes = s.as_bytes();
241        let mut out = String::with_capacity(s.len());
242        let mut i = 0;
243        while i < bytes.len() {
244            if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
245                i += 2;
246                while i < bytes.len() && bytes[i] != b'm' {
247                    i += 1;
248                }
249                if i < bytes.len() {
250                    i += 1;
251                }
252                continue;
253            }
254            out.push(bytes[i] as char);
255            i += 1;
256        }
257        out
258    }
259
260    #[test]
261    fn usage_line_includes_arguments_and_subcommands() {
262        let s = CommandSchema::new("app", "desc")
263            .flag("-v,--verbose", "")
264            .argument("file", "");
265        assert_eq!(usage_line(&s), "app [OPTIONS] <file>");
266    }
267
268    #[test]
269    fn options_descriptions_are_column_aligned() {
270        // Different head widths must produce the same description column.
271        let s = CommandSchema::new("app", "")
272            .flag("-v,--verbose", "Verbose output")
273            .option("-n,--count", "Count");
274        let out = no_ansi(&HelpPrinter::format(&s));
275        // Find the start column of each description.
276        let verbose_col = out.find("Verbose output").unwrap();
277        let count_col = out.find("Count").unwrap();
278        // Each line starts at column 0 after a newline; compute offset on
279        // its own line.
280        let verbose_line = out[..verbose_col].rfind('\n').map(|i| i + 1).unwrap_or(0);
281        let count_line = out[..count_col].rfind('\n').map(|i| i + 1).unwrap_or(0);
282        assert_eq!(
283            verbose_col - verbose_line,
284            count_col - count_line,
285            "option descriptions must start in the same column"
286        );
287    }
288
289    #[test]
290    fn usage_line_puts_positionals_before_subcommand() {
291        let s = CommandSchema::new("app", "")
292            .argument("workspace", "")
293            .subcommand(CommandSchema::new("run", ""));
294        assert_eq!(usage_line(&s), "app <workspace> <COMMAND>");
295    }
296
297    #[test]
298    fn help_format_contains_expected_sections() {
299        let s = CommandSchema::new("app", "The app")
300            .flag("-v,--verbose", "Verbose output")
301            .option("-n,--count", "Count")
302            .argument("file", "Input file")
303            .subcommand(CommandSchema::new("run", "Run it"));
304        let out = no_ansi(&HelpPrinter::format(&s));
305        assert!(out.contains("The app"));
306        assert!(out.contains("Usage:"));
307        assert!(out.contains("<file>"));
308        assert!(out.contains("Arguments:"));
309        assert!(out.contains("Options:"));
310        assert!(out.contains("--verbose"));
311        assert!(out.contains("--count <val>"));
312        assert!(out.contains("Subcommands:"));
313        assert!(out.contains("run"));
314        assert!(out.contains("-h, --help"));
315    }
316}