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 {
411 // Initialize
412 let mut _password = String::new();
413 let mut _confirm_password = String::new();
414
415 // Get new password
416 loop {
417 cli_send!("Desired Password: ");
418 _password = read_password().unwrap();
419
420 if _password.is_empty() {
421 cli_send!("You did not specify a password");
422 continue;
423 }
424
425 // Check strength
426 let strength = zxcvbn(&_password, &[]).unwrap();
427 if strength.score() < req_strength {
428 cli_send!("Password is not strong enough. Please try again.\n\n");
429 continue;
430 }
431
432 // Confirm password
433 cli_send!("Confirm Password: ");
434 _confirm_password = read_password().unwrap();
435 if _password != _confirm_password {
436 cli_send!("Passwords do not match, please try again.\n\n");
437 continue;
438 }
439 break;
440 }
441
442 _password
443}
444
445#[cfg(feature = "mock")]
446pub fn cli_get_new_password(req_strength: u8) -> String {
447 // Initialize
448 let mut _password = String::new();
449 let mut _confirm_password = String::new();
450
451 // Get new password
452 loop {
453 _password = cli_get_input("Desired Password: ", "");
454
455 if _password.is_empty() {
456 cli_send!("You did not specify a password");
457 continue;
458 }
459
460 // Check strength
461 let strength = zxcvbn(&_password, &[]).unwrap();
462 if strength.score() < req_strength {
463 cli_send!("Password is not strong enough. Please try again.\n\n");
464 continue;
465 }
466
467 // Confirm password
468 _confirm_password = cli_get_input("Confirm Password: ", "");
469 if _password != _confirm_password {
470 cli_send!("Passwords do not match, please try again.\n\n");
471 continue;
472 }
473 break;
474 }
475
476 _password
477}
478
479/// Displays data in a formatted table.
480///
481/// Renders data in a tabular format similar to SQL database output, with borders
482/// and properly aligned columns. Column widths are automatically calculated based
483/// on the content.
484///
485/// # Arguments
486///
487/// * `columns` - Slice of column headers
488/// * `rows` - Slice of rows, where each row is a vector of cell values
489///
490/// # Example
491///
492/// ```no_run
493/// use falcon_cli::cli_display_table;
494///
495/// let columns = ["Name", "Age", "City"];
496/// let rows = vec![
497/// vec!["Alice", "30", "New York"],
498/// vec!["Bob", "25", "London"],
499/// vec!["Charlie", "35", "Tokyo"],
500/// ];
501///
502/// cli_display_table(&columns, &rows);
503/// ```
504pub fn cli_display_table<C: Display, R: Display>(columns: &[C], rows: &[Vec<R>]) {
505 // Return if no rows
506 if rows.is_empty() {
507 println!("No rows to display.\n");
508 return;
509 }
510
511 // Initialize sizes - using index-based approach since we can't use T as HashMap key
512 let mut sizes: Vec<usize> = vec![0; columns.len()];
513
514 // Get sizes of column headers
515 for (i, col) in columns.iter().enumerate() {
516 let col_str = col.to_string();
517 sizes[i] = col_str.len();
518 }
519
520 // Get maximum sizes by checking all row values
521 for row in rows {
522 for (i, val) in row.iter().enumerate() {
523 if i < sizes.len() {
524 let val_str = val.to_string();
525 let val_len = val_str.len();
526 if val_len > sizes[i] {
527 sizes[i] = val_len;
528 }
529 }
530 }
531 }
532
533 // Add padding to all column sizes
534 for size in sizes.iter_mut() {
535 *size += 3;
536 }
537
538 // Initialize header variables
539 let mut header = String::from("+");
540 let mut col_header = String::from("|");
541
542 // Print column headers
543 for (i, col) in columns.iter().enumerate() {
544 let col_str = col.to_string();
545 let padded_col = format!("{}{}", col_str, " ".repeat(sizes[i] - col_str.len()));
546 header += &("-".repeat(sizes[i] + 1) + "+");
547 col_header += &format!(" {}|", padded_col);
548 }
549
550 println!("{}\n{}\n{}", header, col_header, header);
551
552 // Display the rows
553 for row in rows {
554 let mut line = String::from("|");
555 for (i, val) in row.iter().enumerate() {
556 if i < sizes.len() {
557 let val_str = val.to_string();
558 let padded_val = format!(" {}{}", val_str, " ".repeat(sizes[i] - val_str.len()));
559 line += &format!("{}|", padded_val);
560 }
561 }
562 println!("{}", line);
563 }
564 println!("{}\n", header);
565}
566
567/// Displays a two-column array with proper spacing and word wrapping.
568///
569/// Formats and displays key-value pairs in two columns with automatic text wrapping.
570/// This function is primarily used by the help system to display parameters and flags,
571/// but can be used for any two-column data display.
572///
573/// # Arguments
574///
575/// * `rows` - An `IndexMap` where keys are displayed in the left column and values in the right
576///
577/// # Example
578///
579/// ```no_run
580/// use falcon_cli::{cli_display_array, indexmap};
581/// use indexmap::IndexMap;
582///
583/// let mut items = indexmap! {
584/// "--verbose" => "Enable verbose output with detailed logging",
585/// "--output" => "Specify the output file path",
586/// "--help" => "Display this help message",
587/// };
588///
589/// cli_display_array(&items);
590/// ```
591pub fn cli_display_array<K: Display, V: Display>(rows: &IndexMap<K, V>) {
592 // Get max left column size
593 let mut size = 0;
594 for key in rows.keys() {
595 let key_str = key.to_string();
596 if key_str.len() + 8 > size {
597 size = key_str.len() + 8;
598 }
599 }
600 let indent = " ".repeat(size);
601 let indent_size = size - 4;
602
603 // Go through rows
604 for (key, value) in rows {
605 let key_str = key.to_string();
606 let value_str = value.to_string();
607 let left_col = format!(" {}{}", key_str, " ".repeat(indent_size - key_str.len()));
608 let options =
609 textwrap::Options::new(75).initial_indent(&left_col).subsequent_indent(&indent);
610 let line = textwrap::fill(&value_str, &options);
611 println!("{}", line);
612 }
613 println!("");
614}
615
616/// Clears the terminal screen.
617///
618/// Sends the ANSI escape sequence to clear all lines and reset the cursor position.
619///
620/// # Example
621///
622/// ```no_run
623/// use falcon_cli::cli_clear_screen;
624///
625/// cli_clear_screen();
626/// println!("Screen cleared!");
627/// ```
628pub fn cli_clear_screen() {
629 print!("\x1B[2J");
630}
631
632/// Opens a text editor for the user to edit content.
633///
634/// Creates a temporary file with the provided contents, opens it in the user's
635/// preferred text editor, and returns the edited content. The editor used is
636/// determined by the `VISUAL` or `EDITOR` environment variables, with sensible
637/// defaults for each platform.
638///
639/// # Arguments
640///
641/// * `contents` - The initial content to populate the editor with
642///
643/// # Returns
644///
645/// Returns `Ok(String)` with the edited content on success, or a `CliError` if
646/// the editor fails to launch or exits with an error.
647///
648/// # Example
649///
650/// ```no_run
651/// use falcon_cli::cli_text_editor;
652///
653/// let initial = "Edit this text...";
654/// match cli_text_editor(initial) {
655/// Ok(edited) => println!("New content: {}", edited),
656/// Err(e) => eprintln!("Error: {}", e),
657/// }
658/// ```
659pub fn cli_text_editor(contents: &str) -> Result<String, CliError> {
660 // Create temp file
661 let temp_dir = env::temp_dir();
662 let temp_file = temp_dir.join(format!(
663 "cli_edit_{}.tmp",
664 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis()
665 ));
666
667 // Write initial contents to temp file
668 fs::write(&temp_file, contents)
669 .map_err(|e| CliError::Generic(format!("Failed to create temp file: {}", e)))?;
670
671 // Get editor command
672 let editor = get_editor();
673
674 // Launch editor
675 let status = if cfg!(target_os = "windows") {
676 Command::new("cmd")
677 .args(&["/C", &format!("{} \"{}\"", editor, temp_file.display())])
678 .status()
679 } else {
680 Command::new(&editor).arg(&temp_file).status()
681 };
682
683 match status {
684 Ok(exit_status) if exit_status.success() => {
685 // Read the file contents
686 let result = fs::read_to_string(&temp_file).unwrap_or_else(|_| String::new());
687
688 // Delete temp file
689 let _ = fs::remove_file(&temp_file);
690
691 Ok(result)
692 }
693 Ok(_) => {
694 let _ = fs::remove_file(&temp_file);
695 Err(CliError::Generic("Editor exited with error".to_string()))
696 }
697 Err(e) => {
698 let _ = fs::remove_file(&temp_file);
699 Err(CliError::Generic(format!("Failed to launch editor: {}", e)))
700 }
701 }
702}
703
704/// Determines the text editor to use based on environment variables and platform.
705///
706/// Checks environment variables in order of preference (`VISUAL`, then `EDITOR`),
707/// falling back to platform-specific defaults if neither is set.
708fn get_editor() -> String {
709 // Check environment variables in order of preference
710 if let Ok(editor) = env::var("VISUAL") {
711 return editor;
712 }
713 if let Ok(editor) = env::var("EDITOR") {
714 return editor;
715 }
716
717 // Platform-specific defaults
718 if cfg!(target_os = "windows") {
719 // Try notepad++ first, fall back to notepad
720 if Command::new("notepad++").arg("--version").output().is_ok() {
721 "notepad++".to_string()
722 } else {
723 "notepad".to_string()
724 }
725 } else if cfg!(target_os = "macos") {
726 // macOS - try nano first (comes default), then vim
727 if Command::new("which").arg("nano").output().is_ok() {
728 "nano".to_string()
729 } else {
730 "vim".to_string()
731 }
732 } else {
733 // Linux/Unix - try in order of user-friendliness
734 for editor in &["nano", "vim", "vi"] {
735 if Command::new("which")
736 .arg(editor)
737 .output()
738 .map(|o| o.status.success())
739 .unwrap_or(false)
740 {
741 return editor.to_string();
742 }
743 }
744 "vi".to_string() // Last resort, should always exist on Unix
745 }
746}
747
748/// Creates and displays a new progress bar.
749///
750/// Initializes a progress bar with the specified message and total value,
751/// and immediately renders it at 0% completion.
752///
753/// # Arguments
754///
755/// * `message` - The message to display alongside the progress bar
756/// * `total` - The total value representing 100% completion
757///
758/// # Returns
759///
760/// Returns a `CliProgressBar` instance that can be updated with `increment()` or `set()`.
761///
762/// # Example
763///
764/// ```no_run
765/// use falcon_cli::cli_progress_bar;
766///
767/// let mut bar = cli_progress_bar("Processing files", 100);
768/// for i in 0..100 {
769/// // Do work...
770/// bar.increment(1);
771/// }
772/// bar.finish();
773/// ```
774pub fn cli_progress_bar(message: &str, total: usize) -> CliProgressBar {
775 let bar = CliProgressBar {
776 value: 0,
777 total,
778 message: message.to_string(),
779 };
780 bar.start();
781 bar
782}
783
784/// A progress bar for displaying task completion in the terminal.
785///
786/// This struct maintains the state of a progress bar and provides methods
787/// to update and render it. The bar displays percentage, a message, and
788/// a visual indicator of progress.
789pub struct CliProgressBar {
790 /// Current value of progress (0 to total).
791 pub value: usize,
792 /// Total value representing 100% completion.
793 pub total: usize,
794 /// Message displayed alongside the progress bar.
795 pub message: String,
796}
797
798impl CliProgressBar {
799 /// Initializes and displays the progress bar.
800 ///
801 /// Renders the progress bar on a new line at its initial state (0%).
802 pub fn start(&self) {
803 self.render();
804 }
805
806 /// Increments the progress value and updates the display.
807 ///
808 /// # Arguments
809 ///
810 /// * `num` - The amount to increment the progress by
811 ///
812 /// # Example
813 ///
814 /// ```no_run
815 /// # use falcon_cli::cli_progress_bar;
816 /// let mut bar = cli_progress_bar("Processing", 100);
817 /// bar.increment(10); // Progress is now at 10%
818 /// bar.increment(15); // Progress is now at 25%
819 /// ```
820 pub fn increment(&mut self, num: usize) {
821 self.value = self.value.saturating_add(num).min(self.total);
822 self.render();
823 }
824
825 /// Sets the progress to a specific value and updates the display.
826 ///
827 /// # Arguments
828 ///
829 /// * `value` - The new progress value (clamped to `total`)
830 ///
831 /// # Example
832 ///
833 /// ```no_run
834 /// # use falcon_cli::cli_progress_bar;
835 /// let mut bar = cli_progress_bar("Processing", 100);
836 /// bar.set(50); // Set progress to 50%
837 /// ```
838 pub fn set(&mut self, value: usize) {
839 self.value = value.min(self.total);
840 self.render();
841 }
842
843 /// Completes the progress bar.
844 ///
845 /// Sets the progress to 100%, renders the final state, and moves to a new line.
846 ///
847 /// # Example
848 ///
849 /// ```no_run
850 /// # use falcon_cli::cli_progress_bar;
851 /// let mut bar = cli_progress_bar("Processing", 100);
852 /// // ... do work ...
853 /// bar.finish();
854 /// ```
855 pub fn finish(&mut self) {
856 self.value = self.total;
857 self.render();
858 println!("");
859 }
860
861 /// Renders the progress bar to the terminal.
862 ///
863 /// Internal method that calculates and displays the progress bar with
864 /// percentage, message, and visual indicator.
865 fn render(&self) {
866 let percent = if self.total > 0 {
867 (self.value * 100) / self.total
868 } else {
869 0
870 };
871
872 // Calculate available space
873 // Format: [ xx% ] <MESSAGE> [****** ]
874 // Fixed parts: "[ ", "% ] ", " [", "]" = 8 chars
875 // Percent: 1-3 chars (0-100)
876 let percent_str = format!("{}", percent);
877 let fixed_overhead = 8 + percent_str.len();
878
879 // Available space for message and bar
880 let available = 75_usize.saturating_sub(fixed_overhead);
881
882 // Reserve minimum 10 chars for bar (including brackets)
883 let bar_size = 10;
884 let message_max = available.saturating_sub(bar_size);
885
886 // Truncate message if needed
887 let display_message = if self.message.len() > message_max {
888 format!("{}...", &self.message[..message_max.saturating_sub(3)])
889 } else {
890 self.message.clone()
891 };
892
893 // Calculate actual bar width (inner width without brackets)
894 let bar_width = available.saturating_sub(display_message.len()).max(8);
895 let filled = (bar_width * self.value) / self.total.max(1);
896 let empty = bar_width.saturating_sub(filled);
897
898 // Build the bar
899 let bar = format!("{}{}", "*".repeat(filled), " ".repeat(empty));
900
901 // Print with carriage return to overwrite line
902 print!("\r[ {}% ] {} [{}]", percent, display_message, bar);
903 io::stdout().flush().unwrap();
904
905 // Print newline when complete
906 if self.value >= self.total {
907 println!();
908 }
909 }
910}