Skip to main content

zoi_deps/
lib.rs

1//! Manages external package dependencies for Zoi.
2//!
3//! This crate provides the logic to parse dependency strings and interact with
4//! various external package managers (e.g. apt, brew, cargo) to install and
5//! uninstall dependencies.
6
7use anyhow::{Result, anyhow};
8use colored::Colorize;
9use dialoguer::Select;
10use dialoguer::theme::ColorfulTheme;
11pub use zoi_core::dependency::Dependency;
12use zoi_core::{types, utils};
13
14include!(concat!(env!("OUT_DIR"), "/generated_managers.rs"));
15
16/// Parses a dependency string with Zoi-specific manager validation.
17///
18/// Recognized managers include "zoi", "native", and all external managers
19/// defined in `managers.json`.
20///
21/// # Errors
22///
23/// Returns an error if the dependency string is malformed or uses an
24/// unsupported manager.
25pub fn parse_dependency_string(dep_str: &str) -> Result<Dependency<'_>> {
26    zoi_core::dependency::parse_dependency_string(dep_str, |m| {
27        m == "zoi" || m == "native" || MANAGERS.contains_key(m)
28    })
29}
30
31/// A callback function type for uninstalling a Zoi package by its name.
32type ZoiUninstaller = dyn Fn(&str) -> Result<()>;
33
34/// Attempts to remove a dependency using its responsible package manager.
35///
36/// Note: Zoi cannot guarantee full rollbacks for external package managers
37/// as it does not own their internal state.
38///
39/// # Errors
40///
41/// Returns an error if:
42/// - The dependency string cannot be parsed.
43/// - The package manager is not supported for uninstallation.
44/// - The uninstallation command fails to execute.
45pub fn uninstall_dependency(
46    dep_str: &str,
47    zoi_uninstaller: &ZoiUninstaller
48) -> Result<()> {
49    let dep = parse_dependency_string(dep_str)?;
50    println!(
51        "-> Attempting to uninstall dependency: {} via {}",
52        dep.package.cyan(),
53        dep.manager.yellow()
54    );
55
56    if dep.manager == "zoi" {
57        return zoi_uninstaller(dep.package);
58    }
59
60    if let Some(pm_commands) = MANAGERS.get(dep.manager) {
61        let mut uninstall_cmd =
62            pm_commands.uninstall.replace("{package}", dep.package);
63
64        if pm_commands.sudo_uninstall && !utils::is_admin() {
65            if let Some(escalator) = utils::get_privilege_escalator() {
66                uninstall_cmd = format!("{escalator} {uninstall_cmd}");
67            } else {
68                eprintln!(
69                    "{}: root privileges are required for '{}' but neither \
70                     'sudo' nor 'doas' was found. Attempting to run without \
71                     escalation...",
72                    "Warning".yellow(),
73                    dep.manager
74                );
75            }
76        }
77
78        println!("Running uninstall command: {}", uninstall_cmd.italic());
79        utils::run_shell_command(&uninstall_cmd)
80    } else {
81        Err(anyhow!(
82            "Unknown or unsupported package manager for uninstall: {}",
83            dep.manager
84        ))
85    }
86}
87
88/// Recursively collects all dependencies for a package, including sub-package
89/// specific ones.
90///
91/// This function handles the complex logic of:
92/// - Required Dependencies: Automatically added to the list.
93/// - Optional Dependencies: Prompts the user (unless `--all-optional` or
94///   `--yes` is used).
95/// - Selectable Options: Prompts the user to choose between multiple providers.
96/// - Recursive Sub-packages: Follows `sub_packages` maps to pull in
97///   component-specific requirements.
98///
99/// # Errors
100///
101/// Returns an error if any of the underlying dependency collection or prompting
102/// operations fail.
103pub fn collect_dependencies_for_group(
104    group: &types::DependencyGroup,
105    sub_package_name: Option<&str>,
106    dep_type: Option<&str>,
107    yes: bool,
108    all_optional: bool
109) -> Result<(Vec<String>, Vec<String>, Vec<String>)> {
110    let mut deps = Vec::new();
111    let mut chosen_options = Vec::new();
112    let mut chosen_optionals = Vec::new();
113
114    match group {
115        types::DependencyGroup::Simple(d) => {
116            deps.extend(d.clone());
117        }
118        types::DependencyGroup::Complex(g) => {
119            deps.extend(g.required.clone());
120
121            let options = prompt_for_options(&g.options, yes)?;
122            chosen_options.extend(options.clone());
123            deps.extend(options);
124
125            let optionals =
126                prompt_for_optionals(&g.optional, dep_type, yes, all_optional)?;
127            chosen_optionals.extend(optionals.clone());
128            deps.extend(optionals);
129
130            if let Some(sub_name) = sub_package_name
131                && let Some(sub_deps_map) = &g.sub_packages
132                && let Some(sub_dep_group) = sub_deps_map.get(sub_name)
133            {
134                let (sub_d, sub_co, sub_coo) = collect_dependencies_for_group(
135                    sub_dep_group,
136                    None,
137                    dep_type,
138                    yes,
139                    all_optional
140                )?;
141                deps.extend(sub_d);
142                chosen_options.extend(sub_co);
143                chosen_optionals.extend(sub_coo);
144            }
145        }
146    }
147    Ok((deps, chosen_options, chosen_optionals))
148}
149
150/// Presents interactive choices for selectable dependency groups.
151///
152/// # Errors
153///
154/// Returns an error if user interaction via the terminal fails or if a
155/// dependency string is invalid.
156pub fn prompt_for_options(
157    option_groups: &[types::DependencyOptionGroup],
158    yes: bool
159) -> Result<Vec<String>> {
160    let mut chosen = Vec::new();
161    if option_groups.is_empty() {
162        return Ok(chosen);
163    }
164
165    for group in option_groups {
166        println!(
167            "{} There are {} options available for {}:",
168            "::".bold().blue(),
169            group.depends.len(),
170            group.desc.italic()
171        );
172
173        let parsed_deps: Vec<_> = group
174            .depends
175            .iter()
176            .map(|d| parse_dependency_string(d))
177            .collect::<Result<_>>()?;
178
179        if yes {
180            if group.all {
181                println!(
182                    "--yes provided, selecting all options for '{}'",
183                    group.name
184                );
185                chosen.extend(group.depends.clone());
186            } else {
187                println!(
188                    "--yes provided, selecting first option for '{}'",
189                    group.name
190                );
191                if let Some(dep) = group.depends.first() {
192                    chosen.push(dep.clone());
193                }
194            }
195            continue;
196        }
197
198        if group.all {
199            let items: Vec<_> = parsed_deps
200                .iter()
201                .map(|d| {
202                    format!(
203                        "{}:{} - {}",
204                        d.manager,
205                        d.package,
206                        d.description.unwrap_or("No description")
207                    )
208                })
209                .collect();
210            let selections =
211                dialoguer::MultiSelect::with_theme(&ColorfulTheme::default())
212                    .with_prompt(
213                        "Choose which to install (space to select, enter to \
214                         confirm)"
215                    )
216                    .items(&items)
217                    .interact()?;
218
219            for i in selections {
220                if let Some(dep) = group.depends.get(i) {
221                    chosen.push(dep.clone());
222                }
223            }
224        } else {
225            let items: Vec<_> = parsed_deps
226                .iter()
227                .map(|d| {
228                    format!(
229                        "{}:{} - {}",
230                        d.manager,
231                        d.package,
232                        d.description.unwrap_or("No description")
233                    )
234                })
235                .collect();
236            let selection = Select::with_theme(&ColorfulTheme::default())
237                .with_prompt("Choose one to install")
238                .items(&items)
239                .default(0)
240                .interact()?;
241            if let Some(dep) = group.depends.get(selection) {
242                chosen.push(dep.clone());
243            }
244        }
245    }
246    Ok(chosen)
247}
248
249/// Prompts the user to install optional dependencies.
250///
251/// # Errors
252///
253/// Returns an error if user interaction via the terminal fails or if a
254/// dependency string is invalid.
255pub fn prompt_for_optionals(
256    deps: &[String],
257    dep_type: Option<&str>,
258    yes: bool,
259    all_optional: bool
260) -> Result<Vec<String>> {
261    if deps.is_empty() {
262        return Ok(Vec::new());
263    }
264
265    let type_str = dep_type.map(|s| format!("{s} ")).unwrap_or_default();
266
267    if all_optional {
268        println!(
269            "{} Installing all optional {}dependencies...",
270            "::".bold().blue(),
271            type_str
272        );
273        return Ok(deps.to_vec());
274    }
275
276    if yes {
277        println!(
278            "{} Skipping optional {}dependencies (--yes provided without \
279             --all-optional).",
280            "::".bold().yellow(),
281            type_str
282        );
283        return Ok(Vec::new());
284    }
285
286    let items: Vec<_> = deps
287        .iter()
288        .map(|d| {
289            parse_dependency_string(d).map(|dep| {
290                format!(
291                    "{}:{} - {}",
292                    dep.manager,
293                    dep.package,
294                    dep.description.unwrap_or("No description")
295                )
296            })
297        })
298        .collect::<Result<_>>()?;
299
300    let selections =
301        dialoguer::MultiSelect::with_theme(&ColorfulTheme::default())
302            .with_prompt(format!(
303                "Select optional {type_str}dependencies to install"
304            ))
305            .items(&items)
306            .defaults(&vec![false; deps.len()])
307            .interact()?;
308
309    let mut chosen = Vec::new();
310    for i in selections {
311        if let Some(dep) = deps.get(i) {
312            chosen.push(dep.clone());
313        }
314    }
315    Ok(chosen)
316}