Skip to main content

zoi_cli/cmd/
list.rs

1//! Command for listing installed or available packages.
2//!
3//! This module provides functionality to list packages based on various
4//! filters like registry, repository, package type, and outdated status.
5
6use std::collections::HashSet;
7
8use anyhow::{Result, anyhow};
9use comfy_table::presets::UTF8_FULL;
10use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
11use rayon::prelude::*;
12
13use crate::pkg::{config, local, types};
14
15/// Runs the list command with the provided filters and options.
16///
17/// # Errors
18///
19/// Returns an error if:
20/// - The package type filter is invalid.
21/// - The `--foreign` flag is used with `--all`.
22/// - There is an error reading the configuration or interacting with the
23///   database.
24/// - There is an error fetching installed or available packages.
25///
26/// # Panics
27///
28/// Panics if the `type_filter` is missing despite being set, which should not
29/// happen.
30pub fn run(
31    all: bool,
32    outdated: bool,
33    registry_filter: Option<&str>,
34    repo_filter: Option<&str>,
35    type_filter: Option<&str>,
36    foreign: bool,
37    names_only: bool,
38    completion: bool
39) -> Result<()> {
40    let package_type = match type_filter {
41        Some("package") => Some(types::PackageType::Package),
42        Some("collection") => Some(types::PackageType::Collection),
43        Some("app") => Some(types::PackageType::App),
44        Some("extension") => Some(types::PackageType::Extension),
45        Some(other) => return Err(anyhow!("Invalid package type: {other}")),
46        None => None
47    };
48
49    if outdated {
50        return run_list_outdated(registry_filter, repo_filter, package_type);
51    }
52
53    if names_only || completion {
54        return run_list_names(
55            all,
56            registry_filter,
57            repo_filter,
58            package_type,
59            completion
60        );
61    }
62
63    if all {
64        if foreign {
65            return Err(anyhow!(
66                "The --foreign flag cannot be used with --all."
67            ));
68        }
69        run_list_all(registry_filter, repo_filter, package_type)?;
70    } else {
71        run_list_installed(
72            registry_filter,
73            repo_filter,
74            package_type,
75            foreign
76        )?;
77    }
78    Ok(())
79}
80
81/// Lists outdated packages.
82fn run_list_outdated(
83    registry_filter: Option<&str>,
84    repo_filter: Option<&str>,
85    type_filter: Option<types::PackageType>
86) -> Result<()> {
87    let installed_packages = local::get_installed_packages()?;
88
89    let mut table = Table::new();
90    table
91        .load_style(UTF8_FULL)
92        .set_content_arrangement(ContentArrangement::Dynamic)
93        .set_header(vec![
94            Cell::new("Package").add_attribute(Attribute::Bold),
95            Cell::new("Current").add_attribute(Attribute::Bold),
96            Cell::new("Latest").add_attribute(Attribute::Bold),
97            Cell::new("Repo").add_attribute(Attribute::Bold),
98            Cell::new("Registry").add_attribute(Attribute::Bold),
99            Cell::new("Scope").add_attribute(Attribute::Bold),
100        ]);
101
102    let mut found_outdated = false;
103
104    for manifest in installed_packages {
105        if let Some(registry_f) = registry_filter
106            && manifest.registry_handle != registry_f
107        {
108            continue;
109        }
110
111        if let Some(repo_f) = repo_filter {
112            let repo_matches = if repo_f.contains('/') {
113                manifest.repo == repo_f
114            } else {
115                manifest.repo.split('/').any(|part| part == repo_f)
116            };
117            if !repo_matches {
118                continue;
119            }
120        }
121
122        if let Some(type_f) = type_filter
123            && manifest.package_type != type_f
124        {
125            continue;
126        }
127
128        let source = if let Some(sub) = &manifest.sub_package {
129            format!(
130                "#{}@{}/{}:{}",
131                manifest.registry_handle, manifest.repo, manifest.name, sub
132            )
133        } else {
134            format!(
135                "#{}@{}/{}",
136                manifest.registry_handle, manifest.repo, manifest.name
137            )
138        };
139
140        if let Ok((pkg, new_version, _, _, _, _, _)) =
141            crate::pkg::resolve::resolve_package_and_version(
142                &source, None, true, false
143            )
144            && (manifest.version != new_version
145                || manifest.revision != pkg.revision)
146        {
147            let current_display = if manifest.revision == "1" {
148                manifest.version.clone()
149            } else {
150                format!("{}-{}", manifest.version, manifest.revision)
151            };
152
153            let latest_display = if pkg.revision == "1" {
154                new_version
155            } else {
156                format!("{}-{}", new_version, pkg.revision)
157            };
158
159            table.add_row(vec![
160                Cell::new(manifest.name.clone()).fg(Color::Cyan),
161                Cell::new(current_display).fg(Color::Yellow),
162                Cell::new(latest_display).fg(Color::Green),
163                Cell::new(manifest.repo.clone()).fg(Color::DarkGrey),
164                Cell::new(manifest.registry_handle.clone()).fg(Color::DarkGrey),
165                Cell::new(format!("{:?}", manifest.scope).to_lowercase())
166                    .fg(Color::DarkGrey),
167            ]);
168            found_outdated = true;
169        }
170    }
171
172    if found_outdated {
173        println!("{table}");
174    } else {
175        println!("No outdated packages found.");
176    }
177
178    Ok(())
179}
180
181/// Lists installed packages.
182fn run_list_installed(
183    registry_filter: Option<&str>,
184    repo_filter: Option<&str>,
185    type_filter: Option<types::PackageType>,
186    foreign: bool
187) -> Result<()> {
188    let config = config::read_config()?;
189    let mut active_registries = HashSet::new();
190    if let Some(default) = &config.default_registry {
191        active_registries.insert(default.handle.clone());
192    }
193    for reg in &config.added_registries {
194        active_registries.insert(reg.handle.clone());
195    }
196
197    let mut db_failed = false;
198    let packages_from_db =
199        if let Ok(pkgs) = crate::pkg::db::list_all_packages("local") {
200            pkgs
201        } else {
202            db_failed = true;
203            Vec::new()
204        };
205
206    let mut table = Table::new();
207    table
208        .load_style(UTF8_FULL)
209        .set_content_arrangement(ContentArrangement::Dynamic)
210        .set_header(vec![
211            Cell::new("Package").add_attribute(Attribute::Bold),
212            Cell::new("Version").add_attribute(Attribute::Bold),
213            Cell::new("Repo").add_attribute(Attribute::Bold),
214            Cell::new("Registry").add_attribute(Attribute::Bold),
215            Cell::new("Type").add_attribute(Attribute::Bold),
216            Cell::new("Scope").add_attribute(Attribute::Bold),
217        ]);
218
219    let mut found_packages = false;
220    // Belt-and-braces display dedupe: legacy databases may still hold
221    // identical rows written before the NULL-safe upsert fix.
222    let mut seen_rows = HashSet::new();
223
224    if !db_failed && !packages_from_db.is_empty() {
225        for pkg in packages_from_db {
226            if foreign
227                && let Some(reg) = &pkg.registry_handle
228                && active_registries.contains(reg)
229            {
230                continue;
231            }
232
233            if let Some(reg_f) = registry_filter
234                && pkg.registry_handle.as_deref() != Some(reg_f)
235            {
236                continue;
237            }
238
239            if let Some(repo_f) = repo_filter {
240                let repo_matches = if repo_f.contains('/') {
241                    pkg.repo == repo_f
242                } else {
243                    pkg.repo.split('/').any(|part| part == repo_f)
244                };
245                if !repo_matches {
246                    continue;
247                }
248            }
249            if type_filter.is_some()
250                && pkg.package_type
251                    != type_filter.ok_or_else(|| {
252                        anyhow!("type_filter missing despite being set")
253                    })?
254            {
255                continue;
256            }
257
258            let version_display = if pkg.revision == "1" {
259                pkg.version.as_deref().unwrap_or("N/A").to_string()
260            } else {
261                format!(
262                    "{}-{}",
263                    pkg.version.as_deref().unwrap_or("N/A"),
264                    pkg.revision
265                )
266            };
267
268            let scope_display = format!("{:?}", pkg.scope).to_lowercase();
269            let row_key = format!(
270                "{}/{}/{}/{}/{}/{}/{}",
271                pkg.registry_handle.as_deref().unwrap_or("none"),
272                pkg.repo,
273                pkg.name,
274                pkg.sub_package.as_deref().unwrap_or(""),
275                scope_display,
276                version_display,
277                pkg.revision,
278            );
279            if !seen_rows.insert(row_key) {
280                continue;
281            }
282
283            let package_display = if let Some(sub) = &pkg.sub_package {
284                format!("{}:{}", pkg.name, sub)
285            } else {
286                pkg.name
287            };
288
289            let repo_display = &pkg.repo;
290
291            table.add_row(vec![
292                Cell::new(package_display).fg(Color::Cyan),
293                Cell::new(version_display).fg(Color::Yellow),
294                Cell::new(repo_display.clone()).fg(Color::Green),
295                Cell::new(
296                    pkg.registry_handle.unwrap_or_else(|| "none".to_string())
297                )
298                .fg(Color::DarkGrey),
299                Cell::new(format!("{:?}", pkg.package_type))
300                    .fg(Color::DarkGrey),
301                Cell::new(scope_display).fg(Color::DarkGrey),
302            ]);
303            found_packages = true;
304        }
305    } else {
306        let packages = local::get_installed_packages_with_type()?;
307        if packages.is_empty() {
308            println!("No packages installed by Zoi.");
309            return Ok(());
310        }
311
312        for pkg in packages {
313            let manifest = local::is_package_installed(
314                &pkg.name,
315                pkg.sub_package.as_deref(),
316                types::Scope::User
317            )?
318            .or(local::is_package_installed(
319                &pkg.name,
320                pkg.sub_package.as_deref(),
321                types::Scope::System
322            )?)
323            .or(local::is_package_installed(
324                &pkg.name,
325                pkg.sub_package.as_deref(),
326                types::Scope::Project
327            )?);
328
329            let Some(m) = manifest else { continue };
330
331            if foreign && active_registries.contains(&m.registry_handle) {
332                continue;
333            }
334
335            if let Some(reg_f) = registry_filter
336                && m.registry_handle != reg_f
337            {
338                continue;
339            }
340            if let Some(repo_f) = repo_filter {
341                let repo_matches = if repo_f.contains('/') {
342                    pkg.repo == repo_f
343                } else {
344                    pkg.repo.split('/').any(|part| part == repo_f)
345                };
346                if !repo_matches {
347                    continue;
348                }
349            }
350            if type_filter.is_some()
351                && pkg.package_type
352                    != type_filter.ok_or_else(|| {
353                        anyhow!("type_filter missing despite being set")
354                    })?
355            {
356                continue;
357            }
358
359            let version_display = if m.revision == "1" {
360                pkg.version.clone()
361            } else {
362                format!("{}-{}", pkg.version, m.revision)
363            };
364
365            let scope_display = format!("{:?}", m.scope).to_lowercase();
366            let row_key = format!(
367                "{}/{}/{}/{}/{}/{}/{}",
368                m.registry_handle,
369                pkg.repo,
370                pkg.name,
371                pkg.sub_package.as_deref().unwrap_or(""),
372                scope_display,
373                version_display,
374                m.revision,
375            );
376            if !seen_rows.insert(row_key) {
377                continue;
378            }
379
380            let package_display = if let Some(sub) = &pkg.sub_package {
381                format!("{}:{}", pkg.name, sub)
382            } else {
383                pkg.name
384            };
385
386            let repo_display = &pkg.repo;
387
388            table.add_row(vec![
389                Cell::new(package_display).fg(Color::Cyan),
390                Cell::new(version_display).fg(Color::Yellow),
391                Cell::new(repo_display.clone()).fg(Color::Green),
392                Cell::new(m.registry_handle).fg(Color::DarkGrey),
393                Cell::new(format!("{:?}", pkg.package_type))
394                    .fg(Color::DarkGrey),
395                Cell::new(scope_display).fg(Color::DarkGrey),
396            ]);
397            found_packages = true;
398        }
399    }
400
401    if found_packages {
402        println!("{table}");
403    } else {
404        println!("No installed packages match your criteria.");
405    }
406
407    Ok(())
408}
409
410/// Lists package names for completion or simple output.
411fn run_list_names(
412    all: bool,
413    registry_filter: Option<&str>,
414    repo_filter: Option<&str>,
415    type_filter: Option<types::PackageType>,
416    completion: bool
417) -> Result<()> {
418    let mut entries = Vec::new();
419    let config = config::read_config()?;
420
421    if all {
422        let mut registries = Vec::new();
423        if let Some(reg) = registry_filter {
424            registries.push(reg.to_string());
425        } else {
426            if let Some(default) = &config.default_registry {
427                registries.push(default.handle.clone());
428            }
429            for reg in &config.added_registries {
430                registries.push(reg.handle.clone());
431            }
432        }
433
434        let default_handle =
435            config.default_registry.as_ref().map(|r| &r.handle);
436
437        for handle in registries {
438            if let Ok(pkgs) =
439                crate::pkg::db::get_packages_for_completion(&handle)
440            {
441                let is_default = default_handle == Some(&handle);
442                for pkg in pkgs {
443                    if let Some(repo_f) = repo_filter
444                        && !pkg.repo.contains(repo_f)
445                    {
446                        continue;
447                    }
448
449                    let base_name = if is_default {
450                        format!("@{}/{}", pkg.repo, pkg.name)
451                    } else {
452                        format!("#{}@{}/{}", handle, pkg.repo, pkg.name)
453                    };
454
455                    let name_with_sub = if let Some(sub) = &pkg.sub_package {
456                        format!("{base_name}:{sub}")
457                    } else {
458                        base_name
459                    };
460
461                    let entry = if completion {
462                        format!(
463                            "{}:{}",
464                            name_with_sub,
465                            pkg.description.replace(':', " ")
466                        )
467                    } else {
468                        name_with_sub
469                    };
470                    entries.push(entry);
471                }
472            }
473        }
474    } else {
475        let installed = if completion {
476            crate::pkg::db::list_all_packages("local")?
477        } else {
478            local::get_installed_packages().map(|v| {
479                v.into_iter()
480                    .map(zoi_core::types::InstallManifest::into_package)
481                    .collect()
482            })?
483        };
484
485        for pkg in installed {
486            if let Some(type_f) = type_filter
487                && pkg.package_type != type_f
488            {
489                continue;
490            }
491
492            if let Some(reg_f) = registry_filter
493                && pkg.registry_handle.as_deref() != Some(reg_f)
494            {
495                continue;
496            }
497
498            if let Some(repo_f) = repo_filter
499                && pkg.repo != repo_f
500            {
501                continue;
502            }
503
504            let name = if let Some(sub) = pkg.sub_package {
505                format!("{}:{}", pkg.name, sub)
506            } else {
507                pkg.name
508            };
509
510            let entry = if completion {
511                format!("{}:{}", name, pkg.description.replace(':', " "))
512            } else {
513                name
514            };
515            entries.push(entry);
516        }
517    }
518
519    entries.sort();
520    entries.dedup();
521    for entry in entries {
522        println!("{entry}");
523    }
524
525    Ok(())
526}
527
528/// Lists all available packages from all active registries.
529fn run_list_all(
530    registry_filter: Option<&str>,
531    repo_filter: Option<&str>,
532    type_filter: Option<types::PackageType>
533) -> Result<()> {
534    let installed_pkgs = local::get_installed_packages()?
535        .into_iter()
536        .map(|p| {
537            if let Some(sub) = p.sub_package {
538                format!("{}:{}", p.name, sub)
539            } else {
540                p.name
541            }
542        })
543        .collect::<HashSet<_>>();
544
545    let config = config::read_config()?;
546
547    let mut all_available = Vec::new();
548    let mut db_failed = false;
549
550    if let Some(reg_handle) = registry_filter {
551        match crate::pkg::db::list_all_packages(reg_handle) {
552            Ok(pkgs) => all_available.extend(pkgs),
553            Err(_) => db_failed = true
554        }
555    } else {
556        let mut registries = Vec::new();
557        if let Some(default) = &config.default_registry {
558            registries.push(default.handle.clone());
559        }
560        for reg in &config.added_registries {
561            registries.push(reg.handle.clone());
562        }
563
564        let results: Vec<Result<Vec<types::Package>>> = registries
565            .into_par_iter()
566            .filter(|h| !h.is_empty())
567            .map(|handle| crate::pkg::db::list_all_packages(&handle))
568            .collect();
569
570        for res in results {
571            if let Ok(pkgs) = res {
572                all_available.extend(pkgs);
573            } else {
574                db_failed = true;
575                break;
576            }
577        }
578    }
579
580    let available_pkgs = if db_failed
581        || (all_available.is_empty()
582            && repo_filter.is_none()
583            && registry_filter.is_none())
584    {
585        if let Some(reg_handle) = registry_filter {
586            let all_repo_names = config::get_all_repos()?;
587            let full_repos: Vec<String> = all_repo_names
588                .into_iter()
589                .map(|r_name| format!("{reg_handle}/{r_name}"))
590                .filter(|full_repo_name| {
591                    if let Some(repo_f) = repo_filter {
592                        if repo_f.contains('/') {
593                            full_repo_name == repo_f
594                        } else {
595                            full_repo_name.split('/').any(|part| part == repo_f)
596                        }
597                    } else {
598                        true
599                    }
600                })
601                .collect();
602            local::get_packages_from_repos(&full_repos)?
603        } else if let Some(repo_f) = repo_filter {
604            let handle = if let Some(reg) = &config.default_registry {
605                reg.handle.clone()
606            } else {
607                return Err(anyhow!("Default registry not configured."));
608            };
609            if handle.is_empty() {
610                return Err(anyhow!(
611                    "Default registry handle is not set. Please run 'zoi \
612                     sync'.."
613                ));
614            }
615            let all_repo_names = config::get_all_repos()?;
616            let repos_to_search: Vec<String> = all_repo_names
617                .into_iter()
618                .map(|r_name| format!("{handle}/{r_name}"))
619                .filter(|full_repo_name| {
620                    if repo_f.contains('/') {
621                        full_repo_name == repo_f
622                    } else {
623                        full_repo_name.split('/').any(|part| part == repo_f)
624                    }
625                })
626                .collect();
627            local::get_packages_from_repos(&repos_to_search)?
628        } else {
629            local::get_all_available_packages()?
630        }
631    } else {
632        if let Some(rf) = repo_filter {
633            all_available.retain(|p| {
634                if rf.contains('/') {
635                    p.repo == rf
636                } else {
637                    p.repo.split('/').any(|part| part == rf)
638                }
639            });
640        }
641        all_available
642    };
643
644    let handle_for_version = registry_filter.or(config
645        .default_registry
646        .as_ref()
647        .map(|reg| reg.handle.as_str()));
648
649    if available_pkgs.is_empty() {
650        if let Some(repo) = repo_filter {
651            println!("No packages found in repo '{repo}'.");
652        } else {
653            println!("No packages found in active repositories.");
654        }
655        return Ok(());
656    }
657
658    let mut table = Table::new();
659    table
660        .load_style(UTF8_FULL)
661        .set_content_arrangement(ContentArrangement::Dynamic)
662        .set_header(vec![
663            Cell::new("Status").add_attribute(Attribute::Bold),
664            Cell::new("Package").add_attribute(Attribute::Bold),
665            Cell::new("Version").add_attribute(Attribute::Bold),
666            Cell::new("Repo").add_attribute(Attribute::Bold),
667            Cell::new("Type").add_attribute(Attribute::Bold),
668        ]);
669
670    for pkg in available_pkgs {
671        if type_filter.is_some()
672            && pkg.package_type
673                != type_filter.ok_or_else(|| {
674                    anyhow!("type_filter missing despite being set")
675                })?
676        {
677            continue;
678        }
679
680        let version =
681            crate::pkg::resolve::get_default_version(&pkg, handle_for_version)
682                .unwrap_or_else(|_| "N/A".to_string());
683
684        let version_display = if pkg.revision == "1" {
685            version.clone()
686        } else {
687            format!("{}-{}", version, pkg.revision)
688        };
689
690        let repo_display =
691            pkg.repo.split_once('/').map_or(pkg.repo.as_str(), |x| x.1);
692
693        let full_name = if let Some(sub) = &pkg.sub_package {
694            format!("{}:{}", pkg.name, sub)
695        } else {
696            pkg.name.clone()
697        };
698
699        if let Some(subs) = &pkg.sub_packages {
700            for sub in subs {
701                let full_name_sub = format!("{}:{}", pkg.name, sub);
702                let (status_str, status_color) =
703                    if installed_pkgs.contains(&full_name_sub) {
704                        ("✓", Color::Green)
705                    } else {
706                        ("", Color::Reset)
707                    };
708                table.add_row(vec![
709                    Cell::new(status_str).fg(status_color),
710                    Cell::new(full_name_sub).fg(Color::Cyan),
711                    Cell::new(version_display.clone()).fg(Color::Yellow),
712                    Cell::new(repo_display.to_string()).fg(Color::Green),
713                    Cell::new(format!("{:?}", pkg.package_type))
714                        .fg(Color::DarkGrey),
715                ]);
716            }
717        } else {
718            let (status_str, status_color) =
719                if installed_pkgs.contains(&full_name) {
720                    ("✓", Color::Green)
721                } else {
722                    ("", Color::Reset)
723                };
724            table.add_row(vec![
725                Cell::new(status_str).fg(status_color),
726                Cell::new(full_name).fg(Color::Cyan),
727                Cell::new(version_display).fg(Color::Yellow),
728                Cell::new(repo_display.to_string()).fg(Color::Green),
729                Cell::new(format!("{:?}", pkg.package_type))
730                    .fg(Color::DarkGrey),
731            ]);
732        }
733    }
734
735    println!("{table}");
736    Ok(())
737}