Skip to main content

rom_core/
display.rs

1//! Display rendering for ROM
2use std::{
3  collections::HashSet,
4  io::{self, Write},
5};
6
7use crossterm::{
8  cursor,
9  execute,
10  style::{Color, ResetColor, SetForegroundColor},
11};
12
13use crate::{
14  icons::Icons,
15  state::{BuildStatus, DerivationId, State, current_time},
16  types::{LegendStyle, SummaryStyle},
17};
18
19/// Format a duration in seconds to a human-readable string
20#[must_use]
21pub fn format_duration(secs: f64) -> String {
22  if secs < 60.0 {
23    format!("{secs:.0}s")
24  } else if secs < 3600.0 {
25    format!("{:.0}m{:.0}s", secs / 60.0, secs % 60.0)
26  } else {
27    format!("{:.0}h{:.0}m", secs / 3600.0, (secs % 3600.0) / 60.0)
28  }
29}
30
31pub struct DisplayConfig {
32  pub show_timers:       bool,
33  pub max_tree_depth:    usize,
34  pub max_visible_lines: usize,
35  pub use_color:         bool,
36  pub format:            crate::types::DisplayFormat,
37  pub legend_style:      LegendStyle,
38  pub summary_style:     SummaryStyle,
39  pub icons:             &'static Icons,
40}
41
42impl Default for DisplayConfig {
43  fn default() -> Self {
44    Self {
45      show_timers:       true,
46      max_tree_depth:    10,
47      max_visible_lines: 100,
48      use_color:         true,
49      format:            crate::types::DisplayFormat::Tree,
50      legend_style:      LegendStyle::Table,
51      summary_style:     SummaryStyle::Concise,
52      icons:             crate::icons::detect(),
53    }
54  }
55}
56
57pub struct Display<W: Write> {
58  writer:            W,
59  config:            DisplayConfig,
60  /// Number of graph lines printed in the last render (cleared on next render)
61  last_lines:        usize,
62  /// Total log lines already printed (they scroll naturally, never cleared)
63  printed_log_lines: usize,
64}
65
66struct TreeNode {
67  drv_id:   DerivationId,
68  children: Vec<Self>,
69}
70
71impl<W: Write> Display<W> {
72  pub const fn new(writer: W, config: DisplayConfig) -> io::Result<Self> {
73    Ok(Self {
74      writer,
75      config,
76      last_lines: 0,
77      printed_log_lines: 0,
78    })
79  }
80
81  pub fn clear_previous(&mut self) -> io::Result<()> {
82    if self.last_lines > 0 {
83      // Move up in a single escape sequence, then clear to end of screen.
84      // This is much cheaper than calling MoveUp(1) in a loop because it
85      // produces one write + one flush instead of N.
86      execute!(
87        self.writer,
88        cursor::MoveToColumn(0),
89        cursor::MoveUp(self.last_lines as u16),
90        cursor::MoveToColumn(0),
91        crossterm::terminal::Clear(
92          crossterm::terminal::ClearType::FromCursorDown
93        )
94      )?;
95    }
96    Ok(())
97  }
98
99  pub fn render(&mut self, state: &State, logs: &[String]) -> io::Result<()> {
100    // Print any log lines that arrived since last render. These are printed
101    // once and scroll up naturally, we never clear them.
102    let new_logs = &logs[self.printed_log_lines.min(logs.len())..];
103    if !new_logs.is_empty() {
104      // Clear the current graph first so new logs appear above it
105      self.clear_previous()?;
106      let mut log_out = String::with_capacity(new_logs.len() * 80);
107      for line in new_logs {
108        log_out.push_str(line);
109        log_out.push('\n');
110      }
111      self.writer.write_all(log_out.as_bytes())?;
112      self.printed_log_lines = logs.len();
113      self.last_lines = 0; // graph was cleared above
114    }
115
116    // Clear only the graph from the previous render
117    self.clear_previous()?;
118
119    // Build graph lines
120    let mut graph_lines = match self.config.format {
121      crate::types::DisplayFormat::Tree => {
122        let tree_lines = self.render_tree_view(state);
123        let has_tree = !tree_lines.is_empty();
124        let mut g = tree_lines;
125        g.extend(self.render_legend(state, has_tree));
126        g
127      },
128      crate::types::DisplayFormat::Plain => self.render_plain_view(state),
129      crate::types::DisplayFormat::Dashboard => {
130        self.render_dashboard_view(state)
131      },
132    };
133
134    if graph_lines.len() > self.config.max_visible_lines {
135      graph_lines.truncate(self.config.max_visible_lines);
136    }
137
138    self.last_lines = graph_lines.len();
139
140    let mut out = String::with_capacity(graph_lines.len() * 80);
141    for line in &graph_lines {
142      out.push_str(line);
143      out.push('\n');
144    }
145    self.writer.write_all(out.as_bytes())?;
146    self.writer.flush()
147  }
148
149  pub fn render_final(&mut self, state: &State) -> io::Result<()> {
150    tracing::debug!("render_final called");
151
152    // Clear any previous render
153    self.clear_previous()?;
154
155    let mut lines = Vec::new();
156
157    // Render final output based on format
158    match self.config.format {
159      crate::types::DisplayFormat::Tree => {
160        // render_tree_view already includes its own header line; only extend if
161        // there are actually active (building/failed) derivations to show
162        let tree_lines = self.render_tree_view(state);
163        lines.extend(tree_lines);
164        lines.extend(self.render_final_summary(state));
165      },
166      crate::types::DisplayFormat::Plain => {
167        lines.extend(self.render_plain_view(state));
168        lines.extend(self.render_final_summary(state));
169      },
170      crate::types::DisplayFormat::Dashboard => {
171        lines.extend(self.render_dashboard_final(state));
172      },
173    }
174
175    tracing::debug!("render_final: {} lines to print", lines.len());
176
177    // Print final output (don't track last_lines since this is final)
178    for line in lines {
179      writeln!(self.writer, "{line}")?;
180    }
181
182    writeln!(self.writer)?;
183    self.writer.flush()?;
184
185    Ok(())
186  }
187
188  fn render_final_summary(&self, state: &State) -> Vec<String> {
189    match self.config.summary_style {
190      SummaryStyle::Concise => self.render_finished_line(state),
191      SummaryStyle::Table => self.render_table_summary(state),
192      SummaryStyle::Full => self.render_full_summary(state),
193    }
194  }
195
196  /// Renders the final single-line summary
197  fn render_finished_line(&self, state: &State) -> Vec<String> {
198    let failed = state.full_summary.failed_builds.len();
199    let completed = state.full_summary.completed_builds.len();
200    let nix_errors = state.nix_errors.len();
201    let duration = current_time() - state.start_time;
202    let now = chrono::Local::now();
203    let at = now.format("%H:%M:%S");
204    let dur = self.format_duration(duration);
205
206    let ic = self.ic();
207    let line = if failed > 0 {
208      let noun = if failed == 1 { "failure" } else { "failures" };
209      format!(
210        "{} {} at {} after {}",
211        self.colored(ic.failed, Color::DarkRed),
212        self.colored(
213          &format!("Exited after {failed} build {noun}"),
214          Color::DarkRed
215        ),
216        self.colored(&at.to_string(), Color::DarkRed),
217        self.colored(&dur, Color::DarkRed),
218      )
219    } else if nix_errors > 0 {
220      let noun = if nix_errors == 1 { "error" } else { "errors" };
221      format!(
222        "{} {} at {} after {}",
223        self.colored(ic.failed, Color::DarkRed),
224        self.colored(
225          &format!("Exited with {nix_errors} nix {noun}"),
226          Color::DarkRed
227        ),
228        self.colored(&at.to_string(), Color::DarkRed),
229        self.colored(&dur, Color::DarkRed),
230      )
231    } else {
232      let mut s = format!(
233        "{} after {}",
234        self.colored(&format!("Finished at {at}"), Color::DarkGreen),
235        self.colored(&dur, Color::DarkGreen),
236      );
237      if completed > 0 {
238        s.push_str(&format!(
239          "  {} {completed}",
240          self.colored(ic.done, Color::DarkGreen)
241        ));
242      }
243      s
244    };
245
246    vec![line]
247  }
248
249  fn render_table_summary(&self, state: &State) -> Vec<String> {
250    let completed = state.full_summary.completed_builds.len();
251    let failed = state.full_summary.failed_builds.len();
252    let dl_done = state.full_summary.completed_downloads.len();
253    let ul_done = state.full_summary.completed_uploads.len();
254    let duration = current_time() - state.start_time;
255    let now = chrono::Local::now();
256    let at = now.format("%H:%M:%S");
257    let dur = self.format_duration(duration);
258
259    if completed + failed + dl_done + ul_done == 0 {
260      return self.render_finished_line(state);
261    }
262
263    // Collect host breakdown
264    let mut host_map: std::collections::HashMap<String, (usize, usize)> =
265      std::collections::HashMap::new();
266    for b in state.full_summary.completed_builds.values() {
267      host_map.entry(b.host.name().to_string()).or_default().0 += 1;
268    }
269    for b in state.full_summary.failed_builds.values() {
270      host_map.entry(b.host.name().to_string()).or_default().1 += 1;
271    }
272    let many_hosts = host_map.len() > 1;
273
274    let mut lines = Vec::new();
275
276    // Header
277    let mut hdr_parts = Vec::new();
278    if completed + failed > 0 {
279      hdr_parts.push("Builds");
280    }
281    if dl_done > 0 {
282      hdr_parts.push("Downloads");
283    }
284    if ul_done > 0 {
285      hdr_parts.push("Uploads");
286    }
287    let ic = self.ic();
288    lines.push(format!(
289      "{} {}",
290      self.colored("┏━━━", Color::DarkBlue),
291      hdr_parts.join("  ")
292    ));
293
294    // Per-host rows when multiple hosts
295    if many_hosts {
296      let mut hosts: Vec<_> = host_map.keys().cloned().collect();
297      hosts.sort();
298      for host in &hosts {
299        let (done, fail) = host_map[host];
300        let mut parts = Vec::new();
301        if done > 0 {
302          parts.push(format!(
303            "{} {done}",
304            self.colored(ic.done, Color::DarkGreen)
305          ));
306        }
307        if fail > 0 {
308          parts.push(format!(
309            "{} {fail}",
310            self.colored(ic.failed, Color::DarkRed)
311          ));
312        }
313        lines.push(format!(
314          "{}  {}  {}",
315          self.colored("┃", Color::DarkBlue),
316          parts.join("  "),
317          self.colored(host, Color::DarkMagenta),
318        ));
319      }
320    }
321
322    // Final ∑ line
323    let mut sum_parts = Vec::new();
324    if completed > 0 {
325      sum_parts.push(format!(
326        "{} {completed}",
327        self.colored(ic.done, Color::DarkGreen)
328      ));
329    }
330    if failed > 0 {
331      sum_parts.push(format!(
332        "{} {failed}",
333        self.colored(ic.failed, Color::DarkRed)
334      ));
335    }
336    if dl_done > 0 {
337      sum_parts.push(format!(
338        "{} {dl_done}",
339        self.colored(ic.download, Color::DarkGreen)
340      ));
341    }
342    if ul_done > 0 {
343      sum_parts.push(format!(
344        "{} {ul_done}",
345        self.colored(ic.upload, Color::DarkGreen)
346      ));
347    }
348
349    let finish = if failed > 0 || !state.nix_errors.is_empty() {
350      self.colored(&format!("Exited at {at} after {dur}"), Color::DarkRed)
351    } else {
352      self.colored(&format!("Finished at {at} after {dur}"), Color::DarkGreen)
353    };
354    sum_parts.push(finish);
355
356    lines.push(format!(
357      "{} ∑ {}",
358      self.colored("┗━", Color::DarkBlue),
359      sum_parts.join("  │  ")
360    ));
361
362    lines
363  }
364
365  fn render_full_summary(&self, state: &State) -> Vec<String> {
366    let completed = state.full_summary.completed_builds.len();
367    let failed = state.full_summary.failed_builds.len();
368    let dl_done = state.full_summary.completed_downloads.len();
369    let dl_running = state.full_summary.running_downloads.len();
370    let ul_done = state.full_summary.completed_uploads.len();
371    let ul_running = state.full_summary.running_uploads.len();
372    let duration = current_time() - state.start_time;
373    let now = chrono::Local::now();
374    let at = now.format("%H:%M:%S");
375
376    let v = self.colored("┃", Color::DarkBlue);
377
378    let mut lines = Vec::new();
379    lines.push(format!(
380      "{} Build Summary",
381      self.colored("┏━━━", Color::DarkBlue)
382    ));
383
384    let ic = self.ic();
385    if completed > 0 || failed > 0 {
386      let mut bp = Vec::new();
387      if completed > 0 {
388        bp.push(format!(
389          "{} {completed} built",
390          self.colored(ic.done, Color::DarkGreen)
391        ));
392      }
393      if failed > 0 {
394        bp.push(format!(
395          "{} {failed} failed",
396          self.colored(ic.failed, Color::DarkRed)
397        ));
398      }
399      lines.push(format!("{}  Builds:     {}", v, bp.join("  ")));
400    }
401
402    let total_dl = dl_done + dl_running;
403    let total_ul = ul_done + ul_running;
404    if total_dl > 0 {
405      lines.push(format!(
406        "{}  Downloads:  {} fetched",
407        v,
408        self.colored(&total_dl.to_string(), Color::DarkGreen)
409      ));
410    }
411    if total_ul > 0 {
412      lines.push(format!(
413        "{}  Uploads:    {} pushed",
414        v,
415        self.colored(&total_ul.to_string(), Color::DarkGreen)
416      ));
417    }
418
419    if !state.nix_errors.is_empty() {
420      lines.push(format!(
421        "{}  {} {} nix error(s)",
422        v,
423        self.colored(ic.failed, Color::DarkRed),
424        state.nix_errors.len()
425      ));
426    }
427
428    let finish_label = if failed > 0 || !state.nix_errors.is_empty() {
429      self.colored(&format!("Exited at {at}"), Color::DarkRed)
430    } else {
431      self.colored(&format!("Finished at {at}"), Color::DarkGreen)
432    };
433    lines.push(format!(
434      "{} {} after {}",
435      self.colored("┗━", Color::DarkBlue),
436      finish_label,
437      self.colored(&self.format_duration(duration), Color::DarkGrey),
438    ));
439
440    lines
441  }
442
443  fn render_legend(&self, state: &State, has_tree: bool) -> Vec<String> {
444    match self.config.legend_style {
445      LegendStyle::Compact => self.render_compact_legend(state),
446      LegendStyle::Table => self.render_table_legend(state, has_tree),
447      LegendStyle::Verbose => self.render_verbose_legend(state, has_tree),
448    }
449  }
450
451  fn render_compact_legend(&self, state: &State) -> Vec<String> {
452    let running = state.full_summary.running_builds.len();
453    let completed = state.full_summary.completed_builds.len();
454    let failed = state.full_summary.failed_builds.len();
455    let planned = state.full_summary.planned_builds.len();
456    let dl = state.full_summary.running_downloads.len();
457    let ul = state.full_summary.running_uploads.len();
458
459    if running + completed + failed + planned + dl + ul == 0 {
460      return vec![];
461    }
462
463    let duration = current_time() - state.start_time;
464    let ic = self.ic();
465
466    // Always emit ⏵ │ ✔ │ ✗ │ ⏸, dim zeros
467    let mut parts: Vec<String> = Vec::new();
468    parts.push(self.count_colored(ic.running, running, Color::DarkYellow));
469    parts.push(self.count_colored(ic.done, completed, Color::DarkGreen));
470    parts.push(self.count_colored(ic.failed, failed, Color::DarkRed));
471    parts.push(self.count_colored(ic.planned, planned, Color::DarkBlue));
472    if dl > 0 {
473      parts.push(format!(
474        "{} {dl}",
475        self.colored(ic.download, Color::DarkYellow)
476      ));
477    }
478    if ul > 0 {
479      parts.push(format!(
480        "{} {ul}",
481        self.colored(ic.upload, Color::DarkYellow)
482      ));
483    }
484    parts.push(format!(
485      "{} {}",
486      self.colored(ic.clock, Color::DarkGrey),
487      self.colored(&self.format_duration(duration), Color::DarkGrey),
488    ));
489
490    vec![format!(
491      "{} {}",
492      self.colored("┗━", Color::DarkBlue),
493      parts.join(" │ ")
494    )]
495  }
496
497  fn render_table_legend(&self, state: &State, has_tree: bool) -> Vec<String> {
498    let running = state.full_summary.running_builds.len();
499    let completed = state.full_summary.completed_builds.len();
500    let failed = state.full_summary.failed_builds.len();
501    let planned = state.full_summary.planned_builds.len();
502    let dl_running = state.full_summary.running_downloads.len();
503    let dl_done = state.full_summary.completed_downloads.len();
504    let ul_running = state.full_summary.running_uploads.len();
505    let ul_done = state.full_summary.completed_uploads.len();
506
507    let show_builds = running + completed + failed + planned > 0;
508    let show_dl = dl_running + dl_done > 0;
509    let show_ul = ul_running + ul_done > 0;
510
511    if !show_builds && !show_dl && !show_ul {
512      return vec![];
513    }
514
515    let now = current_time();
516    let duration = now - state.start_time;
517    let v = self.colored("┃", Color::DarkBlue);
518
519    // Build header section label(s)
520    let mut header_parts: Vec<&str> = Vec::new();
521    if show_builds {
522      header_parts.push("Builds");
523    }
524    if show_dl {
525      header_parts.push("Downloads");
526    }
527    if show_ul {
528      header_parts.push("Uploads");
529    }
530
531    let mut lines = Vec::new();
532
533    // ┏━━━ header (or ┣━━━ when appended below a tree)
534    let header_prefix = if has_tree {
535      "┣━━━"
536    } else {
537      "┏━━━"
538    };
539    lines.push(format!(
540      "{} {}",
541      self.colored(header_prefix, Color::DarkBlue),
542      header_parts.join("  ")
543    ));
544
545    // Per-running-build rows
546    let mut running_entries: Vec<(String, f64, String)> = state
547      .full_summary
548      .running_builds
549      .iter()
550      .filter_map(|(drv_id, build)| {
551        let info = state.get_derivation_info(*drv_id)?;
552        let elapsed = now - build.start;
553        let host_label = match &build.host {
554          cognos::Host::Remote(h) => {
555            format!("  on {}", self.colored(h, Color::DarkMagenta))
556          },
557          _ => String::new(),
558        };
559        Some((info.name.name.clone(), elapsed, host_label))
560      })
561      .collect();
562    // Longest running first
563    running_entries.sort_by(|a, b| {
564      b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
565    });
566
567    let dl_name_width = state
568      .full_summary
569      .running_downloads
570      .keys()
571      .filter_map(|id| {
572        state.store_path_infos.get(id).map(|pi| pi.name.name.len())
573      })
574      .max()
575      .unwrap_or(0);
576
577    let name_width = running_entries
578      .iter()
579      .map(|(n, ..)| n.len())
580      .chain(std::iter::once(dl_name_width))
581      .max()
582      .unwrap_or(0)
583      .min(48);
584
585    // Show per-item rows only when not already shown in the tree above.
586    // When has_tree=true the active builds are visible there; the legend
587    // only needs to supply the ∑ summary line.
588    if !has_tree {
589      let ic = self.ic();
590      for (name, elapsed, host_label) in &running_entries {
591        lines.push(format!(
592          "{}  {} {:<width$}  {} {}{}",
593          v,
594          self.colored(ic.running, Color::DarkYellow),
595          self.truncate_name(name, name_width),
596          self.colored(ic.clock, Color::DarkGrey),
597          self.colored(&self.format_duration(*elapsed), Color::DarkGrey),
598          host_label,
599          width = name_width,
600        ));
601      }
602
603      // Per-running-download rows
604      for (path_id, transfer) in &state.full_summary.running_downloads {
605        if let Some(pi) = state.store_path_infos.get(path_id) {
606          let elapsed = now - transfer.start;
607          let size_str = if let Some(total) = transfer.total_bytes {
608            self.format_bytes(transfer.bytes_transferred, total)
609          } else {
610            format!("{} B", transfer.bytes_transferred)
611          };
612          lines.push(format!(
613            "{}  {} {:<width$}  {} {} {}",
614            v,
615            self.colored(ic.download, Color::DarkYellow),
616            self.truncate_name(&pi.name.name, name_width),
617            self.colored(&size_str, Color::DarkGrey),
618            self.colored(ic.clock, Color::DarkGrey),
619            self.colored(&self.format_duration(elapsed), Color::DarkGrey),
620            width = name_width,
621          ));
622        }
623      }
624
625      // Per-running-upload rows
626      for (path_id, transfer) in &state.full_summary.running_uploads {
627        if let Some(pi) = state.store_path_infos.get(path_id) {
628          let elapsed = now - transfer.start;
629          lines.push(format!(
630            "{}  {} {:<width$}  {} {}",
631            v,
632            self.colored(ic.upload, Color::DarkYellow),
633            self.truncate_name(&pi.name.name, name_width),
634            self.colored(ic.clock, Color::DarkGrey),
635            self.colored(&self.format_duration(elapsed), Color::DarkGrey),
636            width = name_width,
637          ));
638        }
639      }
640    }
641
642    // Always emit all three build-state columns; counts are shown
643    // even when zero, just dimmed to grey.
644    let ic = self.ic();
645    let mut sum_parts: Vec<String> = Vec::new();
646    if show_builds {
647      sum_parts.push(self.count_colored(
648        ic.running,
649        running,
650        Color::DarkYellow,
651      ));
652      sum_parts.push(self.count_colored(ic.done, completed, Color::DarkGreen));
653      sum_parts.push(self.count_colored(ic.failed, failed, Color::DarkRed));
654      sum_parts.push(self.count_colored(ic.planned, planned, Color::DarkBlue));
655    }
656    if show_dl {
657      // Two sub-columns: running (yellow) and done (green)
658      if dl_running > 0 || dl_done > 0 {
659        sum_parts.push(format!(
660          "{} {}",
661          self.colored(ic.download, Color::DarkGrey),
662          [
663            (dl_running > 0).then(|| {
664              self.count_colored(ic.running, dl_running, Color::DarkYellow)
665            }),
666            (dl_done > 0)
667              .then(|| self.count_colored(ic.done, dl_done, Color::DarkGreen)),
668          ]
669          .into_iter()
670          .flatten()
671          .collect::<Vec<_>>()
672          .join(" "),
673        ));
674      }
675    }
676    if show_ul && (ul_running > 0 || ul_done > 0) {
677      sum_parts.push(format!(
678        "{} {}",
679        self.colored(ic.upload, Color::DarkGrey),
680        [
681          (ul_running > 0).then(|| {
682            self.count_colored(ic.running, ul_running, Color::DarkYellow)
683          }),
684          (ul_done > 0)
685            .then(|| self.count_colored(ic.done, ul_done, Color::DarkGreen)),
686        ]
687        .into_iter()
688        .flatten()
689        .collect::<Vec<_>>()
690        .join(" "),
691      ));
692    }
693    // Elapsed with clock icon
694    sum_parts.push(format!(
695      "{} {}",
696      self.colored(ic.clock, Color::DarkGrey),
697      self.colored(&self.format_duration(duration), Color::DarkGrey),
698    ));
699
700    // ┗━ ∑  [summary]
701    lines.push(format!(
702      "{} {} {}",
703      self.colored("┗━", Color::DarkBlue),
704      self.colored(ic.summary, Color::DarkGrey),
705      sum_parts.join(" │ ")
706    ));
707
708    lines
709  }
710
711  fn render_verbose_legend(
712    &self,
713    state: &State,
714    has_tree: bool,
715  ) -> Vec<String> {
716    let running = state.full_summary.running_builds.len();
717    let completed = state.full_summary.completed_builds.len();
718    let failed = state.full_summary.failed_builds.len();
719    let planned = state.full_summary.planned_builds.len();
720    let dl_running = state.full_summary.running_downloads.len();
721    let ul_running = state.full_summary.running_uploads.len();
722
723    if running + completed + failed + planned + dl_running + ul_running == 0 {
724      return vec![];
725    }
726
727    let now = current_time();
728    let duration = now - state.start_time;
729    let prefix = if has_tree {
730      "┣━━━"
731    } else {
732      "┏━━━"
733    };
734    let v = self.colored("┃", Color::DarkBlue);
735
736    let mut lines = Vec::new();
737    lines.push(format!(
738      "{} Build Summary:",
739      self.colored(prefix, Color::DarkBlue)
740    ));
741
742    // One row per running build: name left-aligned, time right
743    let mut running_entries: Vec<(String, String, String)> = state
744      .full_summary
745      .running_builds
746      .iter()
747      .filter_map(|(drv_id, build)| {
748        let info = state.get_derivation_info(*drv_id)?;
749        let elapsed = now - build.start;
750        let host = match &build.host {
751          cognos::Host::Localhost => String::new(),
752          cognos::Host::Remote(h) => {
753            format!("  {}", self.colored(h, Color::DarkMagenta))
754          },
755        };
756        Some((info.name.name.clone(), self.format_duration(elapsed), host))
757      })
758      .collect();
759    running_entries.sort_by(|a, b| a.0.cmp(&b.0));
760
761    let name_width = running_entries
762      .iter()
763      .map(|(n, ..)| n.len())
764      .max()
765      .unwrap_or(0)
766      .min(48);
767
768    let ic = self.ic();
769    for (name, elapsed, host) in &running_entries {
770      lines.push(format!(
771        "{}  {} {:<width$}  {}{}",
772        v,
773        self.colored(ic.running, Color::DarkYellow),
774        self.truncate_name(name, name_width),
775        self.colored(elapsed, Color::DarkGrey),
776        host,
777        width = name_width,
778      ));
779    }
780
781    // Running downloads
782    for (path_id, transfer) in &state.full_summary.running_downloads {
783      if let Some(pi) = state.store_path_infos.get(path_id) {
784        let elapsed = now - transfer.start;
785        let size = if let Some(total) = transfer.total_bytes {
786          self.format_bytes(transfer.bytes_transferred, total)
787        } else {
788          format!("{} B", transfer.bytes_transferred)
789        };
790        lines.push(format!(
791          "{}  {} {:<width$}  {} {}",
792          v,
793          self.colored(ic.download, Color::DarkYellow),
794          self.truncate_name(&pi.name.name, name_width),
795          self.colored(&size, Color::DarkGrey),
796          self.colored(&self.format_duration(elapsed), Color::DarkGrey),
797          width = name_width,
798        ));
799      }
800    }
801
802    let ic = self.ic();
803    let mut sum_parts: Vec<String> = Vec::new();
804    sum_parts.push(format!(
805      "{} {running} running",
806      self.colored(ic.running, Color::DarkYellow)
807    ));
808    sum_parts.push(format!(
809      "{} {completed} completed",
810      self.colored(ic.done, Color::DarkGreen)
811    ));
812    sum_parts.push(format!(
813      "{} {failed} failed",
814      self.colored(ic.failed, Color::DarkRed)
815    ));
816    sum_parts.push(format!(
817      "{} {planned} planned",
818      self.colored(ic.planned, Color::DarkBlue)
819    ));
820    if dl_running > 0 {
821      sum_parts.push(format!(
822        "{} {dl_running} downloading",
823        self.colored(ic.download, Color::DarkYellow)
824      ));
825    }
826    if ul_running > 0 {
827      sum_parts.push(format!(
828        "{} {ul_running} uploading",
829        self.colored(ic.upload, Color::DarkYellow)
830      ));
831    }
832    sum_parts.push(format!(
833      "{} {}",
834      self.colored(ic.clock, Color::DarkGrey),
835      self.colored(&self.format_duration(duration), Color::DarkGrey),
836    ));
837
838    lines.push(format!(
839      "{} {}",
840      self.colored("┗━", Color::DarkBlue),
841      sum_parts.join(" │ ")
842    ));
843
844    lines
845  }
846
847  fn render_plain_view(&self, state: &State) -> Vec<String> {
848    let now = current_time();
849    let duration = now - state.start_time;
850    let running = state.full_summary.running_builds.len();
851    let planned = state.full_summary.planned_builds.len();
852    let completed = state.full_summary.completed_builds.len();
853    let downloading = state.full_summary.running_downloads.len();
854    let uploading = state.full_summary.running_uploads.len();
855
856    if running + planned + completed + downloading + uploading == 0 {
857      return vec![];
858    }
859
860    let mut lines = Vec::new();
861
862    // Running builds
863    let mut builds: Vec<_> = state
864      .full_summary
865      .running_builds
866      .iter()
867      .filter_map(|(drv_id, build)| {
868        let info = state.get_derivation_info(*drv_id)?;
869        Some((info.name.name.clone(), build.clone()))
870      })
871      .collect();
872    builds.sort_by(|a, b| a.0.cmp(&b.0));
873
874    let ic = self.ic();
875
876    let mut header_parts: Vec<String> = Vec::new();
877    if planned > 0 {
878      header_parts.push(format!(
879        "{} {planned} planned",
880        self.colored(ic.planned, Color::DarkBlue)
881      ));
882    }
883    if downloading > 0 {
884      header_parts.push(format!(
885        "{} {downloading} downloading",
886        self.colored(ic.download, Color::DarkYellow)
887      ));
888    }
889    if uploading > 0 {
890      header_parts.push(format!(
891        "{} {uploading} uploading",
892        self.colored(ic.upload, Color::DarkYellow)
893      ));
894    }
895    let duration_str = self.format_duration(duration);
896    let header = if header_parts.is_empty() {
897      format!(
898        "{} {} {}",
899        self.colored("━", Color::DarkBlue),
900        self.colored(ic.clock, Color::DarkGrey),
901        self.colored(&duration_str, Color::DarkGrey),
902      )
903    } else {
904      format!(
905        "{} {} {} {}",
906        self.colored("━", Color::DarkBlue),
907        self.colored(ic.clock, Color::DarkGrey),
908        header_parts.join(" "),
909        self.colored(&duration_str, Color::DarkGrey),
910      )
911    };
912    lines.push(header);
913
914    for (name, build) in &builds {
915      let elapsed = now - build.start;
916      let mut suffix = String::new();
917      if let Some(est) = build.estimate {
918        let remaining = est.saturating_sub(elapsed as u64);
919        suffix = format!(
920          "  {} {}",
921          self.colored(ic.estimate, Color::DarkGrey),
922          self
923            .colored(&self.format_duration(remaining as f64), Color::DarkGrey)
924        );
925      }
926      let host_label = match &build.host {
927        cognos::Host::Remote(h) => {
928          format!("  {}", self.colored(h, Color::DarkMagenta))
929        },
930        _ => String::new(),
931      };
932      lines.push(format!(
933        "  {} {}  {}{}{}",
934        self.colored(ic.running, Color::DarkYellow),
935        name,
936        self.colored(&self.format_duration(elapsed), Color::DarkGrey),
937        suffix,
938        host_label,
939      ));
940    }
941
942    // Running downloads
943    for (path_id, transfer) in &state.full_summary.running_downloads {
944      if let Some(pi) = state.store_path_infos.get(path_id) {
945        let size = if let Some(total) = transfer.total_bytes {
946          self.format_bytes(transfer.bytes_transferred, total)
947        } else {
948          format!("{} B", transfer.bytes_transferred)
949        };
950        lines.push(format!(
951          "  {} {}  {}",
952          self.colored(ic.download, Color::DarkYellow),
953          pi.name.name,
954          self.colored(&size, Color::DarkGrey),
955        ));
956      }
957    }
958
959    // Running uploads
960    for (path_id, transfer) in &state.full_summary.running_uploads {
961      if let Some(pi) = state.store_path_infos.get(path_id) {
962        let size = if let Some(total) = transfer.total_bytes {
963          self.format_bytes(transfer.bytes_transferred, total)
964        } else {
965          format!("{} B", transfer.bytes_transferred)
966        };
967        lines.push(format!(
968          "  {} {}  {}",
969          self.colored(ic.upload, Color::DarkYellow),
970          pi.name.name,
971          self.colored(&size, Color::DarkGrey),
972        ));
973      }
974    }
975
976    lines
977  }
978
979  fn render_dashboard_view(&self, state: &State) -> Vec<String> {
980    let now = current_time();
981    let duration = now - state.start_time;
982    let running = state.full_summary.running_builds.len();
983    let completed = state.full_summary.completed_builds.len();
984    let planned = state.full_summary.planned_builds.len();
985    let failed = state.full_summary.failed_builds.len();
986    let dl = state.full_summary.running_downloads.len();
987    let ul = state.full_summary.running_uploads.len();
988
989    if running + completed + planned + failed + dl + ul == 0 {
990      return vec![];
991    }
992
993    let ic = self.ic();
994    let sep = self.colored(&"─".repeat(44), Color::DarkBlue);
995    let pipe = self.colored("│", Color::DarkBlue);
996
997    let title = state
998      .forest_roots
999      .first()
1000      .and_then(|&id| state.get_derivation_info(id))
1001      .map_or_else(|| "Build".to_string(), |info| info.name.name.clone());
1002
1003    let host = state
1004      .full_summary
1005      .running_builds
1006      .values()
1007      .find_map(|b| {
1008        match &b.host {
1009          cognos::Host::Remote(h) => Some(h.clone()),
1010          _ => None,
1011        }
1012      })
1013      .unwrap_or_else(|| "localhost".to_string());
1014
1015    let (status_icon, status_color, status_label) = if running > 0 {
1016      (ic.running, Color::DarkYellow, "building")
1017    } else if planned > 0 || dl > 0 {
1018      (ic.planned, Color::DarkBlue, "waiting")
1019    } else if failed > 0 {
1020      (ic.failed, Color::DarkRed, "failed")
1021    } else {
1022      (ic.done, Color::DarkGreen, "done")
1023    };
1024    let status_str =
1025      format!("{} {status_label}", self.colored(status_icon, status_color));
1026
1027    let duration_str = self.format_duration(duration);
1028    let host_s = self.colored(&host, Color::DarkMagenta);
1029    let dur_s = self.colored(&duration_str, Color::DarkGrey);
1030    let fail_s = if failed > 0 && self.config.use_color {
1031      format!(
1032        "{}\x1b[1m{failed}\x1b[0m{}",
1033        SetForegroundColor(Color::DarkRed),
1034        ResetColor
1035      )
1036    } else {
1037      failed.to_string()
1038    };
1039    let summary_str = format!(
1040      "jobs={}  ok={}  failed={fail_s}  total={dur_s}",
1041      self.num_str(running + completed + planned + failed),
1042      self.num_str(completed),
1043    );
1044
1045    let header = format!(
1046      "{} BUILD GRAPH: {title}",
1047      self.colored("┏━", Color::DarkBlue)
1048    );
1049
1050    vec![
1051      header,
1052      sep.clone(),
1053      format!("{:<12} {pipe} {host_s}", "Host"),
1054      format!("{:<12} {pipe} {status_str}", "Status"),
1055      format!("{:<12} {pipe} {dur_s}", "Duration"),
1056      sep,
1057      format!("{:<12} {pipe} {summary_str}", "Summary"),
1058    ]
1059  }
1060
1061  fn render_dashboard_final(&self, state: &State) -> Vec<String> {
1062    let duration = current_time() - state.start_time;
1063    let completed = state.full_summary.completed_builds.len();
1064    let failed = state.full_summary.failed_builds.len();
1065    let now = chrono::Local::now();
1066    let at = now.format("%H:%M:%S");
1067
1068    let ic = self.ic();
1069    let sep = self.colored(&"─".repeat(44), Color::DarkBlue);
1070    let pipe = self.colored("│", Color::DarkBlue);
1071
1072    let title = state
1073      .forest_roots
1074      .first()
1075      .and_then(|&id| state.get_derivation_info(id))
1076      .map_or_else(|| "Build".to_string(), |info| info.name.name.clone());
1077
1078    let host = state
1079      .full_summary
1080      .completed_builds
1081      .values()
1082      .find_map(|b| {
1083        match &b.host {
1084          cognos::Host::Remote(h) => Some(h.clone()),
1085          _ => None,
1086        }
1087      })
1088      .or_else(|| {
1089        state.full_summary.failed_builds.values().find_map(|b| {
1090          match &b.host {
1091            cognos::Host::Remote(h) => Some(h.clone()),
1092            _ => None,
1093          }
1094        })
1095      })
1096      .unwrap_or_else(|| "localhost".to_string());
1097
1098    let (status_icon, status_color, status_label) =
1099      if failed > 0 || !state.nix_errors.is_empty() {
1100        (ic.failed, Color::DarkRed, format!("failed at {at}"))
1101      } else {
1102        (ic.done, Color::DarkGreen, format!("finished at {at}"))
1103      };
1104    let status_str =
1105      format!("{} {status_label}", self.colored(status_icon, status_color));
1106
1107    let duration_str = self.format_duration(duration);
1108    let host_s = self.colored(&host, Color::DarkMagenta);
1109    let dur_s = self.colored(&duration_str, Color::DarkGrey);
1110    let jobs = completed + failed;
1111    let fail_s = if failed > 0 && self.config.use_color {
1112      format!(
1113        "{}\x1b[1m{failed}\x1b[0m{}",
1114        SetForegroundColor(Color::DarkRed),
1115        ResetColor
1116      )
1117    } else {
1118      failed.to_string()
1119    };
1120    let summary_str = format!(
1121      "jobs={}  ok={}  failed={fail_s}  total={dur_s}",
1122      self.num_str(jobs),
1123      self.num_str(completed),
1124    );
1125
1126    let header = format!(
1127      "{} BUILD GRAPH: {title}",
1128      self.colored("┏━", Color::DarkBlue)
1129    );
1130
1131    vec![
1132      header,
1133      sep.clone(),
1134      format!("{:<12} {pipe} {host_s}", "Host"),
1135      format!("{:<12} {pipe} {status_str}", "Status"),
1136      format!("{:<12} {pipe} {dur_s}", "Duration"),
1137      sep,
1138      format!("{:<12} {pipe} {summary_str}", "Summary"),
1139    ]
1140  }
1141
1142  fn render_tree_view(&self, state: &State) -> Vec<String> {
1143    // Show roots that have any interesting build activity. This currently
1144    // consists of:
1145    //
1146    // - actively building
1147    // - failed
1148    // - planned (with dependencies)
1149    // - recently completed
1150    //
1151    // Which is the same as showing the full dependency forest, which is
1152    // what we want to do for the tree view.
1153    let visible_roots: Vec<DerivationId> = state
1154      .forest_roots
1155      .iter()
1156      .copied()
1157      .filter(|&drv_id| {
1158        state
1159          .get_derivation_info(drv_id)
1160          .map(|info| self.node_is_visible(info))
1161          .unwrap_or(false)
1162      })
1163      .collect();
1164
1165    if visible_roots.is_empty() {
1166      return Vec::new();
1167    }
1168
1169    let forest = self.build_forest(state, &visible_roots);
1170
1171    if forest.is_empty() {
1172      return Vec::new();
1173    }
1174
1175    let mut lines = Vec::new();
1176    lines.push(format!(
1177      "{} Dependency Graph:",
1178      self.colored("┏━", Color::DarkBlue)
1179    ));
1180
1181    let n = forest.len();
1182    if n == 1 {
1183      // Single root: render directly, no cross-tree connector wrapping.
1184      // render_tree_node already handles ┃ prefix.
1185      self.render_tree_node(state, &forest[0], &mut lines);
1186    } else {
1187      // Multiple roots: render in reverse, apply forest-level connectors.
1188      for (rev_i, node) in forest.iter().rev().enumerate() {
1189        // rev_i == 0 <-> this was the LAST root -> rendered first -> top
1190        let is_top_tree = rev_i == 0;
1191
1192        let mut tree_lines: Vec<String> = Vec::new();
1193        self.render_tree_node(state, node, &mut tree_lines);
1194
1195        // The root-of-tree line is the LAST element in tree_lines (bottom).
1196        for (line_idx, tree_line) in tree_lines.iter().enumerate() {
1197          // Topmost line of this tree block.
1198          let connector = if is_top_tree {
1199            if line_idx == 0 {
1200              self.colored("┌─ ", Color::DarkBlue)
1201            } else {
1202              "   ".to_string()
1203            }
1204          } else if line_idx == 0 {
1205            self.colored("├─ ", Color::DarkBlue)
1206          } else {
1207            self.colored("│  ", Color::DarkBlue)
1208          };
1209          lines.push(format!("{connector}{tree_line}"));
1210        }
1211      }
1212    }
1213
1214    lines
1215  }
1216
1217  /// Determine whether a derivation node is interesting enough to appear in
1218  /// the tree. Basically, show anything whose subtree summary
1219  /// has at least one non-empty build/transfer count, or whose own status is
1220  /// not Unknown-and-empty.
1221  fn node_is_visible(&self, info: &crate::state::DerivationInfo) -> bool {
1222    use crate::state::DependencySummary;
1223    let summary_non_empty = |s: &DependencySummary| {
1224      !s.planned_builds.is_empty()
1225        || !s.running_builds.is_empty()
1226        || !s.completed_builds.is_empty()
1227        || !s.failed_builds.is_empty()
1228        || !s.running_downloads.is_empty()
1229        || !s.running_uploads.is_empty()
1230        || !s.completed_downloads.is_empty()
1231        || !s.completed_uploads.is_empty()
1232    };
1233
1234    match &info.build_status {
1235      BuildStatus::Unknown => summary_non_empty(&info.dependency_summary),
1236      _ => true,
1237    }
1238  }
1239
1240  fn build_forest(
1241    &self,
1242    state: &State,
1243    roots: &[DerivationId],
1244  ) -> Vec<TreeNode> {
1245    let mut forest = Vec::new();
1246    let mut visited = HashSet::new();
1247
1248    for &root_id in roots {
1249      if let Some(node) = self.build_tree_node(state, root_id, &mut visited, 0)
1250      {
1251        forest.push(node);
1252      }
1253    }
1254
1255    forest
1256  }
1257
1258  fn build_tree_node(
1259    &self,
1260    state: &State,
1261    drv_id: DerivationId,
1262    visited: &mut HashSet<DerivationId>,
1263    depth: usize,
1264  ) -> Option<TreeNode> {
1265    if visited.contains(&drv_id) {
1266      return None;
1267    }
1268    visited.insert(drv_id);
1269
1270    if depth >= self.config.max_tree_depth {
1271      return Some(TreeNode {
1272        drv_id,
1273        children: Vec::new(),
1274      });
1275    }
1276
1277    let drv_info = state.get_derivation_info(drv_id)?;
1278
1279    let mut children: Vec<TreeNode> = Vec::new();
1280    for input in &drv_info.input_derivations {
1281      let child_info = match state.get_derivation_info(input.derivation) {
1282        Some(i) => i,
1283        None => continue,
1284      };
1285
1286      // Show the child if it has any build activity (own or in its subtree)
1287      if !self.node_is_visible(child_info) {
1288        continue;
1289      }
1290
1291      if let Some(child) =
1292        self.build_tree_node(state, input.derivation, visited, depth + 1)
1293      {
1294        children.push(child);
1295      }
1296    }
1297
1298    // Failed > Building > Planned/downloads > Done > Unknown
1299    children.sort_by_key(|c| {
1300      state
1301        .get_derivation_info(c.drv_id)
1302        .map(|i| self.tree_sort_priority(&i.build_status))
1303        .unwrap_or(u8::MAX)
1304    });
1305
1306    Some(TreeNode { drv_id, children })
1307  }
1308
1309  /// Returns a sort priority for tree children.
1310  /// Lower number = shown first (most urgent / most important).
1311  fn tree_sort_priority(&self, status: &BuildStatus) -> u8 {
1312    match status {
1313      BuildStatus::Failed { .. } => 0,
1314      BuildStatus::Building(_) => 1,
1315      BuildStatus::Planned => 2,
1316      BuildStatus::Unknown => 3,
1317      BuildStatus::Built { .. } => 4,
1318    }
1319  }
1320
1321  /// Render a single tree node (root-of-a-tree position) and all its
1322  /// children into `lines`.
1323  ///
1324  /// Layout (top -> bottom):
1325  ///   last child's subtree (┌─ connector, 3-space continuation)
1326  ///   ...earlier children (├─ connector, │ continuation)...
1327  ///   root node
1328  ///
1329  /// The last sibling ends up at the top with a ┌─ connector; children are
1330  /// rendered in reverse so that the last child appears first in the output.
1331  fn render_tree_node(
1332    &self,
1333    state: &State,
1334    node: &TreeNode,
1335    lines: &mut Vec<String>,
1336  ) {
1337    let info = match state.get_derivation_info(node.drv_id) {
1338      Some(info) => info,
1339      None => return,
1340    };
1341
1342    // Children are iterated in reverse so the original last child is rendered
1343    // first and appears at the top. The top sibling gets ┌─; all others get ├─.
1344    let n = node.children.len();
1345    for (rev_i, child) in node.children.iter().rev().enumerate() {
1346      let is_top = rev_i == 0;
1347      self.render_tree_child(
1348        state,
1349        child,
1350        lines,
1351        is_top,
1352        &self.colored("┃ ", Color::DarkBlue),
1353      );
1354    }
1355
1356    let _ = n;
1357    let mut line = String::new();
1358    line.push_str(&self.colored("┃ ", Color::DarkBlue));
1359    line.push_str(&self.format_node_content(state, info, false));
1360    lines.push(line);
1361  }
1362
1363  /// Render a child node and its subtree.
1364  ///
1365  /// `is_top` is true when this node is the topmost sibling in the display
1366  /// (i.e. the original last child, rendered first due to the reverse).
1367  /// Top siblings use `┌─` connector and 3-space continuation above them, all
1368  /// other siblings use `├─` and `│  ` continuation.
1369  fn render_tree_child(
1370    &self,
1371    state: &State,
1372    node: &TreeNode,
1373    lines: &mut Vec<String>,
1374    is_top: bool,
1375    prefix: &str,
1376  ) {
1377    let info = match state.get_derivation_info(node.drv_id) {
1378      Some(info) => info,
1379      None => return,
1380    };
1381
1382    // The continuation prefix for grandchildren depends on whether this node is
1383    // the top sibling or not. The incoming prefix already contains colored
1384    // characters.
1385    let child_prefix = if is_top {
1386      format!("{prefix}   ")
1387    } else {
1388      format!("{prefix}{}", self.colored("│  ", Color::DarkBlue))
1389    };
1390
1391    for (rev_i, child) in node.children.iter().rev().enumerate() {
1392      let grandchild_is_top = rev_i == 0;
1393      self.render_tree_child(
1394        state,
1395        child,
1396        lines,
1397        grandchild_is_top,
1398        &child_prefix,
1399      );
1400    }
1401
1402    // prefix + connector + content
1403    let mut line = String::new();
1404    line.push_str(prefix);
1405
1406    // ┌─ for the top sibling (was last before reverse), ├─ for all others
1407    let connector = if is_top { "┌─ " } else { "├─ " };
1408    line.push_str(&self.colored(connector, Color::DarkBlue));
1409
1410    let is_leaf = node.children.is_empty();
1411    line.push_str(&self.format_node_content(state, info, is_leaf));
1412
1413    lines.push(line);
1414  }
1415
1416  /// Format the textual content for a single tree node (without any connector
1417  /// prefix). `is_leaf` controls whether a "waiting for ..." annotation is
1418  /// appended for Planned leaf nodes.
1419  fn format_node_content(
1420    &self,
1421    state: &State,
1422    info: &crate::state::DerivationInfo,
1423    is_leaf: bool,
1424  ) -> String {
1425    let ic = self.ic();
1426    let mut s = String::new();
1427
1428    // Unknown nodes have no icon; all others get icon + space prefix.
1429    if let Some((icon, color)) = self.get_status_icon(&info.build_status) {
1430      s.push_str(&self.colored(icon, color));
1431      s.push(' ');
1432    }
1433    // Name color varies by build status.
1434    let raw_name = self.truncate_name(&info.name.name, 50);
1435    let name_str = match &info.build_status {
1436      BuildStatus::Building(_) => {
1437        self.colored_bold(&raw_name, Color::DarkYellow)
1438      },
1439      BuildStatus::Failed { .. } => {
1440        self.colored_bold(&raw_name, Color::DarkRed)
1441      },
1442      BuildStatus::Built { .. } => self.colored(&raw_name, Color::DarkGreen),
1443      _ => raw_name,
1444    };
1445    s.push_str(&name_str);
1446
1447    match &info.build_status {
1448      BuildStatus::Building(build_info) => {
1449        // Show host in magenta.
1450        if let cognos::Host::Remote(ref host_name) = build_info.host {
1451          s.push_str(
1452            &self.colored(&format!(" on {host_name}"), Color::Magenta),
1453          );
1454        }
1455
1456        // Show current build phase in bold
1457        if let Some(activity_id) = build_info.activity_id
1458          && let Some(activity) = state.activities.get(&activity_id)
1459          && let Some(phase) = &activity.phase
1460        {
1461          s.push_str(
1462            &self.colored_bold(&format!(" ({phase})"), Color::DarkGrey),
1463          );
1464        }
1465
1466        let elapsed = current_time() - build_info.start;
1467
1468        // Hide elapsed if under 1s; it is not meaningful at that resolution.
1469        if self.config.show_timers && elapsed > 1.0 {
1470          s.push_str(&self.colored(
1471            &format!(" {} {}", ic.clock, self.format_duration(elapsed)),
1472            Color::DarkGrey,
1473          ));
1474          // Show the total build estimate after elapsed.
1475          if let Some(estimate_secs) = build_info.estimate {
1476            s.push_str(&self.colored(
1477              &format!(
1478                " ({} {})",
1479                ic.estimate,
1480                self.format_duration(estimate_secs as f64)
1481              ),
1482              Color::DarkGrey,
1483            ));
1484          }
1485        }
1486      },
1487      BuildStatus::Failed {
1488        info: build_info,
1489        fail,
1490      } => {
1491        // Host is shown uncolored for failed nodes.
1492        if let cognos::Host::Remote(ref host_name) = build_info.host {
1493          s.push_str(&format!(" on {host_name}"));
1494        }
1495
1496        // Show failure reason.
1497        let fail_str = match &fail.fail_type {
1498          crate::state::FailType::BuildFailed(code) => {
1499            format!(" failed with exit code {code}")
1500          },
1501          crate::state::FailType::Timeout => " timed out".to_string(),
1502          crate::state::FailType::HashMismatch => " hash mismatch".to_string(),
1503          crate::state::FailType::DependencyFailed => {
1504            " dependency failed".to_string()
1505          },
1506          crate::state::FailType::Unknown => " failed".to_string(),
1507        };
1508        s.push_str(&self.colored(&fail_str, Color::DarkRed));
1509
1510        // Show build phase if known.
1511        if let Some(activity_id) = build_info.activity_id
1512          && let Some(activity) = state.activities.get(&activity_id)
1513          && let Some(phase) = &activity.phase
1514        {
1515          s.push_str(&self.colored(&format!(" in {phase}"), Color::DarkGrey));
1516        }
1517
1518        // Hide elapsed if under 1s.
1519        if self.config.show_timers {
1520          let duration = fail.at - build_info.start;
1521          if duration > 1.0 {
1522            s.push_str(&self.colored(
1523              &format!(" {} {}", ic.clock, self.format_duration(duration)),
1524              Color::DarkGrey,
1525            ));
1526          }
1527        }
1528      },
1529      BuildStatus::Built {
1530        info: build_info,
1531        end,
1532      } => {
1533        // Show host (if remote)
1534        if let cognos::Host::Remote(ref host_name) = build_info.host {
1535          s.push_str(
1536            &self.colored(&format!(" on {host_name}"), Color::DarkGrey),
1537          );
1538        }
1539        // Hide elapsed if under 1s.
1540        if self.config.show_timers {
1541          let duration = end - build_info.start;
1542          if duration > 1.0 {
1543            s.push_str(&self.colored(
1544              &format!(" {} {}", ic.clock, self.format_duration(duration)),
1545              Color::DarkGrey,
1546            ));
1547          }
1548        }
1549      },
1550      BuildStatus::Planned => {
1551        // Planned leaf nodes show a "waiting for ..." annotation summarising
1552        // the unfinished work below them.
1553        if is_leaf {
1554          let waiting = self.format_waiting_summary(&info.dependency_summary);
1555          if !waiting.is_empty() {
1556            s.push_str(
1557              &self
1558                .colored(&format!(" waiting for {waiting}"), Color::DarkGrey),
1559            );
1560          }
1561        }
1562      },
1563      BuildStatus::Unknown => {},
1564    }
1565
1566    s
1567  }
1568
1569  /// Render a compact summary of pending/running activity for a planned leaf
1570  /// node.
1571  fn format_waiting_summary(
1572    &self,
1573    summary: &crate::state::DependencySummary,
1574  ) -> String {
1575    let ic = self.ic();
1576    let mut parts: Vec<String> = Vec::new();
1577
1578    let failed = summary.failed_builds.len();
1579    if failed > 0 {
1580      parts.push(
1581        self.colored(&format!("{} {}", ic.failed, failed), Color::DarkRed),
1582      );
1583    }
1584
1585    let running = summary.running_builds.len();
1586    if running > 0 {
1587      parts.push(
1588        self.colored(&format!("{} {}", ic.running, running), Color::DarkYellow),
1589      );
1590    }
1591
1592    let planned = summary.planned_builds.len();
1593    if planned > 0 {
1594      parts.push(
1595        self.colored(&format!("{} {}", ic.planned, planned), Color::DarkBlue),
1596      );
1597    }
1598
1599    parts.join(" ")
1600  }
1601
1602  fn get_status_icon(
1603    &self,
1604    status: &BuildStatus,
1605  ) -> Option<(&'static str, Color)> {
1606    let ic = self.ic();
1607    match status {
1608      BuildStatus::Building(_) => Some((ic.running, Color::DarkYellow)),
1609      BuildStatus::Planned => Some((ic.planned, Color::DarkBlue)),
1610      BuildStatus::Built { .. } => Some((ic.done, Color::DarkGreen)),
1611      BuildStatus::Failed { .. } => Some((ic.failed, Color::DarkRed)),
1612      // Unknown nodes have no icon.
1613      BuildStatus::Unknown => None,
1614    }
1615  }
1616
1617  /// Shorthand accessor for the configured icon set.
1618  fn ic(&self) -> &'static Icons {
1619    self.config.icons
1620  }
1621
1622  fn colored(&self, text: &str, color: Color) -> String {
1623    if self.config.use_color {
1624      format!("{}{}{}", SetForegroundColor(color), text, ResetColor)
1625    } else {
1626      text.to_string()
1627    }
1628  }
1629
1630  /// Render text in the given color AND bold weight.
1631  fn colored_bold(&self, text: &str, color: Color) -> String {
1632    if self.config.use_color {
1633      format!(
1634        "{}\x1b[1m{}\x1b[0m{}",
1635        SetForegroundColor(color),
1636        text,
1637        ResetColor
1638      )
1639    } else {
1640      text.to_string()
1641    }
1642  }
1643
1644  /// Render an icon + count
1645  fn count_colored(&self, icon: &str, n: usize, active_color: Color) -> String {
1646    let icon_s = self.colored(icon, active_color);
1647    let num_s = if n > 0 && self.config.use_color {
1648      format!("\x1b[1m{n}\x1b[0m")
1649    } else {
1650      n.to_string()
1651    };
1652    format!("{icon_s} {num_s}")
1653  }
1654
1655  /// Render a count as bold-when-nonzero with no icon. This matches the number
1656  /// semantics of `count_colored` for use in the dashboard summary row.
1657  fn num_str(&self, n: usize) -> String {
1658    if n > 0 && self.config.use_color {
1659      format!("\x1b[1m{n}\x1b[0m")
1660    } else {
1661      n.to_string()
1662    }
1663  }
1664
1665  pub fn format_duration(&self, secs: f64) -> String {
1666    if secs < 60.0 {
1667      format!("{secs:.0}s")
1668    } else if secs < 3600.0 {
1669      format!("{:.0}m{:.0}s", secs / 60.0, secs % 60.0)
1670    } else {
1671      format!("{:.0}h{:.0}m", secs / 3600.0, (secs % 3600.0) / 60.0)
1672    }
1673  }
1674
1675  fn truncate_name(&self, name: &str, max_len: usize) -> String {
1676    if name.len() <= max_len {
1677      name.to_string()
1678    } else {
1679      format!("{}…", &name[..max_len.saturating_sub(1)])
1680    }
1681  }
1682
1683  fn format_bytes(&self, transferred: u64, total: u64) -> String {
1684    let pct = if total > 0 {
1685      (transferred as f64 / total as f64 * 100.0) as u64
1686    } else {
1687      0
1688    };
1689    format_size(total) + &self.colored(&format!(" ({pct}%)"), Color::DarkGrey)
1690  }
1691}
1692
1693fn format_size(bytes: u64) -> String {
1694  if bytes < 1024 {
1695    format!("{bytes} B")
1696  } else if bytes < 1024 * 1024 {
1697    format!("{:.1} KiB", bytes as f64 / 1024.0)
1698  } else if bytes < 1024 * 1024 * 1024 {
1699    format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0))
1700  } else {
1701    format!("{:.1} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
1702  }
1703}