whois_cli/
markdown.rs

1use anyhow::{Context, Result};
2use colored::*;
3use pulldown_cmark::{Parser, Event, Tag, CodeBlockKind, HeadingLevel};
4use regex::Regex;
5
6#[cfg(feature = "images")]
7use viuer::{Config as ViuerConfig, print_from_file};
8
9/// Markdown renderer for terminal output with image support
10pub struct MarkdownRenderer {
11    /// Whether to enable image display
12    enable_images: bool,
13}
14
15impl MarkdownRenderer {
16    pub fn new(enable_images: bool) -> Self {
17        Self {
18            enable_images,
19        }
20    }
21
22    /// Render markdown text to colored terminal output
23    pub fn render(&mut self, markdown: &str) -> Result<String> {
24        let parser = Parser::new(markdown);
25        let mut output = String::new();
26        let mut in_code_block = false;
27        let mut in_emphasis = false;
28        let mut in_strong = false;
29        let mut in_heading = false;
30        let mut heading_level = HeadingLevel::H1;
31        let mut list_stack: Vec<bool> = Vec::new(); // true for ordered, false for unordered
32
33        for event in parser {
34            match event {
35                Event::Start(tag) => {
36                    match tag {
37                        Tag::Heading(level, _, _) => {
38                            in_heading = true;
39                            heading_level = level;
40                            output.push('\n');
41                        }
42                        Tag::Paragraph => {
43                            if !output.is_empty() && !output.ends_with('\n') {
44                                output.push('\n');
45                            }
46                        }
47                        Tag::List(start_number) => {
48                            list_stack.push(start_number.is_some());
49                            output.push('\n');
50                        }
51                        Tag::Item => {
52                            let indent = "  ".repeat(list_stack.len().saturating_sub(1));
53                            if let Some(&is_ordered) = list_stack.last() {
54                                if is_ordered {
55                                    output.push_str(&format!("{}1. ", indent));
56                                } else {
57                                    output.push_str(&format!("{}• ", indent));
58                                }
59                            }
60                        }
61                        Tag::Emphasis => {
62                            in_emphasis = true;
63                        }
64                        Tag::Strong => {
65                            in_strong = true;
66                        }
67                        Tag::CodeBlock(kind) => {
68                            in_code_block = true;
69                            output.push('\n');
70                            if let CodeBlockKind::Fenced(lang) = kind {
71                                if !lang.is_empty() {
72                                    output.push_str(&format!("```{}\n", lang.bright_black()));
73                                } else {
74                                    output.push_str("```\n");
75                                }
76                            } else {
77                                output.push_str("```\n");
78                            }
79                        }
80                        // Inline code is handled in Event::Code, not as a Tag
81                        Tag::Link(_link_type, dest_url, title) => {
82                            // Handle hyperlinks
83                            if !title.is_empty() {
84                                output.push_str(&format!("{} ({})", title.bright_blue().underline(), dest_url.bright_black()));
85                            } else {
86                                output.push_str(&dest_url.bright_blue().underline().to_string());
87                            }
88                        }
89                        Tag::Image(_link_type, dest_url, title) => {
90                            self.handle_image(&mut output, dest_url.as_ref(), title.as_ref())?;
91                        }
92                        Tag::BlockQuote => {
93                            output.push_str(&"▍ ".bright_black().to_string());
94                        }
95                        _ => {}
96                    }
97                }
98                Event::End(tag) => {
99                    match tag {
100                        Tag::Heading(_, _, _) => {
101                            in_heading = false;
102                            output.push('\n');
103                        }
104                        Tag::Paragraph => {
105                            output.push('\n');
106                        }
107                        Tag::List(_) => {
108                            list_stack.pop();
109                            output.push('\n');
110                        }
111                        Tag::Item => {
112                            output.push('\n');
113                        }
114                        Tag::Emphasis => {
115                            in_emphasis = false;
116                        }
117                        Tag::Strong => {
118                            in_strong = false;
119                        }
120                        Tag::CodeBlock(_) => {
121                            in_code_block = false;
122                            output.push_str("```\n\n");
123                        }
124                        Tag::BlockQuote => {
125                            output.push('\n');
126                        }
127                        _ => {}
128                    }
129                }
130                Event::Text(text) => {
131                    let rendered_text = if in_code_block {
132                        // Code block - use monospace styling
133                        text.bright_white().on_black().to_string()
134                    } else if in_heading {
135                        // Heading - use appropriate colors based on level
136                        match heading_level {
137                            HeadingLevel::H1 => text.bright_white().bold().to_string(),
138                            HeadingLevel::H2 => text.bright_cyan().bold().to_string(),
139                            HeadingLevel::H3 => text.bright_green().bold().to_string(),
140                            HeadingLevel::H4 => text.bright_yellow().bold().to_string(),
141                            HeadingLevel::H5 => text.bright_magenta().bold().to_string(),
142                            HeadingLevel::H6 => text.bright_blue().bold().to_string(),
143                        }
144                    } else if in_strong {
145                        text.bold().to_string()
146                    } else if in_emphasis {
147                        text.italic().to_string()
148                    } else {
149                        text.to_string()
150                    };
151                    output.push_str(&rendered_text);
152                }
153                Event::Code(code) => {
154                    // Inline code
155                    output.push_str(&code.bright_white().on_black().to_string());
156                }
157                Event::Html(html) => {
158                    // Handle HTML tags if needed - for now, just strip them
159                    output.push_str(&self.strip_html(&html));
160                }
161                Event::SoftBreak => {
162                    output.push(' ');
163                }
164                Event::HardBreak => {
165                    output.push('\n');
166                }
167                Event::Rule => {
168                    output.push_str(&"─".repeat(80).bright_black().to_string());
169                    output.push('\n');
170                }
171                _ => {}
172            }
173        }
174
175        Ok(output)
176    }
177
178    /// Handle image display in terminal
179    fn handle_image(&mut self, output: &mut String, url: &str, title: &str) -> Result<()> {
180        if !self.enable_images {
181            // Images disabled, show as link
182            if !title.is_empty() {
183                output.push_str(&format!("[Image: {}] ({})\n", title.bright_green(), url.bright_black()));
184            } else {
185                output.push_str(&format!("[Image] ({})\n", url.bright_black()));
186            }
187            return Ok(());
188        }
189
190        #[cfg(feature = "images")]
191        {
192            if url.starts_with("data:image/") {
193                // Handle embedded base64 images
194                self.handle_embedded_image(output, url, title)?;
195            } else if url.starts_with("http://") || url.starts_with("https://") {
196                // Handle remote images
197                self.handle_remote_image(output, url, title)?;
198            } else {
199                // Handle local file paths
200                self.handle_local_image(output, url, title)?;
201            }
202        }
203
204        #[cfg(not(feature = "images"))]
205        {
206            // Feature disabled, show as link
207            if !title.is_empty() {
208                output.push_str(&format!("[Image: {}] ({})\n", title.bright_green(), url.bright_black()));
209            } else {
210                output.push_str(&format!("[Image] ({})\n", url.bright_black()));
211            }
212        }
213
214        Ok(())
215    }
216
217    #[cfg(feature = "images")]
218    fn handle_embedded_image(&mut self, output: &mut String, data_url: &str, title: &str) -> Result<()> {
219        use base64::Engine;
220        
221        // Parse data URL: data:image/png;base64,iVBORw0KGgoAAAANS...
222        let re = Regex::new(r"data:image/([^;]+);base64,(.+)").unwrap();
223        if let Some(captures) = re.captures(data_url) {
224            let format = &captures[1];
225            let base64_data = &captures[2];
226            
227            // Decode base64
228            let image_data = base64::engine::general_purpose::STANDARD
229                .decode(base64_data)
230                .context("Failed to decode base64 image data")?;
231            
232            // Write to temporary file
233            let temp_path = format!("/tmp/whois_image_{}.{}", 
234                std::process::id(), format);
235            std::fs::write(&temp_path, &image_data)
236                .context("Failed to write temporary image file")?;
237            
238            // Display image
239            let config = ViuerConfig {
240                width: Some(80),
241                height: Some(24),
242                ..Default::default()
243            };
244            
245            match print_from_file(&temp_path, &config) {
246                Ok(_) => {
247                    if !title.is_empty() {
248                        output.push_str(&format!("\n{}\n", title.bright_green()));
249                    }
250                }
251                Err(_) => {
252                    output.push_str(&format!("[Image display failed: {}]\n", 
253                        if !title.is_empty() { title } else { "embedded image" }));
254                }
255            }
256            
257            // Clean up
258            let _ = std::fs::remove_file(&temp_path);
259        } else {
260            output.push_str(&format!("[Invalid data URL: {}]\n", 
261                if !title.is_empty() { title } else { "embedded image" }));
262        }
263        
264        Ok(())
265    }
266
267    #[cfg(feature = "images")]
268    fn handle_remote_image(&mut self, output: &mut String, url: &str, title: &str) -> Result<()> {
269        // For now, just show as link - could implement downloading in the future
270        if !title.is_empty() {
271            output.push_str(&format!("[Remote Image: {}] ({})\n", title.bright_green(), url.bright_black()));
272        } else {
273            output.push_str(&format!("[Remote Image] ({})\n", url.bright_black()));
274        }
275        Ok(())
276    }
277
278    #[cfg(feature = "images")]
279    fn handle_local_image(&mut self, output: &mut String, path: &str, title: &str) -> Result<()> {
280        let config = ViuerConfig {
281            width: Some(80),
282            height: Some(24),
283            ..Default::default()
284        };
285        
286        match print_from_file(path, &config) {
287            Ok(_) => {
288                if !title.is_empty() {
289                    output.push_str(&format!("\n{}\n", title.bright_green()));
290                }
291            }
292            Err(_) => {
293                output.push_str(&format!("[Image not found: {}]\n", 
294                    if !title.is_empty() { title } else { path }));
295            }
296        }
297        
298        Ok(())
299    }
300
301    /// Strip HTML tags from text
302    fn strip_html(&self, html: &str) -> String {
303        let re = Regex::new(r"<[^>]*>").unwrap();
304        re.replace_all(html, "").to_string()
305    }
306
307    /// Check if text contains markdown syntax
308    pub fn is_markdown(text: &str) -> bool {
309        // Simple heuristics to detect markdown
310        let markdown_patterns = [
311            r"^#{1,6}\s",          // Headers
312            r"\*\*.*\*\*",         // Bold
313            r"\*.*\*",             // Italic
314            r"`.*`",               // Inline code
315            r"```",                // Code blocks
316            r"^\s*[-*+]\s",        // Unordered lists
317            r"^\s*\d+\.\s",        // Ordered lists
318            r"\[.*\]\(.*\)",       // Links
319            r"!\[.*\]\(.*\)",      // Images
320            r"^\s*>",              // Blockquotes
321        ];
322        
323        for pattern in &markdown_patterns {
324            let re = Regex::new(pattern).unwrap();
325            if re.is_match(text) {
326                return true;
327            }
328        }
329        
330        false
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_is_markdown() {
340        assert!(MarkdownRenderer::is_markdown("# Header"));
341        assert!(MarkdownRenderer::is_markdown("**bold text**"));
342        assert!(MarkdownRenderer::is_markdown("- list item"));
343        assert!(MarkdownRenderer::is_markdown("[link](http://example.com)"));
344        assert!(MarkdownRenderer::is_markdown("![image](image.png)"));
345        assert!(MarkdownRenderer::is_markdown("> blockquote"));
346        assert!(MarkdownRenderer::is_markdown("```code```"));
347        assert!(!MarkdownRenderer::is_markdown("plain text"));
348    }
349
350    #[test]
351    fn test_basic_rendering() {
352        let mut renderer = MarkdownRenderer::new(false);
353        let result = renderer.render("# Header\n\nThis is **bold** and *italic*.").unwrap();
354        // Just test that it doesn't crash - detailed output testing would be complex
355        assert!(!result.is_empty());
356    }
357
358    #[test]
359    fn test_code_rendering() {
360        let mut renderer = MarkdownRenderer::new(false);
361        let result = renderer.render("Inline `code` and\n\n```rust\nfn main() {}\n```").unwrap();
362        assert!(!result.is_empty());
363    }
364
365    #[test]
366    fn test_list_rendering() {
367        let mut renderer = MarkdownRenderer::new(false);
368        let result = renderer.render("- Item 1\n- Item 2\n\n1. Numbered\n2. List").unwrap();
369        assert!(!result.is_empty());
370    }
371}