1pub mod search;
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum PackageManager {
12 Arch,
13 Debian,
14 Fedora,
15 Suse,
16 Alpine,
17 Void,
18 Solus,
19}
20
21impl PackageManager {
22 pub const fn executable(self) -> &'static str {
23 match self {
24 Self::Arch => "pacman",
25 Self::Debian => "apt-get",
26 Self::Fedora => "dnf",
27 Self::Suse => "zypper",
28 Self::Alpine => "apk",
29 Self::Void => "xbps-install",
30 Self::Solus => "eopkg",
31 }
32 }
33
34 pub const fn label(self) -> &'static str {
35 match self {
36 Self::Arch => "pacman (Arch family)",
37 Self::Debian => "APT (Debian family)",
38 Self::Fedora => "DNF (Fedora family)",
39 Self::Suse => "zypper (openSUSE family)",
40 Self::Alpine => "apk (Alpine family)",
41 Self::Void => "xbps (Void family)",
42 Self::Solus => "eopkg (Solus family)",
43 }
44 }
45
46 pub fn install_command(self) -> Vec<&'static str> {
48 match self {
49 Self::Arch => vec!["sudo", "pacman", "-S", "--needed"],
50 Self::Debian => vec!["sudo", "apt-get", "install"],
51 Self::Fedora => vec!["sudo", "dnf", "install"],
52 Self::Suse => vec!["sudo", "zypper", "install"],
53 Self::Alpine => vec!["sudo", "apk", "add"],
54 Self::Void => vec!["sudo", "xbps-install", "-S"],
55 Self::Solus => vec!["sudo", "eopkg", "install"],
56 }
57 }
58
59 pub fn remove_command(self) -> Vec<&'static str> {
61 match self {
62 Self::Arch => vec!["sudo", "pacman", "-R"],
63 Self::Debian => vec!["sudo", "apt-get", "remove"],
64 Self::Fedora => vec!["sudo", "dnf", "remove"],
65 Self::Suse => vec!["sudo", "zypper", "remove"],
66 Self::Alpine => vec!["sudo", "apk", "del"],
67 Self::Void => vec!["sudo", "xbps-remove"],
68 Self::Solus => vec!["sudo", "eopkg", "remove"],
69 }
70 }
71}
72
73#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
76pub struct PackageNames {
77 pub arch: Option<String>,
78 pub debian: Option<String>,
79 pub fedora: Option<String>,
80 pub suse: Option<String>,
81 pub alpine: Option<String>,
82 pub void: Option<String>,
83 pub solus: Option<String>,
84}
85
86impl PackageNames {
87 pub fn for_manager(&self, manager: PackageManager) -> Option<&str> {
88 match manager {
89 PackageManager::Arch => self.arch.as_deref(),
90 PackageManager::Debian => self.debian.as_deref(),
91 PackageManager::Fedora => self.fedora.as_deref(),
92 PackageManager::Suse => self.suse.as_deref(),
93 PackageManager::Alpine => self.alpine.as_deref(),
94 PackageManager::Void => self.void.as_deref(),
95 PackageManager::Solus => self.solus.as_deref(),
96 }
97 }
98
99 pub fn all(&self) -> [(&'static str, Option<&str>); 7] {
101 [
102 ("arch", self.arch.as_deref()),
103 ("debian", self.debian.as_deref()),
104 ("fedora", self.fedora.as_deref()),
105 ("suse", self.suse.as_deref()),
106 ("alpine", self.alpine.as_deref()),
107 ("void", self.void.as_deref()),
108 ("solus", self.solus.as_deref()),
109 ]
110 }
111
112 pub fn is_empty(&self) -> bool {
113 self.all().iter().all(|(_, name)| name.is_none())
114 }
115}
116
117#[allow(clippy::large_enum_variant)]
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub enum Action {
123 Package {
126 binary: Option<String>,
131 packages: PackageNames,
132 aur: Option<String>,
133 flatpak: Option<String>,
134 conflicts: Vec<String>,
137 removable: bool,
139 },
140 Script { file: PathBuf, interactive: bool },
143 Command { line: String },
145 Nix { action: NixAction },
147 Native { action: NativeAction },
149 Profile { entries: Vec<String> },
151}
152
153#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum NixAction {
156 ProfileInstall { installable: String },
158 HomeManagerSwitch { flake: String },
160}
161
162#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum NativeAction {
165 SystemReport,
167}
168
169impl NativeAction {
170 pub const fn id(self) -> &'static str {
171 match self {
172 NativeAction::SystemReport => "system-report",
173 }
174 }
175
176 pub fn parse(id: &str) -> Option<Self> {
177 match id {
178 "system-report" => Some(NativeAction::SystemReport),
179 _ => None,
180 }
181 }
182}
183
184#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
186#[serde(rename_all = "lowercase")]
187pub enum Risk {
188 #[default]
190 Low,
191 Medium,
193 High,
195}
196
197impl Risk {
198 pub fn from_flags(flags: &str) -> Self {
201 let mut risk = Risk::Low;
202 for flag in flags.split_whitespace() {
203 let level = match flag {
204 "D" | "K" | "SI" | "PD" | "PK" => Risk::High,
205 "I" | "SS" | "RP" | "MP" => Risk::Medium,
206 other if other.starts_with('P') => Risk::Medium,
207 _ => Risk::Low,
208 };
209 risk = risk.max(level);
210 }
211 risk
212 }
213
214 pub const fn label(self) -> &'static str {
215 match self {
216 Risk::Low => "low",
217 Risk::Medium => "medium",
218 Risk::High => "high",
219 }
220 }
221}
222
223pub fn privileged(flags: &str, action: &Action) -> bool {
225 matches!(action, Action::Package { .. })
226 || flags
227 .split_whitespace()
228 .any(|flag| matches!(flag, "I" | "SS" | "D" | "K" | "SI") || flag.starts_with('P'))
229}
230
231#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
233pub struct Precondition {
234 pub matches: bool,
236 pub check: Check,
237 pub values: Vec<String>,
238}
239
240#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum Check {
243 Environment(String),
245 ContainingFile(PathBuf),
247 FileExists,
249 CommandExists,
251}
252
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct Entry {
255 pub id: String,
256 pub name: String,
257 pub description: String,
258 pub flags: String,
260 pub multi_select: bool,
262 pub writes: Vec<String>,
265 pub action: Action,
266 pub detect: Option<Detect>,
269 pub needs: Needs,
271 pub effects: Effects,
273}
274
275#[derive(Clone, Debug, Default, Eq, PartialEq)]
279pub struct Effects {
280 pub files: Vec<FileEffect>,
281 pub packages: Vec<String>,
283 pub services: Vec<String>,
285 pub takes_effect: Option<String>,
287 pub unknown: Vec<String>,
289}
290
291impl Effects {
292 pub fn is_empty(&self) -> bool {
293 self.files.is_empty()
294 && self.packages.is_empty()
295 && self.services.is_empty()
296 && self.takes_effect.is_none()
297 && self.unknown.is_empty()
298 }
299}
300
301#[derive(Clone, Debug, Default, Eq, PartialEq)]
303pub struct FileEffect {
304 pub path: String,
306 pub change: String,
308 pub render: Vec<String>,
311 pub when: Option<String>,
313}
314
315pub const RENDERERS: [&str; 5] = [
317 "grub",
318 "grub-cmdline",
319 "sddm",
320 "mkinitcpio-hooks",
321 "limine-cmdline",
322];
323
324#[derive(Clone, Debug, Default, Eq, PartialEq)]
326pub struct Needs {
327 pub gpu: Vec<String>,
329 pub repositories: Vec<String>,
331 pub space_mb: Option<u64>,
333 pub after: Vec<String>,
335}
336
337pub fn printable(text: &str) -> String {
341 text.chars()
342 .map(|character| match character {
343 '\t' => ' ',
344 character if character.is_control() => '?',
345 character => character,
346 })
347 .collect()
348}
349
350pub const GPU_VENDORS: [&str; 3] = ["nvidia", "amd", "intel"];
352
353#[derive(Clone, Debug, Default, Eq, PartialEq)]
355pub struct Detect {
356 pub binary: Option<String>,
357 pub packages: PackageNames,
358 pub aur: Option<String>,
359 pub flatpak: Option<String>,
360}
361
362#[derive(Clone, Copy, Debug)]
364pub struct Identity<'a> {
365 pub binary: Option<&'a str>,
366 pub packages: &'a PackageNames,
367 pub aur: Option<&'a str>,
368 pub flatpak: Option<&'a str>,
369 pub conflicts: &'a [String],
370}
371
372impl Entry {
373 pub fn identity(&self) -> Option<Identity<'_>> {
375 match (&self.action, &self.detect) {
376 (
377 Action::Package {
378 binary,
379 packages,
380 aur,
381 flatpak,
382 conflicts,
383 ..
384 },
385 _,
386 ) => Some(Identity {
387 binary: binary.as_deref(),
388 packages,
389 aur: aur.as_deref(),
390 flatpak: flatpak.as_deref(),
391 conflicts,
392 }),
393 (_, Some(detect)) => Some(Identity {
394 binary: detect.binary.as_deref(),
395 packages: &detect.packages,
396 aur: detect.aur.as_deref(),
397 flatpak: detect.flatpak.as_deref(),
398 conflicts: &[],
399 }),
400 (_, None) => None,
401 }
402 }
403}
404
405#[derive(Clone, Debug, Eq, PartialEq)]
406pub struct Group {
407 pub id: String,
408 pub name: String,
409 pub description: String,
410 pub multi_select: bool,
411 pub items: Vec<Item>,
412}
413
414#[allow(clippy::large_enum_variant)]
416#[derive(Clone, Debug, Eq, PartialEq)]
417pub enum Item {
418 Group(Group),
419 Entry(Entry),
420}
421
422impl Item {
423 pub fn name(&self) -> &str {
424 match self {
425 Item::Group(group) => &group.name,
426 Item::Entry(entry) => &entry.name,
427 }
428 }
429}
430
431#[derive(Clone, Debug, Eq, PartialEq)]
432pub struct Tab {
433 pub id: String,
434 pub name: String,
435 pub items: Vec<Item>,
436}
437
438impl Tab {
439 pub fn items_at(&self, path: &[usize]) -> Option<&[Item]> {
441 let mut items = self.items.as_slice();
442 for &index in path {
443 match items.get(index)? {
444 Item::Group(group) => items = &group.items,
445 Item::Entry(_) => return None,
446 }
447 }
448 Some(items)
449 }
450
451 pub fn entries(&self) -> Vec<&Entry> {
453 fn walk<'a>(items: &'a [Item], into: &mut Vec<&'a Entry>) {
454 for item in items {
455 match item {
456 Item::Group(group) => walk(&group.items, into),
457 Item::Entry(entry) => into.push(entry),
458 }
459 }
460 }
461 let mut result = Vec::new();
462 walk(&self.items, &mut result);
463 result
464 }
465}
466
467pub fn valid_package_name(name: &str) -> bool {
469 !name.is_empty()
470 && !name.starts_with('-')
471 && name.len() <= 128
472 && name
473 .bytes()
474 .all(|b| b.is_ascii_alphanumeric() || b"+._@-".contains(&b))
475}
476
477pub fn valid_script_segment(segment: &str) -> bool {
480 !segment.is_empty()
481 && segment.len() <= 64
482 && !segment.starts_with(['-', '_'])
483 && segment
484 .bytes()
485 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
486}
487
488pub fn valid_binary_name(name: &str) -> bool {
491 !name.is_empty()
492 && !name.starts_with(['-', '.'])
493 && name.len() <= 128
494 && name
495 .bytes()
496 .all(|b| b.is_ascii_alphanumeric() || b"+._-".contains(&b))
497}
498
499pub fn valid_flatpak_id(id: &str) -> bool {
502 let parts: Vec<&str> = id.split('.').collect();
503 parts.len() >= 3
504 && id.len() <= 255
505 && parts.iter().all(|part| {
506 !part.is_empty()
507 && !part.starts_with('-')
508 && part
509 .bytes()
510 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
511 })
512}
513
514pub fn valid_id(id: &str) -> bool {
516 !id.is_empty()
517 && id.len() <= 64
518 && id
519 .bytes()
520 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
521 && !id.starts_with('-')
522}
523
524#[cfg(test)]
525mod tests {
526
527 #[test]
528 fn printable_text_carries_no_control_characters() {
529 assert_eq!(printable("plain text"), "plain text");
530 assert_eq!(
531 printable("\u{1b}[31mred\u{1b}[0m\tend\r\u{7}"),
532 "?[31mred?[0m end??"
533 );
534 assert_eq!(printable("ünïcode ✓"), "ünïcode ✓");
535 }
536
537 use super::*;
538
539 #[test]
540 fn package_names_disallow_flags_and_metacharacters() {
541 for bad in ["", "--help", "git;reboot", "git foo", "$(cmd)", "a/b"] {
542 assert!(!valid_package_name(bad), "unexpectedly accepted {bad}");
543 }
544 for good in ["git", "vim-runtime", "python3.12", "libstdc++"] {
545 assert!(valid_package_name(good), "unexpectedly rejected {good}");
546 }
547 }
548
549 #[test]
550 fn identifiers_are_plain() {
551 assert!(valid_id("commander-ghostty"));
552 for bad in ["", "-x", "Caps", "a b", "a/b"] {
553 assert!(!valid_id(bad));
554 }
555 assert!(valid_script_segment("utility_functions"));
556 assert!(valid_binary_name("io.github.zen_browser.zen"));
557 assert!(!valid_binary_name("-rf"));
558 assert!(valid_flatpak_id("io.github.zen_browser.zen"));
559 assert!(valid_flatpak_id("org.gnome.meld"));
560 for bad in [
561 "meld",
562 "org.gnome",
563 "org..meld",
564 "org.gnome.-x",
565 "org.gnome.a b",
566 ] {
567 assert!(!valid_flatpak_id(bad), "{bad}");
568 }
569 for bad in ["", "..", "_x", "A", "a.b"] {
570 assert!(!valid_script_segment(bad));
571 }
572 }
573
574 #[test]
575 fn paths_walk_groups_only() {
576 let entry = Entry {
577 id: "e".into(),
578 name: "E".into(),
579 description: String::new(),
580 flags: String::new(),
581 multi_select: true,
582 writes: Vec::new(),
583 action: Action::Command {
584 line: "true".into(),
585 },
586 detect: None,
587 needs: Default::default(),
588 effects: Default::default(),
589 };
590 assert!(!privileged("FM", &entry.action));
591 assert!(privileged("I FM", &entry.action));
592 assert_eq!(Risk::from_flags("I FM"), Risk::Medium);
593 assert_eq!(Risk::from_flags("PFM"), Risk::Medium);
594 assert_eq!(Risk::from_flags("D"), Risk::High);
595 assert_eq!(Risk::from_flags(""), Risk::Low);
596 let tab = Tab {
597 id: "t".into(),
598 name: "T".into(),
599 items: vec![
600 Item::Group(Group {
601 id: "g".into(),
602 name: "G".into(),
603 description: String::new(),
604 multi_select: true,
605 items: vec![Item::Entry(entry.clone())],
606 }),
607 Item::Entry(entry),
608 ],
609 };
610 assert_eq!(tab.items_at(&[]).unwrap().len(), 2);
611 assert_eq!(tab.items_at(&[0]).unwrap().len(), 1);
612 assert!(tab.items_at(&[1]).is_none());
613 assert!(tab.items_at(&[5]).is_none());
614 assert_eq!(tab.entries().len(), 2);
615 }
616}