runi_cli/launcher/
help.rs1use crate::tint::{Tint, supports_color, supports_color_stdout};
2
3use super::schema::{CLArgument, CLOption, CommandSchema};
4
5pub struct HelpPrinter;
8
9impl HelpPrinter {
10 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 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 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 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
113struct 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
194fn 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 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 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 let verbose_col = out.find("Verbose output").unwrap();
277 let count_col = out.find("Count").unwrap();
278 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}