arch-toolkit 0.3.0

Complete Rust toolkit for Arch Linux package management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
//! Command builders for package installation, removal, and updates.
//!
//! All builders return [`CommandSpec`] values — arch-toolkit never executes
//! commands. Dry runs are the caller's choice: display `spec.to_shell_string()`
//! instead of spawning.

use crate::error::Result;
use crate::types::install::{AurHelper, CascadeMode, CommandSpec, InstallOptions, PrivilegeTool};

use super::shell::validate_package_names;

/// Message printed by shell-fallback bodies when no AUR helper is installed.
///
/// Matches Pacsea's terminal-install error text so migrating callers keep
/// byte-identical output.
pub const NO_AUR_HELPER_MESSAGE: &str = "No AUR helper (paru/yay) found.";

/// Exit status used by shell-fallback bodies when no AUR helper is installed.
///
/// `127` is the POSIX convention for "command not found", so callers can
/// distinguish a missing helper from a failed package operation.
const NO_AUR_HELPER_STATUS: u8 = 127;

/// POSIX option terminator placed between flags and package operands.
///
/// Prevents pacman, paru, and yay from parsing any operand as an option.
const OPERAND_TERMINATOR: &str = "--";

/// What: Build a pacman install command for official repository packages.
///
/// Inputs:
/// - `names`: Package names to install (validated against the safe-name allowlist).
/// - `options`: Flag options (`needed`, `noconfirm`; `aur_only` is ignored).
///
/// Output:
/// - `Ok(CommandSpec)` like `pacman -S --needed --noconfirm -- <names...>`.
///
/// Details:
/// - Does NOT prefix a privilege tool; use [`with_privilege`] for that.
/// - Omit `--needed` (set `options.needed = false`) for explicit reinstalls,
///   mirroring Pacsea's reinstall path.
/// - A `--` operand terminator separates flags from package names so pacman can
///   never reinterpret an operand as an option (defense in depth on top of
///   name validation).
///
/// # Errors
///
/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
/// or `ArchToolkitError::EmptyInput` when `names` is empty.
///
/// # Example
///
/// ```
/// use arch_toolkit::install::build_pacman_install;
/// use arch_toolkit::types::install::InstallOptions;
///
/// let spec = build_pacman_install(&["ripgrep", "fd"], &InstallOptions::default())?;
/// assert_eq!(spec.to_shell_string(), "pacman -S --needed --noconfirm -- ripgrep fd");
/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
/// ```
pub fn build_pacman_install<S: AsRef<str>>(
    names: &[S],
    options: &InstallOptions,
) -> Result<CommandSpec> {
    validate_non_empty(names, "pacman install")?;
    validate_package_names(names, "pacman install")?;
    let mut args = vec!["-S".to_string()];
    if options.needed {
        args.push("--needed".to_string());
    }
    if options.noconfirm {
        args.push("--noconfirm".to_string());
    }
    push_operands(&mut args, names);
    Ok(CommandSpec {
        program: "pacman".to_string(),
        args,
    })
}

/// What: Build an AUR helper install command for AUR packages.
///
/// Inputs:
/// - `helper`: The AUR helper to use (from caller config or [`super::detect_aur_helper`]).
/// - `names`: AUR package names to install (validated).
/// - `options`: Flag options (`needed`, `noconfirm`, `aur_only`).
///
/// Output:
/// - `Ok(CommandSpec)` like `paru -S --aur --needed --noconfirm -- <names...>`.
///
/// Details:
/// - `--aur` (when `options.aur_only`) ensures helpers do not prefer a sync
///   database (e.g., Chaotic-AUR) when the same name exists on the AUR —
///   matching Pacsea's `aur_install_helper_flags`.
/// - A `--` operand terminator separates flags from package names; paru and yay
///   forward it to pacman-style operand parsing.
/// - AUR helpers must NOT run under sudo; they invoke sudo themselves for the
///   pacman step. Do not wrap the result in [`with_privilege`].
///
/// # Errors
///
/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
/// or `ArchToolkitError::EmptyInput` when `names` is empty.
///
/// # Example
///
/// ```
/// use arch_toolkit::install::build_aur_install;
/// use arch_toolkit::types::install::{AurHelper, InstallOptions};
///
/// let spec = build_aur_install(AurHelper::Paru, &["yay-bin"], &InstallOptions::default())?;
/// assert_eq!(spec.to_shell_string(), "paru -S --aur --needed --noconfirm -- yay-bin");
/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
/// ```
pub fn build_aur_install<S: AsRef<str>>(
    helper: AurHelper,
    names: &[S],
    options: &InstallOptions,
) -> Result<CommandSpec> {
    validate_non_empty(names, "AUR install")?;
    validate_package_names(names, "AUR install")?;
    let mut args = vec!["-S".to_string()];
    if options.aur_only {
        args.push("--aur".to_string());
    }
    if options.needed {
        args.push("--needed".to_string());
    }
    if options.noconfirm {
        args.push("--noconfirm".to_string());
    }
    push_operands(&mut args, names);
    Ok(CommandSpec {
        program: helper.binary_name().to_string(),
        args,
    })
}

/// What: Build a pacman remove command with the requested cascade level.
///
/// Inputs:
/// - `names`: Package names to remove (validated).
/// - `cascade`: Cascade level (`-R`, `-Rs`, or `-Rns`).
/// - `noconfirm`: Pass `--noconfirm` for non-interactive removal.
///
/// Output:
/// - `Ok(CommandSpec)` like `pacman -Rns --noconfirm -- <names...>`.
///
/// Details:
/// - Does NOT prefix a privilege tool; use [`with_privilege`] for that.
/// - Cascade semantics ported from Pacsea's `CascadeMode`.
/// - A `--` operand terminator separates flags from package names.
///
/// # Errors
///
/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
/// or `ArchToolkitError::EmptyInput` when `names` is empty.
///
/// # Example
///
/// ```
/// use arch_toolkit::install::build_remove_command;
/// use arch_toolkit::types::install::CascadeMode;
///
/// let spec = build_remove_command(&["ripgrep"], CascadeMode::CascadeWithConfigs, true)?;
/// assert_eq!(spec.to_shell_string(), "pacman -Rns --noconfirm -- ripgrep");
/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
/// ```
pub fn build_remove_command<S: AsRef<str>>(
    names: &[S],
    cascade: CascadeMode,
    noconfirm: bool,
) -> Result<CommandSpec> {
    validate_non_empty(names, "remove")?;
    validate_package_names(names, "remove")?;
    let mut args = vec![cascade.flag().to_string()];
    if noconfirm {
        args.push("--noconfirm".to_string());
    }
    push_operands(&mut args, names);
    Ok(CommandSpec {
        program: "pacman".to_string(),
        args,
    })
}

/// What: Append the `--` operand terminator followed by validated package names.
///
/// Inputs:
/// - `args`: Argument vector already containing every flag for the command.
/// - `names`: Validated package names to place after the terminator.
///
/// Output:
/// - Side effect: `args` gains `--` and then one entry per package name.
///
/// Details:
/// - Called only by builders that take package operands; operand-free commands
///   such as `-Syu`, `-Syyu`, and `-Sua` must never gain a terminator.
/// - Callers must validate names first; this helper performs no validation.
fn push_operands<S: AsRef<str>>(args: &mut Vec<String>, names: &[S]) {
    args.push(OPERAND_TERMINATOR.to_string());
    args.extend(names.iter().map(|n| n.as_ref().to_string()));
}

/// What: Build a full-system update command.
///
/// Inputs:
/// - `helper`: When `Some`, use the AUR helper (`paru -Syu`) which updates both
///   official and AUR packages. When `None`, use plain `pacman -Syu`.
/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
///
/// Output:
/// - `CommandSpec` like `paru -Syu --noconfirm` or `pacman -Syu --noconfirm`.
///
/// Details:
/// - The pacman variant requires privilege wrapping ([`with_privilege`]);
///   helper variants must not be wrapped (helpers call sudo themselves).
///
/// # Example
///
/// ```
/// use arch_toolkit::install::build_update_command;
/// use arch_toolkit::types::install::AurHelper;
///
/// let pacman = build_update_command(None, true);
/// assert_eq!(pacman.to_shell_string(), "pacman -Syu --noconfirm");
///
/// let helper = build_update_command(Some(AurHelper::Yay), false);
/// assert_eq!(helper.to_shell_string(), "yay -Syu");
/// ```
#[must_use]
pub fn build_update_command(helper: Option<AurHelper>, noconfirm: bool) -> CommandSpec {
    let program = helper.map_or("pacman", AurHelper::binary_name).to_string();
    let mut args = vec!["-Syu".to_string()];
    if noconfirm {
        args.push("--noconfirm".to_string());
    }
    CommandSpec { program, args }
}

/// What: Build a full-system update command that force-refreshes sync databases.
///
/// Inputs:
/// - `helper`: When `Some`, use the AUR helper; when `None`, plain pacman.
/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
///
/// Output:
/// - `CommandSpec` like `pacman -Syyu --noconfirm`.
///
/// Details:
/// - `-Syyu` re-downloads all sync databases even when they appear up to date;
///   use after mirror changes (mirrors Pacsea's force-sync update option).
/// - Same privilege rules as [`build_update_command`].
///
/// # Example
///
/// ```
/// use arch_toolkit::install::build_force_sync_update_command;
///
/// let spec = build_force_sync_update_command(None, true);
/// assert_eq!(spec.to_shell_string(), "pacman -Syyu --noconfirm");
/// ```
#[must_use]
pub fn build_force_sync_update_command(helper: Option<AurHelper>, noconfirm: bool) -> CommandSpec {
    let program = helper.map_or("pacman", AurHelper::binary_name).to_string();
    let mut args = vec!["-Syyu".to_string()];
    if noconfirm {
        args.push("--noconfirm".to_string());
    }
    CommandSpec { program, args }
}

/// What: Build an AUR-only update command (`-Sua`).
///
/// Inputs:
/// - `helper`: The AUR helper to run the update with.
/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
///
/// Output:
/// - `CommandSpec` like `paru -Sua --noconfirm`.
///
/// Details:
/// - Updates AUR packages only, leaving official packages to a separate
///   `pacman -Syu` step — mirroring Pacsea's split system-update flow where
///   the AUR step runs conditionally after the pacman step succeeds.
/// - Must NOT be wrapped in [`with_privilege`]; helpers escalate internally.
///
/// # Example
///
/// ```
/// use arch_toolkit::install::build_aur_update_command;
/// use arch_toolkit::types::install::AurHelper;
///
/// let spec = build_aur_update_command(AurHelper::Paru, true);
/// assert_eq!(spec.to_shell_string(), "paru -Sua --noconfirm");
/// ```
#[must_use]
pub fn build_aur_update_command(helper: AurHelper, noconfirm: bool) -> CommandSpec {
    let mut args = vec!["-Sua".to_string()];
    if noconfirm {
        args.push("--noconfirm".to_string());
    }
    CommandSpec {
        program: helper.binary_name().to_string(),
        args,
    }
}

/// What: Build a shell body that installs AUR packages with runtime helper fallback.
///
/// Inputs:
/// - `names`: AUR package names to install (validated).
/// - `options`: Flag options (`needed`, `noconfirm`, `aur_only`).
///
/// Output:
/// - `Ok(String)` with a POSIX shell snippet that picks `paru`, then `yay`,
///   at execution time, or prints [`NO_AUR_HELPER_MESSAGE`].
///
/// Details:
/// - Unlike [`build_aur_install`], helper selection happens inside the spawned
///   shell (the terminal's `PATH`), not in the calling process — matching
///   Pacsea's `aur_install_body`. Prefer this when the command runs in an
///   external terminal whose environment may differ from the caller's.
/// - Names pass the same strict validation as all builders, so interpolating
///   them into the shell string is safe without quoting. A `--` operand
///   terminator is emitted before the names for defense in depth.
/// - When neither helper exists the body writes [`NO_AUR_HELPER_MESSAGE`] to
///   stderr and exits the subshell with status 127, so callers see a failure
///   instead of a successful no-op.
///
/// # Errors
///
/// Returns `ArchToolkitError::InvalidPackageName` when a name fails validation,
/// or `ArchToolkitError::EmptyInput` when `names` is empty.
///
/// # Example
///
/// ```
/// use arch_toolkit::install::aur_install_shell_fallback;
/// use arch_toolkit::types::install::InstallOptions;
///
/// let body = aur_install_shell_fallback(&["yay-bin"], &InstallOptions::default())?;
/// assert!(body.contains("if command -v paru >/dev/null 2>&1; then paru"));
/// assert!(body.contains("elif command -v yay >/dev/null 2>&1; then yay"));
/// assert!(body.contains("--noconfirm -- yay-bin"));
/// assert!(body.contains("exit 127"));
/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
/// ```
pub fn aur_install_shell_fallback<S: AsRef<str>>(
    names: &[S],
    options: &InstallOptions,
) -> Result<String> {
    validate_non_empty(names, "AUR install")?;
    validate_package_names(names, "AUR install")?;
    let mut flags = String::from("-S");
    if options.aur_only {
        flags.push_str(" --aur");
    }
    if options.needed {
        flags.push_str(" --needed");
    }
    if options.noconfirm {
        flags.push_str(" --noconfirm");
    }
    let joined = names
        .iter()
        .map(std::convert::AsRef::as_ref)
        .collect::<Vec<_>>()
        .join(" ");
    Ok(helper_fallback_body(&format!(
        "{flags} {OPERAND_TERMINATOR} {joined}"
    )))
}

/// What: Build a shell body that updates AUR packages with runtime helper fallback.
///
/// Inputs:
/// - `noconfirm`: Pass `--noconfirm` for non-interactive updates.
///
/// Output:
/// - A POSIX shell snippet running `paru -Sua` / `yay -Sua`, or printing
///   [`NO_AUR_HELPER_MESSAGE`] when neither helper exists.
///
/// Details:
/// - Shell-time counterpart of [`build_aur_update_command`], for callers that
///   spawn the update in an external terminal (Pacsea's system-update flow).
/// - Takes no package operands, so no `--` terminator is emitted.
/// - The no-helper branch writes to stderr and exits the subshell with 127.
///
/// # Example
///
/// ```
/// use arch_toolkit::install::aur_update_shell_fallback;
///
/// let body = aur_update_shell_fallback(true);
/// assert!(body.contains("paru -Sua --noconfirm"));
/// ```
#[must_use]
pub fn aur_update_shell_fallback(noconfirm: bool) -> String {
    let flags = if noconfirm {
        "-Sua --noconfirm"
    } else {
        "-Sua"
    };
    helper_fallback_body(flags)
}

/// What: Wrap helper arguments in the paru → yay runtime-fallback shell body.
///
/// Inputs:
/// - `tail`: Flags and package names appended to the chosen helper.
///
/// Output:
/// - Parenthesized `if/elif/else` snippet matching Pacsea's `aur_install_body`,
///   with a failing no-helper branch.
///
/// Details:
/// - The `else` branch writes [`NO_AUR_HELPER_MESSAGE`] to stderr and runs
///   `exit 127`. Because the body is wrapped in `( ... )`, only the fallback
///   subshell terminates; the caller observes a non-zero status.
fn helper_fallback_body(tail: &str) -> String {
    format!(
        "(if command -v paru >/dev/null 2>&1; then paru {tail}; \
         elif command -v yay >/dev/null 2>&1; then yay {tail}; \
         else echo '{NO_AUR_HELPER_MESSAGE}' >&2; exit {NO_AUR_HELPER_STATUS}; fi)"
    )
}

/// What: Wrap a command with a privilege escalation tool prefix.
///
/// Inputs:
/// - `tool`: The privilege tool (`sudo` or `doas`).
/// - `spec`: The command to wrap.
///
/// Output:
/// - New `CommandSpec` like `sudo pacman -S ...`.
///
/// Details:
/// - Pure transformation: the original program becomes the first argument.
/// - Do NOT wrap AUR helper commands; helpers escalate internally.
/// - Password handling (stdin piping, askpass) is intentionally out of scope.
///
/// # Example
///
/// ```
/// use arch_toolkit::install::{build_update_command, with_privilege};
/// use arch_toolkit::types::install::PrivilegeTool;
///
/// let spec = with_privilege(PrivilegeTool::Sudo, build_update_command(None, true));
/// assert_eq!(spec.to_shell_string(), "sudo pacman -Syu --noconfirm");
/// ```
#[must_use]
pub fn with_privilege(tool: PrivilegeTool, spec: CommandSpec) -> CommandSpec {
    let mut args = Vec::with_capacity(spec.args.len() + 1);
    args.push(spec.program);
    args.extend(spec.args);
    CommandSpec {
        program: tool.binary_name().to_string(),
        args,
    }
}

/// What: Reject empty name lists with a descriptive error.
///
/// Inputs:
/// - `names`: The name list to check.
/// - `context`: Operation context for the error message.
///
/// Output:
/// - `Ok(())` when non-empty, `Err(ArchToolkitError::EmptyInput)` otherwise.
///
/// Details:
/// - Prevents building commands like `pacman -S --noconfirm` with no targets.
fn validate_non_empty<S: AsRef<str>>(names: &[S], context: &str) -> Result<()> {
    if names.is_empty() {
        return Err(crate::error::ArchToolkitError::EmptyInput {
            field: "names".to_string(),
            message: format!("at least one package name is required for {context}"),
        });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::ArchToolkitError;

    #[test]
    /// What: Verify pacman install flag combinations (fresh install vs reinstall).
    ///
    /// Inputs:
    /// - Default options and reinstall options (needed=false).
    ///
    /// Output:
    /// - `--needed` present only for fresh installs.
    ///
    /// Details:
    /// - Mirrors Pacsea's `-S --needed --noconfirm` vs `-S --noconfirm` split.
    fn pacman_install_flags() {
        let fresh =
            build_pacman_install(&["ripgrep"], &InstallOptions::default()).expect("build fresh");
        assert_eq!(
            fresh.to_shell_string(),
            "pacman -S --needed --noconfirm -- ripgrep"
        );

        let reinstall_opts = InstallOptions {
            needed: false,
            ..Default::default()
        };
        let reinstall =
            build_pacman_install(&["ripgrep"], &reinstall_opts).expect("build reinstall");
        assert_eq!(
            reinstall.to_shell_string(),
            "pacman -S --noconfirm -- ripgrep"
        );

        let interactive = InstallOptions {
            noconfirm: false,
            ..Default::default()
        };
        let spec = build_pacman_install(&["a", "b"], &interactive).expect("build interactive");
        assert_eq!(spec.to_shell_string(), "pacman -S --needed -- a b");
        assert_eq!(spec.args, ["-S", "--needed", "--", "a", "b"]);
    }

    #[test]
    /// What: Verify AUR install commands include `--aur` and helper preference.
    ///
    /// Inputs:
    /// - Paru and yay helpers with default and reinstall options.
    ///
    /// Output:
    /// - Flag sets matching Pacsea's `aur_install_helper_flags`.
    ///
    /// Details:
    /// - Reinstall path omits `--needed`; `aur_only=false` omits `--aur`.
    fn aur_install_flags() {
        let spec = build_aur_install(AurHelper::Paru, &["yay-bin"], &InstallOptions::default())
            .expect("build");
        assert_eq!(
            spec.to_shell_string(),
            "paru -S --aur --needed --noconfirm -- yay-bin"
        );

        let reinstall = InstallOptions {
            needed: false,
            ..Default::default()
        };
        let spec2 = build_aur_install(AurHelper::Yay, &["yay-bin"], &reinstall).expect("build");
        assert_eq!(
            spec2.to_shell_string(),
            "yay -S --aur --noconfirm -- yay-bin"
        );

        let no_aur_flag = InstallOptions {
            aur_only: false,
            ..Default::default()
        };
        let spec3 = build_aur_install(AurHelper::Paru, &["x"], &no_aur_flag).expect("build");
        assert_eq!(spec3.to_shell_string(), "paru -S --needed --noconfirm -- x");
    }

    #[test]
    /// What: Verify remove commands map cascade modes to pacman flags.
    ///
    /// Inputs:
    /// - All three cascade modes.
    ///
    /// Output:
    /// - `-R`, `-Rs`, `-Rns` respectively, with optional `--noconfirm`.
    ///
    /// Details:
    /// - Matches Pacsea's `CascadeMode::flag()` semantics.
    fn remove_cascade_modes() {
        let basic = build_remove_command(&["pkg"], CascadeMode::Basic, false).expect("build basic");
        assert_eq!(basic.to_shell_string(), "pacman -R -- pkg");

        let cascade =
            build_remove_command(&["pkg"], CascadeMode::Cascade, true).expect("build cascade");
        assert_eq!(cascade.to_shell_string(), "pacman -Rs --noconfirm -- pkg");

        let full = build_remove_command(&["a", "b"], CascadeMode::CascadeWithConfigs, true)
            .expect("build full");
        assert_eq!(full.to_shell_string(), "pacman -Rns --noconfirm -- a b");
        assert_eq!(full.args, ["-Rns", "--noconfirm", "--", "a", "b"]);
    }

    #[test]
    /// What: Verify update command builder for pacman and helper variants.
    ///
    /// Inputs:
    /// - `None` (pacman) and `Some(helper)` variants.
    ///
    /// Output:
    /// - `pacman -Syu` / `<helper> -Syu` with optional `--noconfirm`.
    ///
    /// Details:
    /// - Helper variant updates both official and AUR packages.
    /// - Operand-free update commands must not gain a `--` terminator.
    fn update_commands() {
        let pacman = build_update_command(None, true);
        assert!(!pacman.args.iter().any(|arg| arg == "--"));
        assert_eq!(pacman.to_shell_string(), "pacman -Syu --noconfirm");
        assert_eq!(
            build_update_command(Some(AurHelper::Paru), false).to_shell_string(),
            "paru -Syu"
        );
    }

    #[test]
    /// What: Verify force-sync and AUR-only update builders.
    ///
    /// Inputs:
    /// - Pacman and helper variants with and without `--noconfirm`.
    ///
    /// Output:
    /// - `-Syyu` and `-Sua` flag sets matching Pacsea's system-update flow.
    ///
    /// Details:
    /// - `-Sua` must target the helper only; `-Syyu` force-refreshes databases.
    fn force_sync_and_aur_only_updates() {
        assert_eq!(
            build_force_sync_update_command(None, true).to_shell_string(),
            "pacman -Syyu --noconfirm"
        );
        assert_eq!(
            build_force_sync_update_command(Some(AurHelper::Yay), false).to_shell_string(),
            "yay -Syyu"
        );
        assert_eq!(
            build_aur_update_command(AurHelper::Paru, true).to_shell_string(),
            "paru -Sua --noconfirm"
        );
        assert_eq!(
            build_aur_update_command(AurHelper::Yay, false).to_shell_string(),
            "yay -Sua"
        );
    }

    #[test]
    /// What: Verify runtime-fallback shell bodies match Pacsea's format.
    ///
    /// Inputs:
    /// - Install and update fallback bodies with default options.
    ///
    /// Output:
    /// - Parenthesized paru → yay `if/elif/else` with the exact error message,
    ///   an operand terminator, and a failing no-helper branch.
    ///
    /// Details:
    /// - Helper selection happens at shell execution time, not plan time.
    fn shell_fallback_bodies() {
        let body = aur_install_shell_fallback(&["yay-bin"], &InstallOptions::default())
            .expect("build body");
        assert_eq!(
            body,
            "(if command -v paru >/dev/null 2>&1; \
             then paru -S --aur --needed --noconfirm -- yay-bin; \
             elif command -v yay >/dev/null 2>&1; \
             then yay -S --aur --needed --noconfirm -- yay-bin; \
             else echo 'No AUR helper (paru/yay) found.' >&2; exit 127; fi)"
        );

        let update = aur_update_shell_fallback(false);
        assert!(update.contains("paru -Sua;"));
        assert!(!update.contains("-Sua --"));
        assert!(update.contains(NO_AUR_HELPER_MESSAGE));
        assert!(update.contains("' >&2; exit 127; fi)"));

        let inj = aur_install_shell_fallback(&["bad;rm -rf /"], &InstallOptions::default());
        assert!(matches!(
            inj,
            Err(ArchToolkitError::InvalidPackageName { .. })
        ));

        let empty: [&str; 0] = [];
        assert!(matches!(
            aur_install_shell_fallback(&empty, &InstallOptions::default()),
            Err(ArchToolkitError::EmptyInput { .. })
        ));
    }

    #[test]
    /// What: Verify privilege wrapping prepends the tool and shifts the program.
    ///
    /// Inputs:
    /// - A pacman spec wrapped with sudo and doas.
    ///
    /// Output:
    /// - `sudo pacman ...` / `doas pacman ...`.
    ///
    /// Details:
    /// - The wrapped spec must preserve all original arguments in order.
    fn privilege_wrapping() {
        let spec = build_pacman_install(&["vim"], &InstallOptions::default()).expect("build");
        let sudo = with_privilege(PrivilegeTool::Sudo, spec.clone());
        assert_eq!(
            sudo.to_shell_string(),
            "sudo pacman -S --needed --noconfirm -- vim"
        );
        assert_eq!(
            sudo.args,
            ["pacman", "-S", "--needed", "--noconfirm", "--", "vim"]
        );
        let doas = with_privilege(PrivilegeTool::Doas, spec);
        assert_eq!(
            doas.to_shell_string(),
            "doas pacman -S --needed --noconfirm -- vim"
        );
    }

    #[test]
    /// What: Verify builders reject invalid names and empty lists.
    ///
    /// Inputs:
    /// - Injection attempt and empty slice.
    ///
    /// Output:
    /// - `InvalidPackageName` and `EmptyInput` errors respectively.
    ///
    /// Details:
    /// - Defense-in-depth: names are validated before any command is produced.
    /// - Leading `-`/`.` names are rejected on every builder (U5).
    fn validation_errors() {
        for evil in ["--help", "-S", ".hidden"] {
            assert!(
                build_pacman_install(&[evil], &InstallOptions::default()).is_err(),
                "pacman install should reject {evil}"
            );
            assert!(
                build_aur_install(AurHelper::Paru, &[evil], &InstallOptions::default()).is_err(),
                "AUR install should reject {evil}"
            );
            assert!(
                build_remove_command(&[evil], CascadeMode::Basic, true).is_err(),
                "remove should reject {evil}"
            );
            assert!(
                aur_install_shell_fallback(&[evil], &InstallOptions::default()).is_err(),
                "shell fallback should reject {evil}"
            );
        }

        let inj = build_pacman_install(&["good", "bad;rm -rf /"], &InstallOptions::default());
        assert!(matches!(
            inj,
            Err(ArchToolkitError::InvalidPackageName { .. })
        ));

        let empty: [&str; 0] = [];
        let none = build_pacman_install(&empty, &InstallOptions::default());
        assert!(matches!(none, Err(ArchToolkitError::EmptyInput { .. })));

        let aur_inj = build_aur_install(AurHelper::Paru, &["$(evil)"], &InstallOptions::default());
        assert!(matches!(
            aur_inj,
            Err(ArchToolkitError::InvalidPackageName { .. })
        ));

        let rm_inj = build_remove_command(&["a b"], CascadeMode::Basic, true);
        assert!(matches!(
            rm_inj,
            Err(ArchToolkitError::InvalidPackageName { .. })
        ));
    }
}