1use lattice_plugin_host::{Capability, TrustTier};
8use lattice_plugin_loader::{FailedLoad, PluginHealth, PluginStatus};
9
10pub const PLUGINS_BUFFER_NAME: &str = "*plugins*";
12pub const PLUGINS_MODE_ID: &str = "plugins-mode";
13
14pub const HEADER_LINES: usize = 3;
20
21fn health_label(health: &PluginHealth) -> &'static str {
24 match health {
25 PluginHealth::Healthy => "ok",
26 PluginHealth::Quarantined { .. } => "quarantined",
27 }
28}
29
30fn tier_label(tier: TrustTier) -> &'static str {
32 match tier {
33 TrustTier::Bundled => "bundled",
34 TrustTier::UserInstalled => "user-installed",
35 }
36}
37
38fn 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
59fn 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
69pub fn render_status(plugins: &[PluginStatus]) -> String {
74 render_status_with_failures(plugins, &[])
75}
76
77pub fn render_status_with_failures(plugins: &[PluginStatus], failed: &[FailedLoad]) -> String {
92 render_status_full(plugins, failed, None)
93}
94
95pub 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
116pub struct RenderedStatus {
127 pub text: String,
128 pub spans: Vec<Vec<lattice_cells::StyledSpan>>,
129}
130
131fn 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 for _ in text.chars().count()..width {
153 line.push(' ');
154 }
155}
156
157fn 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 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 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 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 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 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 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 Some(if build.contains("failed") {
291 lattice_cells::Style::DiagnosticWarning
292 } else {
293 dim
294 }),
295 );
296 line.push_str(" ");
297 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 spans.extend(fail_spans);
311 RenderedStatus { text: out, spans }
312}
313
314fn 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()); 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 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 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 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 #[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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 assert!(out.contains("fuzzy-finder"));
745 assert!(out.contains("bundled"));
746 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 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 #[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 #[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}