1use colored::Colorize;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::fmt::Write;
10
11mod 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#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)]
53pub struct MarkdownReview {
54 pub content: String,
56}
57
58impl MarkdownReview {
59 pub fn format(&self) -> String {
61 render_markdown_for_terminal(&self.content)
62 }
63}
64
65#[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 if line.starts_with("```") {
83 if in_code_block {
84 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 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 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 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 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 else if line.trim().is_empty() {
190 output.push('\n');
191 }
192 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
202fn style_header_text(text: &str) -> String {
204 text.to_uppercase()
205}
206
207#[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 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 '[' => {
225 if !current_text.is_empty() {
227 result.push_str(
228 ¤t_text
229 .truecolor(text_color.0, text_color.1, text_color.2)
230 .to_string(),
231 );
232 current_text.clear();
233 }
234
235 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 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 '*' if chars.peek() == Some(&'*') => {
280 if !current_text.is_empty() {
282 result.push_str(
283 ¤t_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(); let mut bold = String::new();
294 while let Some(c) = chars.next() {
295 if c == '*' && chars.peek() == Some(&'*') {
296 chars.next(); 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 '`' => {
311 if !current_text.is_empty() {
313 result.push_str(
314 ¤t_text
315 .truecolor(text_color.0, text_color.1, text_color.2)
316 .to_string(),
317 );
318 current_text.clear();
319 }
320
321 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 if !current_text.is_empty() {
344 result.push_str(
345 ¤t_text
346 .truecolor(text_color.0, text_color.1, text_color.2)
347 .to_string(),
348 );
349 }
350
351 result
352}