use std::collections::BTreeMap;
use std::fmt::Write;
use owo_colors::{OwoColorize, Stream};
use crate::explain::DOCS_BASE;
use crate::scoring::{Score, Scorecard};
use crate::types::{Diagnostic, Severity, SourceFile};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BannerPolicy {
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy)]
pub struct TtyOptions {
pub color_mode: ColorMode,
pub group: bool,
pub explain_hint: bool,
pub score_first: bool,
pub banner: BannerPolicy,
}
impl TtyOptions {
#[must_use]
pub const fn new(color_mode: ColorMode) -> Self {
Self {
color_mode,
group: true,
explain_hint: true,
score_first: false,
banner: BannerPolicy::Auto,
}
}
}
const GROUP_MIN: usize = 2;
const DIVIDER_WIDTH: usize = 60;
#[must_use]
pub fn render(diagnostics: &[Diagnostic], scorecard: &Scorecard, options: TtyOptions) -> String {
let diagnostics_block = render_diagnostics_block(diagnostics, options);
let score_block = render_score_block(scorecard, options.color_mode);
let divider = format!("{}\n", dim(&"─".repeat(DIVIDER_WIDTH), options.color_mode));
let banner = if banner_fires(options.banner, options.color_mode) {
render_banner(options.color_mode)
} else {
String::new()
};
if options.score_first {
format!("{banner}{score_block}{divider}{diagnostics_block}")
} else {
format!("{banner}{diagnostics_block}{divider}{score_block}")
}
}
fn banner_fires(policy: BannerPolicy, color_mode: ColorMode) -> bool {
match policy {
BannerPolicy::Always => true,
BannerPolicy::Never => false,
BannerPolicy::Auto => should_color(color_mode),
}
}
fn render_banner(color_mode: ColorMode) -> String {
const WAVY: &str = "~~~~~";
const LENS: &str = "⟨ • ⟩";
const CLEAN: &str = "─────";
const NAME: &str = "lucid-lint";
const TAGLINE: &str = "cognitive accessibility linter · prose · EN / FR";
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
let prefix_cols =
WAVY.chars().count() + 1 + LENS.chars().count() + 1 + CLEAN.chars().count() + 2;
let indent = " ".repeat(prefix_cols);
let divider = "─".repeat(TAGLINE.chars().count());
format!(
"{} {} {} {} {}\n{indent}{}\n{indent}{}\n\n",
dim(WAVY, color_mode),
blue_bold(LENS, color_mode),
bold(CLEAN, color_mode),
bold(NAME, color_mode),
dim(&version, color_mode),
dim(TAGLINE, color_mode),
dim(÷r, color_mode),
)
}
fn blue(s: &str, mode: ColorMode) -> String {
if should_color(mode) {
s.if_supports_color(Stream::Stdout, OwoColorize::blue)
.to_string()
} else {
s.to_string()
}
}
fn blue_bold(s: &str, mode: ColorMode) -> String {
bold(&blue(s, mode), mode)
}
fn render_diagnostics_block(diagnostics: &[Diagnostic], options: TtyOptions) -> String {
let mut out = String::new();
if diagnostics.is_empty() {
let _ = writeln!(out, "{}", green("No issues found.", options.color_mode));
let _ = writeln!(out);
} else {
if options.group {
render_grouped(&mut out, diagnostics, options);
} else {
for diag in diagnostics {
let _ = write!(out, "{}", format_diagnostic(diag, options.color_mode));
}
let _ = writeln!(out);
let _ = writeln!(out, "{}", summary(diagnostics, options.color_mode));
}
if options.explain_hint {
let _ = writeln!(
out,
"{}",
explain_hint_line(diagnostics, options.color_mode)
);
}
}
out
}
fn render_score_block(scorecard: &Scorecard, color_mode: ColorMode) -> String {
let mut out = String::new();
for line in score_lines(scorecard, color_mode) {
let _ = writeln!(out, "{line}");
}
out
}
fn render_grouped(out: &mut String, diagnostics: &[Diagnostic], options: TtyOptions) {
let mut clusters: BTreeMap<(String, String), Vec<&Diagnostic>> = BTreeMap::new();
let mut cluster_order: Vec<(String, String)> = Vec::new();
for diag in diagnostics {
let key = (file_label(&diag.location.file), diag.rule_id.clone());
let slot = clusters.entry(key.clone()).or_insert_with(|| {
cluster_order.push(key.clone());
Vec::new()
});
slot.push(diag);
}
for key in &cluster_order {
let members = &clusters[key];
if members.len() >= GROUP_MIN {
format_cluster(out, &key.0, &key.1, members, options.color_mode);
} else {
let _ = write!(out, "{}", format_diagnostic(members[0], options.color_mode));
}
}
let _ = writeln!(out);
let _ = writeln!(out, "{}", summary(diagnostics, options.color_mode));
}
fn format_cluster(
out: &mut String,
file: &str,
rule_id: &str,
members: &[&Diagnostic],
color_mode: ColorMode,
) {
let severity = members[0].severity;
let count = members.len();
let coloured_count = severity_colour(severity, &count.to_string(), color_mode);
let shared_section = shared_section(members);
let header_section = shared_section
.as_deref()
.map(|s| format!(" {}", dim(&format!("[section: {s}]"), color_mode)))
.unwrap_or_default();
let header = format!(
"{} {} · {} · {}{}",
severity_label(severity, color_mode),
bold(file, color_mode),
coloured_count,
dim(&format!("[{rule_id}]"), color_mode),
header_section,
);
let _ = writeln!(out, "{header}");
let shared_message = shared_message(members);
if let Some(msg) = shared_message.as_deref() {
let _ = writeln!(out, " {}", dim(msg, color_mode));
}
let max_loc = members
.iter()
.map(|d| {
format!("{}:{}", d.location.line, d.location.column)
.chars()
.count()
})
.max()
.unwrap_or(0);
for diag in members {
let loc = format!("{}:{}", diag.location.line, diag.location.column);
let pad = " ".repeat(max_loc - loc.chars().count());
let per_row_section = if shared_section.is_some() {
None
} else {
diag.section
.as_deref()
.map(|s| dim(&format!("[section: {s}]"), color_mode))
};
let body = if shared_message.is_some() {
per_row_section
.map(|s| format!(" {s}"))
.unwrap_or_default()
} else {
let section_suffix = per_row_section.map(|s| format!(" {s}")).unwrap_or_default();
format!(" {}{}", diag.message, section_suffix)
};
let _ = writeln!(out, " {}{pad}{}", bold(&loc, color_mode), body);
}
}
fn shared_message(members: &[&Diagnostic]) -> Option<String> {
let first = members.first()?;
if members.iter().all(|m| m.message == first.message) {
Some(first.message.clone())
} else {
None
}
}
fn shared_section(members: &[&Diagnostic]) -> Option<String> {
let first = members.first()?.section.as_deref()?;
if members.iter().all(|m| m.section.as_deref() == Some(first)) {
Some(first.to_string())
} else {
None
}
}
fn severity_colour(severity: Severity, text: &str, color_mode: ColorMode) -> String {
match severity {
Severity::Error => red(text, color_mode),
Severity::Warning => yellow(text, color_mode),
Severity::Info => dim(text, color_mode),
}
}
fn file_label(source: &SourceFile) -> String {
source.to_string()
}
fn plural_severity(severity: Severity, count: usize) -> &'static str {
match (severity, count) {
(Severity::Info, 1) => "info",
(Severity::Info, _) => "info",
(Severity::Warning, 1) => "warning",
(Severity::Warning, _) => "warnings",
(Severity::Error, 1) => "error",
(Severity::Error, _) => "errors",
}
}
fn format_diagnostic(diag: &Diagnostic, color_mode: ColorMode) -> String {
let mut out = String::new();
let severity_str = severity_label(diag.severity, color_mode);
let location = diag.location.to_string();
let section_suffix = diag
.section
.as_deref()
.map(|s| format!(" [section: {s}]"))
.unwrap_or_default();
let rule_suffix = format!(" [{}]", diag.rule_id);
let _ = writeln!(
out,
"{} {} {}{}{}",
severity_str,
bold(&location, color_mode),
diag.message,
dim(§ion_suffix, color_mode),
dim(&rule_suffix, color_mode),
);
out
}
fn summary(diagnostics: &[Diagnostic], color_mode: ColorMode) -> String {
let info_count = diagnostics
.iter()
.filter(|d| d.severity == Severity::Info)
.count();
let warn_count = diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.count();
let error_count = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.count();
let mut parts = Vec::new();
if error_count > 0 {
parts.push(format!(
"{} {}",
red(&error_count.to_string(), color_mode),
plural_severity(Severity::Error, error_count),
));
}
if warn_count > 0 {
parts.push(format!(
"{} {}",
yellow(&warn_count.to_string(), color_mode),
plural_severity(Severity::Warning, warn_count),
));
}
if info_count > 0 {
parts.push(format!(
"{} {}",
dim(&info_count.to_string(), color_mode),
plural_severity(Severity::Info, info_count),
));
}
if parts.is_empty() {
"No issues found.".to_string()
} else {
format!("summary: {}.", parts.join(", "))
}
}
fn explain_hint_line(diagnostics: &[Diagnostic], color_mode: ColorMode) -> String {
let mut seen: Vec<&str> = Vec::new();
for diag in diagnostics {
if !seen.iter().any(|id| *id == diag.rule_id) {
seen.push(diag.rule_id.as_str());
}
}
let text = if seen.len() == 1 {
let id = seen[0];
format!("→ run 'lucid-lint explain {id}' or see {DOCS_BASE}/rules/{id}")
} else if seen.len() <= 3 {
let ids = seen.join(", ");
format!("→ run 'lucid-lint explain <rule-id>' — seen here: {ids}")
} else {
let ids = seen.iter().take(3).copied().collect::<Vec<_>>().join(", ");
let extra = seen.len() - 3;
format!("→ run 'lucid-lint explain <rule-id>' — seen here: {ids} + {extra} more")
};
dim(&text, color_mode)
}
const BAR_CELLS: u32 = 5;
const BAR_STEPS_PER_CELL: u32 = 8;
const EIGHTHS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
const EMPTY_CELL: char = '░';
fn score_lines(scorecard: &Scorecard, color_mode: ColorMode) -> Vec<String> {
let mut lines = vec![format!(
"score: {}",
score_fragment_bold(scorecard.global, color_mode),
)];
let label_width = scorecard
.per_category
.iter()
.map(|cs| cs.category.to_string().len())
.max()
.unwrap_or(0);
for cs in &scorecard.per_category {
let label = cs.category.to_string();
let padded = format!("{label:<label_width$}");
lines.push(format!(
" {} {} {}",
dim(&padded, color_mode),
bar(cs.score, color_mode),
score_fragment(cs.score, color_mode),
));
}
lines
}
fn ratio_of(score: Score) -> f64 {
if score.max == 0 {
1.0
} else {
(f64::from(score.value) / f64::from(score.max)).clamp(0.0, 1.0)
}
}
fn band_apply(ratio: f64, text: &str, color_mode: ColorMode) -> String {
if ratio >= 0.80 {
green(text, color_mode)
} else if ratio >= 0.60 {
yellow(text, color_mode)
} else {
red(text, color_mode)
}
}
fn score_fragment(score: Score, color_mode: ColorMode) -> String {
let text = format!("{}/{}", score.value, score.max);
band_apply(ratio_of(score), &text, color_mode)
}
fn score_fragment_bold(score: Score, color_mode: ColorMode) -> String {
let banded = score_fragment(score, color_mode);
bold(&banded, color_mode)
}
fn bar(score: Score, color_mode: ColorMode) -> String {
let ratio = ratio_of(score);
let total = BAR_CELLS * BAR_STEPS_PER_CELL;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let filled_steps = (ratio * f64::from(total)).round() as u32;
let filled_steps = filled_steps.min(total);
let mut filled_str = String::new();
let mut empty_str = String::new();
let mut remaining = filled_steps;
for _ in 0..BAR_CELLS {
let take = remaining.min(BAR_STEPS_PER_CELL);
if take == 0 {
empty_str.push(EMPTY_CELL);
} else {
filled_str.push(EIGHTHS[take as usize]);
}
remaining = remaining.saturating_sub(take);
}
format!(
"{}{}",
band_apply(ratio, &filled_str, color_mode),
dim(&empty_str, color_mode),
)
}
const SEVERITY_WIDTH: usize = 7;
fn severity_label(severity: Severity, color_mode: ColorMode) -> String {
let (word, coloured) = match severity {
Severity::Info => ("info", dim("info", color_mode)),
Severity::Warning => ("warning", yellow("warning", color_mode)),
Severity::Error => ("error", red("error", color_mode)),
};
let pad = " ".repeat(SEVERITY_WIDTH - word.len());
format!("{coloured}{pad}")
}
fn should_color(color_mode: ColorMode) -> bool {
match color_mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => supports_color(),
}
}
#[cfg(not(test))]
fn supports_color() -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal()
}
#[cfg(test)]
const fn supports_color() -> bool {
false
}
fn green(s: &str, mode: ColorMode) -> String {
if should_color(mode) {
s.if_supports_color(Stream::Stdout, OwoColorize::green)
.to_string()
} else {
s.to_string()
}
}
fn red(s: &str, mode: ColorMode) -> String {
if should_color(mode) {
s.if_supports_color(Stream::Stdout, OwoColorize::red)
.to_string()
} else {
s.to_string()
}
}
fn yellow(s: &str, mode: ColorMode) -> String {
if should_color(mode) {
s.if_supports_color(Stream::Stdout, OwoColorize::yellow)
.to_string()
} else {
s.to_string()
}
}
fn bold(s: &str, mode: ColorMode) -> String {
if should_color(mode) {
s.if_supports_color(Stream::Stdout, OwoColorize::bold)
.to_string()
} else {
s.to_string()
}
}
fn dim(s: &str, mode: ColorMode) -> String {
if should_color(mode) {
s.if_supports_color(Stream::Stdout, OwoColorize::dimmed)
.to_string()
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scoring::{self, ScoringConfig};
use crate::types::{Location, SourceFile};
fn sample_diag() -> Diagnostic {
Diagnostic::new(
"structure.sentence-too-long",
Severity::Warning,
Location::new(SourceFile::Anonymous, 3, 1, 42),
"Sentence is 25 words long (maximum 22).",
)
}
fn card(diags: &[Diagnostic]) -> Scorecard {
scoring::compute(diags, 1000, &ScoringConfig::default())
}
#[test]
fn render_empty_says_no_issues() {
let out = render(&[], &card(&[]), TtyOptions::new(ColorMode::Never));
assert!(out.contains("No issues found"));
assert!(out.contains("score: 100/100"));
}
#[test]
fn render_contains_severity_and_message() {
let diag = sample_diag();
let out = render(
std::slice::from_ref(&diag),
&card(std::slice::from_ref(&diag)),
TtyOptions::new(ColorMode::Never),
);
assert!(out.contains("warning"));
assert!(out.contains("Sentence is 25 words long"));
assert!(out.contains("structure.sentence-too-long"));
}
#[test]
fn render_includes_summary_counts() {
let diag = sample_diag();
let out = render(
std::slice::from_ref(&diag),
&card(std::slice::from_ref(&diag)),
TtyOptions::new(ColorMode::Never),
);
assert!(out.contains("summary:"));
assert!(
out.contains("1 warning") && !out.contains("1 warnings"),
"expected singular `1 warning` in summary, got: {out}"
);
}
#[test]
fn render_pluralises_severity_when_count_is_many() {
let diags: Vec<Diagnostic> = (0..3).map(|_| sample_diag()).collect();
let out = render(&diags, &card(&diags), TtyOptions::new(ColorMode::Never));
assert!(out.contains("3 warnings"));
}
#[test]
fn render_emits_explain_hint_by_default() {
let diag = sample_diag();
let out = render(
std::slice::from_ref(&diag),
&card(std::slice::from_ref(&diag)),
TtyOptions::new(ColorMode::Never),
);
assert!(out.contains("lucid-lint explain structure.sentence-too-long"));
assert!(out.contains(
"https://bastien-gallay.github.io/lucid-lint/rules/structure.sentence-too-long"
));
}
#[test]
fn render_suppresses_explain_hint_when_disabled() {
let diag = sample_diag();
let mut opts = TtyOptions::new(ColorMode::Never);
opts.explain_hint = false;
let out = render(
std::slice::from_ref(&diag),
&card(std::slice::from_ref(&diag)),
opts,
);
assert!(!out.contains("lucid-lint explain"));
}
#[test]
fn render_does_not_emit_explain_hint_on_empty_state() {
let out = render(&[], &card(&[]), TtyOptions::new(ColorMode::Never));
assert!(out.contains("No issues found"));
assert!(
!out.contains("lucid-lint explain"),
"empty state must not carry the hint line: {out}"
);
}
#[test]
fn render_includes_section_when_present() {
let diag = sample_diag().with_section("Introduction");
let out = render(
std::slice::from_ref(&diag),
&card(std::slice::from_ref(&diag)),
TtyOptions::new(ColorMode::Never),
);
assert!(out.contains("section: Introduction"));
}
#[test]
fn render_shows_score_line_with_all_five_categories() {
let diag = sample_diag();
let out = render(
std::slice::from_ref(&diag),
&card(std::slice::from_ref(&diag)),
TtyOptions::new(ColorMode::Never),
);
assert!(out.contains("score:"));
for name in ["structure", "rhythm", "lexicon", "syntax", "readability"] {
assert!(out.contains(name), "missing category {name} in output");
}
}
#[test]
fn render_emits_sparkline_bars() {
let out = render(&[], &card(&[]), TtyOptions::new(ColorMode::Never));
assert!(
out.contains('█') || out.contains('░'),
"expected at least one block glyph in output: {out}"
);
}
#[test]
fn bar_fills_all_five_blocks_on_perfect_score() {
let s = Score { value: 20, max: 20 };
let rendered = bar(s, ColorMode::Never);
assert_eq!(
rendered.matches('█').count(),
5,
"expected 5 full-block cells, got: {rendered}"
);
assert_eq!(
rendered.matches('░').count(),
0,
"expected 0 empty cells, got: {rendered}"
);
}
#[test]
fn bar_leaves_all_five_blocks_empty_on_zero_score() {
let s = Score { value: 0, max: 20 };
let rendered = bar(s, ColorMode::Never);
assert_eq!(
rendered.matches('█').count(),
0,
"expected 0 full-block cells on zero, got: {rendered}"
);
assert_eq!(
rendered.matches('░').count(),
5,
"expected 5 empty cells on zero, got: {rendered}"
);
}
#[test]
fn bar_distinguishes_mid_range_scores_via_partial_cell() {
let a = bar(Score { value: 10, max: 20 }, ColorMode::Never);
let b = bar(Score { value: 12, max: 20 }, ColorMode::Never);
assert_ne!(
a, b,
"10/20 and 12/20 must render distinct bars: {a} vs {b}"
);
}
#[test]
fn score_first_reorders_output() {
let diag = sample_diag();
let mut opts = TtyOptions::new(ColorMode::Never);
opts.score_first = true;
let out = render(
std::slice::from_ref(&diag),
&card(std::slice::from_ref(&diag)),
opts,
);
let score_idx = out.find("score:").expect("score line present");
let diag_idx = out
.find("Sentence is 25 words long")
.expect("diagnostic message present");
assert!(
score_idx < diag_idx,
"--score-first must emit the score before the diagnostics:\n{out}"
);
}
#[test]
fn banner_auto_fires_when_tty_is_detected() {
let out = render(&[], &card(&[]), TtyOptions::new(ColorMode::Always));
assert!(out.contains("lucid-lint"));
assert!(out.contains("cognitive accessibility linter"));
}
#[test]
fn banner_auto_suppressed_when_not_a_tty() {
let out = render(&[], &card(&[]), TtyOptions::new(ColorMode::Never));
assert!(!out.contains("cognitive accessibility linter"));
}
#[test]
fn banner_always_fires_even_without_tty() {
let diag = sample_diag();
let mut opts = TtyOptions::new(ColorMode::Never);
opts.banner = BannerPolicy::Always;
let out = render(
std::slice::from_ref(&diag),
&card(std::slice::from_ref(&diag)),
opts,
);
let tagline_idx = out
.find("cognitive accessibility linter")
.expect("tagline present");
let diag_idx = out
.find("Sentence is 25 words long")
.expect("diagnostic message present");
assert!(
tagline_idx < diag_idx,
"banner must precede the first diagnostic when --banner=always"
);
}
#[test]
fn banner_never_suppresses_even_on_tty() {
let mut opts = TtyOptions::new(ColorMode::Always);
opts.banner = BannerPolicy::Never;
let out = render(&[], &card(&[]), opts);
assert!(!out.contains("cognitive accessibility linter"));
}
#[test]
fn severity_labels_pad_to_fixed_width() {
use crate::types::{Location, SourceFile};
let make = |sev: Severity| {
Diagnostic::new(
"structure.sentence-too-long",
sev,
Location::new(SourceFile::Anonymous, 1, 1, 10),
"msg",
)
};
let diags = vec![
make(Severity::Info),
make(Severity::Warning),
make(Severity::Error),
];
let mut opts = TtyOptions::new(ColorMode::Never);
opts.group = false;
let out = render(&diags, &card(&diags), opts);
let offsets: Vec<usize> = out
.lines()
.filter(|l| l.starts_with("info") || l.starts_with("warning") || l.starts_with("error"))
.map(|l| l.find("<input>").expect("anonymous source marker"))
.collect();
assert!(
offsets.len() >= 3,
"expected 3 diagnostic lines, got: {out}"
);
let first = offsets[0];
assert!(
offsets.iter().all(|o| *o == first),
"severity columns misaligned: {offsets:?}"
);
}
#[test]
fn empty_state_has_blank_line_before_divider() {
let out = render(&[], &card(&[]), TtyOptions::new(ColorMode::Never));
let lines: Vec<&str> = out.lines().collect();
let idx = lines
.iter()
.position(|l| l.contains("No issues found"))
.expect("empty-state line present");
assert!(
lines[idx + 1].is_empty(),
"expected blank line after success message, got: {:?}",
lines[idx + 1]
);
}
#[test]
fn cluster_rows_align_on_varying_location_widths() {
use crate::types::{Location, SourceFile};
let path = std::path::PathBuf::from("readme.md");
let make = |line: u32, col: u32, msg: &str| {
Diagnostic::new(
"structure.line-length-wide",
Severity::Warning,
Location::new(SourceFile::Path(path.clone()), line, col, 0),
msg,
)
};
let diags = vec![
make(3, 1, "Line at 3 is too wide."),
make(15, 120, "Line at 15 is too wide."),
make(333, 1, "Line at 333 is too wide."),
];
let out = render(&diags, &card(&diags), TtyOptions::new(ColorMode::Never));
let msg_offsets: Vec<usize> = out
.lines()
.filter(|l| l.contains("is too wide"))
.map(|l| l.find("Line at").unwrap())
.collect();
assert_eq!(msg_offsets.len(), 3, "expected 3 cluster rows");
let first = msg_offsets[0];
assert!(
msg_offsets.iter().all(|o| *o == first),
"cluster message column misaligned: {msg_offsets:?}"
);
}
#[test]
fn cluster_hoists_shared_message_to_header() {
use crate::types::{Location, SourceFile};
let path = std::path::PathBuf::from("readme.md");
let make = |line: u32| {
Diagnostic::new(
"structure.line-length-wide",
Severity::Warning,
Location::new(SourceFile::Path(path.clone()), line, 1, 0),
"Line is too wide.",
)
};
let diags = vec![make(3), make(15), make(333)];
let out = render(&diags, &card(&diags), TtyOptions::new(ColorMode::Never));
assert_eq!(
out.matches("Line is too wide.").count(),
1,
"shared message must be hoisted, not repeated per row:\n{out}"
);
}
#[test]
fn cluster_hoists_shared_section_to_header() {
use crate::types::{Location, SourceFile};
let path = std::path::PathBuf::from("readme.md");
let make = |line: u32| {
Diagnostic::new(
"structure.line-length-wide",
Severity::Warning,
Location::new(SourceFile::Path(path.clone()), line, 1, 0),
format!("Line at {line} is too wide."),
)
.with_section("Introduction")
};
let diags = vec![make(3), make(15)];
let out = render(&diags, &card(&diags), TtyOptions::new(ColorMode::Never));
assert_eq!(
out.matches("[section: Introduction]").count(),
1,
"shared section must be hoisted, not repeated per row:\n{out}"
);
}
#[test]
fn global_score_fragment_wraps_banded_fragment() {
let s = Score {
value: 71,
max: 100,
};
let plain = score_fragment(s, ColorMode::Never);
let bold_wrapped = score_fragment_bold(s, ColorMode::Never);
assert!(bold_wrapped.contains(&plain));
assert!(bold_wrapped.contains("71/100"));
}
}