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