Skip to main content

lattice_plugin_manager/
render.rs

1//! PL8.H.2 — render a `Vec<PluginStatus>` (the loader's read model) into the
2//! `*plugins*` buffer's text. Pure + presentation-only: the loader hands over
3//! structured `PluginStatus` (typed capabilities + health), and the view owns
4//! how it looks. No I/O, no allocation on any hot path (this runs off-thread on
5//! mode activation / a crash re-render).
6
7use lattice_plugin_host::{Capability, TrustTier};
8use lattice_plugin_loader::{FailedLoad, PluginHealth, PluginStatus};
9
10/// The synthetic buffer's user-facing name and the major mode that owns it.
11pub const PLUGINS_BUFFER_NAME: &str = "*plugins*";
12pub const PLUGINS_MODE_ID: &str = "plugins-mode";
13
14/// Buffer lines the header occupies before the first plugin row: the title
15/// (`# Plugins (N loaded)`), a blank line, and the column header. The
16/// interactivity layer (PL8.H.3) maps `cursor.line - HEADER_LINES` → the plugin
17/// at that index (render order == `plugin_status()` order), so this MUST match
18/// the header `render_status` emits — pinned by `header_occupies_exactly_three_lines`.
19pub const HEADER_LINES: usize = 3;
20
21/// Short health label for the status column. Kept terse; the crash provenance
22/// (which export trapped, and how) trails the row so the column stays narrow.
23fn health_label(health: &PluginHealth) -> &'static str {
24    match health {
25        PluginHealth::Healthy => "ok",
26        PluginHealth::Quarantined { .. } => "quarantined",
27    }
28}
29
30/// The trust tier as the wire word used in the manifest / `:plugin-load` docs.
31fn tier_label(tier: TrustTier) -> &'static str {
32    match tier {
33        TrustTier::Bundled => "bundled",
34        TrustTier::UserInstalled => "user-installed",
35    }
36}
37
38/// The capability cell: granted capabilities in wire form (`Capability`'s
39/// `Display`), then any denied ones in a trailing `(denied: …)` note. Empty
40/// grant renders as `—` so the column never looks blank-by-accident.
41fn caps_cell(granted: &[Capability], denied: &[Capability]) -> String {
42    let join = |caps: &[Capability]| {
43        caps.iter()
44            .map(|c| c.to_string())
45            .collect::<Vec<_>>()
46            .join(", ")
47    };
48    let mut cell = if granted.is_empty() {
49        "—".to_string()
50    } else {
51        join(granted)
52    };
53    if !denied.is_empty() {
54        cell.push_str(&format!("  (denied: {})", join(denied)));
55    }
56    cell
57}
58
59/// The crash-provenance suffix for a quarantined plugin (`[trap: <kind> in
60/// <func>]`), empty for a healthy one — trails the row so a glance down the
61/// HEALTH column still reads cleanly.
62fn crash_suffix(health: &PluginHealth) -> String {
63    match health {
64        PluginHealth::Healthy => String::new(),
65        PluginHealth::Quarantined { func, kind } => format!("  [trap: {kind} in {func}]"),
66    }
67}
68
69/// Render the whole `*plugins*` buffer text from the current status snapshot.
70/// Column widths adapt to the content (name + tier), so the table stays aligned
71/// whether one plugin is loaded or fifty. The empty state is an explicit line,
72/// not a bare header.
73pub fn render_status(plugins: &[PluginStatus]) -> String {
74    render_status_with_failures(plugins, &[])
75}
76
77/// WT.4: [`render_status`] plus a trailing section for plugins that tried to
78/// load and could not.
79///
80/// **Trailing, and deliberately so.** The interactivity layer maps
81/// `cursor.line - HEADER_LINES` into the loaded-plugin list, so anything
82/// inserted above or between the rows would put chords on the wrong plugin.
83/// Failed entries have no host id to unload or reload anyway — they are a
84/// report, not a row you can act on — so appending them costs the mapping
85/// nothing.
86///
87/// Why it exists at all: a plugin that failed to load is otherwise
88/// indistinguishable from one that was never installed. That is precisely what
89/// made the reported failure take a debugging session — org was absent, and
90/// absent looks the same either way.
91pub fn render_status_with_failures(plugins: &[PluginStatus], failed: &[FailedLoad]) -> String {
92    render_status_full(plugins, failed, None)
93}
94
95/// [`render_status_with_failures`] with a bulk run's progress on the title
96/// line.
97///
98/// **On the title line, and not a line of its own**, because the
99/// interactivity layer maps `cursor.line - HEADER_LINES` into the plugin list:
100/// an extra header row would silently put every chord on the wrong plugin, and
101/// it would do so only while a bulk run was in flight — a bug that appears and
102/// disappears. Widening a line the header already has costs the mapping
103/// nothing.
104///
105/// This is the view-header surface the async-buffer rule asks for: a bulk run
106/// says where it is in the buffer the user is looking at, not in the status
107/// line and not as a notification.
108pub fn render_status_full(
109    plugins: &[PluginStatus],
110    failed: &[FailedLoad],
111    progress: Option<&str>,
112) -> String {
113    render_status_styled(plugins, failed, progress).text
114}
115
116/// The text **and** the per-line highlight spans, built together.
117///
118/// Two views of one construction, deliberately. The spans carry byte offsets
119/// into the text, so computing them separately from a second pass over the
120/// same data is how the two drift apart — a span that is correct for the text
121/// the author had in mind and wrong for the text that shipped colours the
122/// neighbouring column and nobody notices.
123///
124/// `spans[i]` styles line `i`. Lines with nothing to style carry an empty vec
125/// rather than being absent, because the consumer indexes by line.
126pub struct RenderedStatus {
127    pub text: String,
128    pub spans: Vec<Vec<lattice_cells::StyledSpan>>,
129}
130
131/// A cell written into a line, with the span covering its **content** and not
132/// the padding that follows it — a span over trailing blanks would paint
133/// whitespace between columns.
134fn push_cell(
135    line: &mut String,
136    spans: &mut Vec<lattice_cells::StyledSpan>,
137    text: &str,
138    width: usize,
139    style: Option<lattice_cells::Style>,
140) {
141    let start = line.len();
142    line.push_str(text);
143    if let Some(style) = style {
144        spans.push(lattice_cells::StyledSpan {
145            start,
146            end: line.len(),
147            style,
148        });
149    }
150    // `{:<width$}` pads by char count; pad explicitly so the offsets recorded
151    // above stay byte offsets regardless of what is in the cell.
152    for _ in text.chars().count()..width {
153        line.push(' ');
154    }
155}
156
157/// `None` for a healthy plugin — deliberately.
158///
159/// Eleven rows reading `ok` do not need attention drawn to them; the one
160/// reading `quarantined` does. Decorating the normal case spends the reader's
161/// attention on the rows they do not have to act on, and leaves less contrast
162/// for the row they do.
163///
164/// It also keeps the diff style family out of this view. `Style::DiffAdd`
165/// resolves to `diff.add.text` (`spec().fg("green")`, foreground-only), so it
166/// *should* have been safe — but a diff style on a non-diff surface invites
167/// exactly the confusion it caused, and the styles that remain here
168/// (`DiagnosticError`, `DiagnosticWarning`, `Comment`, `Type`, `Constant`)
169/// are all plain `fg`/`bold` specs.
170fn health_style(health: &PluginHealth) -> Option<lattice_cells::Style> {
171    match health {
172        PluginHealth::Healthy => None,
173        PluginHealth::Quarantined { .. } => Some(lattice_cells::Style::DiagnosticError),
174    }
175}
176
177pub fn render_status_styled(
178    plugins: &[PluginStatus],
179    failed: &[FailedLoad],
180    progress: Option<&str>,
181) -> RenderedStatus {
182    let note = progress.map(|p| format!(" — {p}")).unwrap_or_default();
183    let mut out = format!("# Plugins ({} loaded){note}\n\n", plugins.len());
184    let mut spans: Vec<Vec<lattice_cells::StyledSpan>> = vec![
185        // The title reads as a heading, so it is styled as one.
186        vec![lattice_cells::StyledSpan {
187            start: 0,
188            end: out.lines().next().map(str::len).unwrap_or(0),
189            style: lattice_cells::Style::Heading1,
190        }],
191        Vec::new(),
192    ];
193    if plugins.is_empty() {
194        out.push_str("No plugins are loaded. Load one with `:plugin-load <path>`.\n");
195        spans.push(Vec::new());
196        let (text, fail_spans) = failures_section(failed);
197        out.push_str(&text);
198        spans.extend(fail_spans);
199        return RenderedStatus { text: out, spans };
200    }
201
202    // Column widths: adapt name + tier to their content (health is fixed-vocab).
203    let name_w = plugins
204        .iter()
205        .map(|p| p.name.len())
206        .chain(std::iter::once("NAME".len()))
207        .max()
208        .unwrap_or(4);
209    let health_w = "quarantined".len();
210    let tier_w = plugins
211        .iter()
212        .map(|p| tier_label(p.tier).len())
213        .chain(std::iter::once("TIER".len()))
214        .max()
215        .unwrap_or(7);
216    // PM.8a: SOURCE + BUILD. They sit between TIER and CAPABILITIES rather
217    // than at the end because CAPABILITIES is the one variable-length cell
218    // (it trails a `(denied: …)` note), so anything after it would not line
219    // up down the table.
220    let source_w = plugins
221        .iter()
222        .map(|p| p.source.label().len())
223        .chain(std::iter::once("SOURCE".len()))
224        .max()
225        .unwrap_or(6);
226    let build_w = "build-failed".len();
227
228    // The column header: dim, because it is scaffolding rather than content.
229    let dim = lattice_cells::Style::Comment;
230    let mut head = String::from("  ");
231    let mut head_spans = Vec::new();
232    push_cell(&mut head, &mut head_spans, "NAME", name_w, Some(dim));
233    head.push_str("  ");
234    push_cell(&mut head, &mut head_spans, "HEALTH", health_w, Some(dim));
235    head.push_str("  ");
236    push_cell(&mut head, &mut head_spans, "TIER", tier_w, Some(dim));
237    head.push_str("  ");
238    push_cell(&mut head, &mut head_spans, "SOURCE", source_w, Some(dim));
239    head.push_str("  ");
240    push_cell(&mut head, &mut head_spans, "BUILD", build_w, Some(dim));
241    head.push_str("  ");
242    push_cell(&mut head, &mut head_spans, "CAPABILITIES", 0, Some(dim));
243    out.push_str(&head);
244    out.push('\n');
245    spans.push(head_spans);
246
247    for p in plugins {
248        let mut line = String::from("  ");
249        let mut row_spans = Vec::new();
250        // The name identifies the row — the one cell a reader scans for.
251        push_cell(
252            &mut line,
253            &mut row_spans,
254            &p.name,
255            name_w,
256            Some(lattice_cells::Style::Type),
257        );
258        line.push_str("  ");
259        push_cell(
260            &mut line,
261            &mut row_spans,
262            health_label(&p.health),
263            health_w,
264            health_style(&p.health),
265        );
266        line.push_str("  ");
267        push_cell(
268            &mut line,
269            &mut row_spans,
270            tier_label(p.tier),
271            tier_w,
272            // Bundled is the unremarkable default; user-installed is the row
273            // a reader is more likely to be looking for.
274            Some(match p.tier {
275                TrustTier::Bundled => dim,
276                TrustTier::UserInstalled => lattice_cells::Style::Constant,
277            }),
278        );
279        line.push_str("  ");
280        let source = p.source.label();
281        push_cell(&mut line, &mut row_spans, &source, source_w, Some(dim));
282        line.push_str("  ");
283        let build = p.build.label();
284        push_cell(
285            &mut line,
286            &mut row_spans,
287            build,
288            build_w,
289            // A failed build is the actionable state in this column.
290            Some(if build.contains("failed") {
291                lattice_cells::Style::DiagnosticWarning
292            } else {
293                dim
294            }),
295        );
296        line.push_str("  ");
297        // Capabilities and the crash suffix keep the default foreground: the
298        // cell is variable-length prose, and the crash text is already the
299        // longest thing on the row.
300        line.push_str(&caps_cell(&p.granted, &p.denied));
301        line.push_str(&crash_suffix(&p.health));
302        out.push_str(&line);
303        out.push('\n');
304        spans.push(row_spans);
305    }
306
307    let (failures, fail_spans) = failures_section(failed);
308    out.push_str(&failures);
309    // The section builds its own spans, index-aligned with its own lines.
310    spans.extend(fail_spans);
311    RenderedStatus { text: out, spans }
312}
313
314/// The trailing "failed to load" block, empty when nothing failed.
315///
316/// One entry over two lines — name and directory, then the reason indented
317/// under it — rather than a table column. A load error is a sentence (a wasm
318/// trap, a missing import, a manifest complaint), and squeezing sentences into a
319/// fixed-width cell is how the useful half gets truncated away.
320fn failures_section(failed: &[FailedLoad]) -> (String, Vec<Vec<lattice_cells::StyledSpan>>) {
321    if failed.is_empty() {
322        return (String::new(), Vec::new());
323    }
324    let err = lattice_cells::Style::DiagnosticError;
325    let dim = lattice_cells::Style::Comment;
326    let mut out = String::new();
327    let mut spans: Vec<Vec<lattice_cells::StyledSpan>> = Vec::new();
328
329    let heading = format!("## Failed to load ({})", failed.len());
330    out.push('\n');
331    spans.push(Vec::new()); // the blank line before the heading
332    out.push_str(&heading);
333    out.push('\n');
334    spans.push(vec![lattice_cells::StyledSpan {
335        start: 0,
336        end: heading.len(),
337        style: lattice_cells::Style::Heading2,
338    }]);
339    out.push('\n');
340    spans.push(Vec::new());
341
342    for f in failed {
343        // The NAME carries the error colour: these plugins have no row in the
344        // table above, so this line is the only place they exist, and the
345        // header's `N failed to load` count is pointing here.
346        let mut line = String::from("  ");
347        let mut row = Vec::new();
348        let name_at = line.len();
349        line.push_str(&f.name);
350        row.push(lattice_cells::StyledSpan {
351            start: name_at,
352            end: line.len(),
353            style: err,
354        });
355        line.push_str("  (");
356        let dir_at = line.len();
357        line.push_str(&f.dir.display().to_string());
358        row.push(lattice_cells::StyledSpan {
359            start: dir_at,
360            end: line.len(),
361            style: dim,
362        });
363        line.push(')');
364        out.push_str(&line);
365        out.push('\n');
366        spans.push(row);
367
368        // The reason keeps the default foreground: it is the sentence the
369        // reader has to actually read, and dimming it would bury the useful
370        // half of the report under the decoration.
371        out.push_str(&format!("      {}\n", f.error));
372        spans.push(Vec::new());
373    }
374
375    let hint = "If the plugin API changed, run `lattice --wit-sync` and restart to rebuild.";
376    out.push('\n');
377    spans.push(Vec::new());
378    out.push_str(hint);
379    out.push('\n');
380    spans.push(vec![lattice_cells::StyledSpan {
381        start: 0,
382        end: hint.len(),
383        style: dim,
384    }]);
385    (out, spans)
386}
387
388#[cfg(test)]
389mod tests {
390    #![allow(clippy::unwrap_used)]
391    use super::*;
392    use lattice_plugin_loader::{BuildState, SourceRecord};
393
394    fn status(name: &str, tier: TrustTier, health: PluginHealth) -> PluginStatus {
395        PluginStatus {
396            id: 1,
397            name: name.to_string(),
398            tier,
399            granted: vec![],
400            denied: vec![],
401            health,
402            source: SourceRecord::Unknown,
403            build: BuildState::NotBuilt,
404        }
405    }
406
407    /// A row with a known source + build state, for the PM.8a column tests.
408    fn sourced(name: &str, source: SourceRecord, build: BuildState) -> PluginStatus {
409        PluginStatus {
410            source,
411            build,
412            ..status(name, TrustTier::UserInstalled, PluginHealth::Healthy)
413        }
414    }
415
416    /// The span must select exactly the cell's text. An off-by-one here
417    /// colours the gap or the neighbouring column and still looks plausible,
418    /// which is why this slices the line by the span rather than checking the
419    /// style alone.
420    #[test]
421    fn a_health_span_selects_exactly_the_health_cell() {
422        let plugins = vec![
423            status("auto-pair", TrustTier::Bundled, PluginHealth::Healthy),
424            status(
425                "broken",
426                TrustTier::UserInstalled,
427                PluginHealth::Quarantined {
428                    func: "on-key".into(),
429                    kind: "unreachable".into(),
430                },
431            ),
432        ];
433        let r = render_status_styled(&plugins, &[], None);
434        let lines: Vec<&str> = r.text.lines().collect();
435
436        // The quarantined row's health cell is styled, and the span selects
437        // exactly that word.
438        let span = r.spans[HEADER_LINES + 1]
439            .iter()
440            .find(|s| s.style == lattice_cells::Style::DiagnosticError)
441            .expect("a quarantined plugin's health cell is styled");
442        assert_eq!(
443            &lines[HEADER_LINES + 1][span.start..span.end],
444            "quarantined",
445            "the span must cover the health text and nothing else"
446        );
447
448        // The healthy row's is NOT. Nothing on that line may claim the health
449        // column — decorating the normal case is what this asserts against.
450        //
451        // The column offset comes from the HEADER's own HEALTH span rather
452        // than from `find("ok")`, which would match inside a plugin name like
453        // `tokenizer` and quietly test the wrong column.
454        let header_line = lines[HEADER_LINES - 1];
455        let health_at = r.spans[HEADER_LINES - 1]
456            .iter()
457            .map(|s| s.start)
458            .find(|&start| header_line[start..].starts_with("HEALTH"))
459            .expect("the column header has a HEALTH span");
460        assert!(
461            !r.spans[HEADER_LINES]
462                .iter()
463                .any(|s| s.start <= health_at && health_at < s.end),
464            "a healthy plugin's health cell carries no span: {:?}",
465            r.spans[HEADER_LINES]
466        );
467    }
468
469    #[test]
470    fn a_name_span_selects_exactly_the_name() {
471        let plugins = vec![status(
472            "treesitter-context",
473            TrustTier::Bundled,
474            PluginHealth::Healthy,
475        )];
476        let r = render_status_styled(&plugins, &[], None);
477        let line = r.text.lines().nth(HEADER_LINES).unwrap();
478        let span = r.spans[HEADER_LINES]
479            .iter()
480            .find(|s| s.style == lattice_cells::Style::Type)
481            .unwrap();
482        assert_eq!(&line[span.start..span.end], "treesitter-context");
483    }
484
485    /// The consumer indexes spans by line, so a short `spans` silently drops
486    /// the styling of every line past its end.
487    #[test]
488    fn spans_are_index_aligned_with_the_text_lines() {
489        let plugins = vec![
490            status("a", TrustTier::Bundled, PluginHealth::Healthy),
491            status("b", TrustTier::UserInstalled, PluginHealth::Healthy),
492        ];
493        let r = render_status_styled(&plugins, &[], None);
494        assert_eq!(
495            r.spans.len(),
496            r.text.lines().count(),
497            "one span vec per line, empty where there is nothing to style"
498        );
499    }
500
501    #[test]
502    fn the_styled_render_and_the_text_render_agree() {
503        // `render_status_full` delegates, so the two can never diverge — this
504        // pins that it still does rather than growing a second formatter.
505        let plugins = vec![status("a", TrustTier::Bundled, PluginHealth::Healthy)];
506        assert_eq!(
507            render_status_full(&plugins, &[], None),
508            render_status_styled(&plugins, &[], None).text
509        );
510    }
511
512    #[test]
513    fn the_source_and_build_columns_render_their_labels() {
514        let out = render_status(&[
515            sourced(
516                "from-git",
517                SourceRecord::Git {
518                    url: "https://example.invalid/p.git".into(),
519                    rev: Some("abc1234def".into()),
520                },
521                BuildState::Stale,
522            ),
523            sourced("shipped", SourceRecord::Bundled, BuildState::NotBuilt),
524        ]);
525        assert!(out.contains("SOURCE"), "the header names the column: {out}");
526        assert!(out.contains("BUILD"));
527        assert!(out.contains("git@abc1234"), "short rev in the cell: {out}");
528        assert!(out.contains("stale"));
529        assert!(out.contains("bundled"));
530    }
531
532    fn failure(name: &str, error: &str) -> FailedLoad {
533        FailedLoad {
534            name: name.to_string(),
535            dir: std::path::PathBuf::from(format!("/plugins/{name}")),
536            error: error.to_string(),
537        }
538    }
539
540    /// A plugin that failed to load is the most actionable row in the view —
541    /// it has no entry in the table at all, so this line is the only place it
542    /// exists. It shows in the error colour, and the span selects exactly the
543    /// name rather than bleeding into the directory beside it.
544    #[test]
545    fn a_failed_plugins_name_is_shown_in_the_error_colour() {
546        let r = render_status_styled(
547            &[status("fine", TrustTier::Bundled, PluginHealth::Healthy)],
548            &[failure("org", "unknown import `logging`")],
549            None,
550        );
551        let lines: Vec<&str> = r.text.lines().collect();
552        let (i, span) = lines
553            .iter()
554            .enumerate()
555            .find_map(|(i, _)| {
556                r.spans
557                    .get(i)?
558                    .iter()
559                    .find(|s| s.style == lattice_cells::Style::DiagnosticError)
560                    .map(|s| (i, *s))
561            })
562            .expect("the failed plugin's name carries an error span");
563        assert_eq!(
564            &lines[i][span.start..span.end],
565            "org",
566            "the span covers the name and not the directory after it"
567        );
568    }
569
570    /// The failures section builds its own spans by hand, so its alignment is
571    /// the easiest thing on this path to get wrong by one — and a misalignment
572    /// silently styles a neighbouring line.
573    #[test]
574    fn spans_stay_index_aligned_when_a_plugin_failed_to_load() {
575        let r = render_status_styled(
576            &[status("fine", TrustTier::Bundled, PluginHealth::Healthy)],
577            &[
578                failure("org", "unknown import `logging`"),
579                failure("other", "manifest missing `id`"),
580            ],
581            None,
582        );
583        assert_eq!(
584            r.spans.len(),
585            r.text.lines().count(),
586            "one span vec per line, failures included"
587        );
588    }
589
590    /// WT.4: the whole point. A plugin that failed to load must be visibly
591    /// *present and broken*, not absent — absent is indistinguishable from
592    /// never-installed, which is what made the reported failure invisible.
593    #[test]
594    fn a_failed_plugin_is_named_with_its_reason_and_its_directory() {
595        let out = render_status_with_failures(
596            &[status("fine", TrustTier::Bundled, PluginHealth::Healthy)],
597            &[failure(
598                "org",
599                "plugin runtime error: unknown import `logging`",
600            )],
601        );
602        assert!(out.contains("Failed to load (1)"), "{out}");
603        assert!(out.contains("org"), "the plugin is named: {out}");
604        assert!(
605            out.contains("unknown import `logging`"),
606            "the reason survives in full: {out}"
607        );
608        assert!(
609            out.contains("/plugins/org"),
610            "and which copy on disk to go and look at: {out}"
611        );
612        assert!(out.contains("--wit-sync"), "with the repair to try: {out}");
613    }
614
615    /// The failures trail the table. The interactivity layer maps
616    /// `cursor.line - HEADER_LINES` into the loaded list, so a section inserted
617    /// above or between rows would silently put `u` / `r` / `b` on the wrong
618    /// plugin.
619    #[test]
620    fn failures_render_after_every_loaded_row() {
621        let out = render_status_with_failures(
622            &[
623                status("aaa", TrustTier::Bundled, PluginHealth::Healthy),
624                status("zzz", TrustTier::Bundled, PluginHealth::Healthy),
625            ],
626            &[failure("broken", "nope")],
627        );
628        let lines: Vec<&str> = out.lines().collect();
629        let last_row = lines.iter().rposition(|l| l.contains("zzz")).unwrap();
630        let section = lines
631            .iter()
632            .position(|l| l.contains("Failed to load"))
633            .unwrap();
634        assert!(section > last_row, "failures come last:\n{out}");
635        assert!(
636            lines[HEADER_LINES].contains("aaa"),
637            "and the first row still lands at HEADER_LINES: {out}"
638        );
639    }
640
641    /// Nothing failed, nothing said. A permanent empty "Failed to load (0)"
642    /// heading would train the eye to skip the section that matters.
643    #[test]
644    fn no_failures_renders_no_section() {
645        let out = render_status_with_failures(
646            &[status("fine", TrustTier::Bundled, PluginHealth::Healthy)],
647            &[],
648        );
649        assert!(!out.contains("Failed to load"), "{out}");
650        assert_eq!(
651            out,
652            render_status(&[status("fine", TrustTier::Bundled, PluginHealth::Healthy)]),
653            "and it is byte-identical to the no-failures renderer"
654        );
655    }
656
657    /// The empty-loaded-set case still reports failures — and this is the
658    /// combination the reported failure actually produced: init.rs died, so
659    /// nothing it required installed, so NOTHING was loaded. "No plugins are
660    /// loaded" alone would have been true and useless.
661    #[test]
662    fn a_failure_shows_even_when_nothing_loaded() {
663        let out = render_status_with_failures(&[], &[failure("init", "would not instantiate")]);
664        assert!(out.contains("No plugins are loaded"), "{out}");
665        assert!(out.contains("Failed to load (1)"), "{out}");
666        assert!(out.contains("would not instantiate"), "{out}");
667    }
668
669    #[test]
670    fn an_unknown_source_renders_a_dash_not_a_blank() {
671        // A blank cell reads as a rendering bug; an em dash reads as "we do
672        // not know", which is the truth for a hand-installed plugin.
673        let out = render_status(&[sourced(
674            "hand-installed",
675            SourceRecord::Unknown,
676            BuildState::NotBuilt,
677        )]);
678        let row = out
679            .lines()
680            .find(|l| l.contains("hand-installed"))
681            .expect("row present");
682        assert!(row.contains('—'), "got: {row:?}");
683    }
684
685    #[test]
686    fn the_in_flight_states_render_distinctly() {
687        // `building…` and `build-failed` are what a user sees after pressing
688        // `b`; if either rendered as `—` the chord would look inert.
689        let out = render_status(&[
690            sourced(
691                "busy",
692                SourceRecord::Local("/x".into()),
693                BuildState::Building,
694            ),
695            sourced(
696                "broke",
697                SourceRecord::Local("/y".into()),
698                BuildState::Failed,
699            ),
700        ]);
701        assert!(out.contains("building…"), "got: {out}");
702        assert!(out.contains("build-failed"), "got: {out}");
703    }
704
705    #[test]
706    fn capabilities_stay_last_so_the_table_stays_aligned() {
707        // CAPABILITIES is the only variable-length cell (it trails a
708        // `(denied: …)` note), so a column added after it would not line up.
709        let out = render_status(&[sourced(
710            "p",
711            SourceRecord::Local("/x".into()),
712            BuildState::Cached,
713        )]);
714        let header = out.lines().nth(2).expect("column header");
715        let src_at = header.find("SOURCE").expect("SOURCE present");
716        let build_at = header.find("BUILD").expect("BUILD present");
717        let caps_at = header.find("CAPABILITIES").expect("CAPABILITIES present");
718        assert!(src_at < build_at && build_at < caps_at, "got: {header:?}");
719    }
720
721    #[test]
722    fn empty_state_is_explicit() {
723        let out = render_status(&[]);
724        assert!(out.contains("# Plugins (0 loaded)"));
725        assert!(out.contains("No plugins are loaded"));
726    }
727
728    #[test]
729    fn renders_a_row_per_plugin_with_health_and_tier() {
730        let plugins = vec![
731            status("fuzzy-finder", TrustTier::Bundled, PluginHealth::Healthy),
732            status(
733                "git-blame",
734                TrustTier::UserInstalled,
735                PluginHealth::Quarantined {
736                    func: "on-event".into(),
737                    kind: "fuel".into(),
738                },
739            ),
740        ];
741        let out = render_status(&plugins);
742        assert!(out.contains("# Plugins (2 loaded)"));
743        // Healthy row.
744        assert!(out.contains("fuzzy-finder"));
745        assert!(out.contains("bundled"));
746        // Quarantined row shows the state AND the trap provenance suffix.
747        let crashed_line = out
748            .lines()
749            .find(|l| l.contains("git-blame"))
750            .expect("git-blame row present");
751        assert!(crashed_line.contains("quarantined"));
752        assert!(crashed_line.contains("user-installed"));
753        assert!(crashed_line.contains("[trap: fuel in on-event]"));
754    }
755
756    #[test]
757    fn capabilities_show_granted_and_denied() {
758        let mut s = status(
759            "cap-plugin",
760            TrustTier::UserInstalled,
761            PluginHealth::Healthy,
762        );
763        s.granted = vec![Capability::NetHttp("crates.io".into())];
764        s.denied = vec![Capability::ProcSpawn];
765        let out = render_status(&[s]);
766        let row = out.lines().find(|l| l.contains("cap-plugin")).unwrap();
767        assert!(
768            row.contains("net:http:crates.io"),
769            "granted cap in wire form"
770        );
771        assert!(row.contains("(denied: proc:spawn)"), "denied cap noted");
772    }
773
774    #[test]
775    fn header_occupies_exactly_three_lines() {
776        // The interactivity layer relies on the first plugin row landing at
777        // line index `HEADER_LINES`. Pin it against a render drift.
778        let out = render_status(&[status("first", TrustTier::Bundled, PluginHealth::Healthy)]);
779        let lines: Vec<&str> = out.lines().collect();
780        assert!(lines[0].starts_with("# Plugins"), "line 0 is the title");
781        assert!(lines[1].is_empty(), "line 1 is blank");
782        assert!(lines[2].contains("NAME"), "line 2 is the column header");
783        assert!(
784            lines[HEADER_LINES].contains("first"),
785            "the first plugin row lands at line HEADER_LINES"
786        );
787    }
788
789    /// The progress note goes ON the title line, so the header stays exactly
790    /// three lines and the cursor → plugin mapping is unchanged while a bulk
791    /// run is in flight.
792    ///
793    /// An extra header row would put every chord on the wrong plugin, and only
794    /// for the duration of the run — a bug that appears and disappears, which
795    /// is the worst kind to be handed a report about.
796    #[test]
797    fn a_progress_note_widens_the_title_without_adding_a_line() {
798        let plugins = [status("first", TrustTier::Bundled, PluginHealth::Healthy)];
799        let out = render_status_full(&plugins, &[], Some("updating 1/3 (org)…"));
800        let lines: Vec<&str> = out.lines().collect();
801        assert!(
802            lines[0].starts_with("# Plugins"),
803            "line 0 is still the title"
804        );
805        assert!(
806            lines[0].contains("updating 1/3 (org)…"),
807            "the note rides the title: {:?}",
808            lines[0]
809        );
810        assert!(lines[1].is_empty(), "line 1 is still blank");
811        assert!(
812            lines[HEADER_LINES].contains("first"),
813            "the first plugin row is still at line HEADER_LINES: {:?}",
814            lines[HEADER_LINES]
815        );
816    }
817
818    /// No run in flight, no note — and byte-identical to the plain render, so
819    /// clearing progress cannot leave a stray separator behind.
820    #[test]
821    fn no_progress_note_renders_exactly_the_plain_header() {
822        let plugins = [status("first", TrustTier::Bundled, PluginHealth::Healthy)];
823        assert_eq!(
824            render_status_full(&plugins, &[], None),
825            render_status(&plugins)
826        );
827    }
828
829    #[test]
830    fn empty_grant_renders_a_dash_not_blank() {
831        let out = render_status(&[status("no-caps", TrustTier::Bundled, PluginHealth::Healthy)]);
832        let row = out.lines().find(|l| l.contains("no-caps")).unwrap();
833        assert!(row.trim_end().ends_with('—'), "empty caps render as a dash");
834    }
835}