Skip to main content

zoi_cli/cmd/
installed_select.rs

1//! Utilities for selecting from multiple installed versions of a package.
2
3use std::collections::HashMap;
4
5use anyhow::{Result, anyhow};
6use colored::Colorize;
7use comfy_table::Table;
8use comfy_table::presets::UTF8_FULL;
9use dialoguer::Select;
10use dialoguer::theme::ColorfulTheme;
11
12use crate::pkg::{db, local, types};
13
14/// Represents a package candidate for display in the selection UI.
15#[derive(Clone)]
16struct CandidateDisplay {
17    /// The manifest of the installed package.
18    manifest: types::InstallManifest,
19    /// The description of the package.
20    description: String
21}
22
23/// Returns a human-readable label for a package scope.
24fn scope_label(scope: types::Scope) -> &'static str {
25    match scope {
26        types::Scope::User => "user",
27        types::Scope::System => "system",
28        types::Scope::Project => "project"
29    }
30}
31
32/// Looks up descriptions for all locally registered packages.
33fn lookup_descriptions()
34-> HashMap<(String, String, Option<String>, &'static str, String), String> {
35    let mut descriptions = HashMap::new();
36    if let Ok(packages) = db::list_all_packages("local") {
37        for pkg in packages {
38            let key = (
39                pkg.name.clone(),
40                pkg.repo.clone(),
41                pkg.sub_package.clone(),
42                scope_label(pkg.scope),
43                pkg.registry_handle.unwrap_or_else(|| "local".to_string())
44            );
45            descriptions.insert(key, pkg.description);
46        }
47    }
48    descriptions
49}
50
51/// Builds a list of display objects for the given package candidates.
52fn build_candidate_displays(
53    candidates: &[types::InstallManifest]
54) -> Vec<CandidateDisplay> {
55    let descriptions = lookup_descriptions();
56    candidates
57        .iter()
58        .cloned()
59        .map(|manifest| {
60            let description = descriptions
61                .get(&(
62                    manifest.name.clone(),
63                    manifest.repo.clone(),
64                    manifest.sub_package.clone(),
65                    scope_label(manifest.scope),
66                    manifest.registry_handle.clone()
67                ))
68                .cloned()
69                .or_else(|| {
70                    let source_path =
71                        local::get_package_source_path(&manifest).ok()?;
72                    let path = source_path.to_str()?;
73                    let pkg = crate::pkg::lua::parser::parse_lua_package(
74                        path,
75                        Some(&manifest.version),
76                        Some(manifest.scope),
77                        true
78                    )
79                    .ok()?;
80                    Some(pkg.description)
81                })
82                .unwrap_or_else(|| "Description unavailable".to_string());
83
84            CandidateDisplay {
85                manifest,
86                description
87            }
88        })
89        .collect()
90}
91
92/// Prompts the user to select an installed manifest from a list of candidates.
93///
94/// # Errors
95///
96/// Returns an error if:
97/// - The candidates list is empty.
98/// - Multiple candidates exist and `yes` is true (non-interactive mode).
99/// - User interaction fails.
100///
101/// # Panics
102///
103/// Panics if the candidates list is empty after the check, which should not
104/// happen.
105pub fn choose_installed_manifest(
106    package_name: &str,
107    candidates: &[types::InstallManifest],
108    yes: bool
109) -> Result<types::InstallManifest> {
110    if candidates.is_empty() {
111        return Err(anyhow!("Package '{package_name}' is not installed."));
112    }
113    if candidates.len() == 1 {
114        return Ok(candidates
115            .first()
116            .expect("should have at least one candidate")
117            .clone());
118    }
119    if yes {
120        return Err(anyhow!(
121            "Package '{package_name}' matches multiple installed packages. \
122             Use an explicit source like '#handle@repo/name[:sub]@version'."
123        ));
124    }
125
126    let displays = build_candidate_displays(candidates);
127
128    println!(
129        "Found multiple installed packages matching '{}'. Please choose one:",
130        package_name.cyan()
131    );
132
133    let mut table = Table::new();
134    table.load_style(UTF8_FULL);
135    table.set_header(vec!["#", "Scope", "Source", "Version", "Description"]);
136
137    for (i, display) in displays.iter().enumerate() {
138        table.add_row(vec![
139            (i + 1).to_string(),
140            scope_label(display.manifest.scope).to_string(),
141            local::installed_manifest_source(&display.manifest),
142            display.manifest.version.clone(),
143            display.description.clone(),
144        ]);
145    }
146    println!("{table}");
147
148    let items: Vec<String> = displays
149        .iter()
150        .map(|display| {
151            format!(
152                "{} ({}, v{})",
153                local::installed_manifest_source(&display.manifest),
154                scope_label(display.manifest.scope),
155                display.manifest.version
156            )
157        })
158        .collect();
159
160    let selection = Select::with_theme(&ColorfulTheme::default())
161        .with_prompt("Select an installed package")
162        .items(&items)
163        .default(0)
164        .interact()?;
165
166    Ok(displays
167        .get(selection)
168        .ok_or_else(|| anyhow!("Invalid selection"))?
169        .manifest
170        .clone())
171}