Skip to main content

arch_toolkit/install/
batch.rs

1//! Batch install planning: split targets between pacman and an AUR helper.
2
3use std::collections::HashSet;
4use std::hash::BuildHasher;
5
6use crate::error::{ArchToolkitError, Result};
7use crate::types::dependency::{PackageRef, PackageSource};
8use crate::types::install::{AurHelper, CommandSpec, InstallOptions, PrivilegeTool};
9
10use super::command::{build_aur_install, build_pacman_install, with_privilege};
11
12/// What: The result of planning a batch installation.
13///
14/// Inputs:
15/// - Produced by [`build_batch_install`].
16///
17/// Output:
18/// - Between zero and two commands (one pacman batch, one AUR helper batch)
19///   plus the package names routed to each.
20///
21/// Details:
22/// - Commands should be executed in order: official packages first, then AUR
23///   packages (AUR builds may depend on official packages installed earlier) —
24///   matching Pacsea's mixed-install chaining.
25#[derive(Clone, Debug, Default)]
26pub struct InstallPlan {
27    /// Commands to execute in order (pacman batch first, then AUR helper batch).
28    pub commands: Vec<CommandSpec>,
29    /// Names routed to the pacman command.
30    pub official: Vec<String>,
31    /// Names routed to the AUR helper command.
32    pub aur: Vec<String>,
33}
34
35impl InstallPlan {
36    /// What: Render the plan as a single `&&`-chained shell command line.
37    ///
38    /// Inputs:
39    /// - `&self`: The planned commands, in execution order.
40    ///
41    /// Output:
42    /// - Shell string like `sudo pacman -S ... -- <names> && paru -S --aur ... -- <names>`;
43    ///   empty string for an empty plan.
44    ///
45    /// Details:
46    /// - `&&` chaining stops the AUR step when the pacman step fails —
47    ///   matching Pacsea's mixed-install semantics. Callers executing the
48    ///   `commands` vector directly must replicate this by checking each
49    ///   command's exit status before running the next.
50    ///
51    /// # Example
52    ///
53    /// ```
54    /// use arch_toolkit::install::build_batch_install;
55    /// use arch_toolkit::types::install::{AurHelper, InstallOptions};
56    /// use arch_toolkit::PackageRef;
57    ///
58    /// let targets = vec![
59    ///     PackageRef::official("ripgrep", "14.0.0", "extra", "x86_64"),
60    ///     PackageRef::aur("yay-bin", "12.0.0"),
61    /// ];
62    /// let plan = build_batch_install(
63    ///     &targets,
64    ///     Some(AurHelper::Paru),
65    ///     None,
66    ///     &InstallOptions::default(),
67    ///     None::<&std::collections::HashSet<String>>,
68    /// )?;
69    /// assert_eq!(
70    ///     plan.to_shell_string(),
71    ///     "pacman -S --needed --noconfirm -- ripgrep \
72    ///      && paru -S --aur --needed --noconfirm -- yay-bin"
73    /// );
74    /// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
75    /// ```
76    #[must_use]
77    pub fn to_shell_string(&self) -> String {
78        self.commands
79            .iter()
80            .map(CommandSpec::to_shell_string)
81            .collect::<Vec<_>>()
82            .join(" && ")
83    }
84}
85
86/// What: Plan a batch installation by splitting targets between pacman and an AUR helper.
87///
88/// Inputs:
89/// - `targets`: Packages to install; the `source` field routes each to pacman or the helper.
90/// - `helper`: AUR helper to use for AUR targets. Required when any target is from the AUR.
91/// - `privilege`: When `Some`, the pacman command is wrapped (e.g., `sudo pacman ...`).
92///   AUR helper commands are never wrapped (helpers escalate internally).
93/// - `options`: Flag options; `options.needed` is refined per group when `installed` is given.
94/// - `installed`: Optional set of installed package names. When any name in a group is
95///   already installed, `--needed` is dropped for that group so reinstalls proceed —
96///   mirroring Pacsea's batch reinstall detection. Pass `None` to use `options.needed` as-is.
97///
98/// Output:
99/// - `Ok(InstallPlan)` with grouped commands and routed names.
100///
101/// Details:
102/// - Official packages are grouped into a single `pacman` invocation, AUR packages
103///   into a single helper invocation (from Pacsea's `build_batch_install_command`).
104/// - Both grouped commands inherit strict name validation and the `--` operand
105///   terminator from the underlying direct builders.
106/// - Empty `targets` produces an empty plan (no error), so callers can pass
107///   through selection results unchecked.
108/// - arch-toolkit never executes the plan; run the commands with
109///   `spec.to_command()` or display them with `spec.to_shell_string()` (dry run).
110///
111/// # Errors
112///
113/// - `ArchToolkitError::InvalidInput` when AUR targets are present but `helper` is `None`.
114/// - `ArchToolkitError::InvalidPackageName` when a target name fails validation.
115///
116/// # Example
117///
118/// ```
119/// use arch_toolkit::install::build_batch_install;
120/// use arch_toolkit::types::install::{AurHelper, InstallOptions, PrivilegeTool};
121/// use arch_toolkit::{PackageRef, PackageSource};
122///
123/// let targets = vec![
124///     PackageRef::official("ripgrep", "14.0.0", "extra", "x86_64"),
125///     PackageRef::aur("yay-bin", "12.0.0"),
126/// ];
127/// let plan = build_batch_install(
128///     &targets,
129///     Some(AurHelper::Paru),
130///     Some(PrivilegeTool::Sudo),
131///     &InstallOptions::default(),
132///     None::<&std::collections::HashSet<String>>,
133/// )?;
134/// assert_eq!(plan.commands.len(), 2);
135/// assert_eq!(
136///     plan.commands[0].to_shell_string(),
137///     "sudo pacman -S --needed --noconfirm -- ripgrep"
138/// );
139/// assert_eq!(
140///     plan.commands[1].to_shell_string(),
141///     "paru -S --aur --needed --noconfirm -- yay-bin"
142/// );
143/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
144/// ```
145pub fn build_batch_install<S: BuildHasher>(
146    targets: &[PackageRef],
147    helper: Option<AurHelper>,
148    privilege: Option<PrivilegeTool>,
149    options: &InstallOptions,
150    installed: Option<&HashSet<String, S>>,
151) -> Result<InstallPlan> {
152    let mut official: Vec<String> = Vec::new();
153    let mut aur: Vec<String> = Vec::new();
154    for target in targets {
155        match &target.source {
156            PackageSource::Official { .. } => official.push(target.name.clone()),
157            PackageSource::Aur => aur.push(target.name.clone()),
158        }
159    }
160
161    let mut commands = Vec::new();
162
163    if !official.is_empty() {
164        let opts = group_options(*options, &official, installed);
165        let mut spec = build_pacman_install(&official, &opts)?;
166        if let Some(tool) = privilege {
167            spec = with_privilege(tool, spec);
168        }
169        commands.push(spec);
170    }
171
172    if !aur.is_empty() {
173        let Some(helper) = helper else {
174            return Err(ArchToolkitError::InvalidInput(format!(
175                "{} AUR target(s) present but no AUR helper provided; \
176                 pass one explicitly or use detect_aur_helper()",
177                aur.len()
178            )));
179        };
180        let opts = group_options(*options, &aur, installed);
181        commands.push(build_aur_install(helper, &aur, &opts)?);
182    }
183
184    Ok(InstallPlan {
185        commands,
186        official,
187        aur,
188    })
189}
190
191/// What: Refine install options for a target group based on installed state.
192///
193/// Inputs:
194/// - `options`: Base options from the caller.
195/// - `names`: The group's package names.
196/// - `installed`: Optional installed-package set.
197///
198/// Output:
199/// - Options with `needed` cleared when any group member is already installed.
200///
201/// Details:
202/// - Mirrors Pacsea's batch reinstall detection: a group containing an
203///   already-installed package drops `--needed` so pacman/helper reinstalls it.
204fn group_options<S: BuildHasher>(
205    options: InstallOptions,
206    names: &[String],
207    installed: Option<&HashSet<String, S>>,
208) -> InstallOptions {
209    let has_reinstall = installed.is_some_and(|set| names.iter().any(|name| set.contains(name)));
210    InstallOptions {
211        needed: options.needed && !has_reinstall,
212        ..options
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn official(name: &str) -> PackageRef {
221        PackageRef::official(name, "1.0", "extra", "x86_64")
222    }
223
224    fn aur_pkg(name: &str) -> PackageRef {
225        PackageRef::aur(name, "1.0")
226    }
227
228    const NO_INSTALLED: Option<&HashSet<String>> = None;
229
230    #[test]
231    /// What: Verify official-only batches produce a single privileged pacman command.
232    ///
233    /// Inputs:
234    /// - Two official targets with sudo privilege.
235    ///
236    /// Output:
237    /// - One command: `sudo pacman -S --needed --noconfirm -- a b`.
238    ///
239    /// Details:
240    /// - Grouping mirrors Pacsea's single-invocation batching.
241    fn official_only_batch() {
242        let targets = vec![official("a"), official("b")];
243        let plan = build_batch_install(
244            &targets,
245            None,
246            Some(PrivilegeTool::Sudo),
247            &InstallOptions::default(),
248            NO_INSTALLED,
249        )
250        .expect("plan");
251        assert_eq!(plan.commands.len(), 1);
252        assert_eq!(
253            plan.commands[0].to_shell_string(),
254            "sudo pacman -S --needed --noconfirm -- a b"
255        );
256        assert_eq!(
257            plan.commands[0].args,
258            ["pacman", "-S", "--needed", "--noconfirm", "--", "a", "b"]
259        );
260        assert_eq!(plan.official, ["a", "b"]);
261        assert!(plan.aur.is_empty());
262    }
263
264    #[test]
265    /// What: Verify AUR-only batches produce a single unprivileged helper command.
266    ///
267    /// Inputs:
268    /// - Two AUR targets with paru and sudo configured.
269    ///
270    /// Output:
271    /// - One command without sudo: `paru -S --aur --needed --noconfirm -- x y`.
272    ///
273    /// Details:
274    /// - Helpers must never be wrapped in sudo; they escalate internally.
275    fn aur_only_batch_never_privileged() {
276        let targets = vec![aur_pkg("x"), aur_pkg("y")];
277        let plan = build_batch_install(
278            &targets,
279            Some(AurHelper::Paru),
280            Some(PrivilegeTool::Sudo),
281            &InstallOptions::default(),
282            NO_INSTALLED,
283        )
284        .expect("plan");
285        assert_eq!(plan.commands.len(), 1);
286        assert_eq!(
287            plan.commands[0].to_shell_string(),
288            "paru -S --aur --needed --noconfirm -- x y"
289        );
290    }
291
292    #[test]
293    /// What: Verify batch planning rejects option-like package names.
294    ///
295    /// Inputs:
296    /// - Official and AUR targets named `--help`, `-S`, and `.hidden`.
297    ///
298    /// Output:
299    /// - `InvalidPackageName` from both routing branches.
300    ///
301    /// Details:
302    /// - Batch planning must inherit the leading-byte rule (U5).
303    fn batch_rejects_option_like_names() {
304        for evil in ["--help", "-S", ".hidden"] {
305            for target in [official(evil), aur_pkg(evil)] {
306                let err = build_batch_install(
307                    std::slice::from_ref(&target),
308                    Some(AurHelper::Paru),
309                    Some(PrivilegeTool::Sudo),
310                    &InstallOptions::default(),
311                    NO_INSTALLED,
312                )
313                .expect_err("batch should reject option-like names");
314                assert!(matches!(err, ArchToolkitError::InvalidPackageName { .. }));
315            }
316        }
317    }
318
319    #[test]
320    /// What: Verify mixed batches order pacman before the AUR helper.
321    ///
322    /// Inputs:
323    /// - One official and one AUR target.
324    ///
325    /// Output:
326    /// - Two commands: privileged pacman first, then the helper.
327    ///
328    /// Details:
329    /// - AUR builds may depend on official packages installed in the first step.
330    fn mixed_batch_ordering() {
331        let targets = vec![aur_pkg("helper-pkg"), official("base-pkg")];
332        let plan = build_batch_install(
333            &targets,
334            Some(AurHelper::Yay),
335            Some(PrivilegeTool::Doas),
336            &InstallOptions::default(),
337            NO_INSTALLED,
338        )
339        .expect("plan");
340        assert_eq!(plan.commands.len(), 2);
341        assert!(
342            plan.commands[0]
343                .to_shell_string()
344                .starts_with("doas pacman")
345        );
346        assert!(
347            plan.commands[1]
348                .to_shell_string()
349                .starts_with("yay -S --aur")
350        );
351    }
352
353    #[test]
354    /// What: Verify reinstall detection drops `--needed` per group.
355    ///
356    /// Inputs:
357    /// - Installed set containing one official target; AUR target not installed.
358    ///
359    /// Output:
360    /// - Pacman command without `--needed`; helper command keeps `--needed`.
361    ///
362    /// Details:
363    /// - Mirrors Pacsea's per-group `has_reinstall` logic.
364    fn reinstall_detection_per_group() {
365        let targets = vec![official("vim"), aur_pkg("fresh-pkg")];
366        let installed: HashSet<String> = HashSet::from(["vim".to_string()]);
367        let plan = build_batch_install(
368            &targets,
369            Some(AurHelper::Paru),
370            None,
371            &InstallOptions::default(),
372            Some(&installed),
373        )
374        .expect("plan");
375        assert_eq!(
376            plan.commands[0].to_shell_string(),
377            "pacman -S --noconfirm -- vim"
378        );
379        assert_eq!(
380            plan.commands[1].to_shell_string(),
381            "paru -S --aur --needed --noconfirm -- fresh-pkg"
382        );
383    }
384
385    #[test]
386    /// What: Verify AUR targets without a helper produce a clear error.
387    ///
388    /// Inputs:
389    /// - One AUR target, `helper = None`.
390    ///
391    /// Output:
392    /// - `InvalidInput` error mentioning the missing helper.
393    ///
394    /// Details:
395    /// - Callers should detect or configure a helper before planning.
396    fn aur_without_helper_errors() {
397        let targets = vec![aur_pkg("x")];
398        let err = build_batch_install(
399            &targets,
400            None,
401            None,
402            &InstallOptions::default(),
403            NO_INSTALLED,
404        )
405        .expect_err("should fail");
406        assert!(matches!(err, ArchToolkitError::InvalidInput(_)));
407        assert!(err.to_string().contains("AUR helper"));
408    }
409
410    #[test]
411    /// What: Verify an empty target list yields an empty plan without error.
412    ///
413    /// Inputs:
414    /// - Empty target slice.
415    ///
416    /// Output:
417    /// - Plan with no commands and no routed names.
418    ///
419    /// Details:
420    /// - Allows callers to pass selection results through unchecked.
421    fn empty_targets_empty_plan() {
422        let plan = build_batch_install(&[], None, None, &InstallOptions::default(), NO_INSTALLED)
423            .expect("plan");
424        assert!(plan.commands.is_empty());
425        assert!(plan.official.is_empty());
426        assert!(plan.aur.is_empty());
427    }
428}