Skip to main content

arch_toolkit/types/
install.rs

1//! Install-related data types for package installation command building.
2
3use serde::{Deserialize, Serialize};
4
5/// What: AUR helper used to install AUR packages.
6///
7/// Inputs:
8/// - Selected explicitly by callers or via `install::detect_aur_helper()`.
9///
10/// Output:
11/// - Determines which helper binary appears in built AUR install commands.
12///
13/// Details:
14/// - Preference order follows Pacsea: `paru` first, then `yay`.
15/// - Both helpers accept the same `-S --aur --needed --noconfirm` flag set.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub enum AurHelper {
18    /// The `paru` AUR helper (preferred).
19    Paru,
20    /// The `yay` AUR helper (fallback).
21    Yay,
22}
23
24impl AurHelper {
25    /// What: Return the shell binary name for this helper.
26    ///
27    /// Inputs: None.
28    ///
29    /// Output: `"paru"` or `"yay"`.
30    ///
31    /// Details: Used in command construction and `PATH` lookups.
32    #[must_use]
33    pub const fn binary_name(self) -> &'static str {
34        match self {
35            Self::Paru => "paru",
36            Self::Yay => "yay",
37        }
38    }
39}
40
41impl std::fmt::Display for AurHelper {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str(self.binary_name())
44    }
45}
46
47/// What: Privilege escalation tool used to run pacman as root.
48///
49/// Inputs:
50/// - Selected explicitly by callers or via `install::detect_privilege_tool()`.
51///
52/// Output:
53/// - Determines the prefix binary in privileged commands (e.g., `sudo pacman ...`).
54///
55/// Details:
56/// - Automatic detection prefers `doas`, then falls back to `sudo`, matching Pacsea.
57/// - Password handling (stdin piping, credential caching) is intentionally NOT
58///   part of arch-toolkit; it is a UI/session concern.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
60pub enum PrivilegeTool {
61    /// The `sudo` privilege tool (automatic fallback).
62    Sudo,
63    /// The `doas` privilege tool (preferred for automatic detection).
64    Doas,
65}
66
67impl PrivilegeTool {
68    /// What: Return the shell binary name for this tool.
69    ///
70    /// Inputs: None.
71    ///
72    /// Output: `"sudo"` or `"doas"`.
73    ///
74    /// Details: Used in command construction and `PATH` lookups.
75    #[must_use]
76    pub const fn binary_name(self) -> &'static str {
77        match self {
78            Self::Sudo => "sudo",
79            Self::Doas => "doas",
80        }
81    }
82}
83
84impl std::fmt::Display for PrivilegeTool {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.write_str(self.binary_name())
87    }
88}
89
90/// What: Removal cascade level controlling which pacman remove flags are used.
91///
92/// Inputs:
93/// - Passed to `install::build_remove_command()`.
94///
95/// Output:
96/// - Maps to the pacman flag sequence via `flag()`.
97///
98/// Details:
99/// - `Basic`: `pacman -R` — remove targets only.
100/// - `Cascade`: `pacman -Rs` — remove targets and now-orphaned dependencies.
101/// - `CascadeWithConfigs`: `pacman -Rns` — cascade removal and prune configuration files.
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
103pub enum CascadeMode {
104    /// `pacman -R` – remove targets only.
105    Basic,
106    /// `pacman -Rs` – remove targets and orphaned dependencies.
107    Cascade,
108    /// `pacman -Rns` – cascade removal and prune configuration files.
109    CascadeWithConfigs,
110}
111
112impl CascadeMode {
113    /// What: Return the pacman flag sequence corresponding to this cascade mode.
114    ///
115    /// Inputs: None.
116    ///
117    /// Output: `"-R"`, `"-Rs"`, or `"-Rns"`.
118    ///
119    /// Details: Used directly as the first pacman argument in remove commands.
120    #[must_use]
121    pub const fn flag(self) -> &'static str {
122        match self {
123            Self::Basic => "-R",
124            Self::Cascade => "-Rs",
125            Self::CascadeWithConfigs => "-Rns",
126        }
127    }
128
129    /// What: Short text describing the effect of this cascade mode.
130    ///
131    /// Inputs: None.
132    ///
133    /// Output: Human-readable description string.
134    ///
135    /// Details: Suitable for confirmation prompts in calling applications.
136    #[must_use]
137    pub const fn description(self) -> &'static str {
138        match self {
139            Self::Basic => "targets only",
140            Self::Cascade => "remove dependents",
141            Self::CascadeWithConfigs => "dependents + configs",
142        }
143    }
144}
145
146impl std::fmt::Display for CascadeMode {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.write_str(self.description())
149    }
150}
151
152/// What: A ready-to-run command as program plus argument vector.
153///
154/// Inputs:
155/// - Produced by the `install` module command builders.
156///
157/// Output:
158/// - Can be spawned directly (`to_command()`) or rendered for display (`to_shell_string()`).
159///
160/// Details:
161/// - Argv-style representation avoids shell interpretation entirely when spawned
162///   directly, eliminating quoting bugs.
163/// - arch-toolkit never executes commands itself; callers decide whether to run,
164///   display (dry run), or embed the command.
165#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
166pub struct CommandSpec {
167    /// The program to execute (e.g., `pacman`, `paru`, `sudo`).
168    pub program: String,
169    /// Arguments passed to the program, unquoted (argv semantics).
170    pub args: Vec<String>,
171}
172
173impl CommandSpec {
174    /// What: Create a new command spec from a program and arguments.
175    ///
176    /// Inputs:
177    /// - `program`: Program name or path.
178    /// - `args`: Argument list (unquoted).
179    ///
180    /// Output: `CommandSpec` instance.
181    ///
182    /// Details: Convenience constructor accepting anything convertible to `String`.
183    #[must_use]
184    pub fn new(
185        program: impl Into<String>,
186        args: impl IntoIterator<Item = impl Into<String>>,
187    ) -> Self {
188        Self {
189            program: program.into(),
190            args: args.into_iter().map(Into::into).collect(),
191        }
192    }
193
194    /// What: Render this command as a properly quoted POSIX shell string.
195    ///
196    /// Inputs: None.
197    ///
198    /// Output: Shell-safe string like `sudo pacman -S --needed --noconfirm 'ripgrep'`.
199    ///
200    /// Details:
201    /// - Arguments containing anything outside `[A-Za-z0-9@%^_+=:,./-]` are
202    ///   single-quoted with embedded quotes escaped.
203    /// - Intended for display (dry runs) and for passing to `bash -c` in
204    ///   terminal-spawning callers.
205    #[must_use]
206    pub fn to_shell_string(&self) -> String {
207        let mut out = shell_quote_word(&self.program);
208        for arg in &self.args {
209            out.push(' ');
210            out.push_str(&shell_quote_word(arg));
211        }
212        out
213    }
214
215    /// What: Convert this spec into a `std::process::Command` ready to spawn.
216    ///
217    /// Inputs: None.
218    ///
219    /// Output: Configured `std::process::Command` (not yet spawned).
220    ///
221    /// Details:
222    /// - No shell is involved; arguments are passed verbatim to the OS.
223    /// - Callers remain responsible for spawning and privilege context.
224    #[must_use]
225    pub fn to_command(&self) -> std::process::Command {
226        let mut cmd = std::process::Command::new(&self.program);
227        cmd.args(&self.args);
228        cmd
229    }
230}
231
232impl std::fmt::Display for CommandSpec {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.write_str(&self.to_shell_string())
235    }
236}
237
238/// What: Quote a single word for POSIX shells when necessary.
239///
240/// Inputs:
241/// - `word`: The word to quote.
242///
243/// Output:
244/// - The word unchanged if it contains only safe characters, otherwise single-quoted.
245///
246/// Details:
247/// - Empty strings render as `''`.
248/// - Embedded single quotes are escaped via the `'"'"'` sequence.
249fn shell_quote_word(word: &str) -> String {
250    let safe = !word.is_empty()
251        && word.bytes().all(|b| {
252            b.is_ascii_alphanumeric()
253                || matches!(
254                    b,
255                    b'@' | b'%' | b'^' | b'_' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
256                )
257        });
258    if safe {
259        return word.to_string();
260    }
261    crate::install::shell_single_quote(word)
262}
263
264/// What: Options controlling install command flag construction.
265///
266/// Inputs:
267/// - Passed to `build_pacman_install()`, `build_aur_install()`, and batch builders.
268///
269/// Output:
270/// - Determines which flags (`--needed`, `--noconfirm`, `--aur`) appear in commands.
271///
272/// Details:
273/// - `needed`: Pass `--needed` so up-to-date packages are skipped. Disable for
274///   explicit reinstalls (mirrors Pacsea's reinstall path).
275/// - `noconfirm`: Pass `--noconfirm` for non-interactive operation.
276/// - `aur_only`: Pass `--aur` to AUR helpers so they do not prefer a sync
277///   database (e.g., Chaotic-AUR) when the same name exists on the AUR.
278///   Ignored by pacman builders.
279#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
280pub struct InstallOptions {
281    /// Pass `--needed` (skip reinstalling up-to-date packages).
282    pub needed: bool,
283    /// Pass `--noconfirm` (non-interactive).
284    pub noconfirm: bool,
285    /// Pass `--aur` to AUR helpers (restrict resolution to the AUR).
286    pub aur_only: bool,
287}
288
289impl Default for InstallOptions {
290    fn default() -> Self {
291        Self {
292            needed: true,
293            noconfirm: true,
294            aur_only: true,
295        }
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    /// What: Verify binary names and flag mappings for the install enums.
305    ///
306    /// Inputs:
307    /// - All enum variants.
308    ///
309    /// Output:
310    /// - Expected binary names and pacman flags.
311    ///
312    /// Details:
313    /// - Guards against accidental flag drift from Pacsea semantics.
314    fn enum_mappings() {
315        assert_eq!(AurHelper::Paru.binary_name(), "paru");
316        assert_eq!(AurHelper::Yay.binary_name(), "yay");
317        assert_eq!(PrivilegeTool::Sudo.binary_name(), "sudo");
318        assert_eq!(PrivilegeTool::Doas.binary_name(), "doas");
319        assert_eq!(CascadeMode::Basic.flag(), "-R");
320        assert_eq!(CascadeMode::Cascade.flag(), "-Rs");
321        assert_eq!(CascadeMode::CascadeWithConfigs.flag(), "-Rns");
322        assert_eq!(CascadeMode::Cascade.description(), "remove dependents");
323    }
324
325    #[test]
326    /// What: Verify `CommandSpec::to_shell_string` quotes only when necessary.
327    ///
328    /// Inputs:
329    /// - Command with plain, empty, and quote-containing arguments.
330    ///
331    /// Output:
332    /// - Safe words stay bare; unsafe words are single-quoted with escapes.
333    ///
334    /// Details:
335    /// - Covers the `'"'"'` escape sequence for embedded single quotes.
336    fn command_spec_shell_string() {
337        let spec = CommandSpec::new("pacman", ["-S", "--needed", "ripgrep"]);
338        assert_eq!(spec.to_shell_string(), "pacman -S --needed ripgrep");
339
340        let tricky = CommandSpec::new("echo", ["it's", "", "a b"]);
341        assert_eq!(tricky.to_shell_string(), r#"echo 'it'"'"'s' '' 'a b'"#);
342    }
343
344    #[test]
345    /// What: Verify `CommandSpec::to_command` preserves program and args.
346    ///
347    /// Inputs:
348    /// - Simple pacman spec.
349    ///
350    /// Output:
351    /// - `std::process::Command` with matching program and argument list.
352    ///
353    /// Details:
354    /// - Confirms argv passthrough without shell interpretation.
355    fn command_spec_to_command() {
356        let spec = CommandSpec::new("pacman", ["-Qq"]);
357        let cmd = spec.to_command();
358        assert_eq!(cmd.get_program(), "pacman");
359        let args: Vec<_> = cmd.get_args().collect();
360        assert_eq!(args, ["-Qq"]);
361    }
362
363    #[test]
364    /// What: Verify `InstallOptions::default` matches Pacsea's non-interactive install path.
365    ///
366    /// Inputs:
367    /// - `InstallOptions::default()`.
368    ///
369    /// Output:
370    /// - needed=true, noconfirm=true, `aur_only=true`.
371    ///
372    /// Details:
373    /// - The default corresponds to a fresh (non-reinstall) install.
374    fn install_options_default() {
375        let opts = InstallOptions::default();
376        assert!(opts.needed);
377        assert!(opts.noconfirm);
378        assert!(opts.aur_only);
379    }
380
381    #[test]
382    /// What: Verify serde roundtrips for install types.
383    ///
384    /// Inputs:
385    /// - One value of each type serialized to JSON and back.
386    ///
387    /// Output:
388    /// - Deserialized values equal the originals.
389    ///
390    /// Details:
391    /// - Ensures the types can be persisted in caller configuration.
392    fn serde_roundtrips() {
393        let helper: AurHelper = serde_json::from_str(
394            &serde_json::to_string(&AurHelper::Paru).expect("serialize helper"),
395        )
396        .expect("deserialize helper");
397        assert_eq!(helper, AurHelper::Paru);
398        let tool: PrivilegeTool = serde_json::from_str(
399            &serde_json::to_string(&PrivilegeTool::Doas).expect("serialize tool"),
400        )
401        .expect("deserialize tool");
402        assert_eq!(tool, PrivilegeTool::Doas);
403        let mode: CascadeMode = serde_json::from_str(
404            &serde_json::to_string(&CascadeMode::Cascade).expect("serialize mode"),
405        )
406        .expect("deserialize mode");
407        assert_eq!(mode, CascadeMode::Cascade);
408        let spec: CommandSpec = serde_json::from_str(
409            &serde_json::to_string(&CommandSpec::new("x", ["y"])).expect("serialize spec"),
410        )
411        .expect("deserialize spec");
412        assert_eq!(spec, CommandSpec::new("x", ["y"]));
413    }
414}