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