Skip to main content

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