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}