use std::io::{self, Write};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TargetStatus {
Success,
Skipped,
Failed(String),
}
impl std::fmt::Display for TargetStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TargetStatus::Success => write!(f, "success"),
TargetStatus::Skipped => write!(f, "skipped"),
TargetStatus::Failed(e) => write!(f, "failed: {}", e),
}
}
}
#[derive(Debug, Clone)]
pub enum ProgressEvent {
BuildStarted {
total_targets: usize,
},
TargetStarted {
target_id: String,
},
TargetCompleted {
target_id: String,
status: TargetStatus,
duration_ms: u64,
},
BuildCompleted {
success: bool,
duration_ms: u64,
succeeded: usize,
skipped: usize,
failed: usize,
},
Warning {
target_id: Option<String>,
message: String,
},
Error {
target_id: Option<String>,
message: String,
},
}
pub trait ProgressReporter: Send + Sync {
fn report(&self, event: ProgressEvent);
fn is_verbose(&self) -> bool {
false
}
}
#[derive(Debug, Default)]
pub struct NullProgress;
impl NullProgress {
pub fn new() -> Self {
Self
}
}
impl ProgressReporter for NullProgress {
fn report(&self, _event: ProgressEvent) {
}
}
pub struct ConsoleProgress {
use_colors: bool,
verbose: bool,
current: AtomicUsize,
total: AtomicUsize,
output: Mutex<Box<dyn Write + Send>>,
}
impl std::fmt::Debug for ConsoleProgress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConsoleProgress")
.field("use_colors", &self.use_colors)
.field("verbose", &self.verbose)
.field("current", &self.current)
.field("total", &self.total)
.finish()
}
}
impl ConsoleProgress {
pub fn new() -> Self {
Self {
use_colors: true,
verbose: false,
current: AtomicUsize::new(0),
total: AtomicUsize::new(0),
output: Mutex::new(Box::new(std::io::stderr())),
}
}
pub fn with_output<W: Write + Send + 'static>(output: W) -> Self {
Self {
use_colors: false, verbose: false,
current: AtomicUsize::new(0),
total: AtomicUsize::new(0),
output: Mutex::new(Box::new(output)),
}
}
pub fn with_colors(mut self, use_colors: bool) -> Self {
self.use_colors = use_colors;
self
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
fn color(&self, text: &str, color: &str) -> String {
if self.use_colors {
format!("{}{}\x1b[0m", color, text)
} else {
text.to_string()
}
}
fn green(&self, text: &str) -> String {
self.color(text, "\x1b[32m")
}
fn yellow(&self, text: &str) -> String {
self.color(text, "\x1b[33m")
}
fn red(&self, text: &str) -> String {
self.color(text, "\x1b[31m")
}
fn cyan(&self, text: &str) -> String {
self.color(text, "\x1b[36m")
}
fn bold(&self, text: &str) -> String {
self.color(text, "\x1b[1m")
}
fn writeln(&self, line: &str) {
if let Ok(mut output) = self.output.lock() {
let _ = writeln!(output, "{}", line);
}
}
}
impl Default for ConsoleProgress {
fn default() -> Self {
Self::new()
}
}
impl ProgressReporter for ConsoleProgress {
fn report(&self, event: ProgressEvent) {
match event {
ProgressEvent::BuildStarted { total_targets } => {
self.total.store(total_targets, Ordering::SeqCst);
self.current.store(0, Ordering::SeqCst);
if total_targets > 0 {
self.writeln(&format!(
"{} Building {} target{}...",
self.cyan("[build]"),
total_targets,
if total_targets == 1 { "" } else { "s" }
));
}
}
ProgressEvent::TargetStarted { target_id } => {
if self.verbose {
let current = self.current.load(Ordering::SeqCst) + 1;
let total = self.total.load(Ordering::SeqCst);
self.writeln(&format!(
"{} [{}/{}] Building {}...",
self.cyan("[build]"),
current,
total,
target_id
));
}
}
ProgressEvent::TargetCompleted { target_id, status, duration_ms } => {
self.current.fetch_add(1, Ordering::SeqCst);
let current = self.current.load(Ordering::SeqCst);
let total = self.total.load(Ordering::SeqCst);
let status_str = match &status {
TargetStatus::Success => self.green("ok"),
TargetStatus::Skipped => self.yellow("skipped"),
TargetStatus::Failed(_) => self.red("FAILED"),
};
let duration_str = format_duration(duration_ms);
self.writeln(&format!(
"{} [{}/{}] {} {} ({})",
self.cyan("[build]"),
current,
total,
status_str,
target_id,
duration_str
));
if let TargetStatus::Failed(err) = status {
self.writeln(&format!(" {}", self.red(&err)));
}
}
ProgressEvent::BuildCompleted { success, duration_ms, succeeded, skipped, failed } => {
let duration_str = format_duration(duration_ms);
let total = succeeded + skipped + failed;
if success {
self.writeln(&format!(
"\n{} {} {} built, {} skipped in {}",
self.green("[done]"),
self.bold(&format!("{}", total)),
if total == 1 { "target" } else { "targets" },
skipped,
duration_str
));
} else {
self.writeln(&format!(
"\n{} Build failed: {} succeeded, {} skipped, {} {} in {}",
self.red("[error]"),
succeeded,
skipped,
failed,
if failed == 1 { "failure" } else { "failures" },
duration_str
));
}
}
ProgressEvent::Warning { target_id, message } => {
let prefix = match target_id {
Some(id) => format!("{}: ", id),
None => String::new(),
};
self.writeln(&format!("{} {}{}", self.yellow("[warn]"), prefix, message));
}
ProgressEvent::Error { target_id, message } => {
let prefix = match target_id {
Some(id) => format!("{}: ", id),
None => String::new(),
};
self.writeln(&format!("{} {}{}", self.red("[error]"), prefix, message));
}
}
}
fn is_verbose(&self) -> bool {
self.verbose
}
}
pub struct JsonProgress {
output: Mutex<Box<dyn Write + Send>>,
}
impl std::fmt::Debug for JsonProgress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsonProgress").finish()
}
}
impl JsonProgress {
pub fn new() -> Self {
Self { output: Mutex::new(Box::new(std::io::stderr())) }
}
pub fn with_output<W: Write + Send + 'static>(output: W) -> Self {
Self { output: Mutex::new(Box::new(output)) }
}
fn write_json(&self, json: &str) {
if let Ok(mut output) = self.output.lock() {
let _ = writeln!(output, "{}", json);
}
}
}
impl Default for JsonProgress {
fn default() -> Self {
Self::new()
}
}
impl ProgressReporter for JsonProgress {
fn report(&self, event: ProgressEvent) {
let json = match event {
ProgressEvent::BuildStarted { total_targets } => {
format!(r#"{{"event":"build_started","total_targets":{}}}"#, total_targets)
}
ProgressEvent::TargetStarted { target_id } => {
format!(r#"{{"event":"target_started","target_id":"{}"}}"#, escape_json(&target_id))
}
ProgressEvent::TargetCompleted { target_id, status, duration_ms } => {
let status_str = match &status {
TargetStatus::Success => "success",
TargetStatus::Skipped => "skipped",
TargetStatus::Failed(_) => "failed",
};
let error = match &status {
TargetStatus::Failed(e) => format!(r#","error":"{}""#, escape_json(e)),
_ => String::new(),
};
format!(
r#"{{"event":"target_completed","target_id":"{}","status":"{}","duration_ms":{}{}}}"#,
escape_json(&target_id),
status_str,
duration_ms,
error
)
}
ProgressEvent::BuildCompleted { success, duration_ms, succeeded, skipped, failed } => {
format!(
r#"{{"event":"build_completed","success":{},"duration_ms":{},"succeeded":{},"skipped":{},"failed":{}}}"#,
success, duration_ms, succeeded, skipped, failed
)
}
ProgressEvent::Warning { target_id, message } => {
let target = match target_id {
Some(id) => format!(r#","target_id":"{}""#, escape_json(&id)),
None => String::new(),
};
format!(r#"{{"event":"warning","message":"{}"{}}}"#, escape_json(&message), target)
}
ProgressEvent::Error { target_id, message } => {
let target = match target_id {
Some(id) => format!(r#","target_id":"{}""#, escape_json(&id)),
None => String::new(),
};
format!(r#"{{"event":"error","message":"{}"{}}}"#, escape_json(&message), target)
}
};
self.write_json(&json);
}
}
#[derive(Debug, Default)]
pub struct ProgressTracker {
start_time: Option<Instant>,
total: usize,
completed: usize,
succeeded: usize,
skipped: usize,
failed: usize,
in_progress: Vec<String>,
}
impl ProgressTracker {
pub fn new() -> Self {
Self::default()
}
pub fn start(&mut self, total_targets: usize) {
self.start_time = Some(Instant::now());
self.total = total_targets;
self.completed = 0;
self.succeeded = 0;
self.skipped = 0;
self.failed = 0;
self.in_progress.clear();
}
pub fn target_started(&mut self, target_id: &str) {
self.in_progress.push(target_id.to_string());
}
pub fn target_completed(&mut self, target_id: &str, status: &TargetStatus) {
self.in_progress.retain(|id| id != target_id);
self.completed += 1;
match status {
TargetStatus::Success => self.succeeded += 1,
TargetStatus::Skipped => self.skipped += 1,
TargetStatus::Failed(_) => self.failed += 1,
}
}
pub fn elapsed(&self) -> Duration {
self.start_time.map(|t| t.elapsed()).unwrap_or(Duration::ZERO)
}
pub fn elapsed_ms(&self) -> u64 {
self.elapsed().as_millis() as u64
}
pub fn percentage(&self) -> f64 {
if self.total == 0 {
100.0
} else {
(self.completed as f64 / self.total as f64) * 100.0
}
}
pub fn is_complete(&self) -> bool {
self.completed >= self.total
}
pub fn is_success(&self) -> bool {
self.failed == 0
}
pub fn succeeded(&self) -> usize {
self.succeeded
}
pub fn skipped(&self) -> usize {
self.skipped
}
pub fn failed(&self) -> usize {
self.failed
}
pub fn in_progress(&self) -> &[String] {
&self.in_progress
}
pub fn build_completed_event(&self) -> ProgressEvent {
ProgressEvent::BuildCompleted {
success: self.is_success(),
duration_ms: self.elapsed_ms(),
succeeded: self.succeeded,
skipped: self.skipped,
failed: self.failed,
}
}
}
fn format_duration(ms: u64) -> String {
if ms < 1000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
let minutes = ms / 60_000;
let seconds = (ms % 60_000) / 1000;
format!("{}m {}s", minutes, seconds)
}
}
fn escape_json(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() => {
result.push_str(&format!("\\u{:04x}", c as u32));
}
c => result.push(c),
}
}
result
}
pub struct LiveProgress {
is_tty: bool,
use_colors: bool,
verbose: bool,
total: AtomicUsize,
completed: AtomicUsize,
succeeded: AtomicUsize,
skipped: AtomicUsize,
failed: AtomicUsize,
start_time: Mutex<Option<Instant>>,
current_target: Mutex<Option<String>>,
bar_width: usize,
last_update: Mutex<Option<Instant>>,
update_interval: Duration,
}
impl std::fmt::Debug for LiveProgress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LiveProgress")
.field("is_tty", &self.is_tty)
.field("use_colors", &self.use_colors)
.field("verbose", &self.verbose)
.field("total", &self.total)
.field("completed", &self.completed)
.finish()
}
}
impl LiveProgress {
pub fn new() -> Self {
Self {
is_tty: atty::is(atty::Stream::Stderr),
use_colors: true,
verbose: false,
total: AtomicUsize::new(0),
completed: AtomicUsize::new(0),
succeeded: AtomicUsize::new(0),
skipped: AtomicUsize::new(0),
failed: AtomicUsize::new(0),
start_time: Mutex::new(None),
current_target: Mutex::new(None),
bar_width: 30,
last_update: Mutex::new(None),
update_interval: Duration::from_millis(100),
}
}
pub fn with_bar_width(mut self, width: usize) -> Self {
self.bar_width = width;
self
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn with_colors(mut self, use_colors: bool) -> Self {
self.use_colors = use_colors;
self
}
pub fn with_tty(mut self, is_tty: bool) -> Self {
self.is_tty = is_tty;
self
}
fn elapsed(&self) -> Duration {
self.start_time
.lock()
.ok()
.and_then(|t| t.as_ref().map(|i| i.elapsed()))
.unwrap_or(Duration::ZERO)
}
fn eta(&self) -> Option<Duration> {
let completed = self.completed.load(Ordering::SeqCst);
let total = self.total.load(Ordering::SeqCst);
let elapsed = self.elapsed();
if completed == 0 || total == 0 || completed >= total {
return None;
}
let remaining = total - completed;
let time_per_target = elapsed.as_secs_f64() / completed as f64;
let eta_secs = time_per_target * remaining as f64;
Some(Duration::from_secs_f64(eta_secs))
}
fn rate(&self) -> f64 {
let completed = self.completed.load(Ordering::SeqCst);
let elapsed = self.elapsed().as_secs_f64();
if elapsed > 0.0 {
completed as f64 / elapsed
} else {
0.0
}
}
fn format_eta(duration: Duration) -> String {
let secs = duration.as_secs();
if secs < 60 {
format!("{}s", secs)
} else {
let mins = secs / 60;
let remaining_secs = secs % 60;
format!("{}m {}s", mins, remaining_secs)
}
}
fn color(&self, text: &str, color: &str) -> String {
if self.use_colors && self.is_tty {
format!("{}{}\x1b[0m", color, text)
} else {
text.to_string()
}
}
fn green(&self, text: &str) -> String {
self.color(text, "\x1b[32m")
}
fn yellow(&self, text: &str) -> String {
self.color(text, "\x1b[33m")
}
fn red(&self, text: &str) -> String {
self.color(text, "\x1b[31m")
}
fn cyan(&self, text: &str) -> String {
self.color(text, "\x1b[36m")
}
fn bold(&self, text: &str) -> String {
self.color(text, "\x1b[1m")
}
fn dim(&self, text: &str) -> String {
self.color(text, "\x1b[2m")
}
fn build_progress_bar(&self, percentage: f64) -> String {
let filled = (percentage / 100.0 * self.bar_width as f64).round() as usize;
let empty = self.bar_width.saturating_sub(filled);
let bar = format!("{}{}", "â–ˆ".repeat(filled), "â–‘".repeat(empty));
if self.use_colors && self.is_tty {
format!("\x1b[32m{}\x1b[0m", bar)
} else {
bar
}
}
fn render_progress(&self) -> String {
let completed = self.completed.load(Ordering::SeqCst);
let total = self.total.load(Ordering::SeqCst);
let percentage = if total > 0 { (completed as f64 / total as f64) * 100.0 } else { 0.0 };
let bar = self.build_progress_bar(percentage);
let rate = self.rate();
let current = self.current_target.lock().ok().and_then(|t| t.clone());
let target_display = current
.map(|t| {
if t.len() > 25 {
format!("{}...", &t[..22])
} else {
t
}
})
.unwrap_or_default();
let eta_str =
self.eta().map(|eta| format!(" ETA: {}", Self::format_eta(eta))).unwrap_or_default();
format!(
"{} [{}/{}] {:.1}% {} ({:.1}/s){}{}",
self.cyan("[build]"),
completed,
total,
percentage,
bar,
rate,
eta_str,
if target_display.is_empty() {
String::new()
} else {
format!(" {}", self.dim(&target_display))
}
)
}
fn should_update(&self) -> bool {
let mut last = self.last_update.lock().unwrap();
match *last {
Some(t) if t.elapsed() < self.update_interval => false,
_ => {
*last = Some(Instant::now());
true
}
}
}
fn update_line(&self, content: &str) {
if self.is_tty {
eprint!("\r\x1b[K{}", content);
let _ = io::stderr().flush();
}
}
fn finalize(&self) {
if self.is_tty {
eprintln!();
}
}
}
impl Default for LiveProgress {
fn default() -> Self {
Self::new()
}
}
impl ProgressReporter for LiveProgress {
fn report(&self, event: ProgressEvent) {
match event {
ProgressEvent::BuildStarted { total_targets } => {
self.total.store(total_targets, Ordering::SeqCst);
self.completed.store(0, Ordering::SeqCst);
self.succeeded.store(0, Ordering::SeqCst);
self.skipped.store(0, Ordering::SeqCst);
self.failed.store(0, Ordering::SeqCst);
*self.start_time.lock().unwrap() = Some(Instant::now());
*self.current_target.lock().unwrap() = None;
*self.last_update.lock().unwrap() = None;
if total_targets > 0 {
if self.is_tty {
self.update_line(&self.render_progress());
} else {
eprintln!(
"{} Building {} target{}...",
self.cyan("[build]"),
total_targets,
if total_targets == 1 { "" } else { "s" }
);
}
}
}
ProgressEvent::TargetStarted { target_id } => {
*self.current_target.lock().unwrap() = Some(target_id);
if self.is_tty && self.should_update() {
self.update_line(&self.render_progress());
}
}
ProgressEvent::TargetCompleted { target_id, status, duration_ms } => {
self.completed.fetch_add(1, Ordering::SeqCst);
match &status {
TargetStatus::Success => {
self.succeeded.fetch_add(1, Ordering::SeqCst);
}
TargetStatus::Skipped => {
self.skipped.fetch_add(1, Ordering::SeqCst);
}
TargetStatus::Failed(_) => {
self.failed.fetch_add(1, Ordering::SeqCst);
}
}
if self.is_tty {
if let TargetStatus::Failed(err) = &status {
self.finalize();
eprintln!(
"{} {} {} - {}",
self.red("[fail]"),
target_id,
self.red("FAILED"),
err
);
}
let is_complete =
self.completed.load(Ordering::SeqCst) >= self.total.load(Ordering::SeqCst);
if self.should_update() || is_complete {
self.update_line(&self.render_progress());
}
} else {
let status_str = match &status {
TargetStatus::Success => self.green("ok"),
TargetStatus::Skipped => self.yellow("skipped"),
TargetStatus::Failed(_) => self.red("FAILED"),
};
let duration_str = format_duration(duration_ms);
eprintln!(
"{} [{}/{}] {} {} ({})",
self.cyan("[build]"),
self.completed.load(Ordering::SeqCst),
self.total.load(Ordering::SeqCst),
status_str,
target_id,
duration_str
);
if let TargetStatus::Failed(err) = status {
eprintln!(" {}", self.red(&err));
}
}
}
ProgressEvent::BuildCompleted { success, duration_ms, succeeded, skipped, failed } => {
self.finalize();
let duration_str = format_duration(duration_ms);
let total = succeeded + skipped + failed;
if success {
eprintln!(
"{} {} {} built, {} skipped in {}",
self.green("[done]"),
self.bold(&format!("{}", total)),
if total == 1 { "target" } else { "targets" },
skipped,
duration_str
);
} else {
eprintln!(
"{} Build failed: {} succeeded, {} skipped, {} {} in {}",
self.red("[error]"),
succeeded,
skipped,
failed,
if failed == 1 { "failure" } else { "failures" },
duration_str
);
}
}
ProgressEvent::Warning { target_id, message } => {
if self.is_tty {
self.finalize();
}
let prefix = target_id.map(|id| format!("{}: ", id)).unwrap_or_default();
eprintln!("{} {}{}", self.yellow("[warn]"), prefix, message);
if self.is_tty {
self.update_line(&self.render_progress());
}
}
ProgressEvent::Error { target_id, message } => {
if self.is_tty {
self.finalize();
}
let prefix = target_id.map(|id| format!("{}: ", id)).unwrap_or_default();
eprintln!("{} {}{}", self.red("[error]"), prefix, message);
if self.is_tty {
self.update_line(&self.render_progress());
}
}
}
}
fn is_verbose(&self) -> bool {
self.verbose
}
}
pub fn is_tty() -> bool {
atty::is(atty::Stream::Stderr)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn test_target_status_display() {
assert_eq!(TargetStatus::Success.to_string(), "success");
assert_eq!(TargetStatus::Skipped.to_string(), "skipped");
assert_eq!(TargetStatus::Failed("error".to_string()).to_string(), "failed: error");
}
#[test]
fn test_null_progress() {
let reporter = NullProgress::new();
reporter.report(ProgressEvent::BuildStarted { total_targets: 10 });
reporter.report(ProgressEvent::TargetStarted { target_id: "test".to_string() });
assert!(!reporter.is_verbose());
}
#[test]
fn test_console_progress_build_started() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = ConsoleProgress::with_output(TestWriter(output_clone)).with_colors(false);
reporter.report(ProgressEvent::BuildStarted { total_targets: 5 });
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains("Building 5 targets"));
}
#[test]
fn test_console_progress_target_completed_success() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = ConsoleProgress::with_output(TestWriter(output_clone)).with_colors(false);
reporter.report(ProgressEvent::BuildStarted { total_targets: 1 });
reporter.report(ProgressEvent::TargetCompleted {
target_id: "sprite:test".to_string(),
status: TargetStatus::Success,
duration_ms: 150,
});
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains("ok"));
assert!(text.contains("sprite:test"));
assert!(text.contains("150ms"));
}
#[test]
fn test_console_progress_target_completed_failed() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = ConsoleProgress::with_output(TestWriter(output_clone)).with_colors(false);
reporter.report(ProgressEvent::BuildStarted { total_targets: 1 });
reporter.report(ProgressEvent::TargetCompleted {
target_id: "sprite:test".to_string(),
status: TargetStatus::Failed("file not found".to_string()),
duration_ms: 50,
});
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains("FAILED"));
assert!(text.contains("file not found"));
}
#[test]
fn test_console_progress_build_completed_success() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = ConsoleProgress::with_output(TestWriter(output_clone)).with_colors(false);
reporter.report(ProgressEvent::BuildCompleted {
success: true,
duration_ms: 1500,
succeeded: 5,
skipped: 2,
failed: 0,
});
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains("done"));
assert!(text.contains("7"));
assert!(text.contains("2 skipped"));
}
#[test]
fn test_console_progress_build_completed_failed() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = ConsoleProgress::with_output(TestWriter(output_clone)).with_colors(false);
reporter.report(ProgressEvent::BuildCompleted {
success: false,
duration_ms: 500,
succeeded: 3,
skipped: 1,
failed: 2,
});
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains("error"));
assert!(text.contains("2 failures"));
}
#[test]
fn test_console_progress_warning() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = ConsoleProgress::with_output(TestWriter(output_clone)).with_colors(false);
reporter.report(ProgressEvent::Warning {
target_id: Some("sprite:test".to_string()),
message: "deprecated format".to_string(),
});
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains("warn"));
assert!(text.contains("sprite:test"));
assert!(text.contains("deprecated format"));
}
#[test]
fn test_json_progress_build_started() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = JsonProgress::with_output(TestWriter(output_clone));
reporter.report(ProgressEvent::BuildStarted { total_targets: 10 });
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains(r#""event":"build_started""#));
assert!(text.contains(r#""total_targets":10"#));
}
#[test]
fn test_json_progress_target_completed() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = JsonProgress::with_output(TestWriter(output_clone));
reporter.report(ProgressEvent::TargetCompleted {
target_id: "sprite:test".to_string(),
status: TargetStatus::Success,
duration_ms: 100,
});
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains(r#""event":"target_completed""#));
assert!(text.contains(r#""target_id":"sprite:test""#));
assert!(text.contains(r#""status":"success""#));
assert!(text.contains(r#""duration_ms":100"#));
}
#[test]
fn test_json_progress_target_failed() {
let output = Arc::new(Mutex::new(Vec::new()));
let output_clone = Arc::clone(&output);
let reporter = JsonProgress::with_output(TestWriter(output_clone));
reporter.report(ProgressEvent::TargetCompleted {
target_id: "sprite:test".to_string(),
status: TargetStatus::Failed("not found".to_string()),
duration_ms: 50,
});
let output = output.lock().unwrap();
let text = String::from_utf8_lossy(&output);
assert!(text.contains(r#""status":"failed""#));
assert!(text.contains(r#""error":"not found""#));
}
#[test]
fn test_progress_tracker_new() {
let tracker = ProgressTracker::new();
assert_eq!(tracker.total, 0);
assert_eq!(tracker.completed, 0);
assert!(tracker.is_complete()); }
#[test]
fn test_progress_tracker_lifecycle() {
let mut tracker = ProgressTracker::new();
tracker.start(3);
assert_eq!(tracker.total, 3);
assert!(!tracker.is_complete());
assert_eq!(tracker.percentage(), 0.0);
tracker.target_started("a");
assert_eq!(tracker.in_progress().len(), 1);
tracker.target_completed("a", &TargetStatus::Success);
assert_eq!(tracker.in_progress().len(), 0);
assert_eq!(tracker.succeeded(), 1);
assert!((tracker.percentage() - 33.333).abs() < 0.1);
tracker.target_started("b");
tracker.target_completed("b", &TargetStatus::Skipped);
assert_eq!(tracker.skipped(), 1);
tracker.target_started("c");
tracker.target_completed("c", &TargetStatus::Failed("error".to_string()));
assert_eq!(tracker.failed(), 1);
assert!(tracker.is_complete());
assert!(!tracker.is_success());
}
#[test]
fn test_progress_tracker_build_completed_event() {
let mut tracker = ProgressTracker::new();
tracker.start(2);
tracker.target_completed("a", &TargetStatus::Success);
tracker.target_completed("b", &TargetStatus::Success);
let event = tracker.build_completed_event();
match event {
ProgressEvent::BuildCompleted { success, succeeded, skipped, failed, .. } => {
assert!(success);
assert_eq!(succeeded, 2);
assert_eq!(skipped, 0);
assert_eq!(failed, 0);
}
_ => panic!("Expected BuildCompleted event"),
}
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "0ms");
assert_eq!(format_duration(500), "500ms");
assert_eq!(format_duration(999), "999ms");
assert_eq!(format_duration(1000), "1.0s");
assert_eq!(format_duration(1500), "1.5s");
assert_eq!(format_duration(60000), "1m 0s");
assert_eq!(format_duration(90000), "1m 30s");
}
#[test]
fn test_escape_json() {
assert_eq!(escape_json("hello"), "hello");
assert_eq!(escape_json("hello\"world"), "hello\\\"world");
assert_eq!(escape_json("hello\\world"), "hello\\\\world");
assert_eq!(escape_json("hello\nworld"), "hello\\nworld");
assert_eq!(escape_json("hello\tworld"), "hello\\tworld");
}
struct TestWriter(Arc<Mutex<Vec<u8>>>);
impl Write for TestWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
}