ares/cli_pretty_printing/
mod.rs

1//! CLI Pretty Printing Module
2//!
3//! This module provides a unified interface for all CLI output formatting in Ares.
4//! By centralising all print statements here, we ensure:
5//! - Consistent visual appearance across the application
6//! - Standardised color schemes and formatting
7//! - Proper handling of API mode vs CLI mode
8//! - Centralised error message formatting
9//!
10//! # Color Scheme
11//! The module uses a configurable color scheme with roles:
12//! - Informational: General information and status updates
13//! - Warning: Non-critical warnings and cautions
14//! - Success: Successful operations and confirmations
15//! - Question: Interactive prompts and user queries
16//! - Statement: Standard output and neutral messages
17//!
18//! # Usage
19//! ```rust
20//! use ares::cli_pretty_printing::{success, warning};
21//!
22//! // Print a success message
23//! println!("{}", success("Operation completed successfully"));
24//!
25//! // Print a warning message
26//! println!("{}", warning("Please check your input"));
27//! ```
28
29#[cfg(test)]
30mod tests;
31use crate::storage;
32use crate::storage::wait_athena_storage::PlaintextResult;
33use crate::DecoderResult;
34use colored::Colorize;
35use std::env;
36use std::fs::write;
37use text_io::read;
38
39/// Parse RGB string in format "r,g,b" to RGB values.
40///
41/// The input string should be in the format "r,g,b" where r, g, and b are integers between 0 and 255.
42/// Spaces around numbers are allowed. This function is used internally by the color formatting
43/// functions to convert config-specified RGB strings into usable values.
44///
45/// # Arguments
46/// * `rgb` - The RGB string to parse in format "r,g,b"
47///
48/// # Returns
49/// * `Option<(u8, u8, u8)>` - The parsed RGB values if valid, None if invalid
50///
51/// # Examples
52/// ```
53/// use ares::cli_pretty_printing::parse_rgb;
54///
55/// // Valid formats:
56/// assert!(parse_rgb("255,0,0").is_some());     // Pure red
57/// assert!(parse_rgb("0, 255, 0").is_some());   // Pure green with spaces
58/// assert!(parse_rgb("0,0,255").is_some());     // Pure blue
59/// ```
60///
61/// # Errors
62/// Returns None if:
63/// - The string is not in the correct format (must have exactly 2 commas)
64/// - Any value cannot be parsed as a u8 (must be 0-255)
65pub fn parse_rgb(rgb: &str) -> Option<(u8, u8, u8)> {
66    let parts: Vec<&str> = rgb.split(',').collect();
67    if parts.len() != 3 {
68        eprintln!("Invalid RGB format: '{}'. Expected format: 'r,g,b' where r,g,b are numbers between 0-255", rgb);
69        return None;
70    }
71
72    let r = match parts[0].trim().parse::<u8>() {
73        Ok(val) => val,
74        Err(_) => {
75            eprintln!(
76                "Invalid red value '{}': must be a number between 0-255",
77                parts[0]
78            );
79            return None;
80        }
81    };
82
83    let g = match parts[1].trim().parse::<u8>() {
84        Ok(val) => val,
85        Err(_) => {
86            eprintln!(
87                "Invalid green value '{}': must be a number between 0-255",
88                parts[1]
89            );
90            return None;
91        }
92    };
93
94    let b = match parts[2].trim().parse::<u8>() {
95        Ok(val) => val,
96        Err(_) => {
97            eprintln!(
98                "Invalid blue value '{}': must be a number between 0-255",
99                parts[2]
100            );
101            return None;
102        }
103    };
104
105    Some((r, g, b))
106}
107
108/// Colors a string based on its role using RGB values from the config.
109///
110/// This function is the core color formatting function that all other color
111/// functions use. It retrieves colors from the global config and applies them
112/// based on the specified role.
113///
114/// # Arguments
115/// * `text` - The text to be colored
116/// * `role` - The role determining which color to use (e.g., "informational", "warning")
117///
118/// # Returns
119/// * `String` - The text colored according to the role's RGB values
120///
121/// # Role Colors
122/// - informational: Used for general information
123/// - warning: Used for warnings and cautions
124/// - success: Used for success messages
125/// - question: Used for interactive prompts
126/// - statement: Used for neutral messages
127fn color_string(text: &str, role: &str) -> String {
128    let config = crate::config::get_config();
129
130    // Get the RGB color string, defaulting to statement color if not found
131    let rgb = match config.colourscheme.get(role) {
132        Some(color) => color.clone(),
133        None => config
134            .colourscheme
135            .get("statement")
136            .cloned()
137            .unwrap_or_else(|| "255,255,255".to_string()),
138    };
139
140    if let Some((r, g, b)) = parse_rgb(&rgb) {
141        text.truecolor(r, g, b).bold().to_string()
142    } else {
143        // Default to statement color if RGB parsing fails
144        if let Some(statement_rgb) = config.colourscheme.get("statement") {
145            if let Some((r, g, b)) = parse_rgb(statement_rgb) {
146                return text.truecolor(r, g, b).bold().to_string();
147            }
148        }
149        text.white().to_string()
150    }
151}
152
153/// Colors text based on its role, defaulting to statement color if no role is specified.
154///
155/// # Arguments
156/// * `text` - The text to be colored
157/// * `role` - Optional role to determine color choice. If None, uses statement color
158///
159/// # Returns
160/// * `String` - The colored text string
161///
162/// # Examples
163/// ```
164/// use ares::cli_pretty_printing::statement;
165///
166/// let info = statement("Status update", Some("informational"));
167/// let neutral = statement("Regular text", None);
168/// assert!(!info.is_empty());
169/// assert!(!neutral.is_empty());
170/// ```
171pub fn statement(text: &str, role: Option<&str>) -> String {
172    match role {
173        Some(r) => color_string(text, r),
174        None => color_string(text, "statement"),
175    }
176}
177
178/// Colors text using the warning color from config.
179///
180/// Used for non-critical warnings and cautions that don't prevent
181/// program execution but require user attention.
182///
183/// # Arguments
184/// * `text` - The warning message to be colored
185///
186/// # Returns
187/// * `String` - The text colored in the warning color
188#[allow(dead_code)]
189pub fn warning(text: &str) -> String {
190    color_string(text, "warning")
191}
192
193/// Colors text using the success color from config.
194///
195/// Used for messages indicating successful operations or positive outcomes.
196///
197/// # Arguments
198/// * `text` - The success message to be colored
199///
200/// # Returns
201/// * `String` - The text colored in the success color
202pub fn success(text: &str) -> String {
203    color_string(text, "success")
204}
205
206/// Colors text using the warning color from config for error messages.
207///
208/// Note: Uses warning color since error is not defined in the color scheme.
209/// Used for error messages that indicate operation failure.
210///
211/// # Arguments
212/// * `text` - The error message to be colored
213///
214/// # Returns
215/// * `String` - The text colored in the warning color
216#[allow(dead_code)]
217fn error(text: &str) -> String {
218    color_string(text, "warning")
219}
220
221/// Colors text using the question color from config.
222///
223/// Used for interactive prompts and user queries to make them
224/// stand out from regular output.
225///
226/// # Arguments
227/// * `text` - The question or prompt to be colored
228///
229/// # Returns
230/// * `String` - The text colored in the question color
231fn question(text: &str) -> String {
232    color_string(text, "question")
233}
234
235/// Prints the final output of a successful decoding operation.
236///
237/// This function handles the presentation of decoded text, including special
238/// handling for invisible characters and file output options.
239///
240/// # Arguments
241/// * `result` - The DecoderResult containing the decoded text and metadata
242///
243/// # Behavior
244/// - Checks for API mode and returns early if enabled
245/// - Formats the decoder path with arrows
246/// - Handles invisible character detection and file output
247/// - Presents the decoded text with appropriate formatting
248///
249/// # Panics
250/// Panics if there is an error writing to file when output_method is set to a file
251pub fn program_exiting_successful_decoding(result: DecoderResult) {
252    let config = crate::config::get_config();
253    if config.api_mode {
254        return;
255    }
256    if config.top_results {
257        return;
258    }
259    let plaintext = result.text;
260    // calculate path
261    let decoded_path = result
262        .path
263        .iter()
264        .map(|c| c.decoder)
265        .collect::<Vec<_>>()
266        .join(" → ");
267
268    let decoded_path_coloured = statement(&decoded_path, Some("informational"));
269    let decoded_path_string = if !decoded_path.contains('→') {
270        // handles case where only 1 decoder is used
271        format!("the decoder used is {decoded_path_coloured}")
272    } else {
273        format!("the decoders used are {decoded_path_coloured}")
274    };
275    /// If 30% of the characters are invisible characters, then prompt the
276    /// user to save the resulting plaintext into a file
277    const INVIS_CHARS_DETECTION_PERCENTAGE: f64 = 0.3;
278    let mut invis_chars_found: f64 = 0.0;
279    for char in plaintext[0].chars() {
280        if storage::INVISIBLE_CHARS
281            .iter()
282            .any(|invis_chars| *invis_chars == char)
283        {
284            invis_chars_found += 1.0;
285        }
286    }
287
288    // If the percentage of invisible characters in the plaintext exceeds
289    // the detection percentage, prompt the user asking if they want to
290    // save the plaintext into a file
291    let invis_char_percentage = invis_chars_found / plaintext[0].len() as f64;
292    if invis_char_percentage > INVIS_CHARS_DETECTION_PERCENTAGE {
293        let invis_char_percentage_string = format!("{:2.0}%", invis_char_percentage * 100.0);
294        println!(
295            "{}",
296            question(
297                &format!(
298                    "{} of the plaintext is invisible characters, would you like to save to a file instead? (y/N)", 
299                    invis_char_percentage_string.white().bold()
300                )
301            )
302        );
303        let reply: String = read!("{}\n");
304        let result = reply.to_ascii_lowercase().starts_with('y');
305        if result {
306            println!(
307                "Please enter a filename: (default: {}/ares_text.txt)",
308                env::var("HOME").unwrap_or_default().white().bold()
309            );
310            let mut file_path: String = read!("{}\n");
311            if file_path.is_empty() {
312                file_path = format!("{}/ares_text.txt", env::var("HOME").unwrap_or_default());
313            }
314            println!(
315                "Outputting plaintext to file: {}\n\n{}",
316                statement(&file_path, None),
317                decoded_path_string
318            );
319            write(file_path, &plaintext[0]).expect("Error writing to file.");
320            return;
321        }
322    }
323    println!(
324        "The plaintext is:\n{}\n{}",
325        success(&plaintext[0]),
326        decoded_path_string
327    );
328}
329
330/// Prints the number of decoding attempts performed.
331///
332/// # Arguments
333/// * `depth` - The depth of decoding attempts
334///
335/// # Note
336/// This function automatically calculates the total number of attempts
337/// based on the available decoders and the depth parameter.
338pub fn decoded_how_many_times(depth: u32) {
339    let config = crate::config::get_config();
340    if config.api_mode {
341        return;
342    }
343
344    // Gets how many decoders we have
345    // Then we add 25 for Caesar
346    let decoders = crate::filtration_system::filter_and_get_decoders(&DecoderResult::default());
347    let decoded_times_int = depth * (decoders.components.len() as u32 + 40); //TODO 40 is how many decoders we have. Calculate automatically
348    println!(
349        "\n🄳 Ares has decoded {} times.\n",
350        statement(&decoded_times_int.to_string(), None)
351    );
352}
353
354/// Prompts the user to verify potential plaintext during human checking.
355///
356/// # Arguments
357/// * `description` - Description of why this might be plaintext
358/// * `text` - The potential plaintext to verify
359///
360/// # Note
361/// This function is only called when human checking is enabled and
362/// not in API mode.
363pub fn human_checker_check(description: &str, text: &str) {
364    println!(
365        "šŸ•µļø I think the plaintext is {}.\nPossible plaintext: '{}' (y/N): ",
366        statement(description, Some("informational")),
367        statement(text, Some("informational"))
368    );
369}
370
371/// Prints a failure message when decoding was unsuccessful.
372///
373/// This function provides user guidance by suggesting Discord support
374/// when automated decoding fails.
375///
376/// # Note
377/// This message is suppressed in API mode.
378pub fn failed_to_decode() {
379    let config = crate::config::get_config();
380    if config.api_mode {
381        return;
382    }
383
384    println!(
385        "{}",
386        warning("ā›”ļø Ares has failed to decode the text.\nIf you want more help, please ask in #coded-messages in our Discord http://discord.skerritt.blog")
387    );
388}
389
390/// Updates the user on decoding progress with a countdown timer.
391///
392/// # Arguments
393/// * `seconds_spent_running` - Number of seconds elapsed
394/// * `duration` - Total duration allowed for decoding
395///
396/// # Note
397/// Progress updates are shown every 5 seconds until the duration is reached.
398pub fn countdown_until_program_ends(seconds_spent_running: u32, duration: u32) {
399    let config = crate::config::get_config();
400    if config.api_mode {
401        return;
402    }
403    if seconds_spent_running % 5 == 0 && seconds_spent_running != 0 {
404        let time_left = duration - seconds_spent_running;
405        if time_left == 0 {
406            return;
407        }
408        println!(
409            "{} seconds have passed. {} remaining",
410            statement(&seconds_spent_running.to_string(), None),
411            statement(&time_left.to_string(), None)
412        );
413    }
414}
415
416/// Indicates that the input is already plaintext.
417///
418/// This function is called when the input passes plaintext detection
419/// and no decoding is necessary.
420pub fn return_early_because_input_text_is_plaintext() {
421    let config = crate::config::get_config();
422    if config.api_mode {
423        return;
424    }
425    println!("{}", success("Your input text is the plaintext 🄳"));
426}
427
428/// Handles the error case of receiving both file and text input.
429///
430/// # Panics
431/// This function always panics with a message explaining the input conflict.
432/// Only used in CLI mode.
433pub fn panic_failure_both_input_and_fail_provided() {
434    let config = crate::config::get_config();
435    if config.api_mode {
436        return;
437    }
438    panic!("Failed -- both file and text were provided. Please only use one.")
439}
440
441/// Handles the error case of receiving no input.
442///
443/// # Panics
444/// This function always panics with a message explaining the missing input.
445/// Only used in CLI mode.
446pub fn panic_failure_no_input_provided() {
447    let config = crate::config::get_config();
448    if config.api_mode {
449        return;
450    }
451    panic!("Failed -- no input was provided. Please use -t for text or -f for files.")
452}
453
454/// Warns about unknown configuration keys.
455///
456/// # Arguments
457/// * `key` - The unknown configuration key that was found
458///
459/// # Note
460/// This warning is suppressed in API mode.
461pub fn warning_unknown_config_key(key: &str) {
462    let config = crate::config::get_config();
463    if config.api_mode {
464        return;
465    }
466    eprintln!(
467        "{}",
468        warning(&format!(
469            "Unknown configuration key found in config file: {}",
470            key
471        ))
472    );
473}
474
475/// Display all plaintext results collected by WaitAthena
476pub fn display_top_results(results: &[PlaintextResult]) {
477    let config = crate::config::get_config();
478    if config.api_mode {
479        return;
480    }
481
482    if results.is_empty() {
483        println!("{}", success("No potential plaintexts found."));
484        return;
485    }
486
487    println!("{}", success("\nšŸŽŠ List of Possible Plaintexts šŸŽŠ"));
488    println!(
489        "{}",
490        success(&format!(
491            "Found {} potential plaintext results:",
492            results.len()
493        ))
494    );
495
496    if results.len() > 10 {
497        // ask the user if they want to write to a file
498        println!("{}", warning("There are more than 10 possible plaintexts. I think you should write them to a file."));
499        println!("{}", question("Would you like to write to a file? (y/N)"));
500        let mut input = String::new();
501        std::io::stdin()
502            .read_line(&mut input)
503            .expect("Failed to read input");
504        let result = input.trim().to_ascii_lowercase().starts_with('y');
505
506        if result {
507            println!(
508                "{}",
509                question(&format!(
510                    "Please enter a filename: (default: {}/ares_text.txt)",
511                    statement(&env::var("HOME").unwrap_or_default(), None)
512                ))
513            );
514
515            let mut file_path = String::new();
516            std::io::stdin()
517                .read_line(&mut file_path)
518                .expect("Failed to read input");
519            file_path = file_path.trim().to_string();
520
521            if file_path.is_empty() {
522                file_path = format!("{}/ares_text.txt", env::var("HOME").unwrap_or_default());
523            }
524
525            let mut file_content = String::new();
526            for (i, result) in results.iter().enumerate() {
527                file_content.push_str(&format!("Result #{}: {}\n", i + 1, result.text));
528                file_content.push_str(&format!("Decoder: {}\n", result.decoder_name));
529                file_content.push_str(&format!("Checker: {}\n", result.checker_name));
530                file_content.push_str(&format!("Description: {}\n", result.description));
531                if results.len() > 1 {
532                    file_content.push_str("---\n");
533                }
534            }
535
536            match write(&file_path, file_content) {
537                Ok(_) => println!("{}", success(&format!("Results written to {}", file_path))),
538                Err(e) => println!("{}", warning(&format!("Failed to write to file: {}", e))),
539            }
540
541            return;
542        }
543    }
544
545    for (i, result) in results.iter().enumerate() {
546        println!(
547            "{}",
548            success(&format!("Result #{}: {}", i + 1, result.text))
549        );
550        println!("{}", success(&format!("Decoder: {}", result.decoder_name)));
551        println!("{}", success(&format!("Checker: {}", result.checker_name)));
552        println!(
553            "{}",
554            success(&format!("Description: {}", result.description))
555        );
556        if results.len() > 1 {
557            // only print seperator if more than 1
558            println!("{}", success("---"));
559        }
560    }
561
562    println!("{}", success("=== End of Top Results ===\n"));
563}
564
565#[test]
566fn test_parse_rgb() {
567    let test_cases = vec![
568        "255,0,0",   // Pure red
569        "0, 255, 0", // Pure green with spaces
570        "0,0,255",   // Pure blue
571    ];
572
573    for case in test_cases {
574        let result = parse_rgb(case);
575        assert!(result.is_some());
576    }
577}