use crate::start::{BootFrame, Outcome, StepState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowState {
Active,
Done,
Failed,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TreeRow {
pub depth: usize,
pub label: String,
pub state: RowState,
}
#[derive(Debug, Default)]
pub struct TreeState {
units: Vec<Unit>,
}
#[derive(Debug)]
struct Unit {
project: String,
service: String,
state: RowState,
steps: Vec<Step>,
}
#[derive(Debug)]
struct Step {
id: String,
label: String,
state: RowState,
}
impl TreeState {
pub fn new() -> Self {
Self::default()
}
pub fn apply(&mut self, frame: &BootFrame) {
match frame {
BootFrame::UnitStarting { project, service } => {
self.unit_mut(project, service).state = RowState::Active;
}
BootFrame::Unit {
project,
service,
outcome,
} => {
let state = match outcome {
Outcome::Failed(_) => RowState::Failed,
_ => RowState::Done,
};
let unit = self.unit_mut(project, service);
unit.state = state;
for step in &mut unit.steps {
if step.state == RowState::Active {
step.state = state;
}
}
}
BootFrame::UnitStep {
project,
service,
id,
label,
state,
} => {
let state = match state {
StepState::Active => RowState::Active,
StepState::Done => RowState::Done,
StepState::Failed => RowState::Failed,
};
let unit = self.unit_mut(project, service);
match unit.steps.iter_mut().find(|step| step.id == *id) {
Some(existing) => {
existing.label = label.clone();
existing.state = state;
}
None => unit.steps.push(Step {
id: id.clone(),
label: label.clone(),
state,
}),
}
}
BootFrame::Done { .. } => {}
}
}
pub fn rows(&self) -> Vec<TreeRow> {
let mut rows = Vec::new();
for unit in &self.units {
rows.push(TreeRow {
depth: 0,
label: unit.service.clone(),
state: unit.state,
});
for step in &unit.steps {
rows.push(TreeRow {
depth: 1,
label: step.label.clone(),
state: step.state,
});
}
}
rows
}
pub fn is_active(&self) -> bool {
self.units.iter().any(|unit| {
unit.state == RowState::Active
|| unit.steps.iter().any(|step| step.state == RowState::Active)
})
}
fn unit_mut(&mut self, project: &str, service: &str) -> &mut Unit {
if let Some(index) = self
.units
.iter()
.position(|unit| unit.service == service && unit.project == project)
{
return &mut self.units[index];
}
self.units.push(Unit {
project: project.to_string(),
service: service.to_string(),
state: RowState::Active,
steps: Vec::new(),
});
self.units.last_mut().expect("just pushed")
}
}
pub fn fit_rows(rows: &[TreeRow], height: usize) -> (Vec<TreeRow>, usize) {
if height == 0 {
return (Vec::new(), rows.len());
}
if rows.len() <= height {
return (rows.to_vec(), 0);
}
let mut start = rows.len() - height;
while start > 0 && rows[start].depth > 0 {
start -= 1;
}
if rows.len() - start > height {
let owner = start;
let mut next = start + 1;
while next < rows.len() && rows[next].depth > 0 {
next += 1;
}
if rows.len() - next <= height && next < rows.len() {
return (rows[next..].to_vec(), next);
}
if height == 1 {
return (vec![rows[owner].clone()], rows.len() - 1);
}
let mut kept = vec![rows[owner].clone()];
let tail = rows.len() - (height - 1);
kept.extend_from_slice(&rows[tail..]);
return (kept, rows.len() - height);
}
(rows[start..].to_vec(), start)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::start::Liveness;
fn starting(service: &str) -> BootFrame {
BootFrame::UnitStarting {
project: "p".into(),
service: service.into(),
}
}
fn finished(service: &str, outcome: Outcome) -> BootFrame {
BootFrame::Unit {
project: "p".into(),
service: service.into(),
outcome,
}
}
fn step(service: &str, id: &str, label: &str, state: StepState) -> BootFrame {
BootFrame::UnitStep {
project: "p".into(),
service: service.into(),
id: id.into(),
label: label.into(),
state,
}
}
fn reduce(frames: &[BootFrame]) -> Vec<TreeRow> {
let mut tree = TreeState::new();
for frame in frames {
tree.apply(frame);
}
tree.rows()
}
#[test]
fn units_keep_the_order_they_were_worked() {
let rows = reduce(&[
starting("migrations"),
finished("migrations", Outcome::Completed),
starting("api"),
starting("worker"),
]);
let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect();
assert_eq!(labels, ["migrations", "api", "worker"]);
assert_eq!(rows[0].state, RowState::Done);
assert_eq!(rows[1].state, RowState::Active);
}
#[test]
fn a_step_nests_under_its_unit_and_resolves_before_it() {
let rows = reduce(&[
starting("api"),
step(
"api",
"health",
"health check (attempt 8)",
StepState::Active,
),
]);
assert_eq!(rows[0].depth, 0);
assert_eq!(rows[1].depth, 1);
assert_eq!(rows[1].state, RowState::Active);
let rows = reduce(&[
starting("api"),
step(
"api",
"health",
"health check (attempt 8)",
StepState::Active,
),
step("api", "health", "health check", StepState::Done),
]);
assert_eq!(rows[1].state, RowState::Done);
assert_eq!(
rows[0].state,
RowState::Active,
"the unit is still working after its step resolved"
);
}
#[test]
fn a_progressing_step_replaces_its_row_rather_than_appending() {
let rows = reduce(&[
starting("api"),
step(
"api",
"health",
"health check (attempt 1)",
StepState::Active,
),
step(
"api",
"health",
"health check (attempt 2)",
StepState::Active,
),
step(
"api",
"health",
"health check (attempt 3)",
StepState::Active,
),
]);
assert_eq!(rows.len(), 2, "one unit row and ONE step row");
assert_eq!(rows[1].label, "health check (attempt 3)");
}
#[test]
fn a_failed_health_check_marks_the_step_and_its_unit() {
let rows = reduce(&[
starting("api"),
step("api", "health", "health check", StepState::Active),
step(
"api",
"health",
"health check (7 attempts)",
StepState::Failed,
),
finished(
"api",
Outcome::Failed(crate::start::unit_start_failed("api", "unhealthy")),
),
]);
assert_eq!(rows[0].state, RowState::Failed);
assert_eq!(rows[1].state, RowState::Failed);
}
#[test]
fn a_skipped_unit_is_done_not_failed() {
let rows = reduce(&[finished("legacy", Outcome::Skipped)]);
assert_eq!(
rows[0].state,
RowState::Done,
"an intentional skip is not a failure"
);
}
#[test]
fn a_stopped_unit_is_done() {
let rows = reduce(&[starting("api"), finished("api", Outcome::Stopped)]);
assert_eq!(rows[0].state, RowState::Done);
}
#[test]
fn a_unit_that_finishes_never_leaves_a_step_spinning() {
let rows = reduce(&[
starting("api"),
step("api", "dep", "dependency 'migrations'", StepState::Active),
finished("api", Outcome::Up(Liveness { pid: 42 })),
]);
assert_eq!(rows[1].state, RowState::Done);
assert!(
!TreeState::new().is_active(),
"an empty tree has nothing in flight"
);
}
#[test]
fn is_active_tracks_whether_anything_is_still_running() {
let mut tree = TreeState::new();
tree.apply(&starting("api"));
assert!(tree.is_active());
tree.apply(&finished("api", Outcome::Up(Liveness { pid: 1 })));
assert!(!tree.is_active());
}
#[test]
fn fit_rows_keeps_everything_when_it_fits() {
let rows = reduce(&[starting("a"), starting("b")]);
let (shown, hidden) = fit_rows(&rows, 10);
assert_eq!(shown.len(), 2);
assert_eq!(hidden, 0);
}
#[test]
fn fit_rows_drops_oldest_finished_work_first() {
let rows = reduce(&[
finished("a", Outcome::Completed),
finished("b", Outcome::Completed),
finished("c", Outcome::Completed),
starting("d"),
]);
let (shown, hidden) = fit_rows(&rows, 2);
assert_eq!(hidden, 2, "the two oldest rows are elided");
assert_eq!(shown.len(), 2);
assert_eq!(shown.last().expect("a row").label, "d");
}
#[test]
fn fit_rows_never_shows_a_step_without_its_unit() {
let rows = reduce(&[
finished("a", Outcome::Completed),
starting("b"),
step("b", "health", "health check", StepState::Active),
]);
let (shown, _) = fit_rows(&rows, 2);
assert!(
shown.first().is_none_or(|row| row.depth == 0),
"the first shown row must be a unit, never a dangling step"
);
}
#[test]
fn fit_rows_never_exceeds_height_when_walking_back_to_a_unit() {
let rows = reduce(&[
finished("a", Outcome::Completed),
starting("b"),
step("b", "dep", "waiting for dep", StepState::Active),
step("b", "health", "health check", StepState::Active),
]);
let (shown, hidden) = fit_rows(&rows, 2);
assert!(shown.len() <= 2, "kept {} rows for height 2", shown.len());
assert_eq!(
hidden + shown.len(),
rows.len(),
"every row is kept or counted"
);
assert!(
shown.first().is_none_or(|row| row.depth == 0),
"the first shown row must be a unit, never a dangling step"
);
}
#[test]
fn fit_rows_fits_a_single_subtree_taller_than_the_terminal() {
let rows = reduce(&[
starting("solo"),
step("solo", "one", "step one", StepState::Active),
step("solo", "two", "step two", StepState::Active),
step("solo", "three", "step three", StepState::Active),
]);
let (shown, hidden) = fit_rows(&rows, 2);
assert_eq!(shown.len(), 2);
assert_eq!(hidden, 2);
}
}