1const DEFAULT_WIDTH: usize = 80;
9
10const MIN_WIDTH: usize = 40;
12
13#[allow(dead_code)]
15const DEFAULT_INDENT: usize = 2;
16
17const DEFINITION_INDENT: usize = 2;
19
20const DEFINITION_SPACING: usize = 2;
22
23const MAX_TERM_WIDTH: usize = 24;
25
26#[derive(Debug)]
50pub struct HelpFormatter {
51 width: usize,
53 indent: usize,
55 buffer: String,
57 current_col: usize,
59}
60
61impl HelpFormatter {
62 pub fn new(width: usize) -> Self {
66 let width = if width < MIN_WIDTH { DEFAULT_WIDTH } else { width };
67 Self {
68 width,
69 indent: 0,
70 buffer: String::new(),
71 current_col: 0,
72 }
73 }
74
75 pub fn detect_width() -> Self {
77 let width = detect_terminal_width().unwrap_or(DEFAULT_WIDTH);
78 Self::new(width)
79 }
80
81 pub fn get_help(&self) -> &str {
83 &self.buffer
84 }
85
86 pub fn into_help(self) -> String {
88 self.buffer
89 }
90
91 pub fn width(&self) -> usize {
93 self.width
94 }
95
96 pub fn set_indent(&mut self, indent: usize) {
98 self.indent = indent;
99 }
100
101 pub fn indent(&mut self, amount: usize) {
103 self.indent += amount;
104 }
105
106 pub fn dedent(&mut self, amount: usize) {
108 self.indent = self.indent.saturating_sub(amount);
109 }
110
111 pub fn write_blank(&mut self) {
113 self.buffer.push('\n');
114 self.current_col = 0;
115 }
116
117 pub fn write_raw(&mut self, text: &str) {
119 self.buffer.push_str(text);
120 if let Some(last_newline) = text.rfind('\n') {
122 self.current_col = text.len() - last_newline - 1;
123 } else {
124 self.current_col += text.len();
125 }
126 }
127
128 pub fn write(&mut self, text: &str) {
130 let indent_str = " ".repeat(self.indent);
131 for line in text.lines() {
132 self.buffer.push_str(&indent_str);
133 self.buffer.push_str(line);
134 self.buffer.push('\n');
135 }
136 self.current_col = 0;
137 }
138
139 pub fn write_usage(&mut self, prog: &str, args: &str) {
143 self.buffer.push_str("Usage: ");
144 self.buffer.push_str(prog);
145 if !args.is_empty() {
146 self.buffer.push(' ');
147 self.buffer.push_str(args);
148 }
149 self.buffer.push('\n');
150 self.current_col = 0;
151 }
152
153 pub fn write_heading(&mut self, heading: &str) {
157 if !self.buffer.is_empty() && !self.buffer.ends_with("\n\n") {
158 self.buffer.push('\n');
159 }
160 self.buffer.push_str(heading);
161 self.buffer.push_str(":\n");
162 self.current_col = 0;
163 }
164
165 pub fn write_paragraph(&mut self, text: &str) {
170 if text.is_empty() {
171 return;
172 }
173
174 let max_width = self.width.saturating_sub(self.indent);
175 let indent_str = " ".repeat(self.indent);
176
177 for (i, paragraph) in text.split("\n\n").enumerate() {
179 if i > 0 {
180 self.buffer.push('\n');
181 }
182
183 let wrapped = wrap_text(paragraph, max_width);
185 for line in wrapped.lines() {
186 self.buffer.push_str(&indent_str);
187 self.buffer.push_str(line);
188 self.buffer.push('\n');
189 }
190 }
191
192 self.current_col = 0;
193 }
194
195 pub fn write_definition_list(&mut self, items: &[(&str, &str)]) {
204 let base_indent = " ".repeat(DEFINITION_INDENT);
205 let desc_indent = " ".repeat(DEFINITION_INDENT + MAX_TERM_WIDTH + DEFINITION_SPACING);
206 let desc_width = self.width.saturating_sub(desc_indent.len());
207
208 for (term, description) in items {
209 self.buffer.push_str(&base_indent);
211 self.buffer.push_str(term);
212
213 if term.len() <= MAX_TERM_WIDTH && !description.is_empty() {
214 let padding = MAX_TERM_WIDTH - term.len() + DEFINITION_SPACING;
216 self.buffer.push_str(&" ".repeat(padding));
217
218 let wrapped = wrap_text(description, desc_width);
220 let mut lines = wrapped.lines();
221
222 if let Some(first) = lines.next() {
224 self.buffer.push_str(first);
225 self.buffer.push('\n');
226 }
227
228 for line in lines {
230 self.buffer.push_str(&desc_indent);
231 self.buffer.push_str(line);
232 self.buffer.push('\n');
233 }
234 } else if !description.is_empty() {
235 self.buffer.push('\n');
237
238 let wrapped = wrap_text(description, desc_width);
239 for line in wrapped.lines() {
240 self.buffer.push_str(&desc_indent);
241 self.buffer.push_str(line);
242 self.buffer.push('\n');
243 }
244 } else {
245 self.buffer.push('\n');
246 }
247 }
248
249 self.current_col = 0;
250 }
251
252 pub fn write_definition_list_strings(&mut self, items: &[(String, String)]) {
254 let refs: Vec<(&str, &str)> = items
255 .iter()
256 .map(|(t, d)| (t.as_str(), d.as_str()))
257 .collect();
258 self.write_definition_list(&refs);
259 }
260}
261
262impl Default for HelpFormatter {
263 fn default() -> Self {
264 Self::detect_width()
265 }
266}
267
268pub fn wrap_text(text: &str, width: usize) -> String {
272 if width == 0 {
273 return text.to_string();
274 }
275
276 let mut result = String::new();
277 let mut current_line = String::new();
278 let mut current_width = 0;
279
280 for line in text.lines() {
281 if !result.is_empty() || !current_line.is_empty() {
283 if !current_line.is_empty() {
284 result.push_str(¤t_line);
285 current_line.clear();
286 current_width = 0;
287 }
288 result.push('\n');
289 }
290
291 for word in line.split_whitespace() {
293 let word_width = word.len();
294
295 if current_width == 0 {
296 current_line.push_str(word);
298 current_width = word_width;
299 } else if current_width + 1 + word_width <= width {
300 current_line.push(' ');
302 current_line.push_str(word);
303 current_width += 1 + word_width;
304 } else {
305 result.push_str(¤t_line);
307 result.push('\n');
308 current_line.clear();
309 current_line.push_str(word);
310 current_width = word_width;
311 }
312 }
313 }
314
315 if !current_line.is_empty() {
317 result.push_str(¤t_line);
318 }
319
320 result
321}
322
323pub fn detect_terminal_width() -> Option<usize> {
329 if let Ok(cols) = std::env::var("COLUMNS") {
331 if let Ok(width) = cols.parse::<usize>() {
332 if width >= MIN_WIDTH {
333 return Some(width);
334 }
335 }
336 }
337
338 if let Ok(term) = std::env::var("TERM_PROGRAM") {
340 match term.as_str() {
342 "vscode" | "iTerm.app" | "Apple_Terminal" | "Hyper" => {
343 return Some(DEFAULT_WIDTH);
344 }
345 _ => {}
346 }
347 }
348
349 None
350}
351
352pub fn get_terminal_width() -> usize {
354 detect_terminal_width().unwrap_or(DEFAULT_WIDTH)
355}
356
357pub fn make_rule(char: char, width: usize) -> String {
359 std::iter::repeat(char).take(width).collect()
360}
361
362pub fn truncate_text(text: &str, max_width: usize) -> String {
364 if text.len() <= max_width {
365 return text.to_string();
366 }
367
368 if max_width <= 3 {
369 return "...".to_string();
370 }
371
372 let mut result = text[..max_width - 3].to_string();
373 result.push_str("...");
374 result
375}
376
377pub fn split_into_lines(text: &str, width: usize) -> Vec<String> {
379 wrap_text(text, width)
380 .lines()
381 .map(String::from)
382 .collect()
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn test_wrap_text() {
391 let text = "This is a test of the text wrapping functionality";
392 let wrapped = wrap_text(text, 20);
393 assert!(wrapped.lines().all(|l| l.len() <= 20));
394 }
395
396 #[test]
397 fn test_wrap_preserves_newlines() {
398 let text = "Line one\nLine two\nLine three";
399 let wrapped = wrap_text(text, 80);
400 assert_eq!(wrapped.lines().count(), 3);
401 }
402
403 #[test]
404 fn test_help_formatter_usage() {
405 let mut fmt = HelpFormatter::new(80);
406 fmt.write_usage("mycli", "[OPTIONS] COMMAND");
407 assert!(fmt.get_help().contains("Usage: mycli [OPTIONS] COMMAND"));
408 }
409
410 #[test]
411 fn test_help_formatter_heading() {
412 let mut fmt = HelpFormatter::new(80);
413 fmt.write_heading("Options");
414 assert!(fmt.get_help().contains("Options:\n"));
415 }
416
417 #[test]
418 fn test_help_formatter_definition_list() {
419 let mut fmt = HelpFormatter::new(80);
420 fmt.write_definition_list(&[
421 ("--help, -h", "Show help"),
422 ("--version", "Show version"),
423 ]);
424 let help = fmt.get_help();
425 assert!(help.contains("--help, -h"));
426 assert!(help.contains("Show help"));
427 }
428
429 #[test]
430 fn test_truncate_text() {
431 assert_eq!(truncate_text("hello", 10), "hello");
432 assert_eq!(truncate_text("hello world", 8), "hello...");
433 assert_eq!(truncate_text("hi", 3), "hi");
435 assert_eq!(truncate_text("hello", 3), "...");
437 }
438}