use crate::tui::{BuildEvent, PlannedStage};
pub struct InstructionProgress {
items: Vec<(usize, usize, String)>,
pos: usize,
}
impl InstructionProgress {
#[must_use]
pub fn new(planned: Vec<(usize, usize, String)>) -> Self {
Self {
items: planned,
pos: 0,
}
}
#[must_use]
pub fn from_planned_stages(stages: &[PlannedStage]) -> Self {
let mut items = Vec::new();
for (stage_idx, stage) in stages.iter().enumerate() {
for (inst_idx, text) in stage.instructions.iter().enumerate() {
items.push((stage_idx, inst_idx, text.clone()));
}
}
Self::new(items)
}
#[must_use]
pub fn current(&self) -> Option<(usize, usize)> {
self.items.get(self.pos).map(|(s, i, _)| (*s, *i))
}
#[must_use]
pub fn current_text(&self) -> &str {
self.items.get(self.pos).map_or("", |(_, _, t)| t.as_str())
}
#[must_use]
pub fn start_first(&self) -> Vec<BuildEvent> {
match self.current() {
Some((stage, index)) => vec![BuildEvent::InstructionStarted {
stage,
index,
instruction: self.current_text().to_string(),
}],
None => Vec::new(),
}
}
fn advance(&mut self) -> Option<usize> {
let prev_stage = self.items.get(self.pos).map(|(s, _, _)| *s);
if self.pos < self.items.len() {
self.pos += 1;
}
prev_stage
}
#[must_use]
pub fn on_line(&mut self, line: &str, is_stderr: bool) -> Vec<BuildEvent> {
let mut events = vec![BuildEvent::Output {
line: line.to_string(),
is_stderr,
}];
let trimmed = line.trim_start();
if !trimmed.starts_with("--> ") {
return events;
}
let cached = trimmed.contains("Using cache");
let Some((stage, index)) = self.current() else {
return events;
};
events.push(BuildEvent::InstructionComplete {
stage,
index,
cached,
});
let prev_stage = self.advance();
let next = self.current();
if let Some(prev) = prev_stage {
let entering_new_stage = next.is_none_or(|(s, _)| s != prev);
if entering_new_stage {
events.push(BuildEvent::StageComplete { index: prev });
}
}
if let Some((stage, index)) = next {
events.push(BuildEvent::InstructionStarted {
stage,
index,
instruction: self.current_text().to_string(),
});
}
events
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plan_two_stages() -> Vec<PlannedStage> {
vec![
PlannedStage {
name: Some("builder".to_string()),
base_image: "alpine".to_string(),
instructions: vec!["RUN echo a".to_string(), "RUN echo b".to_string()],
},
PlannedStage {
name: None,
base_image: "alpine".to_string(),
instructions: vec!["COPY --from=builder /a /a".to_string()],
},
]
}
#[test]
fn cursor_flattens_in_dockerfile_order() {
let progress = InstructionProgress::from_planned_stages(&plan_two_stages());
assert_eq!(progress.current(), Some((0, 0)));
assert_eq!(progress.current_text(), "RUN echo a");
assert_eq!(progress.items.len(), 3);
}
#[test]
fn commit_markers_advance_statuses_and_cross_stage_boundary() {
let mut progress = InstructionProgress::from_planned_stages(&plan_two_stages());
let mut events = Vec::new();
events.extend(progress.on_line("--> abc123", false));
events.extend(progress.on_line("--> Using cache def456", false));
events.extend(progress.on_line("--> 789aaa", false));
events.extend(progress.on_line("--> extra", false));
let outputs = events
.iter()
.filter(|e| matches!(e, BuildEvent::Output { .. }))
.count();
assert_eq!(outputs, 4);
let completes: Vec<_> = events
.iter()
.filter_map(|e| match e {
BuildEvent::InstructionComplete {
stage,
index,
cached,
} => Some((*stage, *index, *cached)),
_ => None,
})
.collect();
assert_eq!(completes, vec![(0, 0, false), (0, 1, true), (1, 0, false)]);
let stage_completes: Vec<_> = events
.iter()
.filter_map(|e| match e {
BuildEvent::StageComplete { index } => Some(*index),
_ => None,
})
.collect();
assert_eq!(stage_completes, vec![0, 1]);
let starts: Vec<_> = events
.iter()
.filter_map(|e| match e {
BuildEvent::InstructionStarted { stage, index, .. } => Some((*stage, *index)),
_ => None,
})
.collect();
assert_eq!(starts, vec![(0, 1), (1, 0)]);
}
#[test]
fn non_marker_log_lines_do_not_advance_cursor() {
let mut progress = InstructionProgress::from_planned_stages(&plan_two_stages());
let mut events = Vec::new();
events.extend(progress.on_line("STEP 1/3: RUN echo a", false));
events.extend(progress.on_line("some build output", true));
assert_eq!(events.len(), 2);
assert!(events
.iter()
.all(|e| matches!(e, BuildEvent::Output { .. })));
assert_eq!(progress.current(), Some((0, 0)));
}
}