Skip to main content

zoi_cli/cmd/
search.rs

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