use std::io::{self, Write};
use std::time::Duration;
use haz_exec::run_graph::RunGraphOutcome;
use haz_exec::run_task::{RunOutcome, RunSource, RunState};
use super::color_choice::ColorChoice;
use super::duration::format_duration;
use super::palette::ansi;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct OutcomeCounts {
completed: usize,
cached: usize,
failed: usize,
skipped: usize,
cancelled: usize,
}
impl OutcomeCounts {
fn from_outcome(outcome: &RunGraphOutcome) -> Self {
let mut counts = Self::default();
for run_outcome in outcome.outcomes.values() {
match run_outcome {
RunOutcome::Completed(record) => match record.state {
RunState::Succeeded => {
counts.completed += 1;
if matches!(record.source, RunSource::CacheHit) {
counts.cached += 1;
}
}
RunState::Failed => counts.failed += 1,
RunState::Cancelled => counts.cancelled += 1,
},
RunOutcome::Skipped(_) => counts.skipped += 1,
RunOutcome::Cancelled(_) => counts.cancelled += 1,
}
}
counts.failed += outcome.task_errors.len();
counts
}
}
pub fn write_epilogue<W: Write>(
sink: &mut W,
outcome: &RunGraphOutcome,
elapsed: Duration,
color: ColorChoice,
) -> io::Result<()> {
let line = format_epilogue(outcome, elapsed, color);
sink.write_all(line.as_bytes())
}
fn format_epilogue(outcome: &RunGraphOutcome, elapsed: Duration, color: ColorChoice) -> String {
let counts = OutcomeCounts::from_outcome(outcome);
let mut tasks_line = String::from("\nTasks: ");
push_completed_chunk(&mut tasks_line, counts, color);
push_optional_count(
&mut tasks_line,
counts.failed,
"failed",
ansi::FG_RED,
color,
);
push_optional_count(
&mut tasks_line,
counts.skipped,
"skipped",
ansi::FG_YELLOW,
color,
);
push_optional_count(
&mut tasks_line,
counts.cancelled,
"cancelled",
ansi::FG_YELLOW,
color,
);
tasks_line.push('\n');
let time_line = format!(" Time: {}\n", dim(&format_duration(elapsed), color));
format!("{tasks_line}{time_line}")
}
fn push_completed_chunk(dest: &mut String, counts: OutcomeCounts, color: ColorChoice) {
let completed_chunk = colorise(
&format!("{} completed", counts.completed),
ansi::FG_GREEN,
color,
);
dest.push_str(&completed_chunk);
if counts.cached > 0 {
dest.push(' ');
dest.push_str(&colorise(
&format!("({} cached)", counts.cached),
ansi::FG_GREEN,
color,
));
}
}
fn push_optional_count(
dest: &mut String,
count: usize,
label: &str,
status_color: &str,
color: ColorChoice,
) {
if count == 0 {
return;
}
dest.push_str(", ");
dest.push_str(&colorise(&format!("{count} {label}"), status_color, color));
}
fn colorise(text: &str, status_color: &str, color: ColorChoice) -> String {
if color.is_enabled() {
format!("{status_color}{text}{}", ansi::RESET)
} else {
text.to_owned()
}
}
fn dim(text: &str, color: ColorChoice) -> String {
if color.is_enabled() {
format!("{}{}{}", ansi::DIM, text, ansi::RESET)
} else {
text.to_owned()
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::str::FromStr;
use haz_domain::name::{ProjectName, TaskName};
use haz_domain::task_id::TaskId;
use haz_exec::run_task::{
CancelledRecord, CompletedRecord, RunOutcome, RunSource, RunState, SkipCause, SkipRecord,
};
use super::*;
fn task(project: &str, name: &str) -> TaskId {
TaskId {
project: ProjectName::from_str(project).unwrap(),
task: TaskName::from_str(name).unwrap(),
}
}
fn empty_outcome() -> RunGraphOutcome {
RunGraphOutcome {
outcomes: BTreeMap::new(),
task_errors: BTreeMap::new(),
invariant_violations: Vec::new(),
}
}
fn completed_outcome(task: &TaskId, source: RunSource, state: RunState) -> RunOutcome {
RunOutcome::Completed(CompletedRecord {
task: task.clone(),
source,
state,
exit_status: None,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
materialised_outputs: Vec::new(),
})
}
fn skipped_outcome(task: &TaskId, upstream: &TaskId) -> RunOutcome {
RunOutcome::Skipped(SkipRecord {
task: task.clone(),
cause: SkipCause::UpstreamFailed {
upstream: upstream.clone(),
},
})
}
fn cancelled_outcome(task: &TaskId) -> RunOutcome {
RunOutcome::Cancelled(CancelledRecord::RunCancelled { task: task.clone() })
}
#[test]
fn epilogue_all_completed_no_cache() {
let t1 = task("lib", "build");
let t2 = task("lib", "test");
let mut outcome = empty_outcome();
outcome.outcomes.insert(
t1.clone(),
completed_outcome(&t1, RunSource::FreshRun, RunState::Succeeded),
);
outcome.outcomes.insert(
t2.clone(),
completed_outcome(&t2, RunSource::FreshRun, RunState::Succeeded),
);
let rendered = format_epilogue(&outcome, Duration::from_millis(7_856), ColorChoice::Never);
assert_eq!(rendered, "\nTasks: 2 completed\n Time: 7s 856ms\n");
}
#[test]
fn epilogue_all_cached_carries_cached_subcount() {
let t1 = task("lib", "build");
let mut outcome = empty_outcome();
outcome.outcomes.insert(
t1.clone(),
completed_outcome(&t1, RunSource::CacheHit, RunState::Succeeded),
);
let rendered = format_epilogue(&outcome, Duration::from_millis(176), ColorChoice::Never);
assert_eq!(rendered, "\nTasks: 1 completed (1 cached)\n Time: 176ms\n");
}
#[test]
fn epilogue_mixed_results() {
let t1 = task("lib", "build");
let t2 = task("lib", "test");
let t3 = task("lib", "doc");
let t4 = task("lib", "drained");
let mut outcome = empty_outcome();
outcome.outcomes.insert(
t1.clone(),
completed_outcome(&t1, RunSource::CacheHit, RunState::Succeeded),
);
outcome.outcomes.insert(
t2.clone(),
completed_outcome(&t2, RunSource::FreshRun, RunState::Failed),
);
outcome
.outcomes
.insert(t3.clone(), skipped_outcome(&t3, &t2));
outcome.outcomes.insert(t4.clone(), cancelled_outcome(&t4));
let rendered = format_epilogue(&outcome, Duration::from_secs(1), ColorChoice::Never);
assert_eq!(
rendered,
"\nTasks: 1 completed (1 cached), 1 failed, 1 skipped, 1 cancelled\n Time: 1s 0ms\n",
);
}
#[test]
fn epilogue_color_enabled_wraps_status_chunks() {
let t1 = task("lib", "build");
let mut outcome = empty_outcome();
outcome.outcomes.insert(
t1.clone(),
completed_outcome(&t1, RunSource::FreshRun, RunState::Succeeded),
);
let rendered = format_epilogue(&outcome, Duration::from_millis(50), ColorChoice::Always);
assert!(rendered.contains("\x1b[32m1 completed\x1b[0m"));
assert!(rendered.contains("\x1b[2m50ms\x1b[0m"));
}
#[test]
fn epilogue_task_errors_fold_into_failed_count() {
let mut outcome = empty_outcome();
outcome.task_errors.insert(
task("lib", "broken"),
haz_exec::run_task::RunTaskError::BuildKeyFailed {
source: haz_exec::cache_key::BuildKeyError::TaskNotInWorkspace {
task: task("lib", "broken"),
},
},
);
let rendered = format_epilogue(&outcome, Duration::ZERO, ColorChoice::Never);
assert_eq!(rendered, "\nTasks: 0 completed, 1 failed\n Time: 0ms\n");
}
}