use std::borrow::Cow;
use std::collections::VecDeque;
use std::collections::hash_map::Entry;
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use console::Term;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
use rustc_hash::FxHashMap;
use unicode_width::UnicodeWidthStr;
use crate::hook::Hook;
use crate::printer::Printer;
use crate::workspace;
static CURRENT_REPORTER: Mutex<Option<Weak<ProgressReporter>>> = Mutex::new(None);
const SPINNER_TICKS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
fn set_current_reporter(reporter: Option<&Arc<ProgressReporter>>) {
*CURRENT_REPORTER.lock().unwrap() = reporter.map(Arc::downgrade);
}
pub(crate) fn suspend(f: impl FnOnce() + Send + 'static) {
let reporter = CURRENT_REPORTER.lock().unwrap().clone();
match reporter.and_then(|r| r.upgrade()) {
Some(reporter) => reporter.children.suspend(f),
None => f(),
}
}
#[derive(Default, Debug)]
struct BarState {
bars: FxHashMap<usize, ProgressBar>,
id: usize,
}
impl BarState {
fn id(&mut self) -> usize {
self.id += 1;
self.id
}
}
#[derive(Debug)]
struct HookBar {
hook_key: HookKey,
progress: ProgressBar,
passed: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HookKey {
project_idx: usize,
hook_idx: usize,
}
impl HookKey {
fn from_hook(hook: &Hook) -> Self {
Self {
project_idx: hook.project().idx(),
hook_idx: hook.idx,
}
}
}
#[derive(Debug, Default)]
struct CompletedBars {
visible: VecDeque<HookBar>,
hidden_passed: usize,
hidden_failed: usize,
}
impl CompletedBars {
fn push(&mut self, completed: HookBar) {
self.visible.push_back(completed);
}
fn hide_one_line(&mut self) -> VecDeque<HookBar> {
let count = if self.is_collapsed() { 1 } else { 2 };
debug_assert!(self.can_hide_one_line());
let removed: VecDeque<_> = self.visible.drain(..count).collect();
for completed in &removed {
match completed.passed {
Some(true) => self.hidden_passed += 1,
Some(false) => self.hidden_failed += 1,
None => {}
}
}
removed
}
fn record_result(&mut self, hook_key: HookKey, passed: bool) -> Option<ProgressBar> {
if let Some(completed) = self
.visible
.iter_mut()
.find(|completed| completed.hook_key == hook_key)
{
completed.passed = Some(passed);
return Some(completed.progress.clone());
}
None
}
fn visible_len(&self) -> usize {
self.visible.len()
}
fn can_hide_one_line(&self) -> bool {
let count = if self.is_collapsed() { 1 } else { 2 };
self.visible
.iter()
.take_while(|completed| completed.passed.is_some())
.count()
>= count
}
fn hidden_len(&self) -> usize {
self.hidden_passed + self.hidden_failed
}
fn is_collapsed(&self) -> bool {
self.hidden_len() > 0
}
fn hidden_summary(&self) -> Option<String> {
let hidden = self.hidden_len();
if hidden == 0 {
return None;
}
let status = match (self.hidden_passed, self.hidden_failed) {
(passed, 0) => format!("{passed} passed"),
(0, failed) => format!("{failed} failed"),
(passed, failed) => format!("{passed} passed, {failed} failed"),
};
Some(format!("⋮ {hidden} hooks hidden: {status}"))
}
fn clear(&mut self) -> VecDeque<HookBar> {
self.hidden_passed = 0;
self.hidden_failed = 0;
std::mem::take(&mut self.visible)
}
}
#[derive(Debug)]
struct HookGroup {
order: usize,
header: Option<ProgressBar>,
last_line: Option<ProgressBar>,
hidden_summary: Option<ProgressBar>,
completed: CompletedBars,
}
impl HookGroup {
fn new(order: usize, header: Option<ProgressBar>) -> Self {
let last_line = header.clone();
Self {
order,
header,
last_line,
hidden_summary: None,
completed: CompletedBars::default(),
}
}
fn line_count(&self) -> usize {
usize::from(self.header.is_some())
+ self.completed.visible_len()
+ usize::from(self.completed.is_collapsed())
}
}
type HookGroups = FxHashMap<usize, HookGroup>;
pub(crate) fn project_status_marker(failed: bool) -> String {
if failed {
"×".red().to_string()
} else {
"✓".green().to_string()
}
}
struct ProgressReporter {
printer: Printer,
root: ProgressBar,
state: Arc<Mutex<BarState>>,
children: MultiProgress,
}
impl ProgressReporter {
fn new(root: ProgressBar, children: MultiProgress, printer: Printer) -> Self {
Self {
printer,
root,
state: Arc::default(),
children,
}
}
fn on_start(&self, msg: impl Into<Cow<'static, str>>) -> usize {
let mut state = self.state.lock().unwrap();
let id = state.id();
let progress = self.children.insert_before(
&self.root,
ProgressBar::with_draw_target(None, self.printer.target()),
);
progress.set_style(ProgressStyle::with_template("{wide_msg}").unwrap());
progress.set_message(msg);
state.bars.insert(id, progress);
id
}
fn on_progress(&self, id: usize) {
let progress = {
let mut state = self.state.lock().unwrap();
state.bars.remove(&id).unwrap()
};
self.root.inc(1);
progress.finish_and_clear();
}
fn set_root_prefix(&self, prefix: impl Into<Cow<'static, str>>) {
self.root.set_prefix(prefix);
}
fn on_complete(&self) {
self.root.set_prefix("");
self.root.set_message("");
self.root.finish_and_clear();
}
}
impl From<Printer> for ProgressReporter {
fn from(printer: Printer) -> Self {
let multi = MultiProgress::with_draw_target(printer.target());
let root = multi.add(ProgressBar::with_draw_target(None, printer.target()));
root.enable_steady_tick(Duration::from_millis(200));
root.set_style(
ProgressStyle::with_template(
"{spinner:.cyan.bold.dim} {prefix:.cyan.bold.dim}{msg:.dim}",
)
.unwrap()
.tick_strings(SPINNER_TICKS),
);
Self::new(root, multi, printer)
}
}
pub(crate) struct HookInitReporter {
reporter: Arc<ProgressReporter>,
}
impl HookInitReporter {
pub(crate) fn new(printer: Printer) -> Self {
let reporter = Arc::new(ProgressReporter::from(printer));
set_current_reporter(Some(&reporter));
Self { reporter }
}
}
impl workspace::HookInitReporter for HookInitReporter {
fn on_clone_start(&self, repo: &str) -> usize {
self.reporter.set_root_prefix("Cloning repos...");
self.reporter
.on_start(format!("{} {}", "Cloning".bold().cyan(), repo.dimmed()))
}
fn on_clone_complete(&self, id: usize) {
self.reporter.on_progress(id);
}
fn on_complete(&self) {
self.reporter.on_complete();
}
}
pub(crate) struct HookInstallReporter {
reporter: Arc<ProgressReporter>,
}
impl HookInstallReporter {
pub(crate) fn new(printer: Printer) -> Self {
let reporter = Arc::new(ProgressReporter::from(printer));
set_current_reporter(Some(&reporter));
Self { reporter }
}
pub fn on_install_start(&self, hook: &Hook) -> usize {
self.reporter.set_root_prefix("Installing hooks...");
self.reporter.on_start(format!(
"{} {}",
"Installing".bold().cyan(),
hook.id.dimmed(),
))
}
pub fn on_install_complete(&self, id: usize) {
self.reporter.on_progress(id);
}
pub fn on_complete(&self) {
self.reporter.on_complete();
}
}
pub(crate) struct HookRunReporter {
reporter: Arc<ProgressReporter>,
dots: usize,
show_project_headers: bool,
running: Mutex<FxHashMap<usize, HookBar>>,
groups: Mutex<HookGroups>,
}
impl HookRunReporter {
pub fn new(printer: Printer, dots: usize, show_project_headers: bool) -> Self {
let reporter = Arc::new(ProgressReporter::from(printer));
reporter.set_root_prefix("Running hooks...");
set_current_reporter(Some(&reporter));
Self {
reporter,
dots,
show_project_headers,
running: Mutex::default(),
groups: Mutex::default(),
}
}
pub fn on_run_start(&self, hook: &Hook, len: usize) -> usize {
let id = self.reporter.state.lock().unwrap().id();
let progress_len = if len == 0 { 1 } else { len as u64 };
let mut running = self.running.lock().unwrap();
let mut groups = self.groups.lock().unwrap();
let project_idx = hook.project().idx();
let order = groups.len();
if let Entry::Vacant(entry) = groups.entry(project_idx) {
let header = if self.show_project_headers {
let header = self.reporter.children.insert_before(
&self.reporter.root,
ProgressBar::with_draw_target(None, self.reporter.printer.target()),
);
header.enable_steady_tick(Duration::from_millis(200));
header.set_style(
ProgressStyle::with_template("{spinner:.cyan} {wide_msg}")
.unwrap()
.tick_strings(SPINNER_TICKS),
);
header.set_message(format!("{}", hook.project().display_name().cyan().bold()));
Some(header)
} else {
None
};
entry.insert(HookGroup::new(order, header));
}
for completed in self.collapse_to_fit_new_progress(&mut groups, running.len()) {
self.reporter.children.remove(&completed.progress);
}
let group = groups.get_mut(&project_idx).unwrap();
let progress = if let Some(last_line) = &group.last_line {
self.reporter.children.insert_after(
last_line,
ProgressBar::with_draw_target(Some(progress_len), self.reporter.printer.target()),
)
} else {
self.reporter.children.insert_before(
&self.reporter.root,
ProgressBar::with_draw_target(Some(progress_len), self.reporter.printer.target()),
)
};
group.last_line = Some(progress.clone());
let label = if self.show_project_headers {
format!(" {}", hook.name)
} else {
hook.name.clone()
};
let dots = self.dots.saturating_sub(label.width());
progress.enable_steady_tick(Duration::from_millis(200));
progress.set_style(
ProgressStyle::with_template(&format!("{{msg}}{{bar:{dots}.green/dim}}"))
.unwrap()
.progress_chars(".."),
);
progress.set_message(label);
running.insert(
id,
HookBar {
hook_key: HookKey::from_hook(hook),
progress,
passed: None,
},
);
id
}
pub fn on_run_progress(&self, id: usize, completed: u64) {
let running = self.running.lock().unwrap();
let progress = &running[&id].progress;
progress.inc(completed);
}
pub fn on_run_complete(&self, id: usize) {
let running = {
let mut running = self.running.lock().unwrap();
running.remove(&id).unwrap()
};
self.reporter.root.inc(1);
let progress = &running.progress;
progress.set_position(progress.length().unwrap_or(1));
progress.finish();
self.remember_completed(running);
}
pub fn on_run_result(&self, hook: &Hook, passed: bool) {
let hook_key = HookKey::from_hook(hook);
let progress = {
let mut groups = self.groups.lock().unwrap();
let Some(group) = groups.get_mut(&hook_key.project_idx) else {
return;
};
group.completed.record_result(hook_key, passed)
};
let Some(progress) = progress else {
return;
};
let label = progress.message();
let (status, status_width) = if passed {
("Passed".on_green().to_string(), "Passed".width())
} else {
("Failed".on_red().to_string(), "Failed".width())
};
let dots = self
.dots
.saturating_add("Passed".width())
.saturating_sub(label.width() + status_width);
let dots = ".".repeat(dots).green().to_string();
progress.set_style(ProgressStyle::with_template("{wide_msg}").unwrap());
progress.set_message(format!("{label}{dots}{status}"));
progress.finish();
}
pub fn on_project_complete(&self, project: &workspace::Project, failed: bool) {
let mut groups = self.groups.lock().unwrap();
let Some(group) = groups.get_mut(&project.idx()) else {
return;
};
let Some(header) = &group.header else {
return;
};
header.set_style(ProgressStyle::with_template("{wide_msg}").unwrap());
header.set_message(format!(
"{} {}",
project_status_marker(failed),
project.display_name().cyan().bold()
));
header.finish();
}
pub fn clear_completed(&self) {
let groups = {
let mut groups = self.groups.lock().unwrap();
std::mem::take(&mut *groups)
};
for (_, group) in groups {
self.clear_group(group);
}
}
pub fn suspend<R>(&self, f: impl FnOnce() -> R) -> R {
self.reporter.children.suspend(f)
}
pub fn on_complete(&self) {
self.clear_completed();
self.reporter.on_complete();
}
fn remember_completed(&self, completed: HookBar) {
let mut groups = self.groups.lock().unwrap();
let Some(group) = groups.get_mut(&completed.hook_key.project_idx) else {
drop(groups);
self.reporter.children.remove(&completed.progress);
return;
};
group.completed.push(completed);
}
fn clear_group(&self, mut group: HookGroup) {
if let Some(header) = group.header {
self.reporter.children.remove(&header);
}
if let Some(summary) = group.hidden_summary {
self.reporter.children.remove(&summary);
}
for completed in group.completed.clear() {
self.reporter.children.remove(&completed.progress);
}
}
fn update_group_summary(&self, group: &mut HookGroup, anchor: &ProgressBar) {
let Some(message) = group.completed.hidden_summary() else {
return;
};
let summary = if let Some(summary) = &group.hidden_summary {
summary.clone()
} else {
let summary = if let Some(header) = &group.header {
self.reporter.children.insert_after(
header,
ProgressBar::with_draw_target(None, self.reporter.printer.target()),
)
} else {
self.reporter.children.insert_before(
anchor,
ProgressBar::with_draw_target(None, self.reporter.printer.target()),
)
};
summary.set_style(ProgressStyle::with_template("{wide_msg}").unwrap());
group.hidden_summary = Some(summary.clone());
summary
};
if group.completed.visible_len() == 0 {
group.last_line = Some(summary.clone());
}
if group.header.is_some() {
summary.set_message(format!(" {}", message.dimmed()));
} else {
summary.set_message(format!("{}", message.dimmed()));
}
}
fn progress_line_limit(&self) -> Option<usize> {
if self.reporter.children.is_hidden() {
return None;
}
Term::stderr()
.size_checked()
.map(|(height, _)| usize::from(height))
.filter(|height| *height > 0)
}
fn active_lines(groups: &HookGroups, running: usize) -> usize {
let group_lines = groups.values().map(HookGroup::line_count).sum::<usize>();
1 + running + group_lines
}
fn collapse_candidate(groups: &HookGroups) -> Option<usize> {
groups
.iter()
.filter(|(_, group)| group.completed.can_hide_one_line())
.min_by_key(|(_, group)| group.order)
.map(|(project_idx, _)| *project_idx)
}
fn collapse_to_fit_new_progress(
&self,
groups: &mut HookGroups,
running: usize,
) -> Vec<HookBar> {
let Some(limit) = self.progress_line_limit() else {
return Vec::new();
};
let mut removed = Vec::new();
while Self::active_lines(groups, running).saturating_add(1) > limit {
let Some(project_idx) = Self::collapse_candidate(groups) else {
break;
};
let group = groups.get_mut(&project_idx).unwrap();
let hidden = group.completed.hide_one_line();
let anchor = hidden.front().unwrap().progress.clone();
self.update_group_summary(group, &anchor);
removed.extend(hidden);
}
removed
}
}
#[derive(Clone)]
pub(crate) struct AutoUpdateReporter {
reporter: Arc<ProgressReporter>,
}
impl AutoUpdateReporter {
pub(crate) fn new(printer: Printer) -> Self {
let reporter = Arc::new(ProgressReporter::from(printer));
set_current_reporter(Some(&reporter));
Self { reporter }
}
}
impl AutoUpdateReporter {
pub fn on_update_start(&self, repo: &str) -> usize {
self.reporter.set_root_prefix("Updating repos...");
self.reporter
.on_start(format!("{} {}", "Updating".bold().cyan(), repo.dimmed()))
}
pub fn on_update_complete(&self, id: usize) {
self.reporter.on_progress(id);
}
pub fn on_complete(&self) {
self.reporter.on_complete();
}
}
#[derive(Debug)]
pub(crate) struct CleaningReporter {
bar: ProgressBar,
}
impl CleaningReporter {
pub(crate) fn new(printer: Printer, max: usize) -> Self {
let bar = ProgressBar::with_draw_target(Some(max as u64), printer.target());
bar.set_style(
ProgressStyle::with_template("{prefix} [{bar:20}] {percent}%")
.unwrap()
.progress_chars("=> "),
);
bar.set_prefix(format!("{}", "Cleaning".bold().cyan()));
Self { bar }
}
}
impl CleaningReporter {
pub(crate) fn on_clean(&self) {
self.bar.inc(1);
}
pub(crate) fn on_complete(&self) {
self.bar.finish_and_clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn completed_bar(hook_idx: usize, passed: Option<bool>) -> HookBar {
project_completed_bar(0, hook_idx, passed)
}
fn project_completed_bar(project_idx: usize, hook_idx: usize, passed: Option<bool>) -> HookBar {
HookBar {
hook_key: HookKey {
project_idx,
hook_idx,
},
progress: ProgressBar::hidden(),
passed,
}
}
fn hook_group(order: usize, has_header: bool) -> HookGroup {
HookGroup::new(order, has_header.then(ProgressBar::hidden))
}
fn progress_bar(reporter: &HookRunReporter) -> ProgressBar {
reporter.reporter.children.insert_before(
&reporter.reporter.root,
ProgressBar::with_draw_target(None, reporter.reporter.printer.target()),
)
}
#[test]
fn hidden_summary_shows_total_and_result_breakdown() {
let completed = CompletedBars {
hidden_passed: 8,
..CompletedBars::default()
};
assert_eq!(
completed.hidden_summary().as_deref(),
Some("⋮ 8 hooks hidden: 8 passed")
);
let completed = CompletedBars {
hidden_failed: 8,
..CompletedBars::default()
};
assert_eq!(
completed.hidden_summary().as_deref(),
Some("⋮ 8 hooks hidden: 8 failed")
);
let completed = CompletedBars {
hidden_passed: 6,
hidden_failed: 2,
..CompletedBars::default()
};
assert_eq!(
completed.hidden_summary().as_deref(),
Some("⋮ 8 hooks hidden: 6 passed, 2 failed")
);
}
#[test]
fn hiding_completed_bars_frees_one_line() {
let mut completed = CompletedBars::default();
completed.push(completed_bar(0, Some(true)));
assert!(!completed.can_hide_one_line());
completed.push(completed_bar(1, Some(false)));
completed.push(completed_bar(2, Some(true)));
let removed = completed.hide_one_line();
assert_eq!(removed.len(), 2);
assert_eq!(completed.visible_len(), 1);
assert_eq!(
completed.hidden_summary().as_deref(),
Some("⋮ 2 hooks hidden: 1 passed, 1 failed")
);
let removed = completed.hide_one_line();
assert_eq!(removed.len(), 1);
assert_eq!(completed.visible_len(), 0);
assert_eq!(
completed.hidden_summary().as_deref(),
Some("⋮ 3 hooks hidden: 2 passed, 1 failed")
);
}
#[test]
fn hiding_requires_a_known_result_prefix() {
let mut completed = CompletedBars::default();
completed.push(completed_bar(0, None));
completed.push(completed_bar(1, Some(true)));
completed.push(completed_bar(2, Some(true)));
assert!(!completed.can_hide_one_line());
}
#[test]
fn group_line_count_includes_header_visible_and_hidden_summary() {
let mut group = hook_group(0, false);
assert_eq!(group.line_count(), 0);
group.completed.push(completed_bar(0, Some(true)));
group.completed.push(completed_bar(1, None));
assert_eq!(group.line_count(), 2);
let mut group = hook_group(0, true);
group.completed.push(completed_bar(0, Some(true)));
group.completed.hidden_failed = 1;
assert_eq!(group.line_count(), 3);
}
#[test]
fn active_lines_includes_root_running_and_group_lines() {
let mut groups = HookGroups::default();
let mut first = hook_group(0, true);
first
.completed
.push(project_completed_bar(1, 0, Some(true)));
first.completed.hidden_passed = 2;
groups.insert(1, first);
let mut second = hook_group(1, false);
second
.completed
.push(project_completed_bar(2, 0, Some(false)));
groups.insert(2, second);
assert_eq!(HookRunReporter::active_lines(&groups, 2), 7);
}
#[test]
fn collapse_candidate_picks_oldest_hideable_group() {
let mut groups = HookGroups::default();
let mut oldest = hook_group(0, false);
oldest
.completed
.push(project_completed_bar(10, 0, Some(true)));
groups.insert(10, oldest);
let mut older_hideable = hook_group(1, false);
older_hideable
.completed
.push(project_completed_bar(20, 0, Some(true)));
older_hideable
.completed
.push(project_completed_bar(20, 1, Some(false)));
groups.insert(20, older_hideable);
let mut newer_hideable = hook_group(2, false);
newer_hideable.completed.hidden_passed = 1;
newer_hideable
.completed
.push(project_completed_bar(30, 0, Some(true)));
groups.insert(30, newer_hideable);
assert_eq!(HookRunReporter::collapse_candidate(&groups), Some(20));
}
#[test]
fn update_group_summary_creates_project_summary_line() {
let reporter = HookRunReporter::new(Printer::Silent, 80, true);
let mut group = HookGroup::new(0, Some(progress_bar(&reporter)));
group.completed.hidden_passed = 2;
group.completed.hidden_failed = 1;
reporter.update_group_summary(&mut group, &ProgressBar::hidden());
let summary = group.hidden_summary.as_ref().unwrap();
let message = summary.message().clone();
assert!(message.starts_with(" "));
assert!(message.contains("⋮ 3 hooks hidden: 2 passed, 1 failed"));
assert_eq!(
group.last_line.as_ref().unwrap().message(),
summary.message()
);
}
#[test]
fn update_group_summary_uses_anchor_without_project_header() {
let reporter = HookRunReporter::new(Printer::Silent, 80, false);
let anchor = progress_bar(&reporter);
let mut group = hook_group(0, false);
group.completed.hidden_failed = 1;
reporter.update_group_summary(&mut group, &anchor);
let summary = group.hidden_summary.as_ref().unwrap();
let message = summary.message().clone();
assert!(!message.starts_with(" "));
assert!(message.contains("⋮ 1 hooks hidden: 1 failed"));
assert_eq!(
group.last_line.as_ref().unwrap().message(),
summary.message()
);
}
#[test]
fn update_group_summary_is_noop_without_hidden_completed() {
let reporter = HookRunReporter::new(Printer::Silent, 80, false);
let anchor = progress_bar(&reporter);
let mut group = hook_group(0, false);
reporter.update_group_summary(&mut group, &anchor);
assert!(group.hidden_summary.is_none());
assert!(group.last_line.is_none());
}
}