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    /// What a script entry is expected to change, for the change preview.
272    pub effects: Effects,
273}
274
275/// What an entry's script is expected to change, declared in its catalog
276/// file, so the change preview can show it before it runs. A script may do
277/// more than this; the preview says so.
278#[derive(Clone, Debug, Default, Eq, PartialEq)]
279pub struct Effects {
280    pub files: Vec<FileEffect>,
281    /// Packages the script installs besides any of its own identity.
282    pub packages: Vec<String>,
283    /// Services it enables, disables, starts or restarts.
284    pub services: Vec<String>,
285    /// When the change takes effect, such as "at the next reboot".
286    pub takes_effect: Option<String>,
287    /// What cannot be known before it runs.
288    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/// A file a script changes.
302#[derive(Clone, Debug, Default, Eq, PartialEq)]
303pub struct FileEffect {
304    /// Absolute, or under `~/`.
305    pub path: String,
306    /// What happens to it.
307    pub change: String,
308    /// The arguments to the installers' `configure.py` that print its new
309    /// contents without writing anything, when there is such a renderer.
310    pub render: Vec<String>,
311    /// When it applies, such as "the machine boots with GRUB".
312    pub when: Option<String>,
313}
314
315/// The `configure.py` actions that only print a file's new contents.
316pub const RENDERERS: [&str; 5] = [
317    "grub",
318    "grub-cmdline",
319    "sddm",
320    "mkinitcpio-hooks",
321    "limine-cmdline",
322];
323
324/// What an entry needs from the machine beyond its tools.
325#[derive(Clone, Debug, Default, Eq, PartialEq)]
326pub struct Needs {
327    /// The graphics vendors it is for (nvidia, amd, intel); any one will do.
328    pub gpu: Vec<String>,
329    /// Arch repositories that must be enabled, such as multilib.
330    pub repositories: Vec<String>,
331    /// Free space it needs on the root file system beyond its packages, in MiB.
332    pub space_mb: Option<u64>,
333    /// Entries that run before it when both are queued.
334    pub after: Vec<String>,
335}
336
337/// Text from outside HelixForge (journal lines, file and unit names, error
338/// messages) made safe to show in a terminal: control characters, which could
339/// start escape sequences, become `?`; tabs become spaces.
340pub 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
350/// The graphics vendors `Needs::gpu` may name.
351pub const GPU_VENDORS: [&str; 3] = ["nvidia", "amd", "intel"];
352
353/// The packages and command that show a non-package entry's software.
354#[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/// What an entry installs, from its package action or its `detect` table.
363#[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    /// What the entry installs, if it says.
374    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// Catalog records, built once when the catalog loads; their size does not matter.
415#[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    /// The items at a path of group indexes, starting at the tab's own list.
440    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    /// Every entry in the tab, depth first in catalog order.
452    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
467/// Reject values that could become flags or shell syntax in a command line.
468pub 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
477/// One directory or file name in a script id: lowercase, digits, hyphens and
478/// underscores, as the scripts carried over from Linutil are named.
479pub 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
488/// An executable name to look for on the PATH, including Flatpak export names
489/// such as `org.gnome.meld`.
490pub 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
499/// A Flatpak application id: at least three dot-separated parts of letters,
500/// digits, hyphens and underscores.
501pub 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
514/// Entry, group and tab identifiers: lowercase, digits and hyphens.
515pub 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}