Skip to main content

git_iris/types/
review.rs

1//! Code review types and formatting
2
3use colored::Colorize;
4use schemars::JsonSchema;
5use serde::de::{self, Visitor};
6use serde::{Deserialize, Deserializer, Serialize};
7use std::fmt::{self, Write};
8use std::path::PathBuf;
9
10pub const DEFAULT_MIN_FINDING_CONFIDENCE: u8 = 70;
11
12/// Helper to get themed colors for terminal output
13mod colors {
14    use crate::theme;
15    use crate::theme::names::tokens;
16
17    pub fn accent_primary() -> (u8, u8, u8) {
18        let c = theme::current().color(tokens::ACCENT_PRIMARY);
19        (c.r, c.g, c.b)
20    }
21
22    pub fn accent_secondary() -> (u8, u8, u8) {
23        let c = theme::current().color(tokens::ACCENT_SECONDARY);
24        (c.r, c.g, c.b)
25    }
26
27    pub fn accent_tertiary() -> (u8, u8, u8) {
28        let c = theme::current().color(tokens::ACCENT_TERTIARY);
29        (c.r, c.g, c.b)
30    }
31
32    pub fn warning() -> (u8, u8, u8) {
33        let c = theme::current().color(tokens::WARNING);
34        (c.r, c.g, c.b)
35    }
36
37    pub fn error() -> (u8, u8, u8) {
38        let c = theme::current().color(tokens::ERROR);
39        (c.r, c.g, c.b)
40    }
41
42    pub fn text_secondary() -> (u8, u8, u8) {
43        let c = theme::current().color(tokens::TEXT_SECONDARY);
44        (c.r, c.g, c.b)
45    }
46
47    pub fn text_dim() -> (u8, u8, u8) {
48        let c = theme::current().color(tokens::TEXT_DIM);
49        (c.r, c.g, c.b)
50    }
51}
52
53/// Structured code review with parseable findings.
54#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
55pub struct Review {
56    #[serde(default)]
57    pub summary: String,
58    #[serde(default)]
59    pub metadata: ReviewMetadata,
60    #[serde(default)]
61    pub findings: Vec<Finding>,
62    #[serde(default)]
63    pub stats: ReviewStats,
64    #[serde(default, skip_serializing_if = "is_false")]
65    #[schemars(skip)]
66    pub parse_failed: bool,
67}
68
69impl Review {
70    #[must_use]
71    pub fn from_unstructured(text: &str) -> Self {
72        Self {
73            summary: format!(
74                "**Review parsing failed; raw model output below.**\n\n```text\n{}\n```",
75                escape_fenced_code(text)
76            ),
77            metadata: ReviewMetadata::default(),
78            findings: Vec::new(),
79            stats: ReviewStats::default(),
80            parse_failed: true,
81        }
82    }
83
84    #[must_use]
85    pub fn raw_content(&self) -> String {
86        self.to_markdown()
87    }
88
89    #[must_use]
90    pub fn to_markdown(&self) -> String {
91        let mut output = String::new();
92        writeln!(output, "# Code Review").expect("write to string should not fail");
93
94        if !self.summary.trim().is_empty() {
95            writeln!(output, "\n## Summary\n\n{}", self.summary.trim())
96                .expect("write to string should not fail");
97        }
98
99        if self.parse_failed {
100            return output;
101        }
102
103        self.render_metadata(&mut output);
104
105        let visible_findings = self.visible_findings();
106        let stats = self.visible_stats();
107        writeln!(
108            output,
109            "\n## Findings\n\nReviewed {} file(s). Found {} issue(s): {} critical, {} high, {} medium, {} low.",
110            stats.files_reviewed,
111            stats.findings_count,
112            stats.critical_count,
113            stats.high_count,
114            stats.medium_count,
115            stats.low_count
116        )
117        .expect("write to string should not fail");
118
119        if visible_findings.is_empty() {
120            output.push_str("\nNo blocking issues found.\n");
121            return output;
122        }
123
124        for severity in [
125            Severity::Critical,
126            Severity::High,
127            Severity::Medium,
128            Severity::Low,
129        ] {
130            let findings: Vec<&Finding> = visible_findings
131                .iter()
132                .copied()
133                .filter(|finding| finding.severity == severity)
134                .collect();
135
136            if findings.is_empty() {
137                continue;
138            }
139
140            writeln!(output, "\n### {severity}").expect("write to string should not fail");
141            for finding in findings {
142                writeln!(
143                    output,
144                    "\n- [{severity}] **{} in `{}`**",
145                    finding.title,
146                    finding.location()
147                )
148                .expect("write to string should not fail");
149                writeln!(
150                    output,
151                    "  Category: {}. Confidence: {}%.",
152                    finding.category,
153                    finding.confidence_score()
154                )
155                .expect("write to string should not fail");
156                writeln!(output, "  {}", finding.body.trim())
157                    .expect("write to string should not fail");
158
159                if let Some(fix) = finding
160                    .suggested_fix
161                    .as_deref()
162                    .filter(|fix| !fix.is_empty())
163                {
164                    writeln!(output, "  **Fix**: {}", fix.trim())
165                        .expect("write to string should not fail");
166                }
167
168                if !finding.evidence.is_empty() {
169                    let evidence = finding
170                        .evidence
171                        .iter()
172                        .map(EvidenceRef::label)
173                        .collect::<Vec<_>>()
174                        .join(", ");
175                    writeln!(output, "  Evidence: {evidence}")
176                        .expect("write to string should not fail");
177                }
178            }
179        }
180
181        output
182    }
183
184    #[must_use]
185    pub fn format(&self) -> String {
186        render_markdown_for_terminal(&self.to_markdown())
187    }
188
189    #[must_use]
190    pub fn effective_stats(&self) -> ReviewStats {
191        ReviewStats::from_findings(self.stats.files_reviewed, &self.findings)
192    }
193
194    #[must_use]
195    pub fn visible_findings(&self) -> Vec<&Finding> {
196        self.visible_findings_at(DEFAULT_MIN_FINDING_CONFIDENCE)
197    }
198
199    #[must_use]
200    pub fn visible_findings_at(&self, min_confidence: u8) -> Vec<&Finding> {
201        self.findings
202            .iter()
203            .filter(|finding| finding.confidence_score() >= min_confidence)
204            .collect()
205    }
206
207    #[must_use]
208    pub fn visible_stats(&self) -> ReviewStats {
209        self.visible_stats_at(DEFAULT_MIN_FINDING_CONFIDENCE)
210    }
211
212    #[must_use]
213    pub fn visible_stats_at(&self, min_confidence: u8) -> ReviewStats {
214        let visible_findings = self.visible_findings_at(min_confidence);
215        let mut stats = ReviewStats {
216            files_reviewed: self.stats.files_reviewed,
217            findings_count: visible_findings.len(),
218            ..ReviewStats::default()
219        };
220
221        for finding in visible_findings {
222            match finding.severity {
223                Severity::Critical => stats.critical_count += 1,
224                Severity::High => stats.high_count += 1,
225                Severity::Medium => stats.medium_count += 1,
226                Severity::Low => stats.low_count += 1,
227            }
228        }
229
230        stats
231    }
232
233    fn render_metadata(&self, output: &mut String) {
234        if self.metadata.is_empty() {
235            return;
236        }
237
238        writeln!(output, "\n## Review Coverage").expect("write to string should not fail");
239        if let Some(risk_level) = self.metadata.risk_level {
240            writeln!(output, "\nRisk: {risk_level}").expect("write to string should not fail");
241        }
242        if let Some(strategy) = trimmed_non_empty(&self.metadata.strategy) {
243            writeln!(output, "\nStrategy: {strategy}").expect("write to string should not fail");
244        }
245        let specialist_passes = self
246            .metadata
247            .specialist_passes
248            .iter()
249            .filter_map(|pass| trimmed_non_empty(pass))
250            .collect::<Vec<_>>();
251        if !specialist_passes.is_empty() {
252            writeln!(output, "\nSpecialist passes:").expect("write to string should not fail");
253            for pass in specialist_passes {
254                writeln!(output, "- {pass}").expect("write to string should not fail");
255            }
256        }
257        let coverage_notes = self
258            .metadata
259            .coverage_notes
260            .iter()
261            .filter_map(|note| trimmed_non_empty(note))
262            .collect::<Vec<_>>();
263        if !coverage_notes.is_empty() {
264            writeln!(output, "\nCoverage notes:").expect("write to string should not fail");
265            for note in coverage_notes {
266                writeln!(output, "- {note}").expect("write to string should not fail");
267            }
268        }
269    }
270}
271
272#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
273pub struct ReviewMetadata {
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub risk_level: Option<RiskLevel>,
276    #[serde(default, skip_serializing_if = "str_is_blank")]
277    pub strategy: String,
278    #[serde(default, skip_serializing_if = "string_vec_is_blank")]
279    pub specialist_passes: Vec<String>,
280    #[serde(default, skip_serializing_if = "string_vec_is_blank")]
281    pub coverage_notes: Vec<String>,
282}
283
284/// Deserialization accepts model-style casing and whitespace; the schema advertises canonical lowercase values.
285#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
286#[serde(rename_all = "snake_case")]
287pub enum RiskLevel {
288    Critical,
289    High,
290    Medium,
291    Low,
292}
293
294impl RiskLevel {
295    fn from_model_value(value: &str) -> Option<Self> {
296        match value.trim().to_lowercase().as_str() {
297            "critical" => Some(Self::Critical),
298            "high" => Some(Self::High),
299            "medium" => Some(Self::Medium),
300            "low" => Some(Self::Low),
301            _ => None,
302        }
303    }
304}
305
306impl<'de> Deserialize<'de> for RiskLevel {
307    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
308    where
309        D: Deserializer<'de>,
310    {
311        let value = String::deserialize(deserializer)?;
312        Self::from_model_value(&value).ok_or_else(|| de::Error::custom("invalid risk level"))
313    }
314}
315
316impl fmt::Display for RiskLevel {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        match self {
319            Self::Critical => write!(f, "critical"),
320            Self::High => write!(f, "high"),
321            Self::Medium => write!(f, "medium"),
322            Self::Low => write!(f, "low"),
323        }
324    }
325}
326
327impl ReviewMetadata {
328    #[must_use]
329    pub fn is_empty(&self) -> bool {
330        self.risk_level.is_none()
331            && str_is_blank(&self.strategy)
332            && string_vec_is_blank(&self.specialist_passes)
333            && string_vec_is_blank(&self.coverage_notes)
334    }
335}
336
337fn trimmed_non_empty(value: &str) -> Option<&str> {
338    let value = value.trim();
339    (!value.is_empty()).then_some(value)
340}
341
342fn str_is_blank(value: &str) -> bool {
343    value.trim().is_empty()
344}
345
346fn string_vec_is_blank(values: &[String]) -> bool {
347    values.iter().all(|value| str_is_blank(value))
348}
349
350#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
351pub struct Finding {
352    pub id: FindingId,
353    pub severity: Severity,
354    #[serde(deserialize_with = "deserialize_confidence")]
355    pub confidence: u8,
356    pub file: PathBuf,
357    pub start_line: u32,
358    pub end_line: u32,
359    pub category: Category,
360    pub title: String,
361    pub body: String,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub suggested_fix: Option<String>,
364    #[serde(default)]
365    pub evidence: Vec<EvidenceRef>,
366}
367
368#[allow(clippy::trivially_copy_pass_by_ref)]
369const fn is_false(value: &bool) -> bool {
370    !*value
371}
372
373fn escape_fenced_code(text: &str) -> String {
374    text.replace("```", "`\\`\\`")
375}
376
377impl Finding {
378    #[must_use]
379    pub fn location(&self) -> String {
380        let file = self.file.display();
381        let start = self.start_line.min(self.end_line);
382        let end = self.start_line.max(self.end_line);
383        if start == end {
384            format!("{file}:{start}")
385        } else {
386            format!("{file}:{start}-{end}")
387        }
388    }
389
390    #[must_use]
391    pub fn raw_inline_body(&self) -> String {
392        let mut body = format!(
393            "[{}] **{}**\n\nLocation: `{}`\n\nCategory: {}\n\n{}\n\nConfidence: {}%",
394            self.severity,
395            self.title,
396            self.location(),
397            self.category,
398            self.body.trim(),
399            self.confidence_score()
400        );
401
402        if let Some(fix) = self.suggested_fix.as_deref().filter(|fix| !fix.is_empty()) {
403            write!(body, "\n\n**Fix**: {}", fix.trim()).expect("write to string should not fail");
404        }
405
406        if !self.evidence.is_empty() {
407            let evidence = self
408                .evidence
409                .iter()
410                .map(EvidenceRef::label)
411                .collect::<Vec<_>>()
412                .join(", ");
413            write!(body, "\n\nEvidence: {evidence}").expect("write to string should not fail");
414        }
415
416        body
417    }
418
419    #[must_use]
420    pub fn confidence_score(&self) -> u8 {
421        self.confidence.min(100)
422    }
423}
424
425fn deserialize_confidence<'de, D>(deserializer: D) -> Result<u8, D::Error>
426where
427    D: Deserializer<'de>,
428{
429    struct ConfidenceVisitor;
430
431    impl Visitor<'_> for ConfidenceVisitor {
432        type Value = u8;
433
434        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
435            formatter.write_str("a confidence value as a number, fraction, or numeric string")
436        }
437
438        fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
439        where
440            E: de::Error,
441        {
442            Ok(u8::try_from(value.min(100)).unwrap_or(100))
443        }
444
445        fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
446        where
447            E: de::Error,
448        {
449            Ok(u8::try_from(value.clamp(0, 100)).unwrap_or_default())
450        }
451
452        fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
453        where
454            E: de::Error,
455        {
456            confidence_from_float(value).ok_or_else(|| E::custom("confidence must be finite"))
457        }
458
459        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
460        where
461            E: de::Error,
462        {
463            let value = value.trim().trim_end_matches('%');
464            value
465                .parse::<f64>()
466                .ok()
467                .and_then(confidence_from_float)
468                .ok_or_else(|| E::custom("confidence string must be numeric"))
469        }
470    }
471
472    deserializer.deserialize_any(ConfidenceVisitor)
473}
474
475fn confidence_from_float(value: f64) -> Option<u8> {
476    if !value.is_finite() {
477        return None;
478    }
479
480    let value = if value > 0.0 && value < 1.0 {
481        value * 100.0
482    } else {
483        value
484    };
485
486    let rounded = value.round().clamp(0.0, 100.0);
487    (0..=100).find(|candidate| (f64::from(*candidate) - rounded).abs() < f64::EPSILON)
488}
489
490#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
491#[serde(transparent)]
492pub struct FindingId(pub String);
493
494#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
495pub struct EvidenceRef {
496    pub file: PathBuf,
497    pub line: u32,
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub end_line: Option<u32>,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub note: Option<String>,
502}
503
504impl EvidenceRef {
505    #[must_use]
506    pub fn label(&self) -> String {
507        let file = self.file.display();
508        let line = match self.end_line {
509            Some(end_line) if end_line != self.line => format!("{}-{}", self.line, end_line),
510            _ => self.line.to_string(),
511        };
512
513        match self.note.as_deref().filter(|note| !note.is_empty()) {
514            Some(note) => format!("{file}:{line} ({note})"),
515            None => format!("{file}:{line}"),
516        }
517    }
518}
519
520#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
521#[serde(rename_all = "snake_case")]
522pub enum Severity {
523    Critical,
524    High,
525    Medium,
526    Low,
527}
528
529impl fmt::Display for Severity {
530    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
531        match self {
532            Self::Critical => write!(f, "CRITICAL"),
533            Self::High => write!(f, "HIGH"),
534            Self::Medium => write!(f, "MEDIUM"),
535            Self::Low => write!(f, "LOW"),
536        }
537    }
538}
539
540#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
541#[serde(rename_all = "snake_case")]
542pub enum Category {
543    Security,
544    Performance,
545    ErrorHandling,
546    Complexity,
547    Abstraction,
548    Duplication,
549    Testing,
550    Style,
551    ApiContract,
552    Concurrency,
553    Documentation,
554    Other,
555}
556
557impl Category {
558    #[must_use]
559    pub fn from_model_value(value: &str) -> Self {
560        let normalized: String = value
561            .trim()
562            .chars()
563            .filter(|character| !matches!(*character, '_' | '-' | ' '))
564            .flat_map(char::to_lowercase)
565            .collect();
566
567        match normalized.as_str() {
568            "security" => Self::Security,
569            "performance" => Self::Performance,
570            "errorhandling" => Self::ErrorHandling,
571            "complexity" => Self::Complexity,
572            "abstraction" => Self::Abstraction,
573            "duplication" => Self::Duplication,
574            "testing" => Self::Testing,
575            "style" => Self::Style,
576            "apicontract" => Self::ApiContract,
577            "concurrency" => Self::Concurrency,
578            "documentation" => Self::Documentation,
579            _ => Self::Other,
580        }
581    }
582}
583
584impl<'de> Deserialize<'de> for Category {
585    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
586    where
587        D: Deserializer<'de>,
588    {
589        let value = String::deserialize(deserializer)?;
590        Ok(Self::from_model_value(&value))
591    }
592}
593
594impl fmt::Display for Category {
595    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596        match self {
597            Self::Security => write!(f, "security"),
598            Self::Performance => write!(f, "performance"),
599            Self::ErrorHandling => write!(f, "error handling"),
600            Self::Complexity => write!(f, "complexity"),
601            Self::Abstraction => write!(f, "abstraction"),
602            Self::Duplication => write!(f, "duplication"),
603            Self::Testing => write!(f, "testing"),
604            Self::Style => write!(f, "style"),
605            Self::ApiContract => write!(f, "API contract"),
606            Self::Concurrency => write!(f, "concurrency"),
607            Self::Documentation => write!(f, "documentation"),
608            Self::Other => write!(f, "other"),
609        }
610    }
611}
612
613#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
614pub struct ReviewStats {
615    #[serde(default)]
616    pub files_reviewed: usize,
617    #[serde(default)]
618    pub findings_count: usize,
619    #[serde(default)]
620    pub critical_count: usize,
621    #[serde(default)]
622    pub high_count: usize,
623    #[serde(default)]
624    pub medium_count: usize,
625    #[serde(default)]
626    pub low_count: usize,
627}
628
629impl ReviewStats {
630    #[must_use]
631    pub fn from_findings(files_reviewed: usize, findings: &[Finding]) -> Self {
632        let mut stats = Self {
633            files_reviewed,
634            findings_count: findings.len(),
635            ..Self::default()
636        };
637
638        for finding in findings {
639            match finding.severity {
640                Severity::Critical => stats.critical_count += 1,
641                Severity::High => stats.high_count += 1,
642                Severity::Medium => stats.medium_count += 1,
643                Severity::Low => stats.low_count += 1,
644            }
645        }
646
647        stats
648    }
649}
650
651/// Render markdown content with `SilkCircuit` terminal styling
652///
653/// This function parses markdown and applies our color palette for beautiful
654/// terminal output. It handles:
655/// - Headers (H1, H2, H3) with Electric Purple styling
656/// - Bold text with Neon Cyan
657/// - Code blocks with dimmed background styling
658/// - Bullet lists with Coral bullets
659/// - Severity badges [CRITICAL], [HIGH], etc.
660#[allow(clippy::too_many_lines)]
661#[must_use]
662pub fn render_markdown_for_terminal(markdown: &str) -> String {
663    let mut output = String::new();
664    let mut in_code_block = false;
665    let mut code_block_content = String::new();
666
667    for line in markdown.lines() {
668        // Handle code blocks
669        if line.starts_with("```") {
670            if in_code_block {
671                // End of code block - output it
672                let dim = colors::text_secondary();
673                for code_line in code_block_content.lines() {
674                    writeln!(output, "  {}", code_line.truecolor(dim.0, dim.1, dim.2))
675                        .expect("write to string should not fail");
676                }
677                code_block_content.clear();
678                in_code_block = false;
679            } else {
680                in_code_block = true;
681            }
682            continue;
683        }
684
685        if in_code_block {
686            code_block_content.push_str(line);
687            code_block_content.push('\n');
688            continue;
689        }
690
691        // Handle headers
692        if let Some(header) = line.strip_prefix("### ") {
693            let cyan = colors::accent_secondary();
694            let dim = colors::text_dim();
695            writeln!(
696                output,
697                "\n{} {} {}",
698                "─".truecolor(cyan.0, cyan.1, cyan.2),
699                style_header_text(header)
700                    .truecolor(cyan.0, cyan.1, cyan.2)
701                    .bold(),
702                "─"
703                    .repeat(30usize.saturating_sub(header.len()))
704                    .truecolor(dim.0, dim.1, dim.2)
705            )
706            .expect("write to string should not fail");
707        } else if let Some(header) = line.strip_prefix("## ") {
708            let purple = colors::accent_primary();
709            let dim = colors::text_dim();
710            writeln!(
711                output,
712                "\n{} {} {}",
713                "─".truecolor(purple.0, purple.1, purple.2),
714                style_header_text(header)
715                    .truecolor(purple.0, purple.1, purple.2)
716                    .bold(),
717                "─"
718                    .repeat(32usize.saturating_sub(header.len()))
719                    .truecolor(dim.0, dim.1, dim.2)
720            )
721            .expect("write to string should not fail");
722        } else if let Some(header) = line.strip_prefix("# ") {
723            // Main title - big and bold
724            let purple = colors::accent_primary();
725            let cyan = colors::accent_secondary();
726            writeln!(
727                output,
728                "{}  {}  {}",
729                "━━━".truecolor(purple.0, purple.1, purple.2),
730                style_header_text(header)
731                    .truecolor(cyan.0, cyan.1, cyan.2)
732                    .bold(),
733                "━━━".truecolor(purple.0, purple.1, purple.2)
734            )
735            .expect("write to string should not fail");
736        }
737        // Handle bullet points
738        else if let Some(content) = line.strip_prefix("- ") {
739            let coral = colors::accent_tertiary();
740            let styled = style_line_content(content);
741            writeln!(
742                output,
743                "  {} {}",
744                "•".truecolor(coral.0, coral.1, coral.2),
745                styled
746            )
747            .expect("write to string should not fail");
748        } else if let Some(content) = line.strip_prefix("* ") {
749            let coral = colors::accent_tertiary();
750            let styled = style_line_content(content);
751            writeln!(
752                output,
753                "  {} {}",
754                "•".truecolor(coral.0, coral.1, coral.2),
755                styled
756            )
757            .expect("write to string should not fail");
758        }
759        // Handle numbered lists
760        else if line.chars().next().is_some_and(|c| c.is_ascii_digit()) && line.contains(". ") {
761            if let Some((num, rest)) = line.split_once(". ") {
762                let coral = colors::accent_tertiary();
763                let styled = style_line_content(rest);
764                writeln!(
765                    output,
766                    "  {} {}",
767                    format!("{}.", num)
768                        .truecolor(coral.0, coral.1, coral.2)
769                        .bold(),
770                    styled
771                )
772                .expect("write to string should not fail");
773            }
774        }
775        // Handle empty lines
776        else if line.trim().is_empty() {
777            output.push('\n');
778        }
779        // Regular paragraph text
780        else {
781            let styled = style_line_content(line);
782            writeln!(output, "{styled}").expect("write to string should not fail");
783        }
784    }
785
786    output
787}
788
789/// Style header text - uppercase and clean
790fn style_header_text(text: &str) -> String {
791    text.to_uppercase()
792}
793
794/// Style inline content - handles bold, code, severity badges
795#[allow(clippy::too_many_lines)]
796fn style_line_content(content: &str) -> String {
797    let mut result = String::new();
798    let mut chars = content.chars().peekable();
799    let mut current_text = String::new();
800
801    // Get theme colors once for efficiency
802    let text_color = colors::text_secondary();
803    let error_color = colors::error();
804    let warning_color = colors::warning();
805    let coral_color = colors::accent_tertiary();
806    let cyan_color = colors::accent_secondary();
807
808    while let Some(ch) = chars.next() {
809        match ch {
810            // Handle severity badges [CRITICAL], [HIGH], [MEDIUM], [LOW]
811            '[' => {
812                // Flush current text
813                if !current_text.is_empty() {
814                    result.push_str(
815                        &current_text
816                            .truecolor(text_color.0, text_color.1, text_color.2)
817                            .to_string(),
818                    );
819                    current_text.clear();
820                }
821
822                // Collect badge content
823                let mut badge = String::new();
824                for c in chars.by_ref() {
825                    if c == ']' {
826                        break;
827                    }
828                    badge.push(c);
829                }
830
831                // Style based on severity
832                let badge_upper = badge.to_uppercase();
833                let styled_badge = match badge_upper.as_str() {
834                    "CRITICAL" => format!(
835                        "[{}]",
836                        "CRITICAL"
837                            .truecolor(error_color.0, error_color.1, error_color.2)
838                            .bold()
839                    ),
840                    "HIGH" => format!(
841                        "[{}]",
842                        "HIGH"
843                            .truecolor(error_color.0, error_color.1, error_color.2)
844                            .bold()
845                    ),
846                    "MEDIUM" => format!(
847                        "[{}]",
848                        "MEDIUM"
849                            .truecolor(warning_color.0, warning_color.1, warning_color.2)
850                            .bold()
851                    ),
852                    "LOW" => format!(
853                        "[{}]",
854                        "LOW"
855                            .truecolor(coral_color.0, coral_color.1, coral_color.2)
856                            .bold()
857                    ),
858                    _ => format!(
859                        "[{}]",
860                        badge.truecolor(cyan_color.0, cyan_color.1, cyan_color.2)
861                    ),
862                };
863                result.push_str(&styled_badge);
864            }
865            // Handle bold text **text**
866            '*' if chars.peek() == Some(&'*') => {
867                // Flush current text
868                if !current_text.is_empty() {
869                    result.push_str(
870                        &current_text
871                            .truecolor(text_color.0, text_color.1, text_color.2)
872                            .to_string(),
873                    );
874                    current_text.clear();
875                }
876
877                chars.next(); // consume second *
878
879                // Collect bold content
880                let mut bold = String::new();
881                while let Some(c) = chars.next() {
882                    if c == '*' && chars.peek() == Some(&'*') {
883                        chars.next(); // consume closing **
884                        break;
885                    }
886                    bold.push(c);
887                }
888
889                result.push_str(
890                    &bold
891                        .truecolor(cyan_color.0, cyan_color.1, cyan_color.2)
892                        .bold()
893                        .to_string(),
894                );
895            }
896            // Handle inline code `code`
897            '`' => {
898                // Flush current text
899                if !current_text.is_empty() {
900                    result.push_str(
901                        &current_text
902                            .truecolor(text_color.0, text_color.1, text_color.2)
903                            .to_string(),
904                    );
905                    current_text.clear();
906                }
907
908                // Collect code content
909                let mut code = String::new();
910                for c in chars.by_ref() {
911                    if c == '`' {
912                        break;
913                    }
914                    code.push(c);
915                }
916
917                result.push_str(
918                    &code
919                        .truecolor(warning_color.0, warning_color.1, warning_color.2)
920                        .to_string(),
921                );
922            }
923            _ => {
924                current_text.push(ch);
925            }
926        }
927    }
928
929    // Flush remaining text
930    if !current_text.is_empty() {
931        result.push_str(
932            &current_text
933                .truecolor(text_color.0, text_color.1, text_color.2)
934                .to_string(),
935        );
936    }
937
938    result
939}