Skip to main content

zoi_cli/cmd/
search.rs

1//! Command for searching packages or files in registries.
2//!
3//! This module provides functionality to search for packages by name,
4//! description, or tags, as well as searching for specific files
5//! within packages. It supports both a standard table output and
6//! an interactive TUI.
7
8use std::io::{self};
9
10use anyhow::{Result, anyhow};
11use colored::Colorize;
12use comfy_table::presets::UTF8_FULL;
13use comfy_table::{Attribute, Cell, ContentArrangement, Table};
14use crossterm::event::{
15    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind
16};
17use crossterm::execute;
18use crossterm::terminal::{
19    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
20    enable_raw_mode
21};
22use ratatui::Terminal;
23use ratatui::backend::CrosstermBackend;
24use ratatui::layout::{Constraint, Direction, Layout};
25use ratatui::style::{Color as RatatuiColor, Modifier, Style as RatatuiStyle};
26use ratatui::text::{Line, Span};
27use ratatui::widgets::{
28    Block, Borders, List, ListItem, ListState, Paragraph, Wrap
29};
30use rayon::prelude::*;
31
32use crate::pkg::types::{Package, PackageType};
33use crate::pkg::{config, local};
34
35/// Runs the search command with the provided terms and options.
36///
37/// # Errors
38///
39/// Returns an error if:
40/// - File search fails.
41/// - Configuration reading fails.
42/// - Database search fails.
43/// - Package resolution fails.
44///
45/// # Panics
46///
47/// Panics if the interactive TUI fails to initialize or if layout calculations
48/// fail. # Errors
49///
50/// Returns an error if the search operation fails.
51pub fn run(
52    search_term: &str,
53    registry_filter: Option<&str>,
54    repo: Option<&str>,
55    package_type: Option<&str>,
56    tags: Option<Vec<String>>,
57    sort_by: &str,
58    files: bool,
59    interactive: bool
60) -> Result<()> {
61    if !interactive {
62        let mode = if files { "files" } else { "packages" };
63        println!(
64            "{} Searching for {} matching '{}'...",
65            "::".bold().blue(),
66            mode.yellow(),
67            search_term.cyan().bold()
68        );
69    }
70
71    if files {
72        if interactive {
73            return Err(anyhow!(
74                "Interactive mode is not supported for file search."
75            ));
76        }
77        return run_file_search(
78            search_term,
79            registry_filter,
80            repo,
81            package_type
82        );
83    }
84
85    let config = config::read_config()?;
86
87    let mut all_packages = Vec::new();
88    let mut db_failed = false;
89
90    if let Some(reg_handle) = registry_filter {
91        match crate::pkg::db::search_packages(reg_handle, search_term) {
92            Ok(pkgs) => all_packages.extend(pkgs),
93            Err(_) => db_failed = true
94        }
95    } else {
96        let mut registries = Vec::new();
97        if let Some(default) = &config.default_registry {
98            registries.push(default.handle.clone());
99        }
100        for reg in &config.added_registries {
101            registries.push(reg.handle.clone());
102        }
103
104        let results: Vec<Result<Vec<Package>>> = registries
105            .into_par_iter()
106            .map(|handle| crate::pkg::db::search_packages(&handle, search_term))
107            .collect();
108
109        for res in results {
110            if let Ok(pkgs) = res {
111                all_packages.extend(pkgs);
112            } else {
113                db_failed = true;
114                break;
115            }
116        }
117    }
118
119    let packages = if db_failed
120        || (all_packages.is_empty() && registry_filter.is_none())
121    {
122        if let Some(reg_handle) = registry_filter {
123            let all_repo_names = config::get_all_repos()?;
124            let full_repos: Vec<String> = all_repo_names
125                .into_iter()
126                .map(|r_name| format!("{reg_handle}/{r_name}"))
127                .filter(|full_repo_name| {
128                    if let Some(repo_f) = repo {
129                        if repo_f.contains('/') {
130                            full_repo_name == repo_f
131                        } else {
132                            full_repo_name.split('/').any(|part| part == repo_f)
133                        }
134                    } else {
135                        true
136                    }
137                })
138                .collect();
139            local::get_packages_from_repos(&full_repos)
140        } else if let Some(repo_filter) = repo {
141            let handle = if let Some(reg) = &config.default_registry {
142                reg.handle.clone()
143            } else {
144                return Err(anyhow!("Default registry not configured."));
145            };
146            if handle.is_empty() {
147                return Err(anyhow!(
148                    "Default registry handle is not set. Please run 'zoi \
149                     sync'.."
150                ));
151            }
152            let all_repo_names = config::get_all_repos()?;
153            let repos_to_search: Vec<String> = all_repo_names
154                .into_iter()
155                .map(|r_name| format!("{handle}/{r_name}"))
156                .filter(|full_repo_name| {
157                    if repo_filter.contains('/') {
158                        full_repo_name == repo_filter
159                    } else {
160                        full_repo_name
161                            .split('/')
162                            .any(|part| part == repo_filter)
163                    }
164                })
165                .collect();
166            local::get_packages_from_repos(&repos_to_search)
167        } else {
168            local::get_all_available_packages()
169        }
170    } else {
171        Ok(all_packages)
172    };
173
174    let handle_for_version = registry_filter.or(config
175        .default_registry
176        .as_ref()
177        .map(|reg| reg.handle.as_str()));
178
179    match packages {
180        Ok(all_packages) => {
181            let search_term_lower = search_term.to_lowercase();
182
183            let type_filter =
184                package_type.and_then(|s| match s.to_lowercase().as_str() {
185                    "package" => Some(PackageType::Package),
186                    "collection" => Some(PackageType::Collection),
187                    "app" => Some(PackageType::App),
188                    "extension" => Some(PackageType::Extension),
189                    _ => None
190                });
191
192            let wanted_tags: Vec<String> = tags
193                .unwrap_or_default()
194                .into_iter()
195                .map(|t| t.to_lowercase())
196                .collect();
197
198            let mut matches: Vec<_> = all_packages
199                .into_iter()
200                .filter(|pkg| {
201                    if let Some(ptype) = type_filter
202                        && pkg.package_type != ptype
203                    {
204                        return false;
205                    }
206
207                    if !wanted_tags.is_empty() {
208                        if pkg.tags.is_empty() {
209                            return false;
210                        }
211                        let pkg_tags_lower: Vec<String> =
212                            pkg.tags.iter().map(|t| t.to_lowercase()).collect();
213                        let has_any = wanted_tags.iter().any(|wanted| {
214                            pkg_tags_lower.iter().any(|pt| pt == wanted)
215                        });
216                        if !has_any {
217                            return false;
218                        }
219                    }
220
221                    let name_match =
222                        pkg.name.to_lowercase().contains(&search_term_lower);
223                    let description_match = pkg
224                        .description
225                        .to_lowercase()
226                        .contains(&search_term_lower);
227                    let tags_match = if pkg.tags.is_empty() {
228                        false
229                    } else {
230                        pkg.tags.iter().any(|t| {
231                            t.to_lowercase().contains(&search_term_lower)
232                        })
233                    };
234                    name_match || description_match || tags_match
235                })
236                .collect();
237
238            if matches.is_empty() {
239                if !interactive {
240                    println!(
241                        "\n{} No packages found matching your query.",
242                        "::".bold().yellow()
243                    );
244                }
245                return Ok(());
246            }
247
248            match sort_by {
249                "name" => matches.sort_by(|a, b| a.name.cmp(&b.name)),
250                "repo" => matches.sort_by(|a, b| a.repo.cmp(&b.repo)),
251                "type" => matches.sort_by(|a, b| {
252                    format!("{:?}", a.package_type)
253                        .cmp(&format!("{:?}", b.package_type))
254                }),
255                _ => matches.sort_by(|a, b| a.name.cmp(&b.name))
256            }
257
258            if interactive {
259                return run_tui(&matches, handle_for_version);
260            }
261
262            let mut table = Table::new();
263            table
264                .load_style(UTF8_FULL)
265                .set_content_arrangement(ContentArrangement::Dynamic)
266                .set_header(vec![
267                    Cell::new("Package").add_attribute(Attribute::Bold),
268                    Cell::new("Version").add_attribute(Attribute::Bold),
269                    Cell::new("Repo").add_attribute(Attribute::Bold),
270                    Cell::new("License").add_attribute(Attribute::Bold),
271                    Cell::new("Tags").add_attribute(Attribute::Bold),
272                    Cell::new("Description").add_attribute(Attribute::Bold),
273                ]);
274
275            for pkg in matches {
276                let mut desc = pkg.description.replace('\n', " ");
277                if desc.len() > 60 {
278                    desc.truncate(57);
279                    desc.push_str("...");
280                }
281
282                let version = crate::pkg::resolve::get_default_version(
283                    &pkg,
284                    handle_for_version
285                )
286                .unwrap_or_else(|_| "N/A".to_string());
287
288                let version_display = if pkg.revision == "1" {
289                    version.clone()
290                } else {
291                    format!("{}-{}", version, pkg.revision)
292                };
293
294                let repo_display = &pkg.repo;
295
296                let tags_display = if pkg.tags.is_empty() {
297                    String::new()
298                } else {
299                    let mut tags = pkg.tags.clone();
300                    tags.sort();
301                    if tags.len() > 4 {
302                        let first_four: Vec<_> = tags
303                            .iter()
304                            .take(4)
305                            .map(std::string::String::as_str)
306                            .collect();
307                        format!("{}…", first_four.join(", "))
308                    } else {
309                        tags.join(", ")
310                    }
311                };
312
313                table.add_row(vec![
314                    Cell::new(pkg.name).fg(comfy_table::Color::Cyan),
315                    Cell::new(version_display).fg(comfy_table::Color::Yellow),
316                    Cell::new(repo_display).fg(comfy_table::Color::Green),
317                    Cell::new(pkg.license),
318                    Cell::new(tags_display).fg(comfy_table::Color::DarkGrey),
319                    Cell::new(desc),
320                ]);
321            }
322
323            println!("{table}");
324        }
325        Err(e) => {
326            return Err(e);
327        }
328    }
329    Ok(())
330}
331
332/// Searches for files matching the given term.
333fn run_file_search(
334    term: &str,
335    registry_filter: Option<&str>,
336    repo: Option<&str>,
337    package_type: Option<&str>
338) -> Result<()> {
339    let config = config::read_config()?;
340    let mut registries = Vec::new();
341    if let Some(reg) = registry_filter {
342        registries.push(reg.to_string());
343    } else {
344        if let Some(default) = &config.default_registry {
345            registries.push(default.handle.clone());
346        }
347        for reg in &config.added_registries {
348            registries.push(reg.handle.clone());
349        }
350    }
351
352    let mut results = Vec::new();
353    for handle in registries {
354        if let Ok(res) = crate::pkg::db::search_files(&handle, term) {
355            results.extend(res);
356        }
357    }
358
359    let type_filter =
360        package_type.and_then(|s| match s.to_lowercase().as_str() {
361            "package" => Some(PackageType::Package),
362            "collection" => Some(PackageType::Collection),
363            "app" => Some(PackageType::App),
364            "extension" => Some(PackageType::Extension),
365            _ => None
366        });
367
368    results.retain(|(pkg, _)| {
369        if let Some(pt) = type_filter
370            && pkg.package_type != pt
371        {
372            return false;
373        }
374        if let Some(rf) = &repo {
375            if rf.contains('/') {
376                if pkg.repo != *rf {
377                    return false;
378                }
379            } else if !pkg.repo.split('/').any(|part| part == *rf) {
380                return false;
381            }
382        }
383        true
384    });
385
386    if results.is_empty() {
387        println!("\n{}", "No files found matching your query.".yellow());
388        println!(
389            "Hint: Ensure you have run 'zoi sync --files' to index remote \
390             file lists."
391        );
392        return Ok(());
393    }
394
395    let mut table = Table::new();
396    table
397        .load_style(UTF8_FULL)
398        .set_content_arrangement(ContentArrangement::Dynamic)
399        .set_header(vec![
400            Cell::new("Package").add_attribute(Attribute::Bold),
401            Cell::new("File Path").add_attribute(Attribute::Bold),
402            Cell::new("Repo").add_attribute(Attribute::Bold),
403        ]);
404
405    for (pkg, path) in results {
406        let repo_display = &pkg.repo;
407        table.add_row(vec![
408            Cell::new(pkg.name).fg(comfy_table::Color::Cyan),
409            Cell::new(path).fg(comfy_table::Color::Yellow),
410            Cell::new(repo_display.clone()).fg(comfy_table::Color::Green),
411        ]);
412    }
413
414    println!("{table}");
415    Ok(())
416}
417
418/// Runs the interactive TUI for package search results.
419fn run_tui(
420    packages: &[Package],
421    handle_for_version: Option<&str>
422) -> Result<()> {
423    enable_raw_mode()?;
424    let mut stdout = io::stdout();
425    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
426    let backend = CrosstermBackend::new(stdout);
427    let mut terminal = Terminal::new(backend)?;
428
429    let mut state = ListState::default();
430    state.select(Some(0));
431
432    let res =
433        run_tui_loop(&mut terminal, packages, &mut state, handle_for_version);
434
435    disable_raw_mode()?;
436    execute!(
437        terminal.backend_mut(),
438        LeaveAlternateScreen,
439        DisableMouseCapture
440    )?;
441    terminal.show_cursor()?;
442
443    if let Err(err) = res {
444        eprintln!("{err:?}");
445    }
446
447    Ok(())
448}
449
450/// Main loop for the search TUI.
451fn run_tui_loop(
452    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
453    packages: &[Package],
454    state: &mut ListState,
455    handle_for_version: Option<&str>
456) -> Result<()> {
457    loop {
458        terminal.draw(|f| {
459            let layout = Layout::default()
460                .direction(Direction::Vertical)
461                .constraints([Constraint::Min(0), Constraint::Length(1)])
462                .split(f.area());
463
464            let chunks = Layout::default()
465                .direction(Direction::Horizontal)
466                .constraints([
467                    Constraint::Percentage(30),
468                    Constraint::Percentage(70)
469                ])
470                .split(
471                    *layout
472                        .first()
473                        .expect("layout should have at least one chunk")
474                );
475
476            let items: Vec<ListItem> = packages
477                .iter()
478                .map(|p| {
479                    ListItem::new(Line::from(vec![Span::styled(
480                        p.name.clone(),
481                        RatatuiStyle::default().fg(RatatuiColor::Cyan)
482                    )]))
483                })
484                .collect();
485
486            let list = List::new(items)
487                .block(Block::default().borders(Borders::ALL).title("Packages"))
488                .highlight_style(
489                    RatatuiStyle::default()
490                        .bg(RatatuiColor::DarkGray)
491                        .add_modifier(Modifier::BOLD)
492                )
493                .highlight_symbol(">> ");
494
495            f.render_stateful_widget(
496                list,
497                *chunks
498                    .first()
499                    .expect("chunks should have at least one item"),
500                state
501            );
502
503            if let Some(selected) = state.selected() {
504                let pkg = packages
505                    .get(selected)
506                    .expect("selected index should be within bounds");
507                let version = crate::pkg::resolve::get_default_version(
508                    pkg,
509                    handle_for_version
510                )
511                .unwrap_or_else(|_| "N/A".to_string());
512
513                let version_display = if pkg.revision == "1" {
514                    version
515                } else {
516                    format!("{}-{}", version, pkg.revision)
517                };
518
519                let details = vec![
520                    Line::from(vec![
521                        Span::styled(
522                            "Name: ",
523                            RatatuiStyle::default()
524                                .add_modifier(Modifier::BOLD)
525                        ),
526                        Span::styled(
527                            pkg.name.clone(),
528                            RatatuiStyle::default().fg(RatatuiColor::Cyan)
529                        ),
530                    ]),
531                    Line::from(vec![
532                        Span::styled(
533                            "Version: ",
534                            RatatuiStyle::default()
535                                .add_modifier(Modifier::BOLD)
536                        ),
537                        Span::styled(
538                            version_display,
539                            RatatuiStyle::default().fg(RatatuiColor::Yellow)
540                        ),
541                    ]),
542                    Line::from(vec![
543                        Span::styled(
544                            "Repo: ",
545                            RatatuiStyle::default()
546                                .add_modifier(Modifier::BOLD)
547                        ),
548                        Span::styled(
549                            pkg.repo.clone(),
550                            RatatuiStyle::default().fg(RatatuiColor::Green)
551                        ),
552                    ]),
553                    Line::from(vec![
554                        Span::styled(
555                            "License: ",
556                            RatatuiStyle::default()
557                                .add_modifier(Modifier::BOLD)
558                        ),
559                        Span::raw(pkg.license.clone()),
560                    ]),
561                    Line::from(vec![
562                        Span::styled(
563                            "Type: ",
564                            RatatuiStyle::default()
565                                .add_modifier(Modifier::BOLD)
566                        ),
567                        Span::raw(format!("{:?}", pkg.package_type)),
568                    ]),
569                    Line::from(vec![
570                        Span::styled(
571                            "Tags: ",
572                            RatatuiStyle::default()
573                                .add_modifier(Modifier::BOLD)
574                        ),
575                        Span::raw(pkg.tags.join(", ")),
576                    ]),
577                    Line::from(""),
578                    Line::from(Span::styled(
579                        "Description:",
580                        RatatuiStyle::default().add_modifier(Modifier::BOLD)
581                    )),
582                    Line::from(pkg.description.clone()),
583                ];
584
585                let details_paragraph = Paragraph::new(details)
586                    .block(
587                        Block::default().borders(Borders::ALL).title("Details")
588                    )
589                    .wrap(Wrap { trim: true });
590
591                f.render_widget(
592                    details_paragraph,
593                    *chunks.get(1).expect("chunks should have a second item")
594                );
595            }
596
597            let help_text = Line::from(vec![
598                Span::styled(
599                    " q",
600                    RatatuiStyle::default().add_modifier(Modifier::BOLD)
601                ),
602                Span::raw(": quit | "),
603                Span::styled(
604                    "j/↓",
605                    RatatuiStyle::default().add_modifier(Modifier::BOLD)
606                ),
607                Span::raw(": down | "),
608                Span::styled(
609                    "k/↑",
610                    RatatuiStyle::default().add_modifier(Modifier::BOLD)
611                ),
612                Span::raw(": up"),
613            ]);
614            f.render_widget(
615                Paragraph::new(help_text),
616                *layout.get(1).expect("layout should have a second chunk")
617            );
618        })?;
619
620        if event::poll(std::time::Duration::from_millis(100))?
621            && let Event::Key(key) = event::read()?
622            && key.kind == KeyEventKind::Press
623        {
624            match key.code {
625                KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
626                KeyCode::Down | KeyCode::Char('j') => {
627                    let i = match state.selected() {
628                        Some(i) => {
629                            if i >= packages.len() - 1 {
630                                0
631                            } else {
632                                i + 1
633                            }
634                        }
635                        None => 0
636                    };
637                    state.select(Some(i));
638                }
639                KeyCode::Up | KeyCode::Char('k') => {
640                    let i = match state.selected() {
641                        Some(i) => {
642                            if i == 0 {
643                                packages.len() - 1
644                            } else {
645                                i - 1
646                            }
647                        }
648                        None => 0
649                    };
650                    state.select(Some(i));
651                }
652                _ => {}
653            }
654        }
655    }
656}