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) = ®istry_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) = ®istry_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}