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 pub architectures: Vec<String>,
339}
340
341impl Needs {
342 pub fn runs_on(&self, architecture: &str) -> bool {
344 self.architectures.is_empty()
345 || self.architectures.iter().any(|found| found == architecture)
346 }
347}
348
349pub fn printable(text: &str) -> String {
353 text.chars()
354 .map(|character| match character {
355 '\t' => ' ',
356 character if character.is_control() => '?',
357 character => character,
358 })
359 .collect()
360}
361
362pub const GPU_VENDORS: [&str; 3] = ["nvidia", "amd", "intel"];
364
365pub const ARCHITECTURES: [&str; 2] = ["x86_64", "aarch64"];
368
369#[derive(Clone, Debug, Default, Eq, PartialEq)]
371pub struct Detect {
372 pub binary: Option<String>,
373 pub packages: PackageNames,
374 pub aur: Option<String>,
375 pub flatpak: Option<String>,
376}
377
378#[derive(Clone, Copy, Debug)]
380pub struct Identity<'a> {
381 pub binary: Option<&'a str>,
382 pub packages: &'a PackageNames,
383 pub aur: Option<&'a str>,
384 pub flatpak: Option<&'a str>,
385 pub conflicts: &'a [String],
386}
387
388impl Entry {
389 pub fn identity(&self) -> Option<Identity<'_>> {
391 match (&self.action, &self.detect) {
392 (
393 Action::Package {
394 binary,
395 packages,
396 aur,
397 flatpak,
398 conflicts,
399 ..
400 },
401 _,
402 ) => Some(Identity {
403 binary: binary.as_deref(),
404 packages,
405 aur: aur.as_deref(),
406 flatpak: flatpak.as_deref(),
407 conflicts,
408 }),
409 (_, Some(detect)) => Some(Identity {
410 binary: detect.binary.as_deref(),
411 packages: &detect.packages,
412 aur: detect.aur.as_deref(),
413 flatpak: detect.flatpak.as_deref(),
414 conflicts: &[],
415 }),
416 (_, None) => None,
417 }
418 }
419}
420
421#[derive(Clone, Debug, Eq, PartialEq)]
422pub struct Group {
423 pub id: String,
424 pub name: String,
425 pub description: String,
426 pub multi_select: bool,
427 pub items: Vec<Item>,
428}
429
430#[allow(clippy::large_enum_variant)]
432#[derive(Clone, Debug, Eq, PartialEq)]
433pub enum Item {
434 Group(Group),
435 Entry(Entry),
436}
437
438impl Item {
439 pub fn name(&self) -> &str {
440 match self {
441 Item::Group(group) => &group.name,
442 Item::Entry(entry) => &entry.name,
443 }
444 }
445}
446
447#[derive(Clone, Debug, Eq, PartialEq)]
448pub struct Tab {
449 pub id: String,
450 pub name: String,
451 pub items: Vec<Item>,
452}
453
454impl Tab {
455 pub fn items_at(&self, path: &[usize]) -> Option<&[Item]> {
457 let mut items = self.items.as_slice();
458 for &index in path {
459 match items.get(index)? {
460 Item::Group(group) => items = &group.items,
461 Item::Entry(_) => return None,
462 }
463 }
464 Some(items)
465 }
466
467 pub fn entries(&self) -> Vec<&Entry> {
469 fn walk<'a>(items: &'a [Item], into: &mut Vec<&'a Entry>) {
470 for item in items {
471 match item {
472 Item::Group(group) => walk(&group.items, into),
473 Item::Entry(entry) => into.push(entry),
474 }
475 }
476 }
477 let mut result = Vec::new();
478 walk(&self.items, &mut result);
479 result
480 }
481}
482
483pub fn valid_package_name(name: &str) -> bool {
485 !name.is_empty()
486 && !name.starts_with('-')
487 && name.len() <= 128
488 && name
489 .bytes()
490 .all(|b| b.is_ascii_alphanumeric() || b"+._@-".contains(&b))
491}
492
493pub fn valid_script_segment(segment: &str) -> bool {
496 !segment.is_empty()
497 && segment.len() <= 64
498 && !segment.starts_with(['-', '_'])
499 && segment
500 .bytes()
501 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
502}
503
504pub fn valid_binary_name(name: &str) -> bool {
507 !name.is_empty()
508 && !name.starts_with(['-', '.'])
509 && name.len() <= 128
510 && name
511 .bytes()
512 .all(|b| b.is_ascii_alphanumeric() || b"+._-".contains(&b))
513}
514
515pub fn valid_flatpak_id(id: &str) -> bool {
518 let parts: Vec<&str> = id.split('.').collect();
519 parts.len() >= 3
520 && id.len() <= 255
521 && parts.iter().all(|part| {
522 !part.is_empty()
523 && !part.starts_with('-')
524 && part
525 .bytes()
526 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
527 })
528}
529
530pub fn valid_id(id: &str) -> bool {
532 !id.is_empty()
533 && id.len() <= 64
534 && id
535 .bytes()
536 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
537 && !id.starts_with('-')
538}
539
540#[cfg(test)]
541mod tests {
542
543 #[test]
544 fn printable_text_carries_no_control_characters() {
545 assert_eq!(printable("plain text"), "plain text");
546 assert_eq!(
547 printable("\u{1b}[31mred\u{1b}[0m\tend\r\u{7}"),
548 "?[31mred?[0m end??"
549 );
550 assert_eq!(printable("ünïcode ✓"), "ünïcode ✓");
551 }
552
553 use super::*;
554
555 #[test]
556 fn package_names_disallow_flags_and_metacharacters() {
557 for bad in ["", "--help", "git;reboot", "git foo", "$(cmd)", "a/b"] {
558 assert!(!valid_package_name(bad), "unexpectedly accepted {bad}");
559 }
560 for good in ["git", "vim-runtime", "python3.12", "libstdc++"] {
561 assert!(valid_package_name(good), "unexpectedly rejected {good}");
562 }
563 }
564
565 #[test]
566 fn identifiers_are_plain() {
567 assert!(valid_id("commander-ghostty"));
568 for bad in ["", "-x", "Caps", "a b", "a/b"] {
569 assert!(!valid_id(bad));
570 }
571 assert!(valid_script_segment("utility_functions"));
572 assert!(valid_binary_name("io.github.zen_browser.zen"));
573 assert!(!valid_binary_name("-rf"));
574 assert!(valid_flatpak_id("io.github.zen_browser.zen"));
575 assert!(valid_flatpak_id("org.gnome.meld"));
576 for bad in [
577 "meld",
578 "org.gnome",
579 "org..meld",
580 "org.gnome.-x",
581 "org.gnome.a b",
582 ] {
583 assert!(!valid_flatpak_id(bad), "{bad}");
584 }
585 for bad in ["", "..", "_x", "A", "a.b"] {
586 assert!(!valid_script_segment(bad));
587 }
588 }
589
590 #[test]
591 fn paths_walk_groups_only() {
592 let entry = Entry {
593 id: "e".into(),
594 name: "E".into(),
595 description: String::new(),
596 flags: String::new(),
597 multi_select: true,
598 writes: Vec::new(),
599 action: Action::Command {
600 line: "true".into(),
601 },
602 detect: None,
603 needs: Default::default(),
604 effects: Default::default(),
605 };
606 assert!(!privileged("FM", &entry.action));
607 assert!(privileged("I FM", &entry.action));
608 assert_eq!(Risk::from_flags("I FM"), Risk::Medium);
609 assert_eq!(Risk::from_flags("PFM"), Risk::Medium);
610 assert_eq!(Risk::from_flags("D"), Risk::High);
611 assert_eq!(Risk::from_flags(""), Risk::Low);
612 let tab = Tab {
613 id: "t".into(),
614 name: "T".into(),
615 items: vec![
616 Item::Group(Group {
617 id: "g".into(),
618 name: "G".into(),
619 description: String::new(),
620 multi_select: true,
621 items: vec![Item::Entry(entry.clone())],
622 }),
623 Item::Entry(entry),
624 ],
625 };
626 assert_eq!(tab.items_at(&[]).unwrap().len(), 2);
627 assert_eq!(tab.items_at(&[0]).unwrap().len(), 1);
628 assert!(tab.items_at(&[1]).is_none());
629 assert!(tab.items_at(&[5]).is_none());
630 assert_eq!(tab.entries().len(), 2);
631 }
632}