use diffler_ci::{JobStatus, LogStepMeta, ts_sort_key};
use ratatui::layout::Rect;
pub struct LogStep {
pub name: String,
pub status: Option<JobStatus>,
pub duration_secs: Option<i64>,
pub lines: Vec<String>,
pub folded: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogsRow {
Step(usize),
Line { step: usize, line: usize },
}
pub struct LogsView {
pub steps: Vec<LogStep>,
pub cursor: usize,
pub scroll: usize,
pub visual_anchor: Option<usize>,
pub viewport: u16,
pub body: Rect,
}
impl LogsView {
pub fn parse(raw: &str, metas: &[LogStepMeta]) -> Self {
let steps = if metas.is_empty() {
sections_by_group(raw)
} else {
sections_by_step(raw, metas)
};
Self {
steps,
cursor: 0,
scroll: 0,
visual_anchor: None,
viewport: 0,
body: Rect::default(),
}
}
#[must_use]
pub fn carry_into(self, mut next: LogsView) -> LogsView {
for step in &mut next.steps {
if step.name.is_empty() {
continue;
}
if let Some(prev) = self.steps.iter().find(|p| p.name == step.name) {
step.folded = prev.folded;
}
}
let last = next.rows().len().saturating_sub(1);
next.cursor = self.cursor.min(last);
next.scroll = self.scroll;
next.viewport = self.viewport;
next.body = self.body;
next.visual_anchor = self.visual_anchor.map(|a| a.min(last));
next
}
pub fn rows(&self) -> Vec<LogsRow> {
let mut rows = Vec::new();
for (s, step) in self.steps.iter().enumerate() {
rows.push(LogsRow::Step(s));
if !step.folded {
rows.extend((0..step.lines.len()).map(|line| LogsRow::Line { step: s, line }));
}
}
rows
}
pub fn row_text(&self, row: LogsRow) -> &str {
match row {
LogsRow::Step(s) => self.steps.get(s).map_or("", |st| st.name.as_str()),
LogsRow::Line { step, line } => self
.steps
.get(step)
.and_then(|st| st.lines.get(line))
.map_or("", String::as_str),
}
}
pub fn toggle_fold_at_cursor(&mut self) {
let rows = self.rows();
let Some(step) = rows.get(self.cursor).map(|row| match row {
LogsRow::Step(s) | LogsRow::Line { step: s, .. } => *s,
}) else {
return;
};
if let Some(st) = self.steps.get_mut(step) {
st.folded = !st.folded;
}
let last = self.rows().len().saturating_sub(1);
if let Some(pos) = self.rows().iter().position(|r| *r == LogsRow::Step(step)) {
self.cursor = pos;
}
self.visual_anchor = self.visual_anchor.map(|a| a.min(last));
}
pub fn selection(&self) -> Option<(usize, usize)> {
self.visual_anchor
.map(|a| (a.min(self.cursor), a.max(self.cursor)))
}
pub fn selection_text(&self) -> String {
let rows = self.rows();
let (lo, hi) = match self.visual_anchor {
Some(a) => (a.min(self.cursor), a.max(self.cursor)),
None => (self.cursor, self.cursor),
};
rows.iter()
.skip(lo)
.take(hi - lo + 1)
.map(|row| self.row_text(*row))
.collect::<Vec<_>>()
.join("\n")
}
}
fn sections_by_step(raw: &str, metas: &[LogStepMeta]) -> Vec<LogStep> {
let mut leading: Vec<String> = Vec::new();
let mut buckets: Vec<Vec<String>> = vec![Vec::new(); metas.len()];
for raw_line in raw.lines() {
let (key, content) = line_key_and_content(raw_line);
match metas
.iter()
.enumerate()
.filter(|(_, m)| m.start_key > 0 && m.start_key <= key)
.map(|(i, _)| i)
.next_back()
{
Some(i) => {
if let Some(bucket) = buckets.get_mut(i) {
bucket.push(content);
}
}
None => leading.push(content),
}
}
let mut steps = Vec::new();
if !leading.is_empty() {
steps.push(LogStep {
name: String::new(),
status: None,
duration_secs: None,
lines: leading,
folded: true,
});
}
for (meta, lines) in metas.iter().zip(buckets) {
steps.push(LogStep {
name: meta.name.clone(),
status: Some(meta.status),
duration_secs: meta.duration_secs,
lines,
folded: true,
});
}
steps
}
fn sections_by_group(raw: &str) -> Vec<LogStep> {
let mut steps: Vec<LogStep> = Vec::new();
let mut leading: Vec<String> = Vec::new();
for raw_line in raw.lines() {
let (_, content) = line_key_and_content(raw_line);
if let Some(name) = content.strip_prefix("##[group]") {
steps.push(LogStep {
name: name.trim().to_owned(),
status: None,
duration_secs: None,
lines: Vec::new(),
folded: true,
});
continue;
}
if content.trim_end() == "##[endgroup]" {
continue;
}
match steps.last_mut() {
Some(step) => step.lines.push(content),
None => leading.push(content),
}
}
if !leading.is_empty() {
steps.insert(
0,
LogStep {
name: String::new(),
status: None,
duration_secs: None,
lines: leading,
folded: true,
},
);
}
steps
}
fn line_key_and_content(line: &str) -> (u64, String) {
let field = line.splitn(3, '\t').nth(2).unwrap_or(line);
match field.split_once(' ') {
Some((head, rest))
if head.ends_with('Z')
&& head.len() >= 20
&& head.starts_with(|c: char| c.is_ascii_digit()) =>
{
(ts_sort_key(head), strip_ansi(rest))
}
_ => (0, strip_ansi(field)),
}
}
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const RAW: &str = "lint\tUNKNOWN STEP\t2026-06-20T00:00:00Z runner v2.335.1\n\
lint\tUNKNOWN STEP\t2026-06-20T00:00:01Z ##[group]Build\n\
lint\tUNKNOWN STEP\t2026-06-20T00:00:02Z compiling…\n\
lint\tUNKNOWN STEP\t2026-06-20T00:00:03Z \u{1b}[32mok\u{1b}[0m\n\
lint\tUNKNOWN STEP\t2026-06-20T00:00:04Z ##[endgroup]\n\
lint\tUNKNOWN STEP\t2026-06-20T00:00:05Z ##[group]Test\n\
lint\tUNKNOWN STEP\t2026-06-20T00:00:06Z running\n";
#[test]
fn parse_sections_by_group_strips_prefix_and_ansi() {
let view = LogsView::parse(RAW, &[]);
assert_eq!(view.steps.len(), 3);
assert_eq!(view.steps[0].name, "", "pre-group lines lead unnamed");
assert_eq!(view.steps[0].lines, vec!["runner v2.335.1"]);
assert_eq!(view.steps[1].name, "Build");
assert_eq!(view.steps[1].lines, vec!["compiling…", "ok"]);
assert_eq!(view.steps[2].name, "Test");
assert_eq!(view.steps[2].lines, vec!["running"]);
assert!(view.steps.iter().all(|s| s.folded));
}
#[test]
fn folded_view_shows_only_headers() {
let view = LogsView::parse(RAW, &[]);
assert_eq!(
view.rows(),
vec![LogsRow::Step(0), LogsRow::Step(1), LogsRow::Step(2)]
);
}
#[test]
fn toggle_fold_reveals_lines_and_reseats_cursor() {
let mut view = LogsView::parse(RAW, &[]);
view.cursor = 1; view.toggle_fold_at_cursor();
assert_eq!(
view.rows(),
vec![
LogsRow::Step(0),
LogsRow::Step(1),
LogsRow::Line { step: 1, line: 0 },
LogsRow::Line { step: 1, line: 1 },
LogsRow::Step(2),
]
);
view.cursor = 3; view.toggle_fold_at_cursor();
assert_eq!(
view.cursor, 1,
"cursor re-seats on the folded section header"
);
}
#[test]
fn selection_text_joins_the_visual_range() {
let mut view = LogsView::parse(RAW, &[]);
view.cursor = 1;
view.toggle_fold_at_cursor();
view.visual_anchor = Some(2);
view.cursor = 3;
assert_eq!(view.selection_text(), "compiling…\nok");
}
#[test]
fn folding_clamps_a_stale_visual_anchor() {
let mut view = LogsView::parse(RAW, &[]);
view.cursor = 1;
view.toggle_fold_at_cursor();
view.visual_anchor = Some(3);
view.cursor = 3;
view.toggle_fold_at_cursor();
let last = view.rows().len() - 1;
assert!(view.visual_anchor.is_some_and(|a| a <= last));
}
#[test]
fn carry_into_preserves_fold_state_by_name() {
let mut prev = LogsView::parse(RAW, &[]);
prev.cursor = 1;
prev.toggle_fold_at_cursor();
let next = prev.carry_into(LogsView::parse(RAW, &[]));
assert!(!next.steps[1].folded, "Build stays unfolded across re-poll");
assert!(next.steps[2].folded);
}
#[test]
fn step_metadata_buckets_lines_by_timestamp() {
let metas = vec![
LogStepMeta {
name: "Set up job".into(),
status: JobStatus::Ok,
start_key: ts_sort_key("2026-06-20T00:00:00Z"),
duration_secs: Some(1),
},
LogStepMeta {
name: "Run build".into(),
status: JobStatus::Failed,
start_key: ts_sort_key("2026-06-20T00:00:05Z"),
duration_secs: Some(13),
},
];
let view = LogsView::parse(RAW, &metas);
assert_eq!(view.steps.len(), 2, "one section per real step, no leading");
assert_eq!(view.steps[0].name, "Set up job");
assert_eq!(view.steps[0].status, Some(JobStatus::Ok));
assert!(view.steps[0].lines.iter().any(|l| l == "runner v2.335.1"));
assert!(view.steps[0].lines.iter().any(|l| l == "compiling…"));
assert_eq!(view.steps[1].name, "Run build");
assert_eq!(view.steps[1].duration_secs, Some(13));
assert!(view.steps[1].lines.iter().any(|l| l == "running"));
}
#[test]
fn a_skipped_step_mid_list_claims_no_lines() {
let metas = vec![
LogStepMeta {
name: "Set up job".into(),
status: JobStatus::Ok,
start_key: ts_sort_key("2026-06-20T00:00:00Z"),
duration_secs: Some(1),
},
LogStepMeta {
name: "Cleanup (skipped)".into(),
status: JobStatus::Skipped,
start_key: 0,
duration_secs: None,
},
LogStepMeta {
name: "Run build".into(),
status: JobStatus::Ok,
start_key: ts_sort_key("2026-06-20T00:00:05Z"),
duration_secs: Some(13),
},
];
let view = LogsView::parse(RAW, &metas);
assert!(
view.steps[0].lines.iter().any(|l| l == "compiling…"),
"early lines stay with the first real step, not the skipped one"
);
assert!(
view.steps[1].lines.is_empty(),
"skipped step claims nothing"
);
assert!(view.steps[2].lines.iter().any(|l| l == "running"));
}
}