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}