1use 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
35pub 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
332fn 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
418fn 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
450fn 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}