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}