1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum BannerMode {
34 #[default]
36 Auto,
37 Full,
39 Compact,
41 Off,
43}
44
45impl BannerMode {
46 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Presentation {
78 pub colour: bool,
80 pub unicode: bool,
82 pub columns: usize,
84 pub interactive: bool,
86}
87
88pub const MAX_WIDTH: usize = 78;
91
92pub 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 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
129fn is_terminal(fd: i32) -> bool {
131 unsafe { libc::isatty(fd) == 1 }
134}
135
136fn 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 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
154fn 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 .unwrap_or(true)
167}
168
169struct 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 ellipsis: &'static str,
189 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
235pub 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
267const LABEL_WIDTH: usize = 11;
269
270const COMMAND_WIDTH: usize = 26;
273
274enum Row {
276 Field(String, String),
278 Command(String, String),
280 Blank,
282 Divider,
284}
285
286fn 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 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 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 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 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 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 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 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
432fn 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
446fn 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 _ => parts.push("no upstream".into()),
478 }
479 parts.join(&format!(" {} ", g.bullet))
480}
481
482fn 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
509fn 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 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
539fn 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
556fn 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 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
610fn 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#[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 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 #[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 #[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 #[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 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}