manx_cli/
render.rs

1use crate::client::{CodeExample, DocSection, Documentation, SearchResult};
2use crate::config::Config;
3use anyhow::Result;
4use colored::*;
5use indicatif::{ProgressBar, ProgressStyle};
6use std::io;
7
8pub struct Renderer {
9    quiet_mode: bool,
10    terminal_width: usize,
11    config: Option<Config>,
12}
13
14impl Renderer {
15    pub fn new(quiet: bool) -> Self {
16        let terminal_width = termsize::get().map(|size| size.cols as usize).unwrap_or(80);
17        let config = Config::load().ok();
18
19        Self {
20            quiet_mode: quiet,
21            terminal_width,
22            config,
23        }
24    }
25
26    pub fn render_search_results(&self, results: &[SearchResult]) -> io::Result<()> {
27        self.render_search_results_with_library(results, None, None)
28    }
29
30    pub fn render_search_results_with_library(
31        &self,
32        results: &[SearchResult],
33        library_info: Option<(&str, &str)>,
34        limit: Option<usize>,
35    ) -> io::Result<()> {
36        if self.quiet_mode {
37            // JSON output for scripting
38            println!("{}", serde_json::to_string_pretty(results)?);
39            return Ok(());
40        }
41
42        if results.is_empty() {
43            println!("{}", "No results found.".yellow());
44            return Ok(());
45        }
46
47        println!(
48            "{} {} found:",
49            results.len().to_string().cyan().bold(),
50            if results.len() == 1 {
51                "result"
52            } else {
53                "results"
54            }
55        );
56
57        if let Some((library_title, library_id)) = library_info {
58            println!(
59                "šŸ“š Using library: {} ({})\n",
60                library_title.bright_blue(),
61                library_id.dimmed()
62            );
63        } else {
64            println!();
65        }
66
67        // Use provided limit or default to 10 (0 means unlimited)
68        let display_limit = limit.unwrap_or(10);
69        let total_results = results.len();
70        let results_to_show = if display_limit == 0 {
71            results.iter().take(total_results)
72        } else {
73            results.iter().take(display_limit)
74        };
75
76        for (idx, result) in results_to_show.enumerate() {
77            self.render_search_result(idx + 1, result)?;
78        }
79
80        if display_limit > 0 && total_results > display_limit {
81            println!(
82                "\n{}",
83                format!(
84                    "... and {} more results. Use --limit 0 to show all, or --save-all to export.",
85                    total_results - display_limit
86                )
87                .yellow()
88            );
89        }
90
91        println!(
92            "\n{}",
93            "Tip: Use 'manx get <id>' to expand a result.".dimmed()
94        );
95        Ok(())
96    }
97
98    fn render_search_result(&self, num: usize, result: &SearchResult) -> io::Result<()> {
99        let separator = "─".repeat(self.terminal_width.min(60));
100
101        println!(
102            "{} {} {}",
103            format!("[{}]", num).cyan().bold(),
104            result.title.white().bold(),
105            format!("({})", result.library).dimmed()
106        );
107
108        println!("  {}: {}", "ID".dimmed(), result.id.yellow());
109
110        if let Some(url) = &result.url {
111            println!("  {}: {}", "URL".dimmed(), url.blue().underline());
112        }
113
114        println!();
115
116        // Parse and display Context7 content in a more readable format
117        if result.excerpt.contains("CODE SNIPPETS") {
118            self.render_context7_excerpt(&result.excerpt)?;
119        } else {
120            // Show more of the excerpt for better distinction
121            let max_width = self.terminal_width.max(100) - 4;
122            println!("  {}", self.truncate_text(&result.excerpt, max_width));
123        }
124
125        println!("{}\n", separator.dimmed());
126        Ok(())
127    }
128
129    fn render_context7_excerpt(&self, content: &str) -> io::Result<()> {
130        // Find the first meaningful content after CODE SNIPPETS header
131        let lines: Vec<&str> = content.lines().collect();
132        let mut found_title = false;
133
134        for line in lines.iter().take(10) {
135            // Only show first few lines for excerpt
136            if line.starts_with("TITLE: ") && !found_title {
137                let title = &line[7..];
138                println!("  {}", title.white().bold());
139                found_title = true;
140            } else if line.starts_with("DESCRIPTION: ") && found_title {
141                let desc = &line[13..];
142                let truncated = self.truncate_text(desc, self.terminal_width - 4);
143                println!("  {}", truncated.dimmed());
144                break;
145            }
146        }
147
148        if !found_title {
149            println!("  {}", "Documentation snippets available...".dimmed());
150        }
151
152        Ok(())
153    }
154
155    pub fn render_documentation(&self, doc: &Documentation) -> io::Result<()> {
156        if self.quiet_mode {
157            println!("{}", serde_json::to_string_pretty(doc)?);
158            return Ok(());
159        }
160
161        // Header
162        println!(
163            "\nšŸ“š {} {}",
164            doc.library.name.cyan().bold(),
165            doc.library
166                .version
167                .as_ref()
168                .map(|v| format!("v{}", v))
169                .unwrap_or_default()
170                .dimmed()
171        );
172
173        if let Some(desc) = &doc.library.description {
174            println!("{}\n", desc.dimmed());
175        }
176
177        // Sections
178        for section in &doc.sections {
179            self.render_doc_section(section)?;
180        }
181
182        Ok(())
183    }
184
185    fn render_doc_section(&self, section: &DocSection) -> io::Result<()> {
186        println!("\n{}", section.title.green().bold());
187
188        if let Some(url) = &section.url {
189            println!("{}: {}", "Source".dimmed(), url.blue().underline());
190        }
191
192        println!("\n{}", section.content);
193
194        // Code examples
195        for example in &section.code_examples {
196            self.render_code_example(example)?;
197        }
198
199        Ok(())
200    }
201
202    fn render_code_example(&self, example: &CodeExample) -> io::Result<()> {
203        println!(
204            "\n{} {}:",
205            "ā–¶".cyan(),
206            example
207                .description
208                .as_ref()
209                .unwrap_or(&"Example".to_string())
210                .yellow()
211        );
212
213        println!("{}", format!("```{}", example.language).dimmed());
214
215        // Simple syntax highlighting for common languages
216        let highlighted = self.highlight_code(&example.code, &example.language);
217        println!("{}", highlighted);
218
219        println!("{}", "```".dimmed());
220        Ok(())
221    }
222
223    fn highlight_code(&self, code: &str, language: &str) -> String {
224        if self.quiet_mode {
225            return code.to_string();
226        }
227
228        // Basic syntax highlighting
229        match language {
230            "python" | "py" => self.highlight_python(code),
231            "javascript" | "js" | "typescript" | "ts" => self.highlight_javascript(code),
232            "rust" | "rs" => self.highlight_rust(code),
233            _ => code.to_string(),
234        }
235    }
236
237    fn highlight_python(&self, code: &str) -> String {
238        let keywords = [
239            "def", "class", "import", "from", "return", "if", "else", "elif", "for", "while", "in",
240            "as", "with", "try", "except", "finally", "raise", "yield", "lambda",
241        ];
242
243        let mut highlighted = code.to_string();
244        for keyword in &keywords {
245            let _pattern = format!(r"\b{}\b", keyword);
246            highlighted = highlighted.replace(keyword, &keyword.magenta().to_string());
247        }
248        highlighted
249    }
250
251    fn highlight_javascript(&self, code: &str) -> String {
252        let keywords = [
253            "function", "const", "let", "var", "return", "if", "else", "for", "while", "class",
254            "extends", "import", "export", "async", "await", "try", "catch", "throw", "new",
255        ];
256
257        let mut highlighted = code.to_string();
258        for keyword in &keywords {
259            let _pattern = format!(r"\b{}\b", keyword);
260            highlighted = highlighted.replace(keyword, &keyword.blue().to_string());
261        }
262        highlighted
263    }
264
265    fn highlight_rust(&self, code: &str) -> String {
266        let keywords = [
267            "fn", "let", "mut", "const", "use", "mod", "pub", "impl", "struct", "enum", "trait",
268            "where", "async", "await", "match", "if", "else", "for", "while", "loop", "return",
269        ];
270
271        let mut highlighted = code.to_string();
272        for keyword in &keywords {
273            let _pattern = format!(r"\b{}\b", keyword);
274            highlighted = highlighted.replace(keyword, &keyword.red().to_string());
275        }
276        highlighted
277    }
278
279    fn truncate_text(&self, text: &str, max_len: usize) -> String {
280        if text.len() <= max_len {
281            text.to_string()
282        } else {
283            // Try to break at a word boundary
284            let truncate_at = max_len - 3;
285            if let Some(last_space) = text[..truncate_at].rfind(' ') {
286                format!("{}...", &text[..last_space])
287            } else {
288                format!("{}...", &text[..truncate_at])
289            }
290        }
291    }
292
293    pub fn show_progress(&self, message: &str) -> ProgressBar {
294        if self.quiet_mode {
295            return ProgressBar::hidden();
296        }
297
298        let pb = ProgressBar::new_spinner();
299        pb.set_style(
300            ProgressStyle::default_spinner()
301                .tick_chars("ā ā ‚ā „ā”€ā¢€ā  ā ā ˆ ")
302                .template("{spinner:.cyan} {msg}")
303                .unwrap(),
304        );
305        pb.set_message(message.to_string());
306        pb.enable_steady_tick(std::time::Duration::from_millis(100));
307        pb
308    }
309
310    pub fn print_error(&self, error: &str) {
311        if self.quiet_mode {
312            eprintln!("{{\"error\": \"{}\"}}", error);
313        } else {
314            eprintln!("{} {}", "āœ—".red().bold(), error.red());
315        }
316    }
317
318    pub fn print_success(&self, message: &str) {
319        if !self.quiet_mode {
320            println!("{} {}", "āœ“".green().bold(), message.green());
321        }
322    }
323
324    pub fn render_context7_documentation(&self, library: &str, content: &str) -> io::Result<()> {
325        self.render_context7_documentation_with_limit(library, content, None)
326    }
327
328    pub fn render_context7_documentation_with_limit(
329        &self,
330        library: &str,
331        content: &str,
332        limit: Option<usize>,
333    ) -> io::Result<()> {
334        if self.quiet_mode {
335            println!("{}", content);
336            return Ok(());
337        }
338
339        // Header
340        println!(
341            "\n{} {} {}",
342            "šŸ“š".cyan().bold(),
343            library.white().bold(),
344            "Documentation".dimmed()
345        );
346
347        // Parse and render the Context7 format with limit
348        self.parse_and_render_context7_content_with_limit(content, limit)?;
349
350        // Cache individual sections for the open command
351        let sections = self.extract_doc_sections(content);
352        if self.cache_doc_sections(library, &sections).is_err() {
353            // Silently continue if caching fails
354        }
355
356        Ok(())
357    }
358
359    fn parse_and_render_context7_content_with_limit(
360        &self,
361        content: &str,
362        limit: Option<usize>,
363    ) -> io::Result<()> {
364        let lines: Vec<&str> = content.lines().collect();
365        let mut i = 0;
366        let mut sections_shown = 0;
367        let section_limit = limit.unwrap_or(10); // Default to 10 sections
368
369        while i < lines.len() {
370            // Check if we've reached the limit (but only if limit is not 0, which means unlimited)
371            if limit.is_some() && limit.unwrap() > 0 && sections_shown >= section_limit {
372                let remaining = self.count_remaining_sections(&lines[i..]);
373                if remaining > 0 {
374                    println!(
375                        "\n{}",
376                        format!(
377                            "... and {} more sections. Use --limit 0 to show all.",
378                            remaining
379                        )
380                        .yellow()
381                    );
382                }
383                break;
384            }
385            let line = lines[i];
386
387            // Skip headers and separators
388            if line.starts_with("========================") {
389                if i + 1 < lines.len() && lines[i + 1].starts_with("CODE SNIPPETS") {
390                    println!("\n{}", "šŸ“ Code Examples & Snippets".green().bold());
391                    i += 2;
392                    continue;
393                }
394                i += 1;
395                continue;
396            }
397
398            // Parse title blocks
399            if let Some(title) = line.strip_prefix("TITLE: ") {
400                sections_shown += 1;
401                println!(
402                    "\n{} {}",
403                    format!("[{}]", sections_shown).cyan().bold(),
404                    title.white().bold()
405                );
406                i += 1;
407
408                // Look for description
409                if i < lines.len() && lines[i].starts_with("DESCRIPTION: ") {
410                    let desc = &lines[i][13..];
411                    println!("{}", desc.dimmed());
412                    i += 1;
413                }
414
415                // Skip empty lines
416                while i < lines.len() && lines[i].trim().is_empty() {
417                    i += 1;
418                }
419
420                // Look for source
421                while i < lines.len() && lines[i].starts_with("SOURCE: ") {
422                    let source = &lines[i][8..];
423                    println!("{}: {}", "Source".dimmed(), source.blue());
424                    i += 1;
425                }
426
427                // Skip empty lines
428                while i < lines.len() && lines[i].trim().is_empty() {
429                    i += 1;
430                }
431
432                // Look for language and code block
433                if i < lines.len() && lines[i].starts_with("LANGUAGE: ") {
434                    let language = &lines[i][10..];
435                    i += 1;
436
437                    // Skip "CODE:" line
438                    if i < lines.len() && lines[i].starts_with("CODE:") {
439                        i += 1;
440                    }
441
442                    // Parse code block
443                    if i < lines.len() && lines[i].starts_with("```") {
444                        println!("\n{} {}:", "ā–¶".cyan(), language.yellow());
445                        println!("{}", lines[i].dimmed());
446                        i += 1;
447
448                        // Print code content
449                        while i < lines.len() && !lines[i].starts_with("```") {
450                            let highlighted =
451                                self.highlight_code(lines[i], &language.to_lowercase());
452                            println!("{}", highlighted);
453                            i += 1;
454                        }
455
456                        // Print closing ```
457                        if i < lines.len() && lines[i].starts_with("```") {
458                            println!("{}", lines[i].dimmed());
459                            i += 1;
460                        }
461                    }
462                }
463
464                // Skip separators
465                while i < lines.len() && (lines[i].trim().is_empty() || lines[i].starts_with("---"))
466                {
467                    if lines[i].starts_with("---") {
468                        let separator = "─".repeat(self.terminal_width.min(60));
469                        println!("\n{}", separator.dimmed());
470                    }
471                    i += 1;
472                }
473
474                continue;
475            }
476
477            i += 1;
478        }
479
480        // Add tip message about opening sections
481        if sections_shown > 0 {
482            println!(
483                "\n{}",
484                "Tip: Use 'manx open <section-id>' to expand a specific section.".dimmed()
485            );
486        }
487
488        Ok(())
489    }
490
491    fn count_remaining_sections(&self, lines: &[&str]) -> usize {
492        lines
493            .iter()
494            .filter(|line| line.starts_with("TITLE: "))
495            .count()
496    }
497
498    fn extract_doc_sections(&self, content: &str) -> Vec<String> {
499        let lines: Vec<&str> = content.lines().collect();
500        let mut sections = Vec::new();
501        let mut i = 0;
502
503        while i < lines.len() {
504            let line = lines[i];
505
506            // Look for title blocks (start of a section)
507            if let Some(_title) = line.strip_prefix("TITLE: ") {
508                let section_start = i;
509                let mut section_end = lines.len();
510
511                // Find the end of this section (next TITLE or end of content)
512                for (j, line) in lines.iter().enumerate().skip(i + 1) {
513                    if line.starts_with("TITLE: ") {
514                        section_end = j;
515                        break;
516                    }
517                }
518
519                // Extract the complete section
520                let section_lines = &lines[section_start..section_end];
521                let section_content = section_lines.join("\n").trim().to_string();
522
523                if !section_content.is_empty() {
524                    sections.push(section_content);
525                }
526
527                i = section_end;
528            } else {
529                i += 1;
530            }
531        }
532
533        sections
534    }
535
536    pub fn render_open_section(&self, id: &str, content: &str) -> io::Result<()> {
537        if self.quiet_mode {
538            println!("{}", content);
539            return Ok(());
540        }
541
542        println!(
543            "\n{} {} {}",
544            "šŸ“–".cyan().bold(),
545            id.yellow().bold(),
546            "Documentation Section".dimmed()
547        );
548
549        // Parse and render just this section
550        self.render_single_section(content)?;
551
552        Ok(())
553    }
554
555    fn render_single_section(&self, content: &str) -> io::Result<()> {
556        let lines: Vec<&str> = content.lines().collect();
557        let mut i = 0;
558
559        while i < lines.len() {
560            let line = lines[i];
561
562            // Skip headers and separators
563            if line.starts_with("========================") {
564                if i + 1 < lines.len() && lines[i + 1].starts_with("CODE SNIPPETS") {
565                    println!("\n{}", "šŸ“ Code Examples & Snippets".green().bold());
566                    i += 2;
567                    continue;
568                }
569                i += 1;
570                continue;
571            }
572
573            // Parse title blocks (but don't add numbering)
574            if let Some(title) = line.strip_prefix("TITLE: ") {
575                println!("\n{}", title.white().bold());
576                i += 1;
577
578                // Look for description
579                if i < lines.len() && lines[i].starts_with("DESCRIPTION: ") {
580                    let desc = &lines[i][13..];
581                    println!("{}", desc.dimmed());
582                    i += 1;
583                }
584
585                // Skip empty lines
586                while i < lines.len() && lines[i].trim().is_empty() {
587                    i += 1;
588                }
589
590                // Look for source
591                while i < lines.len() && lines[i].starts_with("SOURCE: ") {
592                    let source = &lines[i][8..];
593                    println!("{}: {}", "Source".dimmed(), source.blue());
594                    i += 1;
595                }
596
597                // Skip empty lines
598                while i < lines.len() && lines[i].trim().is_empty() {
599                    i += 1;
600                }
601
602                // Look for language and code block
603                if i < lines.len() && lines[i].starts_with("LANGUAGE: ") {
604                    let language = &lines[i][10..];
605                    i += 1;
606
607                    // Skip "CODE:" line
608                    if i < lines.len() && lines[i].starts_with("CODE:") {
609                        i += 1;
610                    }
611
612                    // Parse code block
613                    if i < lines.len() && lines[i].starts_with("```") {
614                        println!("\n{} {}:", "ā–¶".cyan(), language.yellow());
615                        println!("{}", lines[i].dimmed());
616                        i += 1;
617
618                        // Print code content
619                        while i < lines.len() && !lines[i].starts_with("```") {
620                            let highlighted =
621                                self.highlight_code(lines[i], &language.to_lowercase());
622                            println!("{}", highlighted);
623                            i += 1;
624                        }
625
626                        // Print closing ```
627                        if i < lines.len() && lines[i].starts_with("```") {
628                            println!("{}", lines[i].dimmed());
629                            i += 1;
630                        }
631                    }
632                }
633
634                // Skip separators
635                while i < lines.len() && (lines[i].trim().is_empty() || lines[i].starts_with("---"))
636                {
637                    if lines[i].starts_with("---") {
638                        let separator = "─".repeat(self.terminal_width.min(60));
639                        println!("\n{}", separator.dimmed());
640                    }
641                    i += 1;
642                }
643
644                continue;
645            }
646
647            i += 1;
648        }
649
650        Ok(())
651    }
652
653    fn cache_doc_sections(&self, library: &str, sections: &[String]) -> Result<()> {
654        if let Some(config) = &self.config {
655            if config.auto_cache_enabled {
656                if let Ok(cache_manager) = crate::cache::CacheManager::new() {
657                    let library_clean = library.to_string();
658                    let sections_clone = sections.to_vec();
659
660                    tokio::spawn(async move {
661                        for (idx, section) in sections_clone.iter().enumerate() {
662                            let cache_key = format!("{}_doc-{}", library_clean, idx + 1);
663                            let _ = cache_manager.set("doc_sections", &cache_key, section).await;
664                        }
665                    });
666                }
667            }
668        }
669        Ok(())
670    }
671}