git_iris/types/
review.rs

1//! Code review types and formatting
2//!
3//! This module provides markdown-based review output that lets the LLM drive
4//! the review structure while we beautify it for terminal display.
5
6use colored::Colorize;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::fmt::Write;
10
11/// Helper to get themed colors for terminal output
12mod colors {
13    use crate::theme;
14
15    pub fn accent_primary() -> (u8, u8, u8) {
16        let c = theme::current().color("accent.primary");
17        (c.r, c.g, c.b)
18    }
19
20    pub fn accent_secondary() -> (u8, u8, u8) {
21        let c = theme::current().color("accent.secondary");
22        (c.r, c.g, c.b)
23    }
24
25    pub fn accent_tertiary() -> (u8, u8, u8) {
26        let c = theme::current().color("accent.tertiary");
27        (c.r, c.g, c.b)
28    }
29
30    pub fn warning() -> (u8, u8, u8) {
31        let c = theme::current().color("warning");
32        (c.r, c.g, c.b)
33    }
34
35    pub fn error() -> (u8, u8, u8) {
36        let c = theme::current().color("error");
37        (c.r, c.g, c.b)
38    }
39
40    pub fn text_secondary() -> (u8, u8, u8) {
41        let c = theme::current().color("text.secondary");
42        (c.r, c.g, c.b)
43    }
44
45    pub fn text_dim() -> (u8, u8, u8) {
46        let c = theme::current().color("text.dim");
47        (c.r, c.g, c.b)
48    }
49}
50
51/// Simple markdown-based review that lets the LLM determine structure
52#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
53pub struct MarkdownReview {
54    /// The full markdown content of the review
55    pub content: String,
56}
57
58impl MarkdownReview {
59    /// Render the markdown content with `SilkCircuit` terminal styling
60    pub fn format(&self) -> String {
61        render_markdown_for_terminal(&self.content)
62    }
63}
64
65/// Render markdown content with `SilkCircuit` terminal styling
66///
67/// This function parses markdown and applies our color palette for beautiful
68/// terminal output. It handles:
69/// - Headers (H1, H2, H3) with Electric Purple styling
70/// - Bold text with Neon Cyan
71/// - Code blocks with dimmed background styling
72/// - Bullet lists with Coral bullets
73/// - Severity badges [CRITICAL], [HIGH], etc.
74#[allow(clippy::too_many_lines)]
75pub fn render_markdown_for_terminal(markdown: &str) -> String {
76    let mut output = String::new();
77    let mut in_code_block = false;
78    let mut code_block_content = String::new();
79
80    for line in markdown.lines() {
81        // Handle code blocks
82        if line.starts_with("```") {
83            if in_code_block {
84                // End of code block - output it
85                let dim = colors::text_secondary();
86                for code_line in code_block_content.lines() {
87                    writeln!(output, "  {}", code_line.truecolor(dim.0, dim.1, dim.2))
88                        .expect("write to string should not fail");
89                }
90                code_block_content.clear();
91                in_code_block = false;
92            } else {
93                in_code_block = true;
94            }
95            continue;
96        }
97
98        if in_code_block {
99            code_block_content.push_str(line);
100            code_block_content.push('\n');
101            continue;
102        }
103
104        // Handle headers
105        if let Some(header) = line.strip_prefix("### ") {
106            let cyan = colors::accent_secondary();
107            let dim = colors::text_dim();
108            writeln!(
109                output,
110                "\n{} {} {}",
111                "─".truecolor(cyan.0, cyan.1, cyan.2),
112                style_header_text(header)
113                    .truecolor(cyan.0, cyan.1, cyan.2)
114                    .bold(),
115                "─"
116                    .repeat(30usize.saturating_sub(header.len()))
117                    .truecolor(dim.0, dim.1, dim.2)
118            )
119            .expect("write to string should not fail");
120        } else if let Some(header) = line.strip_prefix("## ") {
121            let purple = colors::accent_primary();
122            let dim = colors::text_dim();
123            writeln!(
124                output,
125                "\n{} {} {}",
126                "─".truecolor(purple.0, purple.1, purple.2),
127                style_header_text(header)
128                    .truecolor(purple.0, purple.1, purple.2)
129                    .bold(),
130                "─"
131                    .repeat(32usize.saturating_sub(header.len()))
132                    .truecolor(dim.0, dim.1, dim.2)
133            )
134            .expect("write to string should not fail");
135        } else if let Some(header) = line.strip_prefix("# ") {
136            // Main title - big and bold
137            let purple = colors::accent_primary();
138            let cyan = colors::accent_secondary();
139            writeln!(
140                output,
141                "{}  {}  {}",
142                "━━━".truecolor(purple.0, purple.1, purple.2),
143                style_header_text(header)
144                    .truecolor(cyan.0, cyan.1, cyan.2)
145                    .bold(),
146                "━━━".truecolor(purple.0, purple.1, purple.2)
147            )
148            .expect("write to string should not fail");
149        }
150        // Handle bullet points
151        else if let Some(content) = line.strip_prefix("- ") {
152            let coral = colors::accent_tertiary();
153            let styled = style_line_content(content);
154            writeln!(
155                output,
156                "  {} {}",
157                "•".truecolor(coral.0, coral.1, coral.2),
158                styled
159            )
160            .expect("write to string should not fail");
161        } else if let Some(content) = line.strip_prefix("* ") {
162            let coral = colors::accent_tertiary();
163            let styled = style_line_content(content);
164            writeln!(
165                output,
166                "  {} {}",
167                "•".truecolor(coral.0, coral.1, coral.2),
168                styled
169            )
170            .expect("write to string should not fail");
171        }
172        // Handle numbered lists
173        else if line.chars().next().is_some_and(|c| c.is_ascii_digit()) && line.contains(". ") {
174            if let Some((num, rest)) = line.split_once(". ") {
175                let coral = colors::accent_tertiary();
176                let styled = style_line_content(rest);
177                writeln!(
178                    output,
179                    "  {} {}",
180                    format!("{}.", num)
181                        .truecolor(coral.0, coral.1, coral.2)
182                        .bold(),
183                    styled
184                )
185                .expect("write to string should not fail");
186            }
187        }
188        // Handle empty lines
189        else if line.trim().is_empty() {
190            output.push('\n');
191        }
192        // Regular paragraph text
193        else {
194            let styled = style_line_content(line);
195            writeln!(output, "{styled}").expect("write to string should not fail");
196        }
197    }
198
199    output
200}
201
202/// Style header text - uppercase and clean
203fn style_header_text(text: &str) -> String {
204    text.to_uppercase()
205}
206
207/// Style inline content - handles bold, code, severity badges
208#[allow(clippy::too_many_lines)]
209fn style_line_content(content: &str) -> String {
210    let mut result = String::new();
211    let mut chars = content.chars().peekable();
212    let mut current_text = String::new();
213
214    // Get theme colors once for efficiency
215    let text_color = colors::text_secondary();
216    let error_color = colors::error();
217    let warning_color = colors::warning();
218    let coral_color = colors::accent_tertiary();
219    let cyan_color = colors::accent_secondary();
220
221    while let Some(ch) = chars.next() {
222        match ch {
223            // Handle severity badges [CRITICAL], [HIGH], [MEDIUM], [LOW]
224            '[' => {
225                // Flush current text
226                if !current_text.is_empty() {
227                    result.push_str(
228                        &current_text
229                            .truecolor(text_color.0, text_color.1, text_color.2)
230                            .to_string(),
231                    );
232                    current_text.clear();
233                }
234
235                // Collect badge content
236                let mut badge = String::new();
237                for c in chars.by_ref() {
238                    if c == ']' {
239                        break;
240                    }
241                    badge.push(c);
242                }
243
244                // Style based on severity
245                let badge_upper = badge.to_uppercase();
246                let styled_badge = match badge_upper.as_str() {
247                    "CRITICAL" => format!(
248                        "[{}]",
249                        "CRITICAL"
250                            .truecolor(error_color.0, error_color.1, error_color.2)
251                            .bold()
252                    ),
253                    "HIGH" => format!(
254                        "[{}]",
255                        "HIGH"
256                            .truecolor(error_color.0, error_color.1, error_color.2)
257                            .bold()
258                    ),
259                    "MEDIUM" => format!(
260                        "[{}]",
261                        "MEDIUM"
262                            .truecolor(warning_color.0, warning_color.1, warning_color.2)
263                            .bold()
264                    ),
265                    "LOW" => format!(
266                        "[{}]",
267                        "LOW"
268                            .truecolor(coral_color.0, coral_color.1, coral_color.2)
269                            .bold()
270                    ),
271                    _ => format!(
272                        "[{}]",
273                        badge.truecolor(cyan_color.0, cyan_color.1, cyan_color.2)
274                    ),
275                };
276                result.push_str(&styled_badge);
277            }
278            // Handle bold text **text**
279            '*' if chars.peek() == Some(&'*') => {
280                // Flush current text
281                if !current_text.is_empty() {
282                    result.push_str(
283                        &current_text
284                            .truecolor(text_color.0, text_color.1, text_color.2)
285                            .to_string(),
286                    );
287                    current_text.clear();
288                }
289
290                chars.next(); // consume second *
291
292                // Collect bold content
293                let mut bold = String::new();
294                while let Some(c) = chars.next() {
295                    if c == '*' && chars.peek() == Some(&'*') {
296                        chars.next(); // consume closing **
297                        break;
298                    }
299                    bold.push(c);
300                }
301
302                result.push_str(
303                    &bold
304                        .truecolor(cyan_color.0, cyan_color.1, cyan_color.2)
305                        .bold()
306                        .to_string(),
307                );
308            }
309            // Handle inline code `code`
310            '`' => {
311                // Flush current text
312                if !current_text.is_empty() {
313                    result.push_str(
314                        &current_text
315                            .truecolor(text_color.0, text_color.1, text_color.2)
316                            .to_string(),
317                    );
318                    current_text.clear();
319                }
320
321                // Collect code content
322                let mut code = String::new();
323                for c in chars.by_ref() {
324                    if c == '`' {
325                        break;
326                    }
327                    code.push(c);
328                }
329
330                result.push_str(
331                    &code
332                        .truecolor(warning_color.0, warning_color.1, warning_color.2)
333                        .to_string(),
334                );
335            }
336            _ => {
337                current_text.push(ch);
338            }
339        }
340    }
341
342    // Flush remaining text
343    if !current_text.is_empty() {
344        result.push_str(
345            &current_text
346                .truecolor(text_color.0, text_color.1, text_color.2)
347                .to_string(),
348        );
349    }
350
351    result
352}