falcon_cli/lib.rs
1//#![allow(warnings)]
2// Copyright 2025 Aquila Labs of Alberta, Canada <matt@cicero.sh>
3// Licensed under either the Apache License, Version 2.0 OR the MIT License, at your option.
4// You may not use this file except in compliance with one of the Licenses.
5// Apache License text: https://www.apache.org/licenses/LICENSE-2.0
6// MIT License text: https://opensource.org/licenses/MIT
7
8pub use self::error::CliError;
9pub use self::help::CliHelpScreen;
10pub use self::macros::*;
11pub use self::request::{CliFormat, CliRequest};
12pub use self::router::CliRouter;
13pub use anyhow;
14pub use indexmap::{IndexMap, indexmap};
15
16use rpassword::read_password;
17use std::fmt::Display;
18use std::hash::Hash;
19use std::process::{Command, exit};
20use std::str::FromStr;
21use std::{env, fs};
22use zxcvbn::zxcvbn;
23
24pub mod error;
25mod help;
26pub mod macros;
27mod request;
28mod router;
29
30/// Trait that all CLI commands must implement.
31///
32/// This trait defines the interface for CLI commands, requiring implementations
33/// to provide both a `process` method for executing the command and a `help` method
34/// for generating help documentation.
35///
36/// # Example
37///
38/// ```
39/// use falcon_cli::{CliCommand, CliRequest, CliHelpScreen};
40///
41/// struct MyCommand;
42///
43/// impl CliCommand for MyCommand {
44/// fn process(&self, req: &CliRequest) -> anyhow::Result<()> {
45/// println!("Executing command");
46/// Ok(())
47/// }
48///
49/// fn help(&self) -> CliHelpScreen {
50/// CliHelpScreen::new("My Command", "myapp mycommand", "Does something useful")
51/// }
52/// }
53/// ```
54pub trait CliCommand {
55 /// Processes the CLI command with the given request.
56 ///
57 /// # Arguments
58 ///
59 /// * `req` - The CLI request containing arguments, flags, and other parsed data
60 ///
61 /// # Returns
62 ///
63 /// Returns `Ok(())` on success or an error on failure.
64 fn process(&self, req: &CliRequest) -> anyhow::Result<()>;
65
66 /// Returns the help screen for this command.
67 ///
68 /// This method should create and return a `CliHelpScreen` with information
69 /// about how to use the command, including parameters, flags, and examples.
70 fn help(&self) -> CliHelpScreen;
71}
72
73/// Executes the CLI command router and processes the appropriate command.
74///
75/// This function should be called once all necessary routes have been defined via
76/// `router.add()`. It will parse command line arguments, look up the appropriate
77/// command handler, and execute it or display help as needed.
78///
79/// # Arguments
80///
81/// * `router` - A mutable reference to the configured CLI router
82///
83/// # Example
84///
85/// ```no_run
86/// use falcon_cli::{CliRouter, cli_run};
87///
88/// let mut router = CliRouter::new();
89/// router.app_name("My App");
90/// // Add commands here...
91/// cli_run(&mut router);
92/// ```
93pub fn cli_run(router: &mut CliRouter) {
94 // Lookup route
95 let (req, cmd) = match router.lookup() {
96 Some(r) => r,
97 None => {
98 CliHelpScreen::render_index(&router);
99 exit(0);
100 }
101 };
102
103 // Process as needed
104 if req.is_help {
105 CliHelpScreen::render(&cmd, &req.cmd_alias, &req.shortcuts);
106 } else if let Err(e) = cmd.process(&req) {
107 cli_send!("ERROR: {}\n", e);
108 }
109}
110
111/// Displays a formatted header in the terminal.
112///
113/// Outputs the given text with 30 dashes at the top and bottom to create a header section.
114///
115/// # Arguments
116///
117/// * `text` - The text to display in the header
118///
119/// # Example
120///
121/// ```
122/// use falcon_cli::cli_header;
123///
124/// cli_header("My Application");
125/// // Output:
126/// // ------------------------------
127/// // -- My Application
128/// // ------------------------------
129/// ```
130pub fn cli_header(text: &str) {
131 println!("------------------------------");
132 println!("-- {}", text);
133 println!("------------------------------\n");
134}
135
136/// Prompts the user to select an option from a list.
137///
138/// Displays a question and list of options, then waits for the user to select one.
139/// The function will continue prompting until a valid option is selected.
140///
141/// # Arguments
142///
143/// * `question` - The question or prompt to display
144/// * `options` - An `IndexMap` of options where keys are option identifiers and values are descriptions
145///
146/// # Returns
147///
148/// Returns the key of the selected option.
149///
150/// # Example
151///
152/// ```no_run
153/// use falcon_cli::{cli_get_option, indexmap};
154/// use indexmap::IndexMap;
155///
156/// let options = indexmap! {
157/// 1 => "First option",
158/// 2 => "Second option",
159/// 3 => "Third option",
160/// };
161///
162/// let selected = cli_get_option("Which option do you prefer?", &options);
163/// println!("You selected: {}", selected);
164/// ```
165pub fn cli_get_option<K, V>(question: &str, options: &IndexMap<K, V>) -> K
166where
167 K: Display + Eq + PartialEq + Hash + FromStr,
168 <K as FromStr>::Err: Display,
169 V: Display,
170{
171 let message = format!("{}\n\n", question);
172 cli_send!(&message);
173 for (key, value) in options.iter() {
174 cli_send!(&format!(" [{}] {}\n", key, value));
175 }
176 cli_send!("\nSelect One: ");
177
178 // Get user input
179 let mut input: String;
180 loop {
181 input = String::new();
182
183 io::stdin().read_line(&mut input).expect("Failed to read line");
184 let input = input.trim();
185
186 if let Ok(value) = input.parse::<K>() {
187 if options.contains_key(&value) {
188 return value;
189 }
190 }
191
192 print!("\r\nInvalid option, try again: ");
193 io::stdout().flush().unwrap();
194 }
195}
196
197/// Gets text input from the user.
198///
199/// Displays a prompt message and waits for the user to enter text. If the user
200/// enters nothing, the default value is returned.
201///
202/// # Arguments
203///
204/// * `message` - The prompt message to display
205/// * `default_value` - The value to return if the user enters nothing
206///
207/// # Returns
208///
209/// Returns the user's input as a `String`, or the default value if no input was provided.
210///
211/// # Example
212///
213/// ```no_run
214/// use falcon_cli::cli_get_input;
215///
216/// let name = cli_get_input("Enter your name: ", "Anonymous");
217/// println!("Hello, {}!", name);
218/// ```
219pub fn cli_get_input(message: &str, default_value: &str) -> String {
220 // Display message
221 cli_send!(message);
222 io::stdout().flush().unwrap();
223
224 // Get user input
225 let mut input = String::new();
226 io::stdin().read_line(&mut input).expect("Failed to read line");
227 let mut input = input.trim();
228
229 // Default value, if needed
230 if input.trim().is_empty() {
231 input = default_value;
232 }
233
234 input.to_string()
235}
236
237/// Gets multi-line text input from the user.
238///
239/// Displays a prompt message and collects multiple lines of input from the user.
240/// Input collection stops when the user enters an empty line.
241///
242/// # Arguments
243///
244/// * `message` - The prompt message to display
245///
246/// # Returns
247///
248/// Returns all entered lines joined with newline characters as a single `String`.
249///
250/// # Example
251///
252/// ```no_run
253/// use falcon_cli::cli_get_multiline_input;
254///
255/// let description = cli_get_multiline_input("Enter description:");
256/// println!("You entered:\n{}", description);
257/// ```
258pub fn cli_get_multiline_input(message: &str) -> String {
259 // Display message
260 cli_send!(&format!("{} (empty line to stop)\n\n", message));
261 io::stdout().flush().unwrap();
262
263 // Get user input
264 let mut res: Vec<String> = Vec::new();
265 loop {
266 let mut input = String::new();
267 io::stdin().read_line(&mut input).expect("Failed to read line");
268 let input = input.trim();
269
270 if input.is_empty() {
271 break;
272 }
273 res.push(input.to_string());
274 }
275
276 res.join("\n").to_string()
277}
278
279/// Requests confirmation from the user.
280///
281/// Displays a message and prompts the user to answer yes (y) or no (n).
282/// The function will continue prompting until a valid response is received.
283///
284/// # Arguments
285///
286/// * `message` - The confirmation message to display
287///
288/// # Returns
289///
290/// Returns `true` if the user answered 'y', `false` if they answered 'n'.
291///
292/// # Example
293///
294/// ```no_run
295/// use falcon_cli::cli_confirm;
296///
297/// if cli_confirm("Do you want to continue?") {
298/// println!("Continuing...");
299/// } else {
300/// println!("Cancelled.");
301/// }
302/// ```
303pub fn cli_confirm(message: &str) -> bool {
304 // Send message
305 let confirm_message = format!("{} (y/n): ", message);
306 cli_send!(&confirm_message);
307
308 // Get user input
309 let mut _input = "".to_string();
310 loop {
311 _input = String::new();
312
313 io::stdin().read_line(&mut _input).expect("Failed to read line");
314 let _input = _input.trim().to_lowercase();
315
316 if _input != "y" && _input != "n" {
317 cli_send!("Invalid option, please try again. Enter (y/n): ");
318 } else {
319 break;
320 }
321 }
322
323 // Return
324 let res_char = _input.chars().next().unwrap();
325
326 res_char == 'y'
327}
328
329/// Gets a password from the user without displaying the input on screen.
330///
331/// Prompts the user for a password with input hidden from the terminal.
332/// Optionally can require a non-empty password.
333///
334/// # Arguments
335///
336/// * `message` - The prompt message to display (defaults to "Password: " if empty)
337/// * `allow_blank` - Whether to allow an empty password
338///
339/// # Returns
340///
341/// Returns the entered password as a `String`.
342///
343/// # Example
344///
345/// ```no_run
346/// use falcon_cli::cli_get_password;
347///
348/// let password = cli_get_password("Enter password: ", false);
349/// println!("Password entered successfully");
350/// ```
351#[cfg(not(feature="mock"))]
352pub fn cli_get_password(message: &str, allow_blank: bool) -> String {
353 // Get message
354 let password_message = if message.is_empty() {
355 "Password: "
356 } else {
357 message
358 };
359
360 // Get password
361 let mut _password = String::new();
362 loop {
363 cli_send!(password_message);
364 _password = read_password().unwrap();
365
366 if _password.is_empty() && !allow_blank {
367 cli_send!("You did not specify a password");
368 } else {
369 break;
370 }
371 }
372
373 _password
374}
375
376#[cfg(feature="mock")]
377pub fn cli_get_password(message: &str, allow_blank: bool) -> String {
378 cli_get_input(message, if allow_blank { "" } else { "password" })
379}
380
381/// Gets a new password from the user with confirmation and strength validation.
382///
383/// Prompts the user to enter a password twice for confirmation and validates it
384/// against a required strength level using the zxcvbn algorithm. The function will
385/// continue prompting until a password meeting all requirements is entered.
386///
387/// # Arguments
388///
389/// * `req_strength` - Required password strength (0-4, where 4 is strongest)
390/// - 0: Too guessable
391/// - 1: Very guessable
392/// - 2: Somewhat guessable
393/// - 3: Safely unguessable
394/// - 4: Very unguessable
395///
396/// # Returns
397///
398/// Returns the validated password as a `String`.
399///
400/// # Example
401///
402/// ```no_run
403/// use falcon_cli::cli_get_new_password;
404///
405/// // Require a password with strength level 3
406/// let password = cli_get_new_password(3);
407/// println!("Strong password created successfully");
408/// ```
409#[cfg(not(feature = "mock"))]
410pub fn cli_get_new_password(req_strength: u8) -> String {
411println!("Nope, at the prod one");
412 // Initialize
413 let mut _password = String::new();
414 let mut _confirm_password = String::new();
415
416 // Get new password
417 loop {
418 cli_send!("Desired Password: ");
419 _password = read_password().unwrap();
420
421 if _password.is_empty() {
422 cli_send!("You did not specify a password");
423 continue;
424 }
425
426 // Check strength
427 let strength = zxcvbn(&_password, &[]).unwrap();
428 if strength.score() < req_strength {
429 cli_send!("Password is not strong enough. Please try again.\n\n");
430 continue;
431 }
432
433 // Confirm password
434 cli_send!("Confirm Password: ");
435 _confirm_password = read_password().unwrap();
436 if _password != _confirm_password {
437 cli_send!("Passwords do not match, please try again.\n\n");
438 continue;
439 }
440 break;
441 }
442
443 _password
444}
445
446#[cfg(feature = "mock")]
447pub fn cli_get_new_password(req_strength: u8) -> String {
448println!("Yes at the testing one");
449 // Initialize
450 let mut _password = String::new();
451 let mut _confirm_password = String::new();
452
453 // Get new password
454 loop {
455 _password = cli_get_input("Desired Password: ", "");
456
457 if _password.is_empty() {
458 cli_send!("You did not specify a password");
459 continue;
460 }
461
462 // Check strength
463 let strength = zxcvbn(&_password, &[]).unwrap();
464 if strength.score() < req_strength {
465 cli_send!("Password is not strong enough. Please try again.\n\n");
466 continue;
467 }
468
469 // Confirm password
470 _confirm_password = cli_get_input("Confirm Password: ", "");
471 if _password != _confirm_password {
472 cli_send!("Passwords do not match, please try again.\n\n");
473 continue;
474 }
475 break;
476 }
477
478 _password
479}
480
481/// Displays data in a formatted table.
482///
483/// Renders data in a tabular format similar to SQL database output, with borders
484/// and properly aligned columns. Column widths are automatically calculated based
485/// on the content.
486///
487/// # Arguments
488///
489/// * `columns` - Slice of column headers
490/// * `rows` - Slice of rows, where each row is a vector of cell values
491///
492/// # Example
493///
494/// ```no_run
495/// use falcon_cli::cli_display_table;
496///
497/// let columns = ["Name", "Age", "City"];
498/// let rows = vec![
499/// vec!["Alice", "30", "New York"],
500/// vec!["Bob", "25", "London"],
501/// vec!["Charlie", "35", "Tokyo"],
502/// ];
503///
504/// cli_display_table(&columns, &rows);
505/// ```
506pub fn cli_display_table<C: Display, R: Display>(columns: &[C], rows: &[Vec<R>]) {
507 // Return if no rows
508 if rows.is_empty() {
509 println!("No rows to display.\n");
510 return;
511 }
512
513 // Initialize sizes - using index-based approach since we can't use T as HashMap key
514 let mut sizes: Vec<usize> = vec![0; columns.len()];
515
516 // Get sizes of column headers
517 for (i, col) in columns.iter().enumerate() {
518 let col_str = col.to_string();
519 sizes[i] = col_str.len();
520 }
521
522 // Get maximum sizes by checking all row values
523 for row in rows {
524 for (i, val) in row.iter().enumerate() {
525 if i < sizes.len() {
526 let val_str = val.to_string();
527 let val_len = val_str.len();
528 if val_len > sizes[i] {
529 sizes[i] = val_len;
530 }
531 }
532 }
533 }
534
535 // Add padding to all column sizes
536 for size in sizes.iter_mut() {
537 *size += 3;
538 }
539
540 // Initialize header variables
541 let mut header = String::from("+");
542 let mut col_header = String::from("|");
543
544 // Print column headers
545 for (i, col) in columns.iter().enumerate() {
546 let col_str = col.to_string();
547 let padded_col = format!("{}{}", col_str, " ".repeat(sizes[i] - col_str.len()));
548 header += &("-".repeat(sizes[i] + 1) + "+");
549 col_header += &format!(" {}|", padded_col);
550 }
551
552 println!("{}\n{}\n{}", header, col_header, header);
553
554 // Display the rows
555 for row in rows {
556 let mut line = String::from("|");
557 for (i, val) in row.iter().enumerate() {
558 if i < sizes.len() {
559 let val_str = val.to_string();
560 let padded_val = format!(" {}{}", val_str, " ".repeat(sizes[i] - val_str.len()));
561 line += &format!("{}|", padded_val);
562 }
563 }
564 println!("{}", line);
565 }
566 println!("{}\n", header);
567}
568
569/// Displays a two-column array with proper spacing and word wrapping.
570///
571/// Formats and displays key-value pairs in two columns with automatic text wrapping.
572/// This function is primarily used by the help system to display parameters and flags,
573/// but can be used for any two-column data display.
574///
575/// # Arguments
576///
577/// * `rows` - An `IndexMap` where keys are displayed in the left column and values in the right
578///
579/// # Example
580///
581/// ```no_run
582/// use falcon_cli::{cli_display_array, indexmap};
583/// use indexmap::IndexMap;
584///
585/// let mut items = indexmap! {
586/// "--verbose" => "Enable verbose output with detailed logging",
587/// "--output" => "Specify the output file path",
588/// "--help" => "Display this help message",
589/// };
590///
591/// cli_display_array(&items);
592/// ```
593pub fn cli_display_array<K: Display, V: Display>(rows: &IndexMap<K, V>) {
594 // Get max left column size
595 let mut size = 0;
596 for key in rows.keys() {
597 let key_str = key.to_string();
598 if key_str.len() + 8 > size {
599 size = key_str.len() + 8;
600 }
601 }
602 let indent = " ".repeat(size);
603 let indent_size = size - 4;
604
605 // Go through rows
606 for (key, value) in rows {
607 let key_str = key.to_string();
608 let value_str = value.to_string();
609 let left_col = format!(" {}{}", key_str, " ".repeat(indent_size - key_str.len()));
610 let options =
611 textwrap::Options::new(75).initial_indent(&left_col).subsequent_indent(&indent);
612 let line = textwrap::fill(&value_str, &options);
613 println!("{}", line);
614 }
615 println!("");
616}
617
618/// Clears the terminal screen.
619///
620/// Sends the ANSI escape sequence to clear all lines and reset the cursor position.
621///
622/// # Example
623///
624/// ```no_run
625/// use falcon_cli::cli_clear_screen;
626///
627/// cli_clear_screen();
628/// println!("Screen cleared!");
629/// ```
630pub fn cli_clear_screen() {
631 print!("\x1B[2J");
632}
633
634/// Opens a text editor for the user to edit content.
635///
636/// Creates a temporary file with the provided contents, opens it in the user's
637/// preferred text editor, and returns the edited content. The editor used is
638/// determined by the `VISUAL` or `EDITOR` environment variables, with sensible
639/// defaults for each platform.
640///
641/// # Arguments
642///
643/// * `contents` - The initial content to populate the editor with
644///
645/// # Returns
646///
647/// Returns `Ok(String)` with the edited content on success, or a `CliError` if
648/// the editor fails to launch or exits with an error.
649///
650/// # Example
651///
652/// ```no_run
653/// use falcon_cli::cli_text_editor;
654///
655/// let initial = "Edit this text...";
656/// match cli_text_editor(initial) {
657/// Ok(edited) => println!("New content: {}", edited),
658/// Err(e) => eprintln!("Error: {}", e),
659/// }
660/// ```
661pub fn cli_text_editor(contents: &str) -> Result<String, CliError> {
662 // Create temp file
663 let temp_dir = env::temp_dir();
664 let temp_file = temp_dir.join(format!(
665 "cli_edit_{}.tmp",
666 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis()
667 ));
668
669 // Write initial contents to temp file
670 fs::write(&temp_file, contents)
671 .map_err(|e| CliError::Generic(format!("Failed to create temp file: {}", e)))?;
672
673 // Get editor command
674 let editor = get_editor();
675
676 // Launch editor
677 let status = if cfg!(target_os = "windows") {
678 Command::new("cmd")
679 .args(&["/C", &format!("{} \"{}\"", editor, temp_file.display())])
680 .status()
681 } else {
682 Command::new(&editor).arg(&temp_file).status()
683 };
684
685 match status {
686 Ok(exit_status) if exit_status.success() => {
687 // Read the file contents
688 let result = fs::read_to_string(&temp_file).unwrap_or_else(|_| String::new());
689
690 // Delete temp file
691 let _ = fs::remove_file(&temp_file);
692
693 Ok(result)
694 }
695 Ok(_) => {
696 let _ = fs::remove_file(&temp_file);
697 Err(CliError::Generic("Editor exited with error".to_string()))
698 }
699 Err(e) => {
700 let _ = fs::remove_file(&temp_file);
701 Err(CliError::Generic(format!("Failed to launch editor: {}", e)))
702 }
703 }
704}
705
706/// Determines the text editor to use based on environment variables and platform.
707///
708/// Checks environment variables in order of preference (`VISUAL`, then `EDITOR`),
709/// falling back to platform-specific defaults if neither is set.
710fn get_editor() -> String {
711 // Check environment variables in order of preference
712 if let Ok(editor) = env::var("VISUAL") {
713 return editor;
714 }
715 if let Ok(editor) = env::var("EDITOR") {
716 return editor;
717 }
718
719 // Platform-specific defaults
720 if cfg!(target_os = "windows") {
721 // Try notepad++ first, fall back to notepad
722 if Command::new("notepad++").arg("--version").output().is_ok() {
723 "notepad++".to_string()
724 } else {
725 "notepad".to_string()
726 }
727 } else if cfg!(target_os = "macos") {
728 // macOS - try nano first (comes default), then vim
729 if Command::new("which").arg("nano").output().is_ok() {
730 "nano".to_string()
731 } else {
732 "vim".to_string()
733 }
734 } else {
735 // Linux/Unix - try in order of user-friendliness
736 for editor in &["nano", "vim", "vi"] {
737 if Command::new("which")
738 .arg(editor)
739 .output()
740 .map(|o| o.status.success())
741 .unwrap_or(false)
742 {
743 return editor.to_string();
744 }
745 }
746 "vi".to_string() // Last resort, should always exist on Unix
747 }
748}
749
750/// Creates and displays a new progress bar.
751///
752/// Initializes a progress bar with the specified message and total value,
753/// and immediately renders it at 0% completion.
754///
755/// # Arguments
756///
757/// * `message` - The message to display alongside the progress bar
758/// * `total` - The total value representing 100% completion
759///
760/// # Returns
761///
762/// Returns a `CliProgressBar` instance that can be updated with `increment()` or `set()`.
763///
764/// # Example
765///
766/// ```no_run
767/// use falcon_cli::cli_progress_bar;
768///
769/// let mut bar = cli_progress_bar("Processing files", 100);
770/// for i in 0..100 {
771/// // Do work...
772/// bar.increment(1);
773/// }
774/// bar.finish();
775/// ```
776pub fn cli_progress_bar(message: &str, total: usize) -> CliProgressBar {
777 let bar = CliProgressBar {
778 value: 0,
779 total,
780 message: message.to_string(),
781 };
782 bar.start();
783 bar
784}
785
786/// A progress bar for displaying task completion in the terminal.
787///
788/// This struct maintains the state of a progress bar and provides methods
789/// to update and render it. The bar displays percentage, a message, and
790/// a visual indicator of progress.
791pub struct CliProgressBar {
792 /// Current value of progress (0 to total).
793 pub value: usize,
794 /// Total value representing 100% completion.
795 pub total: usize,
796 /// Message displayed alongside the progress bar.
797 pub message: String,
798}
799
800impl CliProgressBar {
801 /// Initializes and displays the progress bar.
802 ///
803 /// Renders the progress bar on a new line at its initial state (0%).
804 pub fn start(&self) {
805 self.render();
806 }
807
808 /// Increments the progress value and updates the display.
809 ///
810 /// # Arguments
811 ///
812 /// * `num` - The amount to increment the progress by
813 ///
814 /// # Example
815 ///
816 /// ```no_run
817 /// # use falcon_cli::cli_progress_bar;
818 /// let mut bar = cli_progress_bar("Processing", 100);
819 /// bar.increment(10); // Progress is now at 10%
820 /// bar.increment(15); // Progress is now at 25%
821 /// ```
822 pub fn increment(&mut self, num: usize) {
823 self.value = self.value.saturating_add(num).min(self.total);
824 self.render();
825 }
826
827 /// Sets the progress to a specific value and updates the display.
828 ///
829 /// # Arguments
830 ///
831 /// * `value` - The new progress value (clamped to `total`)
832 ///
833 /// # Example
834 ///
835 /// ```no_run
836 /// # use falcon_cli::cli_progress_bar;
837 /// let mut bar = cli_progress_bar("Processing", 100);
838 /// bar.set(50); // Set progress to 50%
839 /// ```
840 pub fn set(&mut self, value: usize) {
841 self.value = value.min(self.total);
842 self.render();
843 }
844
845 /// Completes the progress bar.
846 ///
847 /// Sets the progress to 100%, renders the final state, and moves to a new line.
848 ///
849 /// # Example
850 ///
851 /// ```no_run
852 /// # use falcon_cli::cli_progress_bar;
853 /// let mut bar = cli_progress_bar("Processing", 100);
854 /// // ... do work ...
855 /// bar.finish();
856 /// ```
857 pub fn finish(&mut self) {
858 self.value = self.total;
859 self.render();
860 println!("");
861 }
862
863 /// Renders the progress bar to the terminal.
864 ///
865 /// Internal method that calculates and displays the progress bar with
866 /// percentage, message, and visual indicator.
867 fn render(&self) {
868 let percent = if self.total > 0 {
869 (self.value * 100) / self.total
870 } else {
871 0
872 };
873
874 // Calculate available space
875 // Format: [ xx% ] <MESSAGE> [****** ]
876 // Fixed parts: "[ ", "% ] ", " [", "]" = 8 chars
877 // Percent: 1-3 chars (0-100)
878 let percent_str = format!("{}", percent);
879 let fixed_overhead = 8 + percent_str.len();
880
881 // Available space for message and bar
882 let available = 75_usize.saturating_sub(fixed_overhead);
883
884 // Reserve minimum 10 chars for bar (including brackets)
885 let bar_size = 10;
886 let message_max = available.saturating_sub(bar_size);
887
888 // Truncate message if needed
889 let display_message = if self.message.len() > message_max {
890 format!("{}...", &self.message[..message_max.saturating_sub(3)])
891 } else {
892 self.message.clone()
893 };
894
895 // Calculate actual bar width (inner width without brackets)
896 let bar_width = available.saturating_sub(display_message.len()).max(8);
897 let filled = (bar_width * self.value) / self.total.max(1);
898 let empty = bar_width.saturating_sub(filled);
899
900 // Build the bar
901 let bar = format!("{}{}", "*".repeat(filled), " ".repeat(empty));
902
903 // Print with carriage return to overwrite line
904 print!("\r[ {}% ] {} [{}]", percent, display_message, bar);
905 io::stdout().flush().unwrap();
906
907 // Print newline when complete
908 if self.value >= self.total {
909 println!();
910 }
911 }
912}