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        ]);
100
101    let mut found_outdated = false;
102
103    for manifest in installed_packages {
104        if let Some(registry_f) = registry_filter
105            && manifest.registry_handle != registry_f
106        {
107            continue;
108        }
109
110        if let Some(repo_f) = repo_filter {
111            let repo_matches = if repo_f.contains('/') {
112                manifest.repo == repo_f
113            } else {
114                manifest.repo.split('/').any(|part| part == repo_f)
115            };
116            if !repo_matches {
117                continue;
118            }
119        }
120
121        if let Some(type_f) = type_filter
122            && manifest.package_type != type_f
123        {
124            continue;
125        }
126
127        let source = if let Some(sub) = &manifest.sub_package {
128            format!(
129                "#{}@{}/{}:{}",
130                manifest.registry_handle, manifest.repo, manifest.name, sub
131            )
132        } else {
133            format!(
134                "#{}@{}/{}",
135                manifest.registry_handle, manifest.repo, manifest.name
136            )
137        };
138
139        if let Ok((pkg, new_version, _, _, _, _, _)) =
140            crate::pkg::resolve::resolve_package_and_version(
141                &source, None, true, false
142            )
143            && (manifest.version != new_version
144                || manifest.revision != pkg.revision)
145        {
146            let current_display = if manifest.revision == "1" {
147                manifest.version.clone()
148            } else {
149                format!("{}-{}", manifest.version, manifest.revision)
150            };
151
152            let latest_display = if pkg.revision == "1" {
153                new_version
154            } else {
155                format!("{}-{}", new_version, pkg.revision)
156            };
157
158            table.add_row(vec![
159                Cell::new(manifest.name.clone()).fg(Color::Cyan),
160                Cell::new(current_display).fg(Color::Yellow),
161                Cell::new(latest_display).fg(Color::Green),
162                Cell::new(manifest.repo.clone()).fg(Color::DarkGrey),
163                Cell::new(manifest.registry_handle.clone()).fg(Color::DarkGrey),
164            ]);
165            found_outdated = true;
166        }
167    }
168
169    if found_outdated {
170        println!("{table}");
171    } else {
172        println!("No outdated packages found.");
173    }
174
175    Ok(())
176}
177
178/// Lists installed packages.
179fn run_list_installed(
180    registry_filter: Option<&str>,
181    repo_filter: Option<&str>,
182    type_filter: Option<types::PackageType>,
183    foreign: bool
184) -> Result<()> {
185    let config = config::read_config()?;
186    let mut active_registries = HashSet::new();
187    if let Some(default) = &config.default_registry {
188        active_registries.insert(default.handle.clone());
189    }
190    for reg in &config.added_registries {
191        active_registries.insert(reg.handle.clone());
192    }
193
194    let mut db_failed = false;
195    let packages_from_db =
196        if let Ok(pkgs) = crate::pkg::db::list_all_packages("local") {
197            pkgs
198        } else {
199            db_failed = true;
200            Vec::new()
201        };
202
203    let mut table = Table::new();
204    table
205        .load_style(UTF8_FULL)
206        .set_content_arrangement(ContentArrangement::Dynamic)
207        .set_header(vec![
208            Cell::new("Package").add_attribute(Attribute::Bold),
209            Cell::new("Version").add_attribute(Attribute::Bold),
210            Cell::new("Repo").add_attribute(Attribute::Bold),
211            Cell::new("Registry").add_attribute(Attribute::Bold),
212            Cell::new("Type").add_attribute(Attribute::Bold),
213        ]);
214
215    let mut found_packages = false;
216
217    if !db_failed && !packages_from_db.is_empty() {
218        for pkg in packages_from_db {
219            if foreign
220                && let Some(reg) = &pkg.registry_handle
221                && active_registries.contains(reg)
222            {
223                continue;
224            }
225
226            if let Some(reg_f) = registry_filter
227                && pkg.registry_handle.as_deref() != Some(reg_f)
228            {
229                continue;
230            }
231
232            if let Some(repo_f) = repo_filter {
233                let repo_matches = if repo_f.contains('/') {
234                    pkg.repo == repo_f
235                } else {
236                    pkg.repo.split('/').any(|part| part == repo_f)
237                };
238                if !repo_matches {
239                    continue;
240                }
241            }
242            if type_filter.is_some()
243                && pkg.package_type
244                    != type_filter.ok_or_else(|| {
245                        anyhow!("type_filter missing despite being set")
246                    })?
247            {
248                continue;
249            }
250
251            let package_display = if let Some(sub) = &pkg.sub_package {
252                format!("{}:{}", pkg.name, sub)
253            } else {
254                pkg.name
255            };
256
257            let version_display = if pkg.revision == "1" {
258                pkg.version.unwrap_or_else(|| "N/A".to_string())
259            } else {
260                format!(
261                    "{}-{}",
262                    pkg.version.as_deref().unwrap_or("N/A"),
263                    pkg.revision
264                )
265            };
266
267            let repo_display = &pkg.repo;
268
269            table.add_row(vec![
270                Cell::new(package_display).fg(Color::Cyan),
271                Cell::new(version_display).fg(Color::Yellow),
272                Cell::new(repo_display.clone()).fg(Color::Green),
273                Cell::new(
274                    pkg.registry_handle.unwrap_or_else(|| "none".to_string())
275                )
276                .fg(Color::DarkGrey),
277                Cell::new(format!("{:?}", pkg.package_type))
278                    .fg(Color::DarkGrey),
279            ]);
280            found_packages = true;
281        }
282    } else {
283        let packages = local::get_installed_packages_with_type()?;
284        if packages.is_empty() {
285            println!("No packages installed by Zoi.");
286            return Ok(());
287        }
288
289        for pkg in packages {
290            let manifest = local::is_package_installed(
291                &pkg.name,
292                pkg.sub_package.as_deref(),
293                types::Scope::User
294            )?
295            .or(local::is_package_installed(
296                &pkg.name,
297                pkg.sub_package.as_deref(),
298                types::Scope::System
299            )?)
300            .or(local::is_package_installed(
301                &pkg.name,
302                pkg.sub_package.as_deref(),
303                types::Scope::Project
304            )?);
305
306            let Some(m) = manifest else { continue };
307
308            if foreign && active_registries.contains(&m.registry_handle) {
309                continue;
310            }
311
312            if let Some(reg_f) = registry_filter
313                && m.registry_handle != reg_f
314            {
315                continue;
316            }
317            if let Some(repo_f) = repo_filter {
318                let repo_matches = if repo_f.contains('/') {
319                    pkg.repo == repo_f
320                } else {
321                    pkg.repo.split('/').any(|part| part == repo_f)
322                };
323                if !repo_matches {
324                    continue;
325                }
326            }
327            if type_filter.is_some()
328                && pkg.package_type
329                    != type_filter.ok_or_else(|| {
330                        anyhow!("type_filter missing despite being set")
331                    })?
332            {
333                continue;
334            }
335
336            let package_display = if let Some(sub) = pkg.sub_package {
337                format!("{}:{}", pkg.name, sub)
338            } else {
339                pkg.name
340            };
341
342            let version_display = if m.revision == "1" {
343                pkg.version
344            } else {
345                format!("{}-{}", pkg.version, m.revision)
346            };
347
348            let repo_display = &pkg.repo;
349
350            table.add_row(vec![
351                Cell::new(package_display).fg(Color::Cyan),
352                Cell::new(version_display).fg(Color::Yellow),
353                Cell::new(repo_display.clone()).fg(Color::Green),
354                Cell::new(m.registry_handle).fg(Color::DarkGrey),
355                Cell::new(format!("{:?}", pkg.package_type))
356                    .fg(Color::DarkGrey),
357            ]);
358            found_packages = true;
359        }
360    }
361
362    if found_packages {
363        println!("{table}");
364    } else {
365        println!("No installed packages match your criteria.");
366    }
367
368    Ok(())
369}
370
371/// Lists package names for completion or simple output.
372fn run_list_names(
373    all: bool,
374    registry_filter: Option<&str>,
375    repo_filter: Option<&str>,
376    type_filter: Option<types::PackageType>,
377    completion: bool
378) -> Result<()> {
379    let mut entries = Vec::new();
380    let config = config::read_config()?;
381
382    if all {
383        let mut registries = Vec::new();
384        if let Some(reg) = registry_filter {
385            registries.push(reg.to_string());
386        } else {
387            if let Some(default) = &config.default_registry {
388                registries.push(default.handle.clone());
389            }
390            for reg in &config.added_registries {
391                registries.push(reg.handle.clone());
392            }
393        }
394
395        let default_handle =
396            config.default_registry.as_ref().map(|r| &r.handle);
397
398        for handle in registries {
399            if let Ok(pkgs) =
400                crate::pkg::db::get_packages_for_completion(&handle)
401            {
402                let is_default = default_handle == Some(&handle);
403                for pkg in pkgs {
404                    if let Some(repo_f) = repo_filter
405                        && !pkg.repo.contains(repo_f)
406                    {
407                        continue;
408                    }
409
410                    let base_name = if is_default {
411                        format!("@{}/{}", pkg.repo, pkg.name)
412                    } else {
413                        format!("#{}@{}/{}", handle, pkg.repo, pkg.name)
414                    };
415
416                    let name_with_sub = if let Some(sub) = &pkg.sub_package {
417                        format!("{base_name}:{sub}")
418                    } else {
419                        base_name
420                    };
421
422                    let entry = if completion {
423                        format!(
424                            "{}:{}",
425                            name_with_sub,
426                            pkg.description.replace(':', " ")
427                        )
428                    } else {
429                        name_with_sub
430                    };
431                    entries.push(entry);
432                }
433            }
434        }
435    } else {
436        let installed = if completion {
437            crate::pkg::db::list_all_packages("local")?
438        } else {
439            local::get_installed_packages().map(|v| {
440                v.into_iter()
441                    .map(zoi_core::types::InstallManifest::into_package)
442                    .collect()
443            })?
444        };
445
446        for pkg in installed {
447            if let Some(type_f) = type_filter
448                && pkg.package_type != type_f
449            {
450                continue;
451            }
452
453            if let Some(reg_f) = registry_filter
454                && pkg.registry_handle.as_deref() != Some(reg_f)
455            {
456                continue;
457            }
458
459            if let Some(repo_f) = repo_filter
460                && pkg.repo != repo_f
461            {
462                continue;
463            }
464
465            let name = if let Some(sub) = pkg.sub_package {
466                format!("{}:{}", pkg.name, sub)
467            } else {
468                pkg.name
469            };
470
471            let entry = if completion {
472                format!("{}:{}", name, pkg.description.replace(':', " "))
473            } else {
474                name
475            };
476            entries.push(entry);
477        }
478    }
479
480    entries.sort();
481    entries.dedup();
482    for entry in entries {
483        println!("{entry}");
484    }
485
486    Ok(())
487}
488
489/// Lists all available packages from all active registries.
490fn run_list_all(
491    registry_filter: Option<&str>,
492    repo_filter: Option<&str>,
493    type_filter: Option<types::PackageType>
494) -> Result<()> {
495    let installed_pkgs = local::get_installed_packages()?
496        .into_iter()
497        .map(|p| {
498            if let Some(sub) = p.sub_package {
499                format!("{}:{}", p.name, sub)
500            } else {
501                p.name
502            }
503        })
504        .collect::<HashSet<_>>();
505
506    let config = config::read_config()?;
507
508    let mut all_available = Vec::new();
509    let mut db_failed = false;
510
511    if let Some(reg_handle) = registry_filter {
512        match crate::pkg::db::list_all_packages(reg_handle) {
513            Ok(pkgs) => all_available.extend(pkgs),
514            Err(_) => db_failed = true
515        }
516    } else {
517        let mut registries = Vec::new();
518        if let Some(default) = &config.default_registry {
519            registries.push(default.handle.clone());
520        }
521        for reg in &config.added_registries {
522            registries.push(reg.handle.clone());
523        }
524
525        let results: Vec<Result<Vec<types::Package>>> = registries
526            .into_par_iter()
527            .filter(|h| !h.is_empty())
528            .map(|handle| crate::pkg::db::list_all_packages(&handle))
529            .collect();
530
531        for res in results {
532            if let Ok(pkgs) = res {
533                all_available.extend(pkgs);
534            } else {
535                db_failed = true;
536                break;
537            }
538        }
539    }
540
541    let available_pkgs = if db_failed
542        || (all_available.is_empty()
543            && repo_filter.is_none()
544            && registry_filter.is_none())
545    {
546        if let Some(reg_handle) = registry_filter {
547            let all_repo_names = config::get_all_repos()?;
548            let full_repos: Vec<String> = all_repo_names
549                .into_iter()
550                .map(|r_name| format!("{reg_handle}/{r_name}"))
551                .filter(|full_repo_name| {
552                    if let Some(repo_f) = repo_filter {
553                        if repo_f.contains('/') {
554                            full_repo_name == repo_f
555                        } else {
556                            full_repo_name.split('/').any(|part| part == repo_f)
557                        }
558                    } else {
559                        true
560                    }
561                })
562                .collect();
563            local::get_packages_from_repos(&full_repos)?
564        } else if let Some(repo_f) = repo_filter {
565            let handle = if let Some(reg) = &config.default_registry {
566                reg.handle.clone()
567            } else {
568                return Err(anyhow!("Default registry not configured."));
569            };
570            if handle.is_empty() {
571                return Err(anyhow!(
572                    "Default registry handle is not set. Please run 'zoi \
573                     sync'.."
574                ));
575            }
576            let all_repo_names = config::get_all_repos()?;
577            let repos_to_search: Vec<String> = all_repo_names
578                .into_iter()
579                .map(|r_name| format!("{handle}/{r_name}"))
580                .filter(|full_repo_name| {
581                    if repo_f.contains('/') {
582                        full_repo_name == repo_f
583                    } else {
584                        full_repo_name.split('/').any(|part| part == repo_f)
585                    }
586                })
587                .collect();
588            local::get_packages_from_repos(&repos_to_search)?
589        } else {
590            local::get_all_available_packages()?
591        }
592    } else {
593        if let Some(rf) = repo_filter {
594            all_available.retain(|p| {
595                if rf.contains('/') {
596                    p.repo == rf
597                } else {
598                    p.repo.split('/').any(|part| part == rf)
599                }
600            });
601        }
602        all_available
603    };
604
605    let handle_for_version = registry_filter.or(config
606        .default_registry
607        .as_ref()
608        .map(|reg| reg.handle.as_str()));
609
610    if available_pkgs.is_empty() {
611        if let Some(repo) = repo_filter {
612            println!("No packages found in repo '{repo}'.");
613        } else {
614            println!("No packages found in active repositories.");
615        }
616        return Ok(());
617    }
618
619    let mut table = Table::new();
620    table
621        .load_style(UTF8_FULL)
622        .set_content_arrangement(ContentArrangement::Dynamic)
623        .set_header(vec![
624            Cell::new("Status").add_attribute(Attribute::Bold),
625            Cell::new("Package").add_attribute(Attribute::Bold),
626            Cell::new("Version").add_attribute(Attribute::Bold),
627            Cell::new("Repo").add_attribute(Attribute::Bold),
628            Cell::new("Type").add_attribute(Attribute::Bold),
629        ]);
630
631    for pkg in available_pkgs {
632        if type_filter.is_some()
633            && pkg.package_type
634                != type_filter.ok_or_else(|| {
635                    anyhow!("type_filter missing despite being set")
636                })?
637        {
638            continue;
639        }
640
641        let version =
642            crate::pkg::resolve::get_default_version(&pkg, handle_for_version)
643                .unwrap_or_else(|_| "N/A".to_string());
644
645        let version_display = if pkg.revision == "1" {
646            version.clone()
647        } else {
648            format!("{}-{}", version, pkg.revision)
649        };
650
651        let repo_display =
652            pkg.repo.split_once('/').map_or(pkg.repo.as_str(), |x| x.1);
653
654        let full_name = if let Some(sub) = &pkg.sub_package {
655            format!("{}:{}", pkg.name, sub)
656        } else {
657            pkg.name.clone()
658        };
659
660        if let Some(subs) = &pkg.sub_packages {
661            for sub in subs {
662                let full_name_sub = format!("{}:{}", pkg.name, sub);
663                let (status_str, status_color) =
664                    if installed_pkgs.contains(&full_name_sub) {
665                        ("✓", Color::Green)
666                    } else {
667                        ("", Color::Reset)
668                    };
669                table.add_row(vec![
670                    Cell::new(status_str).fg(status_color),
671                    Cell::new(full_name_sub).fg(Color::Cyan),
672                    Cell::new(version_display.clone()).fg(Color::Yellow),
673                    Cell::new(repo_display.to_string()).fg(Color::Green),
674                    Cell::new(format!("{:?}", pkg.package_type))
675                        .fg(Color::DarkGrey),
676                ]);
677            }
678        } else {
679            let (status_str, status_color) =
680                if installed_pkgs.contains(&full_name) {
681                    ("✓", Color::Green)
682                } else {
683                    ("", Color::Reset)
684                };
685            table.add_row(vec![
686                Cell::new(status_str).fg(status_color),
687                Cell::new(full_name).fg(Color::Cyan),
688                Cell::new(version_display).fg(Color::Yellow),
689                Cell::new(repo_display.to_string()).fg(Color::Green),
690                Cell::new(format!("{:?}", pkg.package_type))
691                    .fg(Color::DarkGrey),
692            ]);
693        }
694    }
695
696    println!("{table}");
697    Ok(())
698}