1use crate::progress::color::{self, ColorMode};
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4pub enum StatusLevel {
5 Info,
6 Success,
7 Warning,
8 Error,
9 Failure,
10 Debug,
11}
12
13impl StatusLevel {
14 pub fn from_name(name: &str) -> Option<Self> {
15 match name {
16 "Info" | "INFO" => Some(StatusLevel::Info),
17 "Success" | "SUCCESS" => Some(StatusLevel::Success),
18 "Warning" | "Warn" | "WARNING" | "WARN" => Some(StatusLevel::Warning),
19 "Error" | "ERROR" => Some(StatusLevel::Error),
20 "Failure" | "Fail" | "FAILURE" | "FAIL" => Some(StatusLevel::Failure),
21 "Debug" | "DEBUG" => Some(StatusLevel::Debug),
22 _ => None,
23 }
24 }
25
26 pub fn label(self) -> &'static str {
27 match self {
28 StatusLevel::Info => "INFO",
29 StatusLevel::Success => "SUCCESS",
30 StatusLevel::Warning => "WARNING",
31 StatusLevel::Error => "ERROR",
32 StatusLevel::Failure => "FAILURE",
33 StatusLevel::Debug => "DEBUG",
34 }
35 }
36}
37
38pub struct ProgressEvent<'a> {
39 pub level: StatusLevel,
40 pub host: Option<&'a str>,
41 pub message: String,
42}
43
44pub fn normalize_multiline_message(message: &str) -> String {
45 let mut lines: Vec<&str> = message.lines().collect();
46 if lines.len() <= 1 {
47 return message.to_string();
48 }
49
50 while lines.first().is_some_and(|line| line.trim().is_empty()) {
51 lines.remove(0);
52 }
53 while lines.last().is_some_and(|line| line.trim().is_empty()) {
54 lines.pop();
55 }
56
57 let common_indent = lines
58 .iter()
59 .filter(|line| !line.trim().is_empty())
60 .map(|line| line.chars().take_while(|ch| *ch == ' ' || *ch == '\t').count())
61 .min()
62 .unwrap_or(0);
63
64 lines
65 .iter()
66 .map(|line| {
67 if line.trim().is_empty() {
68 ""
69 } else {
70 line.char_indices().nth(common_indent).map(|(idx, _)| &line[idx..]).unwrap_or("")
71 }
72 })
73 .collect::<Vec<_>>()
74 .join("\n")
75}
76
77impl<'a> ProgressEvent<'a> {
78 pub fn new(level: StatusLevel, host: Option<&'a str>, message: impl Into<String>) -> Self {
79 Self { level, host, message: message.into() }
80 }
81
82 pub fn render(&self, color_mode: ColorMode) -> String {
83 let prefix = if let Some(host) = self.host { format!("[{}] ", host) } else { "".to_string() };
84
85 match self.level {
86 StatusLevel::Info => {
87 format!("{}{}", prefix, self.message)
88 }
89 StatusLevel::Success => {
90 let success_label = color::colorize("SUCCESS", color::GREEN, color_mode);
91 format!("{}{} {}", prefix, success_label, self.message)
92 }
93 StatusLevel::Warning => {
94 let warning_label = color::colorize("WARNING", color::YELLOW, color_mode);
95 format!("{}{} {}", prefix, warning_label, self.message)
96 }
97 StatusLevel::Error => {
98 let error_label = color::colorize("ERROR", color::RED, color_mode);
99 format!("{}{} {}", prefix, error_label, self.message)
100 }
101 StatusLevel::Failure => {
102 let failure_label = color::colorize("FAILURE", color::RED, color_mode);
103 format!("{}{} {}", prefix, failure_label, self.message)
104 }
105 StatusLevel::Debug => {
106 let debug_label = color::colorize("[DEBUG]", color::GRAY, color_mode);
107 format!("{} {}", debug_label, self.message)
108 }
109 }
110 }
111
112 pub fn log(&self, color_mode: ColorMode) {
113 let msg = self.render(color_mode);
114 eprintln!("{}", msg);
115 }
116}
117
118pub fn print_elapsed_summary(prefix: &str, duration: std::time::Duration) {
119 let event = ProgressEvent::new(
120 StatusLevel::Success,
121 None,
122 format!("{} completed in {}!", prefix, format_elapsed(duration)),
123 );
124 event.log(ColorMode::Auto);
125}
126
127pub fn format_elapsed(duration: std::time::Duration) -> String {
128 let seconds = duration.as_secs();
129 let hours = seconds / 3600;
130 let minutes = (seconds % 3600) / 60;
131 let seconds = seconds % 60;
132 if hours > 0 {
133 format!("{hours}h {minutes}m {seconds}s")
134 } else if minutes > 0 {
135 format!("{minutes}m {seconds}s")
136 } else {
137 format!("{seconds}s")
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn test_progress_event_rendering() {
147 let event = ProgressEvent::new(StatusLevel::Success, Some("host1"), "done");
148 let rendered = event.render(ColorMode::Never);
149 assert_eq!(rendered, "[host1] SUCCESS done");
150 }
151
152 #[test]
153 fn test_debug_rendering() {
154 let event = ProgressEvent::new(StatusLevel::Debug, None, "testing");
155 let rendered = event.render(ColorMode::Never);
156 assert_eq!(rendered, "[DEBUG] testing");
157 }
158
159 #[test]
160 fn normalizes_multiline_message_indentation() {
161 let normalized = normalize_multiline_message(
162 "
163 Timing breakdown:
164 Context loading: 1s
165 Execution: 2s
166 ",
167 );
168 assert_eq!(normalized, "Timing breakdown:\n Context loading: 1s\n Execution: 2s");
169 }
170
171 #[test]
172 fn preserves_single_line_indentation() {
173 assert_eq!(normalize_multiline_message(" already aligned"), " already aligned");
174 }
175
176 #[test]
177 fn formats_elapsed_time_compactly() {
178 assert_eq!(format_elapsed(std::time::Duration::from_secs(39)), "39s");
179 assert_eq!(format_elapsed(std::time::Duration::from_secs(91)), "1m 31s");
180 assert_eq!(format_elapsed(std::time::Duration::from_secs(3723)), "1h 2m 3s");
181 }
182}