Skip to main content

arch_toolkit/install/
command.rs

1//! Command builders for package installation, removal, and updates.
2//!
3//! All builders return [`CommandSpec`] values — arch-toolkit never executes
4//! commands. Dry runs are the caller's choice: display `spec.to_shell_string()`
5//! instead of spawning.
6
7use crate::error::Result;
8use crate::types::install::{AurHelper, CascadeMode, CommandSpec, InstallOptions, PrivilegeTool};
9
10use super::shell::validate_package_names;
11
12/// Message printed by shell-fallback bodies when no AUR helper is installed.
13///
14/// Matches Pacsea's terminal-install error text so migrating callers keep
15/// byte-identical output.
16pub const NO_AUR_HELPER_MESSAGE: &str = "No AUR helper (paru/yay) found.";
17
18/// Exit status used by shell-fallback bodies when no AUR helper is installed.
19///
20/// `127` is the POSIX convention for "command not found", so callers can
21/// distinguish a missing helper from a failed package operation.
22const NO_AUR_HELPER_STATUS: u8 = 127;
23
24/// POSIX option terminator placed between flags and package operands.
25///
26/// Prevents pacman, paru, and yay from parsing any operand as an option.
27const OPERAND_TERMINATOR: &str = "--";
28
29/// What: Build a pacman install command for official repository packages.
30///
31/// Inputs:
32/// - `names`: Package names to install (validated against the safe-name allowlist).
33/// - `options`: Flag options (`needed`, `noconfirm`; `aur_only` is ignored).
34///
35/// Output:
36/// - `Ok(CommandSpec)` like `pacman -S --needed --noconfirm -- <names...>`.
37///
38/// Details:
39/// - Does NOT prefix a privilege tool; use [`with_privilege`] for that.
40/// - Omit `--needed` (set `options.needed = false`) for explicit reinstalls,
41///   mirroring Pacsea's reinstall path.
42/// - A `--` operand terminator separates flags from package names so pacman can
43///   never reinterpret an operand as an option (defense in depth on top of
44///   name validation).
45///
46/// # Errors
47///
48/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
49/// or `ArchToolkitError::EmptyInput` when `names` is empty.
50///
51/// # Example
52///
53/// ```
54/// use arch_toolkit::install::build_pacman_install;
55/// use arch_toolkit::types::install::InstallOptions;
56///
57/// let spec = build_pacman_install(&["ripgrep", "fd"], &InstallOptions::default())?;
58/// assert_eq!(spec.to_shell_string(), "pacman -S --needed --noconfirm -- ripgrep fd");
59/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
60/// ```
61pub fn build_pacman_install<S: AsRef<str>>(
62    names: &[S],
63    options: &InstallOptions,
64) -> Result<CommandSpec> {
65    validate_non_empty(names, "pacman install")?;
66    validate_package_names(names, "pacman install")?;
67    let mut args = vec!["-S".to_string()];
68    if options.needed {
69        args.push("--needed".to_string());
70    }
71    if options.noconfirm {
72        args.push("--noconfirm".to_string());
73    }
74    push_operands(&mut args, names);
75    Ok(CommandSpec {
76        program: "pacman".to_string(),
77        args,
78    })
79}
80
81/// What: Build an AUR helper install command for AUR packages.
82///
83/// Inputs:
84/// - `helper`: The AUR helper to use (from caller config or [`super::detect_aur_helper`]).
85/// - `names`: AUR package names to install (validated).
86/// - `options`: Flag options (`needed`, `noconfirm`, `aur_only`).
87///
88/// Output:
89/// - `Ok(CommandSpec)` like `paru -S --aur --needed --noconfirm -- <names...>`.
90///
91/// Details:
92/// - `--aur` (when `options.aur_only`) ensures helpers do not prefer a sync
93///   database (e.g., Chaotic-AUR) when the same name exists on the AUR —
94///   matching Pacsea's `aur_install_helper_flags`.
95/// - A `--` operand terminator separates flags from package names; paru and yay
96///   forward it to pacman-style operand parsing.
97/// - AUR helpers must NOT run under sudo; they invoke sudo themselves for the
98///   pacman step. Do not wrap the result in [`with_privilege`].
99///
100/// # Errors
101///
102/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
103/// or `ArchToolkitError::EmptyInput` when `names` is empty.
104///
105/// # Example
106///
107/// ```
108/// use arch_toolkit::install::build_aur_install;
109/// use arch_toolkit::types::install::{AurHelper, InstallOptions};
110///
111/// let spec = build_aur_install(AurHelper::Paru, &["yay-bin"], &InstallOptions::default())?;
112/// assert_eq!(spec.to_shell_string(), "paru -S --aur --needed --noconfirm -- yay-bin");
113/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
114/// ```
115pub fn build_aur_install<S: AsRef<str>>(
116    helper: AurHelper,
117    names: &[S],
118    options: &InstallOptions,
119) -> Result<CommandSpec> {
120    validate_non_empty(names, "AUR install")?;
121    validate_package_names(names, "AUR install")?;
122    let mut args = vec!["-S".to_string()];
123    if options.aur_only {
124        args.push("--aur".to_string());
125    }
126    if options.needed {
127        args.push("--needed".to_string());
128    }
129    if options.noconfirm {
130        args.push("--noconfirm".to_string());
131    }
132    push_operands(&mut args, names);
133    Ok(CommandSpec {
134        program: helper.binary_name().to_string(),
135        args,
136    })
137}
138
139/// What: Build a pacman remove command with the requested cascade level.
140///
141/// Inputs:
142/// - `names`: Package names to remove (validated).
143/// - `cascade`: Cascade level (`-R`, `-Rs`, or `-Rns`).
144/// - `noconfirm`: Pass `--noconfirm` for non-interactive removal.
145///
146/// Output:
147/// - `Ok(CommandSpec)` like `pacman -Rns --noconfirm -- <names...>`.
148///
149/// Details:
150/// - Does NOT prefix a privilege tool; use [`with_privilege`] for that.
151/// - Cascade semantics ported from Pacsea's `CascadeMode`.
152/// - A `--` operand terminator separates flags from package names.
153///
154/// # Errors
155///
156/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
157/// or `ArchToolkitError::EmptyInput` when `names` is empty.
158///
159/// # Example
160///
161/// ```
162/// use arch_toolkit::install::build_remove_command;
163/// use arch_toolkit::types::install::CascadeMode;
164///
165/// let spec = build_remove_command(&["ripgrep"], CascadeMode::CascadeWithConfigs, true)?;
166/// assert_eq!(spec.to_shell_string(), "pacman -Rns --noconfirm -- ripgrep");
167/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
168/// ```
169pub fn build_remove_command<S: AsRef<str>>(
170    names: &[S],
171    cascade: CascadeMode,
172    noconfirm: bool,
173) -> Result<CommandSpec> {
174    validate_non_empty(names, "remove")?;
175    validate_package_names(names, "remove")?;
176    let mut args = vec![cascade.flag().to_string()];
177    if noconfirm {
178        args.push("--noconfirm".to_string());
179    }
180    push_operands(&mut args, names);
181    Ok(CommandSpec {
182        program: "pacman".to_string(),
183        args,
184    })
185}
186
187/// What: Append the `--` operand terminator followed by validated package names.
188///
189/// Inputs:
190/// - `args`: Argument vector already containing every flag for the command.
191/// - `names`: Validated package names to place after the terminator.
192///
193/// Output:
194/// - Side effect: `args` gains `--` and then one entry per package name.
195///
196/// Details:
197/// - Called only by builders that take package operands; operand-free commands
198///   such as `-Syu`, `-Syyu`, and `-Sua` must never gain a terminator.
199/// - Callers must validate names first; this helper performs no validation.
200fn push_operands<S: AsRef<str>>(args: &mut Vec<String>, names: &[S]) {
201    args.push(OPERAND_TERMINATOR.to_string());
202    args.extend(names.iter().map(|n| n.as_ref().to_string()));
203}
204
205/// What: Build a full-system update command.
206///
207/// Inputs:
208/// - `helper`: When `Some`, use the AUR helper (`paru -Syu`) which updates both
209///   official and AUR packages. When `None`, use plain `pacman -Syu`.
210/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
211///
212/// Output:
213/// - `CommandSpec` like `paru -Syu --noconfirm` or `pacman -Syu --noconfirm`.
214///
215/// Details:
216/// - The pacman variant requires privilege wrapping ([`with_privilege`]);
217///   helper variants must not be wrapped (helpers call sudo themselves).
218///
219/// # Example
220///
221/// ```
222/// use arch_toolkit::install::build_update_command;
223/// use arch_toolkit::types::install::AurHelper;
224///
225/// let pacman = build_update_command(None, true);
226/// assert_eq!(pacman.to_shell_string(), "pacman -Syu --noconfirm");
227///
228/// let helper = build_update_command(Some(AurHelper::Yay), false);
229/// assert_eq!(helper.to_shell_string(), "yay -Syu");
230/// ```
231#[must_use]
232pub fn build_update_command(helper: Option<AurHelper>, noconfirm: bool) -> CommandSpec {
233    let program = helper.map_or("pacman", AurHelper::binary_name).to_string();
234    let mut args = vec!["-Syu".to_string()];
235    if noconfirm {
236        args.push("--noconfirm".to_string());
237    }
238    CommandSpec { program, args }
239}
240
241/// What: Build a full-system update command that force-refreshes sync databases.
242///
243/// Inputs:
244/// - `helper`: When `Some`, use the AUR helper; when `None`, plain pacman.
245/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
246///
247/// Output:
248/// - `CommandSpec` like `pacman -Syyu --noconfirm`.
249///
250/// Details:
251/// - `-Syyu` re-downloads all sync databases even when they appear up to date;
252///   use after mirror changes (mirrors Pacsea's force-sync update option).
253/// - Same privilege rules as [`build_update_command`].
254///
255/// # Example
256///
257/// ```
258/// use arch_toolkit::install::build_force_sync_update_command;
259///
260/// let spec = build_force_sync_update_command(None, true);
261/// assert_eq!(spec.to_shell_string(), "pacman -Syyu --noconfirm");
262/// ```
263#[must_use]
264pub fn build_force_sync_update_command(helper: Option<AurHelper>, noconfirm: bool) -> CommandSpec {
265    let program = helper.map_or("pacman", AurHelper::binary_name).to_string();
266    let mut args = vec!["-Syyu".to_string()];
267    if noconfirm {
268        args.push("--noconfirm".to_string());
269    }
270    CommandSpec { program, args }
271}
272
273/// What: Build an AUR-only update command (`-Sua`).
274///
275/// Inputs:
276/// - `helper`: The AUR helper to run the update with.
277/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
278///
279/// Output:
280/// - `CommandSpec` like `paru -Sua --noconfirm`.
281///
282/// Details:
283/// - Updates AUR packages only, leaving official packages to a separate
284///   `pacman -Syu` step — mirroring Pacsea's split system-update flow where
285///   the AUR step runs conditionally after the pacman step succeeds.
286/// - Must NOT be wrapped in [`with_privilege`]; helpers escalate internally.
287///
288/// # Example
289///
290/// ```
291/// use arch_toolkit::install::build_aur_update_command;
292/// use arch_toolkit::types::install::AurHelper;
293///
294/// let spec = build_aur_update_command(AurHelper::Paru, true);
295/// assert_eq!(spec.to_shell_string(), "paru -Sua --noconfirm");
296/// ```
297#[must_use]
298pub fn build_aur_update_command(helper: AurHelper, noconfirm: bool) -> CommandSpec {
299    let mut args = vec!["-Sua".to_string()];
300    if noconfirm {
301        args.push("--noconfirm".to_string());
302    }
303    CommandSpec {
304        program: helper.binary_name().to_string(),
305        args,
306    }
307}
308
309/// What: Build a shell body that installs AUR packages with runtime helper fallback.
310///
311/// Inputs:
312/// - `names`: AUR package names to install (validated).
313/// - `options`: Flag options (`needed`, `noconfirm`, `aur_only`).
314///
315/// Output:
316/// - `Ok(String)` with a POSIX shell snippet that picks `paru`, then `yay`,
317///   at execution time, or prints [`NO_AUR_HELPER_MESSAGE`].
318///
319/// Details:
320/// - Unlike [`build_aur_install`], helper selection happens inside the spawned
321///   shell (the terminal's `PATH`), not in the calling process — matching
322///   Pacsea's `aur_install_body`. Prefer this when the command runs in an
323///   external terminal whose environment may differ from the caller's.
324/// - Names pass the same strict validation as all builders, so interpolating
325///   them into the shell string is safe without quoting. A `--` operand
326///   terminator is emitted before the names for defense in depth.
327/// - When neither helper exists the body writes [`NO_AUR_HELPER_MESSAGE`] to
328///   stderr and exits the subshell with status 127, so callers see a failure
329///   instead of a successful no-op.
330///
331/// # Errors
332///
333/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
334/// or `ArchToolkitError::EmptyInput` when `names` is empty.
335///
336/// # Example
337///
338/// ```
339/// use arch_toolkit::install::aur_install_shell_fallback;
340/// use arch_toolkit::types::install::InstallOptions;
341///
342/// let body = aur_install_shell_fallback(&["yay-bin"], &InstallOptions::default())?;
343/// assert!(body.contains("if command -v paru >/dev/null 2>&1; then paru"));
344/// assert!(body.contains("elif command -v yay >/dev/null 2>&1; then yay"));
345/// assert!(body.contains("--noconfirm -- yay-bin"));
346/// assert!(body.contains("exit 127"));
347/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
348/// ```
349pub fn aur_install_shell_fallback<S: AsRef<str>>(
350    names: &[S],
351    options: &InstallOptions,
352) -> Result<String> {
353    validate_non_empty(names, "AUR install")?;
354    validate_package_names(names, "AUR install")?;
355    let mut flags = String::from("-S");
356    if options.aur_only {
357        flags.push_str(" --aur");
358    }
359    if options.needed {
360        flags.push_str(" --needed");
361    }
362    if options.noconfirm {
363        flags.push_str(" --noconfirm");
364    }
365    let joined = names
366        .iter()
367        .map(std::convert::AsRef::as_ref)
368        .collect::<Vec<_>>()
369        .join(" ");
370    Ok(helper_fallback_body(&format!(
371        "{flags} {OPERAND_TERMINATOR} {joined}"
372    )))
373}
374
375/// What: Build a shell body that updates AUR packages with runtime helper fallback.
376///
377/// Inputs:
378/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
379///
380/// Output:
381/// - A POSIX shell snippet running `paru -Sua` / `yay -Sua`, or printing
382///   [`NO_AUR_HELPER_MESSAGE`] when neither helper exists.
383///
384/// Details:
385/// - Shell-time counterpart of [`build_aur_update_command`], for callers that
386///   spawn the update in an external terminal (Pacsea's system-update flow).
387/// - Takes no package operands, so no `--` terminator is emitted.
388/// - The no-helper branch writes to stderr and exits the subshell with 127.
389///
390/// # Example
391///
392/// ```
393/// use arch_toolkit::install::aur_update_shell_fallback;
394///
395/// let body = aur_update_shell_fallback(true);
396/// assert!(body.contains("paru -Sua --noconfirm"));
397/// ```
398#[must_use]
399pub fn aur_update_shell_fallback(noconfirm: bool) -> String {
400    let flags = if noconfirm {
401        "-Sua --noconfirm"
402    } else {
403        "-Sua"
404    };
405    helper_fallback_body(flags)
406}
407
408/// What: Wrap helper arguments in the paru → yay runtime-fallback shell body.
409///
410/// Inputs:
411/// - `tail`: Flags and package names appended to the chosen helper.
412///
413/// Output:
414/// - Parenthesized `if/elif/else` snippet matching Pacsea's `aur_install_body`,
415///   with a failing no-helper branch.
416///
417/// Details:
418/// - The `else` branch writes [`NO_AUR_HELPER_MESSAGE`] to stderr and runs
419///   `exit 127`. Because the body is wrapped in `( ... )`, only the fallback
420///   subshell terminates; the caller observes a non-zero status.
421fn helper_fallback_body(tail: &str) -> String {
422    format!(
423        "(if command -v paru >/dev/null 2>&1; then paru {tail}; \
424         elif command -v yay >/dev/null 2>&1; then yay {tail}; \
425         else echo '{NO_AUR_HELPER_MESSAGE}' >&2; exit {NO_AUR_HELPER_STATUS}; fi)"
426    )
427}
428
429/// What: Wrap a command with a privilege escalation tool prefix.
430///
431/// Inputs:
432/// - `tool`: The privilege tool (`sudo` or `doas`).
433/// - `spec`: The command to wrap.
434///
435/// Output:
436/// - New `CommandSpec` like `sudo pacman -S ...`.
437///
438/// Details:
439/// - Pure transformation: the original program becomes the first argument.
440/// - Do NOT wrap AUR helper commands; helpers escalate internally.
441/// - Password handling (stdin piping, askpass) is intentionally out of scope.
442///
443/// # Example
444///
445/// ```
446/// use arch_toolkit::install::{build_update_command, with_privilege};
447/// use arch_toolkit::types::install::PrivilegeTool;
448///
449/// let spec = with_privilege(PrivilegeTool::Sudo, build_update_command(None, true));
450/// assert_eq!(spec.to_shell_string(), "sudo pacman -Syu --noconfirm");
451/// ```
452#[must_use]
453pub fn with_privilege(tool: PrivilegeTool, spec: CommandSpec) -> CommandSpec {
454    let mut args = Vec::with_capacity(spec.args.len() + 1);
455    args.push(spec.program);
456    args.extend(spec.args);
457    CommandSpec {
458        program: tool.binary_name().to_string(),
459        args,
460    }
461}
462
463/// What: Reject empty name lists with a descriptive error.
464///
465/// Inputs:
466/// - `names`: The name list to check.
467/// - `context`: Operation context for the error message.
468///
469/// Output:
470/// - `Ok(())` when non-empty, `Err(ArchToolkitError::EmptyInput)` otherwise.
471///
472/// Details:
473/// - Prevents building commands like `pacman -S --noconfirm` with no targets.
474fn validate_non_empty<S: AsRef<str>>(names: &[S], context: &str) -> Result<()> {
475    if names.is_empty() {
476        return Err(crate::error::ArchToolkitError::EmptyInput {
477            field: "names".to_string(),
478            message: format!("at least one package name is required for {context}"),
479        });
480    }
481    Ok(())
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::error::ArchToolkitError;
488
489    #[test]
490    /// What: Verify pacman install flag combinations (fresh install vs reinstall).
491    ///
492    /// Inputs:
493    /// - Default options and reinstall options (needed=false).
494    ///
495    /// Output:
496    /// - `--needed` present only for fresh installs.
497    ///
498    /// Details:
499    /// - Mirrors Pacsea's `-S --needed --noconfirm` vs `-S --noconfirm` split.
500    fn pacman_install_flags() {
501        let fresh =
502            build_pacman_install(&["ripgrep"], &InstallOptions::default()).expect("build fresh");
503        assert_eq!(
504            fresh.to_shell_string(),
505            "pacman -S --needed --noconfirm -- ripgrep"
506        );
507
508        let reinstall_opts = InstallOptions {
509            needed: false,
510            ..Default::default()
511        };
512        let reinstall =
513            build_pacman_install(&["ripgrep"], &reinstall_opts).expect("build reinstall");
514        assert_eq!(
515            reinstall.to_shell_string(),
516            "pacman -S --noconfirm -- ripgrep"
517        );
518
519        let interactive = InstallOptions {
520            noconfirm: false,
521            ..Default::default()
522        };
523        let spec = build_pacman_install(&["a", "b"], &interactive).expect("build interactive");
524        assert_eq!(spec.to_shell_string(), "pacman -S --needed -- a b");
525        assert_eq!(spec.args, ["-S", "--needed", "--", "a", "b"]);
526    }
527
528    #[test]
529    /// What: Verify AUR install commands include `--aur` and helper preference.
530    ///
531    /// Inputs:
532    /// - Paru and yay helpers with default and reinstall options.
533    ///
534    /// Output:
535    /// - Flag sets matching Pacsea's `aur_install_helper_flags`.
536    ///
537    /// Details:
538    /// - Reinstall path omits `--needed`; `aur_only=false` omits `--aur`.
539    fn aur_install_flags() {
540        let spec = build_aur_install(AurHelper::Paru, &["yay-bin"], &InstallOptions::default())
541            .expect("build");
542        assert_eq!(
543            spec.to_shell_string(),
544            "paru -S --aur --needed --noconfirm -- yay-bin"
545        );
546
547        let reinstall = InstallOptions {
548            needed: false,
549            ..Default::default()
550        };
551        let spec2 = build_aur_install(AurHelper::Yay, &["yay-bin"], &reinstall).expect("build");
552        assert_eq!(
553            spec2.to_shell_string(),
554            "yay -S --aur --noconfirm -- yay-bin"
555        );
556
557        let no_aur_flag = InstallOptions {
558            aur_only: false,
559            ..Default::default()
560        };
561        let spec3 = build_aur_install(AurHelper::Paru, &["x"], &no_aur_flag).expect("build");
562        assert_eq!(spec3.to_shell_string(), "paru -S --needed --noconfirm -- x");
563    }
564
565    #[test]
566    /// What: Verify remove commands map cascade modes to pacman flags.
567    ///
568    /// Inputs:
569    /// - All three cascade modes.
570    ///
571    /// Output:
572    /// - `-R`, `-Rs`, `-Rns` respectively, with optional `--noconfirm`.
573    ///
574    /// Details:
575    /// - Matches Pacsea's `CascadeMode::flag()` semantics.
576    fn remove_cascade_modes() {
577        let basic = build_remove_command(&["pkg"], CascadeMode::Basic, false).expect("build basic");
578        assert_eq!(basic.to_shell_string(), "pacman -R -- pkg");
579
580        let cascade =
581            build_remove_command(&["pkg"], CascadeMode::Cascade, true).expect("build cascade");
582        assert_eq!(cascade.to_shell_string(), "pacman -Rs --noconfirm -- pkg");
583
584        let full = build_remove_command(&["a", "b"], CascadeMode::CascadeWithConfigs, true)
585            .expect("build full");
586        assert_eq!(full.to_shell_string(), "pacman -Rns --noconfirm -- a b");
587        assert_eq!(full.args, ["-Rns", "--noconfirm", "--", "a", "b"]);
588    }
589
590    #[test]
591    /// What: Verify update command builder for pacman and helper variants.
592    ///
593    /// Inputs:
594    /// - `None` (pacman) and `Some(helper)` variants.
595    ///
596    /// Output:
597    /// - `pacman -Syu` / `<helper> -Syu` with optional `--noconfirm`.
598    ///
599    /// Details:
600    /// - Helper variant updates both official and AUR packages.
601    /// - Operand-free update commands must not gain a `--` terminator.
602    fn update_commands() {
603        let pacman = build_update_command(None, true);
604        assert!(!pacman.args.iter().any(|arg| arg == "--"));
605        assert_eq!(pacman.to_shell_string(), "pacman -Syu --noconfirm");
606        assert_eq!(
607            build_update_command(Some(AurHelper::Paru), false).to_shell_string(),
608            "paru -Syu"
609        );
610    }
611
612    #[test]
613    /// What: Verify force-sync and AUR-only update builders.
614    ///
615    /// Inputs:
616    /// - Pacman and helper variants with and without `--noconfirm`.
617    ///
618    /// Output:
619    /// - `-Syyu` and `-Sua` flag sets matching Pacsea's system-update flow.
620    ///
621    /// Details:
622    /// - `-Sua` must target the helper only; `-Syyu` force-refreshes databases.
623    fn force_sync_and_aur_only_updates() {
624        assert_eq!(
625            build_force_sync_update_command(None, true).to_shell_string(),
626            "pacman -Syyu --noconfirm"
627        );
628        assert_eq!(
629            build_force_sync_update_command(Some(AurHelper::Yay), false).to_shell_string(),
630            "yay -Syyu"
631        );
632        assert_eq!(
633            build_aur_update_command(AurHelper::Paru, true).to_shell_string(),
634            "paru -Sua --noconfirm"
635        );
636        assert_eq!(
637            build_aur_update_command(AurHelper::Yay, false).to_shell_string(),
638            "yay -Sua"
639        );
640    }
641
642    #[test]
643    /// What: Verify runtime-fallback shell bodies match Pacsea's format.
644    ///
645    /// Inputs:
646    /// - Install and update fallback bodies with default options.
647    ///
648    /// Output:
649    /// - Parenthesized paru → yay `if/elif/else` with the exact error message,
650    ///   an operand terminator, and a failing no-helper branch.
651    ///
652    /// Details:
653    /// - Helper selection happens at shell execution time, not plan time.
654    fn shell_fallback_bodies() {
655        let body = aur_install_shell_fallback(&["yay-bin"], &InstallOptions::default())
656            .expect("build body");
657        assert_eq!(
658            body,
659            "(if command -v paru >/dev/null 2>&1; \
660             then paru -S --aur --needed --noconfirm -- yay-bin; \
661             elif command -v yay >/dev/null 2>&1; \
662             then yay -S --aur --needed --noconfirm -- yay-bin; \
663             else echo 'No AUR helper (paru/yay) found.' >&2; exit 127; fi)"
664        );
665
666        let update = aur_update_shell_fallback(false);
667        assert!(update.contains("paru -Sua;"));
668        assert!(!update.contains("-Sua --"));
669        assert!(update.contains(NO_AUR_HELPER_MESSAGE));
670        assert!(update.contains("' >&2; exit 127; fi)"));
671
672        let inj = aur_install_shell_fallback(&["bad;rm -rf /"], &InstallOptions::default());
673        assert!(matches!(
674            inj,
675            Err(ArchToolkitError::InvalidPackageName { .. })
676        ));
677
678        let empty: [&str; 0] = [];
679        assert!(matches!(
680            aur_install_shell_fallback(&empty, &InstallOptions::default()),
681            Err(ArchToolkitError::EmptyInput { .. })
682        ));
683    }
684
685    #[test]
686    /// What: Verify privilege wrapping prepends the tool and shifts the program.
687    ///
688    /// Inputs:
689    /// - A pacman spec wrapped with sudo and doas.
690    ///
691    /// Output:
692    /// - `sudo pacman ...` / `doas pacman ...`.
693    ///
694    /// Details:
695    /// - The wrapped spec must preserve all original arguments in order.
696    fn privilege_wrapping() {
697        let spec = build_pacman_install(&["vim"], &InstallOptions::default()).expect("build");
698        let sudo = with_privilege(PrivilegeTool::Sudo, spec.clone());
699        assert_eq!(
700            sudo.to_shell_string(),
701            "sudo pacman -S --needed --noconfirm -- vim"
702        );
703        assert_eq!(
704            sudo.args,
705            ["pacman", "-S", "--needed", "--noconfirm", "--", "vim"]
706        );
707        let doas = with_privilege(PrivilegeTool::Doas, spec);
708        assert_eq!(
709            doas.to_shell_string(),
710            "doas pacman -S --needed --noconfirm -- vim"
711        );
712    }
713
714    #[test]
715    /// What: Verify builders reject invalid names and empty lists.
716    ///
717    /// Inputs:
718    /// - Injection attempt and empty slice.
719    ///
720    /// Output:
721    /// - `InvalidPackageName` and `EmptyInput` errors respectively.
722    ///
723    /// Details:
724    /// - Defense-in-depth: names are validated before any command is produced.
725    /// - Leading `-`/`.` names are rejected on every builder (U5).
726    fn validation_errors() {
727        for evil in ["--help", "-S", ".hidden"] {
728            assert!(
729                build_pacman_install(&[evil], &InstallOptions::default()).is_err(),
730                "pacman install should reject {evil}"
731            );
732            assert!(
733                build_aur_install(AurHelper::Paru, &[evil], &InstallOptions::default()).is_err(),
734                "AUR install should reject {evil}"
735            );
736            assert!(
737                build_remove_command(&[evil], CascadeMode::Basic, true).is_err(),
738                "remove should reject {evil}"
739            );
740            assert!(
741                aur_install_shell_fallback(&[evil], &InstallOptions::default()).is_err(),
742                "shell fallback should reject {evil}"
743            );
744        }
745
746        let inj = build_pacman_install(&["good", "bad;rm -rf /"], &InstallOptions::default());
747        assert!(matches!(
748            inj,
749            Err(ArchToolkitError::InvalidPackageName { .. })
750        ));
751
752        let empty: [&str; 0] = [];
753        let none = build_pacman_install(&empty, &InstallOptions::default());
754        assert!(matches!(none, Err(ArchToolkitError::EmptyInput { .. })));
755
756        let aur_inj = build_aur_install(AurHelper::Paru, &["$(evil)"], &InstallOptions::default());
757        assert!(matches!(
758            aur_inj,
759            Err(ArchToolkitError::InvalidPackageName { .. })
760        ));
761
762        let rm_inj = build_remove_command(&["a b"], CascadeMode::Basic, true);
763        assert!(matches!(
764            rm_inj,
765            Err(ArchToolkitError::InvalidPackageName { .. })
766        ));
767    }
768}