Skip to main content

majordomus_cli/environment/
render.rs

1//! The banner: a snapshot rendered for a person at a terminal.
2//!
3//! This is a view and nothing else. It discovers nothing, reads no file, runs no command
4//! and holds no fact of its own — every number, name, URL and command in it comes out of
5//! the [`super::RepositoryEnvironment`] it was handed. That is what keeps the thing a
6//! person sees on `cd` and the thing `GET /api/v1/environment` returns from ever
7//! disagreeing.
8//!
9//! # What it refuses to do
10//!
11//! - Print anything it was not given. A count that was not resolved is shown as `—`, never
12//!   as `0`.
13//! - Print anything unsanitised. Every value goes through [`super::text::sanitise`]; a
14//!   branch name is arbitrary text chosen by whoever made the branch.
15//! - Measure in bytes. Every column is [`super::text::width`], so a box does not fall
16//!   apart on a name that is not ASCII.
17//! - Colour when it was not asked to. `NO_COLOR`, a pipe, and `CI` each turn it off.
18//! - Fail. A render error would take the shell down with it, so there is no path here
19//!   that can produce one.
20
21use std::fmt::Write as _;
22
23use crate::model::Severity;
24
25use super::text::{pad_with, sanitise, truncate_with, width};
26use super::{
27    ProjectionState, RepositoryEnvironment, ServiceAvailability, TierState, ToolchainAvailability,
28    VcsState,
29};
30
31/// How much of a snapshot to show.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum BannerMode {
34    /// Decide from the terminal and from whether this snapshot says anything new.
35    #[default]
36    Auto,
37    /// The whole box.
38    Full,
39    /// Two lines.
40    Compact,
41    /// Nothing.
42    Off,
43}
44
45impl BannerMode {
46    /// Parse a mode by name; `None` for a word that is not one.
47    ///
48    /// ```
49    /// use majordomus_cli::environment::render::BannerMode;
50    /// assert_eq!(BannerMode::parse("compact"), Some(BannerMode::Compact));
51    /// assert_eq!(BannerMode::parse("COMPACT"), Some(BannerMode::Compact));
52    /// assert_eq!(BannerMode::parse("loud"), None);
53    /// ```
54    pub fn parse(word: &str) -> Option<Self> {
55        match word.trim().to_ascii_lowercase().as_str() {
56            "auto" => Some(BannerMode::Auto),
57            "full" => Some(BannerMode::Full),
58            "compact" => Some(BannerMode::Compact),
59            "off" | "none" | "silent" => Some(BannerMode::Off),
60            _ => None,
61        }
62    }
63
64    /// The word as written.
65    pub fn as_str(self) -> &'static str {
66        match self {
67            BannerMode::Auto => "auto",
68            BannerMode::Full => "full",
69            BannerMode::Compact => "compact",
70            BannerMode::Off => "off",
71        }
72    }
73}
74
75/// What the terminal on the other end can take.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Presentation {
78    /// Whether escape sequences may be used.
79    pub colour: bool,
80    /// Whether characters outside ASCII may be used.
81    pub unicode: bool,
82    /// How many columns there are.
83    pub columns: usize,
84    /// Whether the output is going to a terminal at all.
85    pub interactive: bool,
86}
87
88/// The widest a banner is drawn, however wide the terminal is. A box that stretches across
89/// a 200-column window is harder to read than one that does not.
90pub const MAX_WIDTH: usize = 78;
91
92/// The narrowest terminal the box is drawn in; below it, the plain two-line form is used
93/// whatever the mode, because a box that does not fit is worse than no box.
94pub const MIN_BOX_WIDTH: usize = 46;
95
96impl Default for Presentation {
97    fn default() -> Self {
98        Presentation {
99            colour: false,
100            unicode: true,
101            columns: MAX_WIDTH,
102            interactive: false,
103        }
104    }
105}
106
107impl Presentation {
108    /// What this process's standard error can take.
109    ///
110    /// The banner goes to standard error, not standard output: standard output belongs to
111    /// whatever the caller is capturing, and a banner in the middle of `majordomus env
112    /// status --json | jq` would be a bug in every consumer at once.
113    ///
114    /// `NO_COLOR` — set to anything at all, including the empty string, per the
115    /// convention — turns colour off. So does `CI`, and so does a standard error that is
116    /// not a terminal.
117    pub fn detect() -> Self {
118        let interactive = is_terminal(libc::STDERR_FILENO);
119        let ci = std::env::var_os("CI").is_some();
120        Presentation {
121            colour: interactive && !ci && std::env::var_os("NO_COLOR").is_none(),
122            unicode: unicode_locale(),
123            columns: terminal_columns().unwrap_or(MAX_WIDTH),
124            interactive: interactive && !ci,
125        }
126    }
127}
128
129/// Is this file descriptor a terminal?
130fn is_terminal(fd: i32) -> bool {
131    // SAFETY: isatty takes a file descriptor and reads no memory; any integer is a
132    // defined argument, and an invalid one returns 0.
133    unsafe { libc::isatty(fd) == 1 }
134}
135
136/// The terminal's width, from `COLUMNS` when a shell exported it, else from the terminal
137/// itself. `None` when neither answers, which is every non-interactive case.
138fn terminal_columns() -> Option<usize> {
139    if let Some(columns) = std::env::var("COLUMNS")
140        .ok()
141        .and_then(|c| c.trim().parse::<usize>().ok())
142    {
143        if columns > 0 {
144            return Some(columns);
145        }
146    }
147    let mut size: libc::winsize = unsafe { std::mem::zeroed() };
148    // SAFETY: TIOCGWINSZ writes a winsize into the pointer given; the struct is owned
149    // here, correctly sized, and the call is a no-op returning -1 on a non-terminal.
150    let ok = unsafe { libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut size) } == 0;
151    (ok && size.ws_col > 0).then_some(size.ws_col as usize)
152}
153
154/// Does the locale say this terminal can render characters outside ASCII?
155fn unicode_locale() -> bool {
156    ["LC_ALL", "LC_CTYPE", "LANG"]
157        .iter()
158        .filter_map(|k| std::env::var(k).ok())
159        .find(|v| !v.is_empty())
160        .map(|v| {
161            v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8")
162        })
163        // No locale set at all: a terminal that says nothing is assumed to be modern.
164        // Being wrong here costs a few mojibake characters, and being wrong the other way
165        // costs every non-ASCII repository a legible banner.
166        .unwrap_or(true)
167}
168
169/// The characters the box is drawn with.
170struct Glyphs {
171    top_left: &'static str,
172    top_right: &'static str,
173    bottom_left: &'static str,
174    bottom_right: &'static str,
175    horizontal: &'static str,
176    vertical: &'static str,
177    tee_left: &'static str,
178    tee_right: &'static str,
179    dirty: &'static str,
180    ahead: &'static str,
181    behind: &'static str,
182    yes: &'static str,
183    no: &'static str,
184    unknown: &'static str,
185    bullet: &'static str,
186    mark: &'static str,
187    /// What a truncated value ends with.
188    ellipsis: &'static str,
189    /// What stands in for a value nothing resolved. Never a zero.
190    unresolved: &'static str,
191}
192
193const UNICODE: Glyphs = Glyphs {
194    top_left: "╭",
195    top_right: "╮",
196    bottom_left: "╰",
197    bottom_right: "╯",
198    horizontal: "─",
199    vertical: "│",
200    tee_left: "├",
201    tee_right: "┤",
202    dirty: "✱",
203    ahead: "↑",
204    behind: "↓",
205    yes: "✓",
206    no: "✗",
207    unknown: "○",
208    bullet: "·",
209    mark: "◆",
210    ellipsis: "…",
211    unresolved: "—",
212};
213
214const ASCII: Glyphs = Glyphs {
215    top_left: "+",
216    top_right: "+",
217    bottom_left: "+",
218    bottom_right: "+",
219    horizontal: "-",
220    vertical: "|",
221    tee_left: "+",
222    tee_right: "+",
223    dirty: "*",
224    ahead: "^",
225    behind: "v",
226    yes: "ok",
227    no: "!",
228    unknown: "?",
229    bullet: "-",
230    mark: ">",
231    ellipsis: "...",
232    unresolved: "n/a",
233};
234
235/// Render a banner, or nothing when the mode says nothing.
236///
237/// `seen_before` is the [`RepositoryEnvironment::news_digest`] of the last snapshot shown to
238/// this person, which decides what `auto` does: a repository that has not changed since
239/// they last looked gets the two-line form, and a first look or a changed repository gets
240/// the box. A server that only came up or went away is not a changed repository.
241pub fn banner(
242    environment: &RepositoryEnvironment,
243    mode: BannerMode,
244    presentation: &Presentation,
245    seen_before: Option<&str>,
246) -> Option<String> {
247    let resolved = match mode {
248        BannerMode::Off => return None,
249        BannerMode::Auto => {
250            if !presentation.interactive {
251                return None;
252            }
253            match seen_before {
254                Some(digest) if digest == environment.news_digest() => BannerMode::Compact,
255                _ => BannerMode::Full,
256            }
257        }
258        other => other,
259    };
260    Some(match resolved {
261        BannerMode::Compact => compact(environment, presentation),
262        _ if presentation.columns < MIN_BOX_WIDTH => compact(environment, presentation),
263        _ => full(environment, presentation),
264    })
265}
266
267/// How wide the label column of an ordinary row is.
268const LABEL_WIDTH: usize = 11;
269
270/// How wide the label column of a command row is: a command is the label there, and
271/// `just cockpit-assets-check` truncated to eleven columns is not a command.
272const COMMAND_WIDTH: usize = 26;
273
274/// One line of the box.
275enum Row {
276    /// A label and a value.
277    Field(String, String),
278    /// A command and what it does; the command gets the wider column.
279    Command(String, String),
280    /// Nothing, for spacing.
281    Blank,
282    /// A horizontal divider.
283    Divider,
284}
285
286/// The full banner.
287fn full(environment: &RepositoryEnvironment, presentation: &Presentation) -> String {
288    let g = glyphs(presentation);
289    let inner = presentation.columns.clamp(MIN_BOX_WIDTH, MAX_WIDTH) - 2;
290    let mut rows: Vec<Row> = Vec::new();
291
292    // ---- what this is
293    let name = sanitise(&environment.project.name).to_uppercase();
294    let version = format!("v{}", sanitise(&environment.project.version));
295    let title_gap = inner.saturating_sub(2 + width(&name) + width(&version) + 2);
296    rows.push(Row::Field(
297        format!("{name}{}{version}", " ".repeat(title_gap.max(1))),
298        String::new(),
299    ));
300    rows.push(Row::Field(
301        truncate_with(
302            &sanitise(&environment.project.summary),
303            inner - 4,
304            g.ellipsis,
305        ),
306        String::new(),
307    ));
308    rows.push(Row::Divider);
309
310    // ---- the checkout
311    let branch = match &environment.vcs {
312        VcsState::Git(tree) => tree
313            .branch
314            .clone()
315            .map(|b| sanitise(&b))
316            .unwrap_or_else(|| match &tree.head {
317                Some(head) => format!("detached at {}", short(head)),
318                None => "unborn".into(),
319            }),
320        VcsState::Unavailable { .. } => g.unresolved.into(),
321    };
322    let repository_name = sanitise(&environment.repository.name);
323    rows.push(Row::Field(
324        "repo".into(),
325        two_columns(
326            &repository_name,
327            "branch",
328            &branch,
329            inner - LABEL_WIDTH - 2,
330            g,
331        ),
332    ));
333    rows.push(Row::Field("git".into(), git_line(environment, g)));
334    let runtime = toolchain_line(environment, g);
335    if !runtime.is_empty() {
336        rows.push(Row::Field("runtime".into(), runtime));
337    }
338
339    // ---- what the layer holds
340    rows.push(Row::Blank);
341    rows.push(Row::Field(
342        "layer".into(),
343        layer_line(environment, g, inner - LABEL_WIDTH - 2),
344    ));
345    let providers = provider_line(environment, g);
346    if !providers.is_empty() {
347        rows.push(Row::Field("agents".into(), providers));
348    }
349    if let Some(r) = &environment.reasoning {
350        // Reasoning is operational with no advisor at all, so the line leads with that and
351        // counts advisors after it: absence of an optional advisor is a number, not a mark.
352        let advisors = if r.available.is_empty() {
353            "local review only".to_string()
354        } else {
355            format!("{} available", r.available.len())
356        };
357        rows.push(Row::Field(
358            "advisors".into(),
359            truncate_with(
360                &format!(
361                    "reasoning {} {} {} {} {advisors} {} {} optional absent",
362                    g.yes,
363                    g.bullet,
364                    sanitise(&r.mode),
365                    g.bullet,
366                    g.bullet,
367                    r.unavailable
368                ),
369                inner - LABEL_WIDTH - 2,
370                g.ellipsis,
371            ),
372        ));
373    }
374
375    // ---- where things are
376    let running: Vec<&super::ServiceState> = environment
377        .services
378        .iter()
379        .filter(|s| s.url.is_some() && s.id != "index" && s.id != "openapi")
380        .collect();
381    if !running.is_empty() {
382        rows.push(Row::Blank);
383        for service in running {
384            let url = service.url.as_deref().unwrap_or_default();
385            let mark = match service.availability {
386                ServiceAvailability::Available => g.yes,
387                ServiceAvailability::NotRunning => g.no,
388                ServiceAvailability::Unknown => g.unknown,
389            };
390            rows.push(Row::Field(
391                sanitise(&service.id),
392                format!("{} {mark}", sanitise(url)),
393            ));
394        }
395    }
396
397    // ---- what to run
398    let entrypoints = &environment.workflows.entrypoints;
399    if !entrypoints.is_empty() {
400        rows.push(Row::Divider);
401        for entry in entrypoints.iter().take(4) {
402            rows.push(Row::Command(
403                sanitise(&entry.command),
404                truncate_with(
405                    &sanitise(entry.description.as_deref().unwrap_or_default()),
406                    inner.saturating_sub(COMMAND_WIDTH + 2),
407                    g.ellipsis,
408                ),
409            ));
410        }
411    }
412
413    // ---- anything wrong
414    if let Some(worst) = environment.headline_diagnostic() {
415        rows.push(Row::Divider);
416        rows.push(Row::Field(
417            match worst.severity {
418                Severity::Error => g.no.into(),
419                _ => g.unknown.into(),
420            },
421            truncate_with(
422                &sanitise(&worst.message),
423                inner.saturating_sub(6),
424                g.ellipsis,
425            ),
426        ));
427    }
428
429    draw(&rows, inner, g, presentation)
430}
431
432/// A label column and a value column, both padded, for a row that carries two pairs.
433fn two_columns(first: &str, label: &str, second: &str, budget: usize, g: &Glyphs) -> String {
434    let left = budget.saturating_sub(width(label) + 2).min(budget) / 2 + 4;
435    format!(
436        "{}{label}  {}",
437        pad_with(first, left.min(budget), g.ellipsis),
438        truncate_with(
439            second,
440            budget.saturating_sub(left + width(label) + 2),
441            g.ellipsis
442        )
443    )
444}
445
446/// `✱ 3 modified · 2 staged · ↑2 ↓1`, or `clean`.
447fn git_line(environment: &RepositoryEnvironment, g: &Glyphs) -> String {
448    let Some(tree) = environment.vcs.tree() else {
449        return match &environment.vcs {
450            VcsState::Unavailable { reason } => truncate_with(&sanitise(reason), 50, g.ellipsis),
451            VcsState::Git(_) => unreachable!("the tree was just taken"),
452        };
453    };
454    let mut parts: Vec<String> = Vec::new();
455    if tree.clean {
456        parts.push("clean".into());
457    } else {
458        let mut dirt: Vec<String> = Vec::new();
459        if tree.staged > 0 {
460            dirt.push(format!("{} staged", tree.staged));
461        }
462        if tree.modified > 0 {
463            dirt.push(format!("{} modified", tree.modified));
464        }
465        if tree.untracked > 0 {
466            dirt.push(format!("{} untracked", tree.untracked));
467        }
468        if tree.conflicted > 0 {
469            dirt.push(format!("{} conflicted", tree.conflicted));
470        }
471        parts.push(format!("{} {}", g.dirty, dirt.join(", ")));
472    }
473    match (tree.ahead, tree.behind) {
474        (Some(0), Some(0)) => parts.push("in step".into()),
475        (Some(a), Some(b)) => parts.push(format!("{}{a} {}{b}", g.ahead, g.behind)),
476        // No upstream is not "in step with nothing"; it is a branch nobody else has.
477        _ => parts.push("no upstream".into()),
478    }
479    parts.join(&format!(" {} ", g.bullet))
480}
481
482/// `Rust 1.90.0 · Node 22.20.0`, showing only what the repository declares.
483fn toolchain_line(environment: &RepositoryEnvironment, g: &Glyphs) -> String {
484    environment
485        .toolchains
486        .iter()
487        .map(|t| {
488            let version = match (&t.installed, t.availability) {
489                (Some(v), ToolchainAvailability::Mismatch) => format!(
490                    "{} {} {}",
491                    sanitise(v),
492                    g.no,
493                    t.declared.as_deref().map(sanitise).unwrap_or_default()
494                ),
495                (Some(v), _) => sanitise(v),
496                (None, ToolchainAvailability::Missing) => format!("{} missing", g.no),
497                (None, _) => t
498                    .declared
499                    .as_deref()
500                    .map(|d| format!("{} declared", sanitise(d)))
501                    .unwrap_or_else(|| g.unresolved.into()),
502            };
503            format!("{} {version}", sanitise(&t.title))
504        })
505        .collect::<Vec<_>>()
506        .join(&format!(" {} ", g.bullet))
507}
508
509/// `rule 87 · use-case 38 · adr 19`, largest first, or a note that nothing counted them.
510fn layer_line(environment: &RepositoryEnvironment, g: &Glyphs, budget: usize) -> String {
511    if environment.layer.state == TierState::Unavailable {
512        return format!("{}  (run `majordomus env status` to count)", g.unresolved);
513    }
514    let mut kinds = environment.layer.kinds.clone();
515    // Largest first: what a repository is mostly made of is what a person wants to see
516    // when the line has to be cut. The rank is the negated count, so the canonical order —
517    // which ranks ascending and ends on an identity — reads the same way here as everywhere.
518    crate::order::canonical(&mut kinds);
519    let mut line = String::new();
520    for kind in &kinds {
521        let part = format!("{} {}", sanitise(&kind.kind), kind.count);
522        let separator = if line.is_empty() {
523            String::new()
524        } else {
525            format!(" {} ", g.bullet)
526        };
527        if width(&line) + width(&separator) + width(&part) > budget {
528            break;
529        }
530        line.push_str(&separator);
531        line.push_str(&part);
532    }
533    if line.is_empty() {
534        return g.unresolved.into();
535    }
536    line
537}
538
539/// `AGENTS.md ✓ · CLAUDE.md ✗`.
540fn provider_line(environment: &RepositoryEnvironment, g: &Glyphs) -> String {
541    environment
542        .providers
543        .iter()
544        .map(|p| {
545            let mark = match p.state {
546                ProjectionState::Current => g.yes,
547                ProjectionState::Stale | ProjectionState::Absent => g.no,
548                ProjectionState::Unknown => g.unknown,
549            };
550            format!("{} {mark}", sanitise(&p.target))
551        })
552        .collect::<Vec<_>>()
553        .join(&format!(" {} ", g.bullet))
554}
555
556/// Draw the rows inside a box.
557fn draw(rows: &[Row], inner: usize, g: &Glyphs, presentation: &Presentation) -> String {
558    let dim = |s: &str| -> String {
559        if presentation.colour {
560            format!("\u{1b}[2m{s}\u{1b}[0m")
561        } else {
562            s.to_string()
563        }
564    };
565    let bold = |s: &str| -> String {
566        if presentation.colour {
567            format!("\u{1b}[1m{s}\u{1b}[0m")
568        } else {
569            s.to_string()
570        }
571    };
572    let mut out = String::new();
573    let bar = g.horizontal.repeat(inner);
574    let _ = writeln!(out, "{}{bar}{}", g.top_left, g.top_right);
575    for (i, row) in rows.iter().enumerate() {
576        match row {
577            Row::Divider => {
578                let _ = writeln!(out, "{}{bar}{}", g.tee_left, g.tee_right);
579            }
580            Row::Blank => {
581                let _ = writeln!(out, "{}{}{}", g.vertical, " ".repeat(inner), g.vertical);
582            }
583            Row::Field(label, value) if value.is_empty() => {
584                // A heading row: the label is the whole line.
585                let text = pad_with(&format!("  {label}"), inner, g.ellipsis);
586                let painted = if i == 0 { bold(&text) } else { dim(&text) };
587                let _ = writeln!(out, "{}{painted}{}", g.vertical, g.vertical);
588            }
589            Row::Field(label, value) | Row::Command(label, value) => {
590                let column = match row {
591                    Row::Command(_, _) => COMMAND_WIDTH,
592                    _ => LABEL_WIDTH,
593                };
594                let padded_label = pad_with(&format!("  {label}"), column, g.ellipsis);
595                let padded_value = pad_with(value, inner.saturating_sub(column), g.ellipsis);
596                let _ = writeln!(
597                    out,
598                    "{}{}{padded_value}{}",
599                    g.vertical,
600                    dim(&padded_label),
601                    g.vertical
602                );
603            }
604        }
605    }
606    let _ = writeln!(out, "{}{bar}{}", g.bottom_left, g.bottom_right);
607    out
608}
609
610/// The two-line form.
611fn compact(environment: &RepositoryEnvironment, presentation: &Presentation) -> String {
612    let g = glyphs(presentation);
613    let budget = presentation.columns.clamp(20, MAX_WIDTH + 20);
614    let mut parts: Vec<String> = vec![sanitise(&environment.repository.name)];
615    if let Some(tree) = environment.vcs.tree() {
616        let mut state = tree
617            .branch
618            .clone()
619            .map(|b| sanitise(&b))
620            .unwrap_or_else(|| "detached".into());
621        if !tree.clean {
622            let _ = write!(state, " {}{}", g.dirty, tree.dirty_files() + tree.untracked);
623        }
624        if let (Some(a), Some(b)) = (tree.ahead, tree.behind) {
625            if a > 0 {
626                let _ = write!(state, " {}{a}", g.ahead);
627            }
628            if b > 0 {
629                let _ = write!(state, " {}{b}", g.behind);
630            }
631        }
632        parts.push(state);
633    }
634    if let Some(rust) = environment
635        .toolchains
636        .iter()
637        .find_map(|t| t.installed.as_ref().map(|v| format!("{} {}", t.title, v)))
638    {
639        parts.push(sanitise(&rust));
640    }
641    if let Some(objects) = environment.layer.objects {
642        parts.push(format!("{objects} objects"));
643    }
644    if let Some(cockpit) = environment.service("cockpit") {
645        if cockpit.availability == ServiceAvailability::Available {
646            parts.push(format!("cockpit {}", g.yes));
647        }
648    }
649    let separator = format!(" {} ", g.bullet);
650    let head = format!("{} {}", g.mark, parts.join(&separator));
651
652    let commands: Vec<String> = environment
653        .workflows
654        .entrypoints
655        .iter()
656        .take(3)
657        .map(|e| sanitise(&e.command))
658        .collect();
659    let mut out = truncate_with(&head, budget, g.ellipsis);
660    if !commands.is_empty() {
661        let _ = write!(
662            out,
663            "\n{}",
664            truncate_with(
665                &format!("  {}", commands.join(&separator)),
666                budget,
667                g.ellipsis
668            )
669        );
670    }
671    if let Some(worst) = environment.headline_diagnostic() {
672        let _ = write!(
673            out,
674            "\n{}",
675            truncate_with(
676                &format!("  {} {}", g.unknown, sanitise(&worst.message)),
677                budget,
678                g.ellipsis
679            )
680        );
681    }
682    out.push('\n');
683    out
684}
685
686fn glyphs(presentation: &Presentation) -> &'static Glyphs {
687    if presentation.unicode {
688        &UNICODE
689    } else {
690        &ASCII
691    }
692}
693
694fn short(commit: &str) -> String {
695    commit.chars().take(12).collect()
696}
697
698/// A snapshot with something in every section, for the tests of this module and of the
699/// ones that render or export the same value. Written once: three modules asserting
700/// against three hand-built snapshots would be asserting against three different products.
701#[cfg(test)]
702pub(crate) mod tests_support {
703    use super::*;
704    use crate::environment::{
705        GitWorkingTree, KindCount, LayerSummary, ProjectIdentity, RepositoryIdentity, Resolution,
706        ServiceState, ToolchainState, WorkflowCatalogue, WorkflowEntrypoint,
707    };
708
709    /// A fully populated snapshot of a repository that does not exist.
710    pub(crate) fn environment() -> RepositoryEnvironment {
711        RepositoryEnvironment {
712            schema: RepositoryEnvironment::schema_id(),
713            generated_at: "2026-09-06T12:00:00Z".into(),
714            resolution: Resolution::Full,
715            project: ProjectIdentity::of_this_build(),
716            repository: RepositoryIdentity {
717                name: "prismatic-majordomus".into(),
718                root: "/somewhere/prismatic-majordomus".into(),
719                layer_schema: "ai-repository/v1".into(),
720                sections: Default::default(),
721                local_path: ".ai/local".into(),
722                linked_worktree: false,
723            },
724            vcs: VcsState::Git(GitWorkingTree {
725                head: Some("c027ef6eac5d953ca539547bfcecbef727c3d369".into()),
726                branch: Some("master".into()),
727                detached: false,
728                upstream: Some("origin/master".into()),
729                ahead: Some(2),
730                behind: Some(0),
731                staged: 1,
732                modified: 3,
733                untracked: 1,
734                conflicted: 0,
735                clean: false,
736                changed_paths: vec![],
737            }),
738            toolchains: vec![ToolchainState {
739                id: "rust".into(),
740                title: "Rust".into(),
741                declared: Some("1.85".into()),
742                declared_by: "apps/majordomus-cli/Cargo.toml".into(),
743                installed: Some("1.90.0".into()),
744                availability: ToolchainAvailability::Installed,
745            }],
746            layer: LayerSummary {
747                state: TierState::Resolved,
748                kinds: vec![
749                    KindCount {
750                        kind: "rule".into(),
751                        count: 87,
752                    },
753                    KindCount {
754                        kind: "adr".into(),
755                        count: 19,
756                    },
757                ],
758                objects: Some(902),
759                capabilities: Some(934),
760                invalid: Some(0),
761                refused: Vec::new(),
762                degraded: Some(false),
763            },
764            workflows: WorkflowCatalogue {
765                state: TierState::Resolved,
766                source: Some("just".into()),
767                workflows: vec![],
768                entrypoints: vec![WorkflowEntrypoint {
769                    group: "check".into(),
770                    workflow: "check".into(),
771                    command: "just check".into(),
772                    description: Some("Every gate.".into()),
773                }],
774            },
775            providers: vec![],
776            services: vec![
777                ServiceState {
778                    id: "cockpit".into(),
779                    title: "Cockpit".into(),
780                    path: "/cockpit".into(),
781                    url: Some("http://127.0.0.1:8741/cockpit".into()),
782                    availability: ServiceAvailability::Available,
783                },
784                ServiceState {
785                    id: "index".into(),
786                    title: "Home page".into(),
787                    path: "/".into(),
788                    url: Some("http://127.0.0.1:8741/".into()),
789                    availability: ServiceAvailability::Available,
790                },
791            ],
792            reasoning: None,
793            diagnostics: vec![],
794            provenance: vec![],
795        }
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::tests_support::environment;
802    use super::*;
803    use crate::environment::LayerSummary;
804
805    fn plain(columns: usize) -> Presentation {
806        Presentation {
807            colour: false,
808            unicode: true,
809            columns,
810            interactive: true,
811        }
812    }
813
814    /// The box has to be a box: every line exactly as wide as every other, whatever is in
815    /// it. Measured in columns, which is the whole reason `text::width` exists.
816    #[test]
817    fn every_line_of_the_box_is_the_same_width() {
818        for columns in [MIN_BOX_WIDTH, 60, 78, 200] {
819            let drawn =
820                banner(&environment(), BannerMode::Full, &plain(columns), None).expect("a banner");
821            let widths: Vec<usize> = drawn.lines().map(width).collect();
822            let first = widths[0];
823            assert!(
824                widths.iter().all(|w| *w == first),
825                "at {columns} columns the lines are {widths:?}"
826            );
827            assert!(
828                first <= columns.max(MIN_BOX_WIDTH),
829                "wider than the terminal"
830            );
831        }
832    }
833
834    #[test]
835    fn a_narrow_terminal_gets_the_plain_form_rather_than_a_broken_box() {
836        let drawn = banner(&environment(), BannerMode::Full, &plain(30), None).expect("a banner");
837        assert!(!drawn.contains('╭'), "a box was drawn into 30 columns");
838        for line in drawn.lines() {
839            assert!(width(line) <= 30, "{line:?} overflows 30 columns");
840        }
841    }
842
843    /// The rule the whole model exists to keep: a count that was not counted is never
844    /// rendered as a zero.
845    #[test]
846    fn an_unresolved_count_is_shown_as_unknown_and_never_as_zero() {
847        let mut env = environment();
848        env.layer = LayerSummary::unavailable();
849        let drawn = banner(&env, BannerMode::Full, &plain(78), None).expect("a banner");
850        assert!(drawn.contains("—"));
851        assert!(
852            !drawn.contains("rule 0") && !drawn.contains("0 objects"),
853            "{drawn}"
854        );
855    }
856
857    #[test]
858    fn no_colour_means_no_escape_sequence_anywhere() {
859        let drawn = banner(&environment(), BannerMode::Full, &plain(78), None).expect("a banner");
860        assert!(!drawn.contains('\u{1b}'));
861        let coloured = banner(
862            &environment(),
863            BannerMode::Full,
864            &Presentation {
865                colour: true,
866                ..plain(78)
867            },
868            None,
869        )
870        .expect("a banner");
871        assert!(coloured.contains('\u{1b}'), "colour was asked for");
872    }
873
874    #[test]
875    fn an_ascii_terminal_gets_no_character_outside_ascii() {
876        let drawn = banner(
877            &environment(),
878            BannerMode::Full,
879            &Presentation {
880                unicode: false,
881                ..plain(78)
882            },
883            None,
884        )
885        .expect("a banner");
886        assert!(drawn.is_ascii(), "{drawn}");
887    }
888
889    /// The banner prints repository metadata, and repository metadata is not trusted.
890    #[test]
891    fn a_hostile_branch_name_cannot_reach_the_terminal_through_the_banner() {
892        let mut env = environment();
893        if let VcsState::Git(tree) = &mut env.vcs {
894            tree.branch = Some("\u{1b}]0;pwned\u{7}evil".into());
895        }
896        env.repository.name = "\u{1b}[2Jwiped".into();
897        let drawn = banner(&env, BannerMode::Full, &plain(78), None).expect("a banner");
898        assert!(!drawn.contains('\u{1b}'), "an escape reached the output");
899        assert!(drawn.contains("evil"), "the readable part is still shown");
900    }
901
902    #[test]
903    fn off_renders_nothing_at_all() {
904        assert_eq!(
905            banner(&environment(), BannerMode::Off, &plain(78), None),
906            None
907        );
908    }
909
910    #[test]
911    fn auto_is_silent_when_nothing_is_watching() {
912        let piped = Presentation {
913            interactive: false,
914            ..plain(78)
915        };
916        assert_eq!(banner(&environment(), BannerMode::Auto, &piped, None), None);
917    }
918
919    #[test]
920    fn auto_shows_the_box_once_and_the_short_form_on_a_return() {
921        let env = environment();
922        let first = banner(&env, BannerMode::Auto, &plain(78), None).expect("a banner");
923        assert!(first.lines().count() > 5, "a first look gets the box");
924        let again =
925            banner(&env, BannerMode::Auto, &plain(78), Some(&env.news_digest())).expect("a banner");
926        assert!(again.lines().count() <= 3, "a return gets the short form");
927        let changed = banner(
928            &env,
929            BannerMode::Auto,
930            &plain(78),
931            Some("a different digest"),
932        )
933        .expect("a banner");
934        assert!(
935            changed.lines().count() > 5,
936            "a changed repository is news again"
937        );
938    }
939
940    #[test]
941    fn a_server_coming_up_after_the_box_is_not_a_second_box() {
942        // What direnv did on a cold entry: the box with no server, then the lease moved
943        // under `watch_file` and entry ran again with the address published.
944        let seen = environment();
945        let mut arrived = seen.clone();
946        for service in &mut arrived.services {
947            service.url = None;
948            service.availability = ServiceAvailability::NotRunning;
949        }
950        assert_ne!(seen.digest(), arrived.digest(), "the snapshots do differ");
951        let again = banner(
952            &arrived,
953            BannerMode::Auto,
954            &plain(78),
955            Some(&seen.news_digest()),
956        )
957        .expect("a banner");
958        assert!(
959            again.lines().count() <= 3,
960            "a server coming or going redrew the box:\n{again}"
961        );
962    }
963
964    #[test]
965    fn the_compact_form_fits_the_terminal_it_was_given() {
966        for columns in [20, 40, 80, 200] {
967            let drawn = banner(&environment(), BannerMode::Compact, &plain(columns), None)
968                .expect("a banner");
969            for line in drawn.lines() {
970                assert!(width(line) <= columns, "{line:?} overflows {columns}");
971            }
972        }
973    }
974
975    #[test]
976    fn the_commands_shown_are_the_ones_the_snapshot_carries() {
977        let mut env = environment();
978        let drawn = banner(&env, BannerMode::Full, &plain(78), None).expect("a banner");
979        assert!(drawn.contains("just check"));
980        env.workflows.entrypoints.clear();
981        let bare = banner(&env, BannerMode::Full, &plain(78), None).expect("a banner");
982        assert!(
983            !bare.contains("just "),
984            "a snapshot with no workflows must not suggest one"
985        );
986    }
987
988    #[test]
989    fn a_repository_without_git_still_renders() {
990        let mut env = environment();
991        env.vcs = VcsState::Unavailable {
992            reason: "not a work tree".into(),
993        };
994        let drawn = banner(&env, BannerMode::Full, &plain(78), None).expect("a banner");
995        assert!(drawn.contains("not a work tree"));
996        let short = banner(&env, BannerMode::Compact, &plain(78), None).expect("a banner");
997        assert!(!short.is_empty());
998    }
999}