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}
272
273#[derive(Clone, Debug, Default, Eq, PartialEq)]
275pub struct Needs {
276 pub gpu: Vec<String>,
278 pub repositories: Vec<String>,
280 pub space_mb: Option<u64>,
282 pub after: Vec<String>,
284}
285
286pub fn printable(text: &str) -> String {
290 text.chars()
291 .map(|character| match character {
292 '\t' => ' ',
293 character if character.is_control() => '?',
294 character => character,
295 })
296 .collect()
297}
298
299pub const GPU_VENDORS: [&str; 3] = ["nvidia", "amd", "intel"];
301
302#[derive(Clone, Debug, Default, Eq, PartialEq)]
304pub struct Detect {
305 pub binary: Option<String>,
306 pub packages: PackageNames,
307 pub aur: Option<String>,
308 pub flatpak: Option<String>,
309}
310
311#[derive(Clone, Copy, Debug)]
313pub struct Identity<'a> {
314 pub binary: Option<&'a str>,
315 pub packages: &'a PackageNames,
316 pub aur: Option<&'a str>,
317 pub flatpak: Option<&'a str>,
318 pub conflicts: &'a [String],
319}
320
321impl Entry {
322 pub fn identity(&self) -> Option<Identity<'_>> {
324 match (&self.action, &self.detect) {
325 (
326 Action::Package {
327 binary,
328 packages,
329 aur,
330 flatpak,
331 conflicts,
332 ..
333 },
334 _,
335 ) => Some(Identity {
336 binary: binary.as_deref(),
337 packages,
338 aur: aur.as_deref(),
339 flatpak: flatpak.as_deref(),
340 conflicts,
341 }),
342 (_, Some(detect)) => Some(Identity {
343 binary: detect.binary.as_deref(),
344 packages: &detect.packages,
345 aur: detect.aur.as_deref(),
346 flatpak: detect.flatpak.as_deref(),
347 conflicts: &[],
348 }),
349 (_, None) => None,
350 }
351 }
352}
353
354#[derive(Clone, Debug, Eq, PartialEq)]
355pub struct Group {
356 pub id: String,
357 pub name: String,
358 pub description: String,
359 pub multi_select: bool,
360 pub items: Vec<Item>,
361}
362
363#[allow(clippy::large_enum_variant)]
365#[derive(Clone, Debug, Eq, PartialEq)]
366pub enum Item {
367 Group(Group),
368 Entry(Entry),
369}
370
371impl Item {
372 pub fn name(&self) -> &str {
373 match self {
374 Item::Group(group) => &group.name,
375 Item::Entry(entry) => &entry.name,
376 }
377 }
378}
379
380#[derive(Clone, Debug, Eq, PartialEq)]
381pub struct Tab {
382 pub id: String,
383 pub name: String,
384 pub items: Vec<Item>,
385}
386
387impl Tab {
388 pub fn items_at(&self, path: &[usize]) -> Option<&[Item]> {
390 let mut items = self.items.as_slice();
391 for &index in path {
392 match items.get(index)? {
393 Item::Group(group) => items = &group.items,
394 Item::Entry(_) => return None,
395 }
396 }
397 Some(items)
398 }
399
400 pub fn entries(&self) -> Vec<&Entry> {
402 fn walk<'a>(items: &'a [Item], into: &mut Vec<&'a Entry>) {
403 for item in items {
404 match item {
405 Item::Group(group) => walk(&group.items, into),
406 Item::Entry(entry) => into.push(entry),
407 }
408 }
409 }
410 let mut result = Vec::new();
411 walk(&self.items, &mut result);
412 result
413 }
414}
415
416pub fn valid_package_name(name: &str) -> bool {
418 !name.is_empty()
419 && !name.starts_with('-')
420 && name.len() <= 128
421 && name
422 .bytes()
423 .all(|b| b.is_ascii_alphanumeric() || b"+._@-".contains(&b))
424}
425
426pub fn valid_script_segment(segment: &str) -> bool {
429 !segment.is_empty()
430 && segment.len() <= 64
431 && !segment.starts_with(['-', '_'])
432 && segment
433 .bytes()
434 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
435}
436
437pub fn valid_binary_name(name: &str) -> bool {
440 !name.is_empty()
441 && !name.starts_with(['-', '.'])
442 && name.len() <= 128
443 && name
444 .bytes()
445 .all(|b| b.is_ascii_alphanumeric() || b"+._-".contains(&b))
446}
447
448pub fn valid_flatpak_id(id: &str) -> bool {
451 let parts: Vec<&str> = id.split('.').collect();
452 parts.len() >= 3
453 && id.len() <= 255
454 && parts.iter().all(|part| {
455 !part.is_empty()
456 && !part.starts_with('-')
457 && part
458 .bytes()
459 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
460 })
461}
462
463pub fn valid_id(id: &str) -> bool {
465 !id.is_empty()
466 && id.len() <= 64
467 && id
468 .bytes()
469 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
470 && !id.starts_with('-')
471}
472
473#[cfg(test)]
474mod tests {
475
476 #[test]
477 fn printable_text_carries_no_control_characters() {
478 assert_eq!(printable("plain text"), "plain text");
479 assert_eq!(
480 printable("\u{1b}[31mred\u{1b}[0m\tend\r\u{7}"),
481 "?[31mred?[0m end??"
482 );
483 assert_eq!(printable("ünïcode ✓"), "ünïcode ✓");
484 }
485
486 use super::*;
487
488 #[test]
489 fn package_names_disallow_flags_and_metacharacters() {
490 for bad in ["", "--help", "git;reboot", "git foo", "$(cmd)", "a/b"] {
491 assert!(!valid_package_name(bad), "unexpectedly accepted {bad}");
492 }
493 for good in ["git", "vim-runtime", "python3.12", "libstdc++"] {
494 assert!(valid_package_name(good), "unexpectedly rejected {good}");
495 }
496 }
497
498 #[test]
499 fn identifiers_are_plain() {
500 assert!(valid_id("commander-ghostty"));
501 for bad in ["", "-x", "Caps", "a b", "a/b"] {
502 assert!(!valid_id(bad));
503 }
504 assert!(valid_script_segment("utility_functions"));
505 assert!(valid_binary_name("io.github.zen_browser.zen"));
506 assert!(!valid_binary_name("-rf"));
507 assert!(valid_flatpak_id("io.github.zen_browser.zen"));
508 assert!(valid_flatpak_id("org.gnome.meld"));
509 for bad in [
510 "meld",
511 "org.gnome",
512 "org..meld",
513 "org.gnome.-x",
514 "org.gnome.a b",
515 ] {
516 assert!(!valid_flatpak_id(bad), "{bad}");
517 }
518 for bad in ["", "..", "_x", "A", "a.b"] {
519 assert!(!valid_script_segment(bad));
520 }
521 }
522
523 #[test]
524 fn paths_walk_groups_only() {
525 let entry = Entry {
526 id: "e".into(),
527 name: "E".into(),
528 description: String::new(),
529 flags: String::new(),
530 multi_select: true,
531 writes: Vec::new(),
532 action: Action::Command {
533 line: "true".into(),
534 },
535 detect: None,
536 needs: Default::default(),
537 };
538 assert!(!privileged("FM", &entry.action));
539 assert!(privileged("I FM", &entry.action));
540 assert_eq!(Risk::from_flags("I FM"), Risk::Medium);
541 assert_eq!(Risk::from_flags("PFM"), Risk::Medium);
542 assert_eq!(Risk::from_flags("D"), Risk::High);
543 assert_eq!(Risk::from_flags(""), Risk::Low);
544 let tab = Tab {
545 id: "t".into(),
546 name: "T".into(),
547 items: vec![
548 Item::Group(Group {
549 id: "g".into(),
550 name: "G".into(),
551 description: String::new(),
552 multi_select: true,
553 items: vec![Item::Entry(entry.clone())],
554 }),
555 Item::Entry(entry),
556 ],
557 };
558 assert_eq!(tab.items_at(&[]).unwrap().len(), 2);
559 assert_eq!(tab.items_at(&[0]).unwrap().len(), 1);
560 assert!(tab.items_at(&[1]).is_none());
561 assert!(tab.items_at(&[5]).is_none());
562 assert_eq!(tab.entries().len(), 2);
563 }
564}