Skip to main content

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