Skip to main content

nxd_core/progress/
log.rs

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}