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 #[must_use]
62 pub fn format(&self) -> String {
63 render_markdown_for_terminal(&self.content)
64 }
65}
66
67#[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 if line.starts_with("```") {
86 if in_code_block {
87 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 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 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 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 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 else if line.trim().is_empty() {
193 output.push('\n');
194 }
195 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
205fn style_header_text(text: &str) -> String {
207 text.to_uppercase()
208}
209
210#[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 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 '[' => {
228 if !current_text.is_empty() {
230 result.push_str(
231 ¤t_text
232 .truecolor(text_color.0, text_color.1, text_color.2)
233 .to_string(),
234 );
235 current_text.clear();
236 }
237
238 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 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 '*' if chars.peek() == Some(&'*') => {
283 if !current_text.is_empty() {
285 result.push_str(
286 ¤t_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(); let mut bold = String::new();
297 while let Some(c) = chars.next() {
298 if c == '*' && chars.peek() == Some(&'*') {
299 chars.next(); 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 '`' => {
314 if !current_text.is_empty() {
316 result.push_str(
317 ¤t_text
318 .truecolor(text_color.0, text_color.1, text_color.2)
319 .to_string(),
320 );
321 current_text.clear();
322 }
323
324 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 if !current_text.is_empty() {
347 result.push_str(
348 ¤t_text
349 .truecolor(text_color.0, text_color.1, text_color.2)
350 .to_string(),
351 );
352 }
353
354 result
355}