use std::collections::HashSet;
use std::hash::Hash;
use std::time::Duration;
use std::time::Instant;
use crate::project::AbsolutePath;
#[derive(Debug, Default)]
pub enum Denominator<K: Eq + Hash> {
#[default]
Unknown,
Growing(HashSet<K>),
Stable(HashSet<K>),
}
impl<K: Eq + Hash> Denominator<K> {
pub const fn keys(&self) -> Option<&HashSet<K>> {
match self {
Self::Unknown => None,
Self::Growing(keys) | Self::Stable(keys) => Some(keys),
}
}
pub fn len(&self) -> usize { self.keys().map_or(0, HashSet::len) }
pub const fn is_unknown(&self) -> bool { matches!(self, Self::Unknown) }
pub const fn is_growing(&self) -> bool { matches!(self, Self::Growing(_)) }
pub(super) fn stabilize(&mut self) {
if let Self::Growing(keys) = self {
*self = Self::Stable(std::mem::take(keys));
}
}
pub(super) fn insert(&mut self, key: K) -> bool {
match self {
Self::Growing(keys) | Self::Stable(keys) => keys.insert(key),
Self::Unknown => {
let mut keys = HashSet::new();
let inserted = keys.insert(key);
*self = Self::Stable(keys);
inserted
},
}
}
}
#[derive(Debug)]
pub struct KeyedPhase<K: Eq + Hash> {
pub expected: Denominator<K>,
pub seen: HashSet<K>,
pub complete_at: Option<Instant>,
pub first_seen: Option<Instant>,
pub failure: Option<FailureReason>,
}
impl<K: Eq + Hash> Default for KeyedPhase<K> {
fn default() -> Self {
Self {
expected: Denominator::Unknown,
seen: HashSet::new(),
complete_at: None,
first_seen: None,
failure: None,
}
}
}
impl<K: Eq + Hash> KeyedPhase<K> {
pub(super) fn expected_len(&self) -> usize { self.expected.len() }
pub(super) fn stamp_first_seen(&mut self, now: Instant) { self.first_seen.get_or_insert(now); }
pub(super) fn pending_sample<F: Fn(&K) -> String>(&self, label: F) -> Option<String> {
let expected = self.expected.keys()?;
expected
.iter()
.filter(|key| !self.seen.contains(*key))
.map(label)
.min()
}
pub(super) fn time_out(&mut self, now: Instant, timeout: Duration) -> Option<Duration> {
if self.failure.is_some() || self.complete_at.is_some() || self.expected.is_unknown() {
return None;
}
let elapsed = now.duration_since(self.first_seen?);
if elapsed <= timeout {
return None;
}
self.failure = Some(FailureReason::Timeout(elapsed));
Some(elapsed)
}
pub(super) fn reset_with_expected(&mut self, expected: HashSet<K>) {
self.expected = Denominator::Stable(expected);
self.seen.clear();
self.complete_at = None;
self.first_seen = None;
self.failure = None;
}
pub(super) fn reset_unknown(&mut self) {
self.expected = Denominator::Unknown;
self.seen.clear();
self.complete_at = None;
self.first_seen = None;
self.failure = None;
}
pub(super) fn reset_growing(&mut self) {
self.expected = Denominator::Growing(HashSet::new());
self.seen.clear();
self.complete_at = None;
self.first_seen = None;
self.failure = None;
}
}
#[derive(Debug, Default)]
pub struct CountedPhase {
pub expected: Option<usize>,
pub seen: usize,
pub complete_at: Option<Instant>,
}
#[derive(Debug)]
pub struct LanguagePhase {
pub expected: Denominator<AbsolutePath>,
pub seen: HashSet<AbsolutePath>,
pub work_expected: usize,
pub work_seen: usize,
pub complete_at: Option<Instant>,
pub first_seen: Option<Instant>,
pub failure: Option<FailureReason>,
}
impl Default for LanguagePhase {
fn default() -> Self {
Self {
expected: Denominator::Unknown,
seen: HashSet::new(),
work_expected: 0,
work_seen: 0,
complete_at: None,
first_seen: None,
failure: None,
}
}
}
impl LanguagePhase {
pub(super) fn expected_len(&self) -> usize { self.expected.len() + self.work_expected }
pub(super) fn stamp_first_seen(&mut self, now: Instant) { self.first_seen.get_or_insert(now); }
pub(super) fn reset_with_expected_roots(&mut self, expected: HashSet<AbsolutePath>) {
self.expected = Denominator::Stable(expected);
self.seen.clear();
self.work_expected = 0;
self.work_seen = 0;
self.complete_at = None;
self.first_seen = None;
self.failure = None;
}
pub(super) const fn add_work_expected(&mut self, units: usize) {
if units == 0 {
return;
}
self.work_expected = self.work_expected.saturating_add(units);
self.complete_at = None;
}
pub(super) const fn add_work_seen(&mut self, units: usize) {
self.work_seen = self.work_seen.saturating_add(units);
}
pub(super) fn time_out(&mut self, now: Instant, timeout: Duration) -> Option<Duration> {
if self.failure.is_some() || self.complete_at.is_some() || self.expected.is_unknown() {
return None;
}
let elapsed = now.duration_since(self.first_seen?);
if elapsed <= timeout {
return None;
}
self.failure = Some(FailureReason::Timeout(elapsed));
Some(elapsed)
}
fn root_progress(&self) -> Option<(usize, usize)> {
let expected = self.expected.keys()?;
let done = expected.iter().filter(|k| self.seen.contains(*k)).count();
Some((done, expected.len()))
}
fn work_progress(&self) -> (usize, usize) {
(self.work_seen.min(self.work_expected), self.work_expected)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Percentage(u8);
impl Percentage {
pub(super) const fn empty() -> Self { Self(0) }
pub(super) const fn full() -> Self { Self(100) }
pub(super) fn from_fraction(seen: usize, expected: usize) -> Self {
if expected == 0 || seen >= expected {
return Self(100);
}
Self(u8::try_from(seen * 100 / expected).unwrap_or(100))
}
pub(super) const fn get(self) -> u8 { self.0 }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureReason {
RateLimited,
FetchError,
Timeout(Duration),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ProgressState {
Active(Percentage),
CompleteHeld,
Waiting,
Failed,
}
#[derive(Debug, Clone)]
pub(super) struct ProgressRow {
pub label: &'static str,
pub state: ProgressState,
pub detail: Option<String>,
}
pub(super) trait PhaseCompletion {
fn is_complete(&self) -> bool;
fn complete_at(&self) -> Option<Instant>;
fn mark_complete_at(&mut self, now: Instant);
fn is_omitted(&self) -> bool;
fn first_seen(&self) -> Option<Instant>;
fn is_failed(&self) -> bool;
fn progress_state(&self, now: Instant, min_visible: Duration) -> Option<ProgressState>;
fn complete_once(&mut self, now: Instant) -> bool {
if self.complete_at().is_some() || !self.is_complete() {
return false;
}
self.mark_complete_at(now);
true
}
fn is_terminal(&self) -> bool { self.complete_at().is_some() || self.is_failed() }
fn min_visible_elapsed(&self, now: Instant, min_visible: Duration) -> bool {
let floor = match (self.complete_at(), self.first_seen()) {
(Some(complete_at), Some(first_seen)) => complete_at.max(first_seen + min_visible),
(Some(complete_at), None) => complete_at,
(None, Some(first_seen)) => first_seen + min_visible,
(None, None) => return true,
};
now >= floor
}
fn gate_satisfied(&self, now: Instant, min_visible: Duration) -> bool {
self.is_omitted()
|| self.is_failed()
|| (self.complete_at().is_some() && self.min_visible_elapsed(now, min_visible))
}
}
impl<K: Eq + Hash> PhaseCompletion for KeyedPhase<K> {
fn is_complete(&self) -> bool {
matches!(self.expected.keys(), Some(expected) if expected.is_subset(&self.seen))
}
fn complete_at(&self) -> Option<Instant> { self.complete_at }
fn mark_complete_at(&mut self, now: Instant) { self.complete_at = Some(now); }
fn is_omitted(&self) -> bool { self.expected.is_unknown() }
fn first_seen(&self) -> Option<Instant> { self.first_seen }
fn is_failed(&self) -> bool { self.failure.is_some() }
fn progress_state(&self, now: Instant, min_visible: Duration) -> Option<ProgressState> {
if self.failure.is_some() {
return Some(ProgressState::Failed);
}
if self.expected.is_unknown() {
return None;
}
if self.expected.is_growing() {
return Some(ProgressState::Waiting);
}
let expected = self.expected.keys()?;
let done = expected.iter().filter(|k| self.seen.contains(k)).count();
let percentage = Percentage::from_fraction(done, expected.len());
if self.is_complete() && !self.min_visible_elapsed(now, min_visible) {
return Some(ProgressState::CompleteHeld);
}
Some(ProgressState::Active(percentage))
}
}
impl PhaseCompletion for CountedPhase {
fn is_complete(&self) -> bool {
matches!(self.expected, Some(expected) if self.seen >= expected)
}
fn complete_at(&self) -> Option<Instant> { self.complete_at }
fn mark_complete_at(&mut self, now: Instant) { self.complete_at = Some(now); }
fn is_omitted(&self) -> bool { true }
fn first_seen(&self) -> Option<Instant> { None }
fn is_failed(&self) -> bool { false }
fn progress_state(&self, _: Instant, _: Duration) -> Option<ProgressState> { None }
}
impl PhaseCompletion for LanguagePhase {
fn is_complete(&self) -> bool {
let roots_complete =
matches!(self.expected.keys(), Some(expected) if expected.is_subset(&self.seen));
roots_complete && self.work_seen >= self.work_expected
}
fn complete_at(&self) -> Option<Instant> { self.complete_at }
fn mark_complete_at(&mut self, now: Instant) { self.complete_at = Some(now); }
fn is_omitted(&self) -> bool { self.expected.is_unknown() && self.work_expected == 0 }
fn first_seen(&self) -> Option<Instant> { self.first_seen }
fn is_failed(&self) -> bool { self.failure.is_some() }
fn progress_state(&self, now: Instant, min_visible: Duration) -> Option<ProgressState> {
if self.failure.is_some() {
return Some(ProgressState::Failed);
}
if self.expected.is_unknown() && self.work_expected == 0 {
return None;
}
if self.expected.is_growing() {
return Some(ProgressState::Waiting);
}
let (root_seen, root_expected) = self.root_progress().unwrap_or((0, 0));
let (work_seen, work_expected) = self.work_progress();
let percentage =
Percentage::from_fraction(root_seen + work_seen, root_expected + work_expected);
if self.is_complete() && !self.min_visible_elapsed(now, min_visible) {
return Some(ProgressState::CompleteHeld);
}
Some(ProgressState::Active(percentage))
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests should panic on unexpected values"
)]
#[allow(
clippy::unwrap_used,
reason = "tests should panic on unexpected values"
)]
mod tests {
use std::time::Duration;
use super::*;
fn instant_at(offset_ms: u64) -> Instant { Instant::now() + Duration::from_millis(offset_ms) }
#[test]
fn keyed_unknown_vs_initialized_empty() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
assert!(
!phase.is_complete(),
"Unknown denominator is never complete"
);
phase.expected = Denominator::Stable(HashSet::new());
assert!(phase.is_complete(), "initialized-empty is complete");
}
#[test]
fn keyed_complete_when_all_seen() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.reset_with_expected([1, 2, 3].into_iter().collect());
assert!(!phase.is_complete());
phase.seen.insert(1);
phase.seen.insert(2);
assert!(!phase.is_complete());
phase.seen.insert(3);
assert!(phase.is_complete());
}
#[test]
fn counted_complete_when_seen_reaches_expected() {
let mut phase = CountedPhase::default();
assert!(
!phase.is_complete(),
"Unknown (expected=None) is never complete"
);
phase.expected = Some(3);
assert!(!phase.is_complete());
phase.seen = 2;
assert!(!phase.is_complete());
phase.seen = 3;
assert!(phase.is_complete());
phase.seen = 4;
assert!(phase.is_complete(), "overshoot stays complete");
}
#[test]
fn counted_initialized_zero_of_zero_is_complete() {
let phase = CountedPhase {
expected: Some(0),
..CountedPhase::default()
};
assert!(phase.is_complete());
}
#[test]
fn complete_once_transitions_only_once() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.reset_with_expected(HashSet::new());
let first = instant_at(0);
let second = instant_at(10);
assert!(phase.complete_once(first));
assert_eq!(phase.complete_at(), Some(first));
assert!(
!phase.complete_once(second),
"already complete, no transition"
);
assert_eq!(
phase.complete_at(),
Some(first),
"timestamp not overwritten"
);
}
#[test]
fn complete_once_noop_when_not_complete() {
let mut phase = CountedPhase {
expected: Some(2),
seen: 1,
..CountedPhase::default()
};
assert!(!phase.complete_once(instant_at(0)));
assert!(phase.complete_at().is_none());
}
#[test]
fn denominator_insert_initializes_unknown() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
assert!(phase.expected.is_unknown());
assert!(phase.expected.insert(42), "first insert is new");
assert_eq!(phase.expected_len(), 1);
assert!(phase.expected.keys().unwrap().contains(&42));
}
#[test]
fn keyed_reset_clears_seen_and_timestamp() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.seen.insert(7);
phase.complete_at = Some(instant_at(0));
phase.reset_with_expected(std::iter::once(1).collect());
assert!(phase.seen.is_empty());
assert!(phase.complete_at.is_none());
assert_eq!(phase.expected_len(), 1);
}
const MIN_VISIBLE: Duration = Duration::from_millis(400);
#[test]
fn percentage_from_fraction_clamps_and_handles_empty() {
assert_eq!(
Percentage::from_fraction(0, 0).get(),
100,
"empty expected renders 100%"
);
assert_eq!(
Percentage::from_fraction(5, 3).get(),
100,
"overshoot clamps to 100%"
);
assert_eq!(Percentage::from_fraction(0, 4).get(), 0);
assert_eq!(Percentage::from_fraction(1, 4).get(), 25);
assert_eq!(
Percentage::from_fraction(3, 8).get(),
37,
"fractional percent floors"
);
}
#[test]
fn progress_state_omits_unknown_row() {
let phase: KeyedPhase<i32> = KeyedPhase::default();
assert!(
phase.progress_state(Instant::now(), MIN_VISIBLE).is_none(),
"an Unknown denominator omits the row"
);
}
#[test]
fn progress_state_reports_partial_fraction() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.reset_with_expected([1, 2, 3, 4].into_iter().collect());
phase.stamp_first_seen(Instant::now());
phase.seen.insert(1);
assert_eq!(
phase.progress_state(Instant::now(), MIN_VISIBLE),
Some(ProgressState::Active(Percentage::from_fraction(1, 4)))
);
}
#[test]
fn complete_row_held_until_min_visible_then_full() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.reset_with_expected(HashSet::new());
let start = instant_at(0);
phase.stamp_first_seen(start);
assert!(phase.complete_once(start));
assert_eq!(
phase.progress_state(start, MIN_VISIBLE),
Some(ProgressState::CompleteHeld),
"a row that completes instantly is held full within the floor"
);
assert!(
!phase.gate_satisfied(start, MIN_VISIBLE),
"the floor keeps the row gating the panel"
);
let after = start + MIN_VISIBLE + Duration::from_millis(1);
assert_eq!(
phase.progress_state(after, MIN_VISIBLE),
Some(ProgressState::Active(Percentage::full())),
"past the floor the row renders a full bar"
);
assert!(
phase.gate_satisfied(after, MIN_VISIBLE),
"past the floor the row no longer gates"
);
}
#[test]
fn gate_satisfied_for_omitted_but_open_for_incomplete() {
let omitted: KeyedPhase<i32> = KeyedPhase::default();
assert!(
omitted.gate_satisfied(Instant::now(), MIN_VISIBLE),
"an omitted row never holds the panel"
);
let mut running: KeyedPhase<i32> = KeyedPhase::default();
running.reset_with_expected(std::iter::once(1).collect());
assert!(
!running.gate_satisfied(Instant::now(), MIN_VISIBLE),
"an incomplete row holds the panel open"
);
}
#[test]
fn growing_denominator_renders_waiting_until_stable() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.reset_growing();
phase.stamp_first_seen(Instant::now());
assert!(phase.expected.insert(1));
assert!(phase.expected.insert(2));
assert_eq!(
phase.progress_state(Instant::now(), MIN_VISIBLE),
Some(ProgressState::Waiting),
"a growing denominator renders Waiting, never a regressing bar"
);
assert!(
!phase.gate_satisfied(Instant::now(), MIN_VISIBLE),
"a waiting row holds the panel open"
);
phase.seen.insert(1);
phase.expected.stabilize();
let state = phase
.progress_state(Instant::now(), MIN_VISIBLE)
.expect("row");
assert_eq!(
state,
ProgressState::Active(Percentage::from_fraction(1, 2)),
"once stable the row renders a determinate bar"
);
}
#[test]
fn timeout_marks_failed_and_releases_the_gate() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.reset_with_expected(std::iter::once(1).collect());
let start = instant_at(0);
phase.stamp_first_seen(start);
let timeout = Duration::from_mins(2);
assert!(
phase.time_out(start, timeout).is_none(),
"no timeout before the deadline"
);
let after = start + timeout + Duration::from_secs(1);
assert!(
phase.time_out(after, timeout).is_some(),
"times out past the deadline"
);
assert!(
phase.time_out(after, timeout).is_none(),
"already failed — no second transition"
);
assert!(phase.is_failed() && phase.is_terminal());
assert_eq!(
phase.progress_state(after, MIN_VISIBLE),
Some(ProgressState::Failed)
);
assert!(
phase.gate_satisfied(after, MIN_VISIBLE),
"a failed row never holds the panel open"
);
}
#[test]
fn complete_phase_does_not_time_out() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.reset_with_expected(HashSet::new());
let start = instant_at(0);
phase.stamp_first_seen(start);
assert!(phase.complete_once(start));
let after = start + Duration::from_mins(10);
assert!(
phase.time_out(after, Duration::from_mins(2)).is_none(),
"a completed phase is immune to the timeout"
);
assert!(!phase.is_failed());
}
#[test]
fn omitted_phase_does_not_time_out() {
let mut phase: KeyedPhase<i32> = KeyedPhase::default();
phase.stamp_first_seen(instant_at(0));
let after = instant_at(0) + Duration::from_mins(10);
assert!(
phase.time_out(after, Duration::from_mins(2)).is_none(),
"an omitted (Unknown) phase never times out"
);
}
}