Skip to main content

helixforge_core/
lib.rs

1//! Shared data types: the catalog tree of tabs, groups and entries, and the
2//! actions an entry performs. Nothing here touches the system.
3
4pub mod search;
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// The package manager families that package entries can name.
9#[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    /// The command that installs packages, without the package names.
47    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    /// The command that removes packages, without the package names.
60    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/// Package names per family; a missing family means the entry is not
74/// available there.
75#[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    /// Every family with its package name, for listings and validation.
100    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/// What an entry does when run: the operation model shared by every
118/// interface. The engine turns these into explicit plans.
119// Catalog records, built once when the catalog loads; their size does not matter.
120#[allow(clippy::large_enum_variant)]
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub enum Action {
123    /// Install one package: the family's native package, else on Arch the
124    /// AUR package through paru or yay, else the Flatpak from Flathub.
125    Package {
126        /// A command the package provides. Whether the entry is installed is
127        /// asked of the package manager and Flatpak first; the command only
128        /// recognises an install made some other way (Nix, a download) when
129        /// none of its packages, and none it conflicts with, is installed.
130        binary: Option<String>,
131        packages: PackageNames,
132        aur: Option<String>,
133        flatpak: Option<String>,
134        /// Packages of the same family this one replaces, such as wine for
135        /// wine-staging: when one is installed, the plan says so.
136        conflicts: Vec<String>,
137        /// Whether the entry offers removal as well.
138        removable: bool,
139    },
140    /// Run a script from its own directory. Interactive scripts need a
141    /// terminal for their prompts; the others could run unattended.
142    Script { file: PathBuf, interactive: bool },
143    /// Run an inline shell command.
144    Command { line: String },
145    /// A Nix operation.
146    Nix { action: NixAction },
147    /// An operation implemented in Rust by the engine itself.
148    Native { action: NativeAction },
149    /// A reviewable list of other entries, run in order.
150    Profile { entries: Vec<String> },
151}
152
153#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum NixAction {
156    /// `nix profile install <installable>`.
157    ProfileInstall { installable: String },
158    /// `home-manager switch --flake <flake>`.
159    HomeManagerSwitch { flake: String },
160}
161
162#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum NativeAction {
165    /// Print what HelixForge knows about the machine.
166    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/// How much an entry can affect the system, derived from its flags.
185#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
186#[serde(rename_all = "lowercase")]
187pub enum Risk {
188    /// Reads or user files only.
189    #[default]
190    Low,
191    /// Installs packages or changes system files with privileges.
192    Medium,
193    /// Disk, kernel or whole-system changes.
194    High,
195}
196
197impl Risk {
198    /// `D`, `K` and `SI` are high; anything privileged or installing is
199    /// medium; the rest is low.
200    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
223/// Whether an entry needs elevated privileges, from its flags and kind.
224pub 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/// A condition that hides an entry on machines it does not apply to.
232#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
233pub struct Precondition {
234    /// Whether the values must match (true) or must not match (false).
235    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    /// An environment variable whose value is one of the values.
244    Environment(String),
245    /// A file containing every value.
246    ContainingFile(PathBuf),
247    /// Every value is an existing file.
248    FileExists,
249    /// Every value is a command on the PATH.
250    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    /// Short codes for what the entry does, shown in the flags column.
259    pub flags: String,
260    /// Whether the entry may be queued with others.
261    pub multi_select: bool,
262    /// User paths the entry replaces, with `~` for the home directory, so the
263    /// engine can refuse to run over files another tool manages.
264    pub writes: Vec<String>,
265    pub action: Action,
266    /// What a script or command entry installs, so its status can be shown;
267    /// package entries carry this in their action.
268    pub detect: Option<Detect>,
269    /// What it needs from the machine, checked before a run.
270    pub needs: Needs,
271}
272
273/// What an entry needs from the machine beyond its tools.
274#[derive(Clone, Debug, Default, Eq, PartialEq)]
275pub struct Needs {
276    /// The graphics vendors it is for (nvidia, amd, intel); any one will do.
277    pub gpu: Vec<String>,
278    /// Arch repositories that must be enabled, such as multilib.
279    pub repositories: Vec<String>,
280    /// Free space it needs on the root file system beyond its packages, in MiB.
281    pub space_mb: Option<u64>,
282    /// Entries that run before it when both are queued.
283    pub after: Vec<String>,
284}
285
286/// Text from outside HelixForge (journal lines, file and unit names, error
287/// messages) made safe to show in a terminal: control characters, which could
288/// start escape sequences, become `?`; tabs become spaces.
289pub 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
299/// The graphics vendors `Needs::gpu` may name.
300pub const GPU_VENDORS: [&str; 3] = ["nvidia", "amd", "intel"];
301
302/// The packages and command that show a non-package entry's software.
303#[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/// What an entry installs, from its package action or its `detect` table.
312#[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    /// What the entry installs, if it says.
323    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// Catalog records, built once when the catalog loads; their size does not matter.
364#[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    /// The items at a path of group indexes, starting at the tab's own list.
389    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    /// Every entry in the tab, depth first in catalog order.
401    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
416/// Reject values that could become flags or shell syntax in a command line.
417pub 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
426/// One directory or file name in a script id: lowercase, digits, hyphens and
427/// underscores, as the scripts carried over from Linutil are named.
428pub 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
437/// An executable name to look for on the PATH, including Flatpak export names
438/// such as `org.gnome.meld`.
439pub 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
448/// A Flatpak application id: at least three dot-separated parts of letters,
449/// digits, hyphens and underscores.
450pub 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
463/// Entry, group and tab identifiers: lowercase, digits and hyphens.
464pub 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}