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 {
67 DEFAULT_WIDTH
68 } else {
69 width
70 };
71 Self {
72 width,
73 indent: 0,
74 buffer: String::new(),
75 current_col: 0,
76 }
77 }
78
79 pub fn detect_width() -> Self {
81 let width = detect_terminal_width().unwrap_or(DEFAULT_WIDTH);
82 Self::new(width)
83 }
84
85 pub fn get_help(&self) -> &str {
87 &self.buffer
88 }
89
90 pub fn into_help(self) -> String {
92 self.buffer
93 }
94
95 pub fn width(&self) -> usize {
97 self.width
98 }
99
100 pub fn set_indent(&mut self, indent: usize) {
102 self.indent = indent;
103 }
104
105 pub fn indent(&mut self, amount: usize) {
107 self.indent += amount;
108 }
109
110 pub fn dedent(&mut self, amount: usize) {
112 self.indent = self.indent.saturating_sub(amount);
113 }
114
115 pub fn write_blank(&mut self) {
117 self.buffer.push('\n');
118 self.current_col = 0;
119 }
120
121 pub fn write_raw(&mut self, text: &str) {
123 self.buffer.push_str(text);
124 if let Some(last_newline) = text.rfind('\n') {
126 self.current_col = text.len() - last_newline - 1;
127 } else {
128 self.current_col += text.len();
129 }
130 }
131
132 pub fn write(&mut self, text: &str) {
134 let indent_str = " ".repeat(self.indent);
135 for line in text.lines() {
136 self.buffer.push_str(&indent_str);
137 self.buffer.push_str(line);
138 self.buffer.push('\n');
139 }
140 self.current_col = 0;
141 }
142
143 pub fn write_usage(&mut self, prog: &str, args: &str) {
147 self.buffer.push_str("Usage: ");
148 self.buffer.push_str(prog);
149 if !args.is_empty() {
150 self.buffer.push(' ');
151 self.buffer.push_str(args);
152 }
153 self.buffer.push('\n');
154 self.current_col = 0;
155 }
156
157 pub fn write_heading(&mut self, heading: &str) {
161 if !self.buffer.is_empty() && !self.buffer.ends_with("\n\n") {
162 self.buffer.push('\n');
163 }
164 self.buffer.push_str(heading);
165 self.buffer.push_str(":\n");
166 self.current_col = 0;
167 }
168
169 pub fn write_paragraph(&mut self, text: &str) {
174 if text.is_empty() {
175 return;
176 }
177
178 let max_width = self.width.saturating_sub(self.indent);
179 let indent_str = " ".repeat(self.indent);
180
181 for (i, paragraph) in text.split("\n\n").enumerate() {
183 if i > 0 {
184 self.buffer.push('\n');
185 }
186
187 let wrapped = wrap_text(paragraph, max_width);
189 for line in wrapped.lines() {
190 self.buffer.push_str(&indent_str);
191 self.buffer.push_str(line);
192 self.buffer.push('\n');
193 }
194 }
195
196 self.current_col = 0;
197 }
198
199 pub fn write_definition_list(&mut self, items: &[(&str, &str)]) {
208 let base_indent = " ".repeat(DEFINITION_INDENT);
209 let desc_indent = " ".repeat(DEFINITION_INDENT + MAX_TERM_WIDTH + DEFINITION_SPACING);
210 let desc_width = self.width.saturating_sub(desc_indent.len());
211
212 for (term, description) in items {
213 self.buffer.push_str(&base_indent);
215 self.buffer.push_str(term);
216
217 if term.len() <= MAX_TERM_WIDTH && !description.is_empty() {
218 let padding = MAX_TERM_WIDTH - term.len() + DEFINITION_SPACING;
220 self.buffer.push_str(&" ".repeat(padding));
221
222 let wrapped = wrap_text(description, desc_width);
224 let mut lines = wrapped.lines();
225
226 if let Some(first) = lines.next() {
228 self.buffer.push_str(first);
229 self.buffer.push('\n');
230 }
231
232 for line in lines {
234 self.buffer.push_str(&desc_indent);
235 self.buffer.push_str(line);
236 self.buffer.push('\n');
237 }
238 } else if !description.is_empty() {
239 self.buffer.push('\n');
241
242 let wrapped = wrap_text(description, desc_width);
243 for line in wrapped.lines() {
244 self.buffer.push_str(&desc_indent);
245 self.buffer.push_str(line);
246 self.buffer.push('\n');
247 }
248 } else {
249 self.buffer.push('\n');
250 }
251 }
252
253 self.current_col = 0;
254 }
255
256 pub fn write_definition_list_strings(&mut self, items: &[(String, String)]) {
258 let refs: Vec<(&str, &str)> = items
259 .iter()
260 .map(|(t, d)| (t.as_str(), d.as_str()))
261 .collect();
262 self.write_definition_list(&refs);
263 }
264}
265
266impl Default for HelpFormatter {
267 fn default() -> Self {
268 Self::detect_width()
269 }
270}
271
272pub fn wrap_text(text: &str, width: usize) -> String {
276 if width == 0 {
277 return text.to_string();
278 }
279
280 let mut result = String::new();
281 let mut current_line = String::new();
282 let mut current_width = 0;
283
284 for line in text.lines() {
285 if !result.is_empty() || !current_line.is_empty() {
287 if !current_line.is_empty() {
288 result.push_str(¤t_line);
289 current_line.clear();
290 current_width = 0;
291 }
292 result.push('\n');
293 }
294
295 for word in line.split_whitespace() {
297 let word_width = word.len();
298
299 if current_width == 0 {
300 current_line.push_str(word);
302 current_width = word_width;
303 } else if current_width + 1 + word_width <= width {
304 current_line.push(' ');
306 current_line.push_str(word);
307 current_width += 1 + word_width;
308 } else {
309 result.push_str(¤t_line);
311 result.push('\n');
312 current_line.clear();
313 current_line.push_str(word);
314 current_width = word_width;
315 }
316 }
317 }
318
319 if !current_line.is_empty() {
321 result.push_str(¤t_line);
322 }
323
324 result
325}
326
327pub fn detect_terminal_width() -> Option<usize> {
333 if let Ok(cols) = std::env::var("COLUMNS") {
335 if let Ok(width) = cols.parse::<usize>() {
336 if width >= MIN_WIDTH {
337 return Some(width);
338 }
339 }
340 }
341
342 if let Ok(term) = std::env::var("TERM_PROGRAM") {
344 match term.as_str() {
346 "vscode" | "iTerm.app" | "Apple_Terminal" | "Hyper" => {
347 return Some(DEFAULT_WIDTH);
348 }
349 _ => {}
350 }
351 }
352
353 None
354}
355
356pub fn get_terminal_width() -> usize {
358 detect_terminal_width().unwrap_or(DEFAULT_WIDTH)
359}
360
361pub fn make_rule(char: char, width: usize) -> String {
363 std::iter::repeat(char).take(width).collect()
364}
365
366pub fn truncate_text(text: &str, max_width: usize) -> String {
368 if text.len() <= max_width {
369 return text.to_string();
370 }
371
372 if max_width <= 3 {
373 return "...".to_string();
374 }
375
376 let mut result = text[..max_width - 3].to_string();
377 result.push_str("...");
378 result
379}
380
381pub fn split_into_lines(text: &str, width: usize) -> Vec<String> {
383 wrap_text(text, width).lines().map(String::from).collect()
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn test_wrap_text() {
392 let text = "This is a test of the text wrapping functionality";
393 let wrapped = wrap_text(text, 20);
394 assert!(wrapped.lines().all(|l| l.len() <= 20));
395 }
396
397 #[test]
398 fn test_wrap_preserves_newlines() {
399 let text = "Line one\nLine two\nLine three";
400 let wrapped = wrap_text(text, 80);
401 assert_eq!(wrapped.lines().count(), 3);
402 }
403
404 #[test]
405 fn test_help_formatter_usage() {
406 let mut fmt = HelpFormatter::new(80);
407 fmt.write_usage("mycli", "[OPTIONS] COMMAND");
408 assert!(fmt.get_help().contains("Usage: mycli [OPTIONS] COMMAND"));
409 }
410
411 #[test]
412 fn test_help_formatter_heading() {
413 let mut fmt = HelpFormatter::new(80);
414 fmt.write_heading("Options");
415 assert!(fmt.get_help().contains("Options:\n"));
416 }
417
418 #[test]
419 fn test_help_formatter_definition_list() {
420 let mut fmt = HelpFormatter::new(80);
421 fmt.write_definition_list(&[("--help, -h", "Show help"), ("--version", "Show version")]);
422 let help = fmt.get_help();
423 assert!(help.contains("--help, -h"));
424 assert!(help.contains("Show help"));
425 }
426
427 #[test]
428 fn test_truncate_text() {
429 assert_eq!(truncate_text("hello", 10), "hello");
430 assert_eq!(truncate_text("hello world", 8), "hello...");
431 assert_eq!(truncate_text("hi", 3), "hi");
433 assert_eq!(truncate_text("hello", 3), "...");
435 }
436}