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