pub mod search;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PackageManager {
Arch,
Debian,
Fedora,
Suse,
Alpine,
Void,
Solus,
}
impl PackageManager {
pub const fn executable(self) -> &'static str {
match self {
Self::Arch => "pacman",
Self::Debian => "apt-get",
Self::Fedora => "dnf",
Self::Suse => "zypper",
Self::Alpine => "apk",
Self::Void => "xbps-install",
Self::Solus => "eopkg",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Arch => "pacman (Arch family)",
Self::Debian => "APT (Debian family)",
Self::Fedora => "DNF (Fedora family)",
Self::Suse => "zypper (openSUSE family)",
Self::Alpine => "apk (Alpine family)",
Self::Void => "xbps (Void family)",
Self::Solus => "eopkg (Solus family)",
}
}
pub fn install_command(self) -> Vec<&'static str> {
match self {
Self::Arch => vec!["sudo", "pacman", "-S", "--needed"],
Self::Debian => vec!["sudo", "apt-get", "install"],
Self::Fedora => vec!["sudo", "dnf", "install"],
Self::Suse => vec!["sudo", "zypper", "install"],
Self::Alpine => vec!["sudo", "apk", "add"],
Self::Void => vec!["sudo", "xbps-install", "-S"],
Self::Solus => vec!["sudo", "eopkg", "install"],
}
}
pub fn remove_command(self) -> Vec<&'static str> {
match self {
Self::Arch => vec!["sudo", "pacman", "-R"],
Self::Debian => vec!["sudo", "apt-get", "remove"],
Self::Fedora => vec!["sudo", "dnf", "remove"],
Self::Suse => vec!["sudo", "zypper", "remove"],
Self::Alpine => vec!["sudo", "apk", "del"],
Self::Void => vec!["sudo", "xbps-remove"],
Self::Solus => vec!["sudo", "eopkg", "remove"],
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct PackageNames {
pub arch: Option<String>,
pub debian: Option<String>,
pub fedora: Option<String>,
pub suse: Option<String>,
pub alpine: Option<String>,
pub void: Option<String>,
pub solus: Option<String>,
}
impl PackageNames {
pub fn for_manager(&self, manager: PackageManager) -> Option<&str> {
match manager {
PackageManager::Arch => self.arch.as_deref(),
PackageManager::Debian => self.debian.as_deref(),
PackageManager::Fedora => self.fedora.as_deref(),
PackageManager::Suse => self.suse.as_deref(),
PackageManager::Alpine => self.alpine.as_deref(),
PackageManager::Void => self.void.as_deref(),
PackageManager::Solus => self.solus.as_deref(),
}
}
pub fn all(&self) -> [(&'static str, Option<&str>); 7] {
[
("arch", self.arch.as_deref()),
("debian", self.debian.as_deref()),
("fedora", self.fedora.as_deref()),
("suse", self.suse.as_deref()),
("alpine", self.alpine.as_deref()),
("void", self.void.as_deref()),
("solus", self.solus.as_deref()),
]
}
pub fn is_empty(&self) -> bool {
self.all().iter().all(|(_, name)| name.is_none())
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Action {
Package {
binary: Option<String>,
packages: PackageNames,
aur: Option<String>,
flatpak: Option<String>,
conflicts: Vec<String>,
removable: bool,
},
Script { file: PathBuf, interactive: bool },
Command { line: String },
Nix { action: NixAction },
Native { action: NativeAction },
Profile { entries: Vec<String> },
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NixAction {
ProfileInstall { installable: String },
HomeManagerSwitch { flake: String },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NativeAction {
SystemReport,
}
impl NativeAction {
pub const fn id(self) -> &'static str {
match self {
NativeAction::SystemReport => "system-report",
}
}
pub fn parse(id: &str) -> Option<Self> {
match id {
"system-report" => Some(NativeAction::SystemReport),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Risk {
#[default]
Low,
Medium,
High,
}
impl Risk {
pub fn from_flags(flags: &str) -> Self {
let mut risk = Risk::Low;
for flag in flags.split_whitespace() {
let level = match flag {
"D" | "K" | "SI" | "PD" | "PK" => Risk::High,
"I" | "SS" | "RP" | "MP" => Risk::Medium,
other if other.starts_with('P') => Risk::Medium,
_ => Risk::Low,
};
risk = risk.max(level);
}
risk
}
pub const fn label(self) -> &'static str {
match self {
Risk::Low => "low",
Risk::Medium => "medium",
Risk::High => "high",
}
}
}
pub fn privileged(flags: &str, action: &Action) -> bool {
matches!(action, Action::Package { .. })
|| flags
.split_whitespace()
.any(|flag| matches!(flag, "I" | "SS" | "D" | "K" | "SI") || flag.starts_with('P'))
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Precondition {
pub matches: bool,
pub check: Check,
pub values: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Check {
Environment(String),
ContainingFile(PathBuf),
FileExists,
CommandExists,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Entry {
pub id: String,
pub name: String,
pub description: String,
pub flags: String,
pub multi_select: bool,
pub writes: Vec<String>,
pub action: Action,
pub detect: Option<Detect>,
pub needs: Needs,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Needs {
pub gpu: Vec<String>,
pub repositories: Vec<String>,
pub space_mb: Option<u64>,
pub after: Vec<String>,
}
pub fn printable(text: &str) -> String {
text.chars()
.map(|character| match character {
'\t' => ' ',
character if character.is_control() => '?',
character => character,
})
.collect()
}
pub const GPU_VENDORS: [&str; 3] = ["nvidia", "amd", "intel"];
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Detect {
pub binary: Option<String>,
pub packages: PackageNames,
pub aur: Option<String>,
pub flatpak: Option<String>,
}
#[derive(Clone, Copy, Debug)]
pub struct Identity<'a> {
pub binary: Option<&'a str>,
pub packages: &'a PackageNames,
pub aur: Option<&'a str>,
pub flatpak: Option<&'a str>,
pub conflicts: &'a [String],
}
impl Entry {
pub fn identity(&self) -> Option<Identity<'_>> {
match (&self.action, &self.detect) {
(
Action::Package {
binary,
packages,
aur,
flatpak,
conflicts,
..
},
_,
) => Some(Identity {
binary: binary.as_deref(),
packages,
aur: aur.as_deref(),
flatpak: flatpak.as_deref(),
conflicts,
}),
(_, Some(detect)) => Some(Identity {
binary: detect.binary.as_deref(),
packages: &detect.packages,
aur: detect.aur.as_deref(),
flatpak: detect.flatpak.as_deref(),
conflicts: &[],
}),
(_, None) => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Group {
pub id: String,
pub name: String,
pub description: String,
pub multi_select: bool,
pub items: Vec<Item>,
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Item {
Group(Group),
Entry(Entry),
}
impl Item {
pub fn name(&self) -> &str {
match self {
Item::Group(group) => &group.name,
Item::Entry(entry) => &entry.name,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Tab {
pub id: String,
pub name: String,
pub items: Vec<Item>,
}
impl Tab {
pub fn items_at(&self, path: &[usize]) -> Option<&[Item]> {
let mut items = self.items.as_slice();
for &index in path {
match items.get(index)? {
Item::Group(group) => items = &group.items,
Item::Entry(_) => return None,
}
}
Some(items)
}
pub fn entries(&self) -> Vec<&Entry> {
fn walk<'a>(items: &'a [Item], into: &mut Vec<&'a Entry>) {
for item in items {
match item {
Item::Group(group) => walk(&group.items, into),
Item::Entry(entry) => into.push(entry),
}
}
}
let mut result = Vec::new();
walk(&self.items, &mut result);
result
}
}
pub fn valid_package_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('-')
&& name.len() <= 128
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"+._@-".contains(&b))
}
pub fn valid_script_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment.len() <= 64
&& !segment.starts_with(['-', '_'])
&& segment
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
}
pub fn valid_binary_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with(['-', '.'])
&& name.len() <= 128
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"+._-".contains(&b))
}
pub fn valid_flatpak_id(id: &str) -> bool {
let parts: Vec<&str> = id.split('.').collect();
parts.len() >= 3
&& id.len() <= 255
&& parts.iter().all(|part| {
!part.is_empty()
&& !part.starts_with('-')
&& part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
})
}
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !id.starts_with('-')
}
#[cfg(test)]
mod tests {
#[test]
fn printable_text_carries_no_control_characters() {
assert_eq!(printable("plain text"), "plain text");
assert_eq!(
printable("\u{1b}[31mred\u{1b}[0m\tend\r\u{7}"),
"?[31mred?[0m end??"
);
assert_eq!(printable("ünïcode ✓"), "ünïcode ✓");
}
use super::*;
#[test]
fn package_names_disallow_flags_and_metacharacters() {
for bad in ["", "--help", "git;reboot", "git foo", "$(cmd)", "a/b"] {
assert!(!valid_package_name(bad), "unexpectedly accepted {bad}");
}
for good in ["git", "vim-runtime", "python3.12", "libstdc++"] {
assert!(valid_package_name(good), "unexpectedly rejected {good}");
}
}
#[test]
fn identifiers_are_plain() {
assert!(valid_id("commander-ghostty"));
for bad in ["", "-x", "Caps", "a b", "a/b"] {
assert!(!valid_id(bad));
}
assert!(valid_script_segment("utility_functions"));
assert!(valid_binary_name("io.github.zen_browser.zen"));
assert!(!valid_binary_name("-rf"));
assert!(valid_flatpak_id("io.github.zen_browser.zen"));
assert!(valid_flatpak_id("org.gnome.meld"));
for bad in [
"meld",
"org.gnome",
"org..meld",
"org.gnome.-x",
"org.gnome.a b",
] {
assert!(!valid_flatpak_id(bad), "{bad}");
}
for bad in ["", "..", "_x", "A", "a.b"] {
assert!(!valid_script_segment(bad));
}
}
#[test]
fn paths_walk_groups_only() {
let entry = Entry {
id: "e".into(),
name: "E".into(),
description: String::new(),
flags: String::new(),
multi_select: true,
writes: Vec::new(),
action: Action::Command {
line: "true".into(),
},
detect: None,
needs: Default::default(),
};
assert!(!privileged("FM", &entry.action));
assert!(privileged("I FM", &entry.action));
assert_eq!(Risk::from_flags("I FM"), Risk::Medium);
assert_eq!(Risk::from_flags("PFM"), Risk::Medium);
assert_eq!(Risk::from_flags("D"), Risk::High);
assert_eq!(Risk::from_flags(""), Risk::Low);
let tab = Tab {
id: "t".into(),
name: "T".into(),
items: vec![
Item::Group(Group {
id: "g".into(),
name: "G".into(),
description: String::new(),
multi_select: true,
items: vec![Item::Entry(entry.clone())],
}),
Item::Entry(entry),
],
};
assert_eq!(tab.items_at(&[]).unwrap().len(), 2);
assert_eq!(tab.items_at(&[0]).unwrap().len(), 1);
assert!(tab.items_at(&[1]).is_none());
assert!(tab.items_at(&[5]).is_none());
assert_eq!(tab.entries().len(), 2);
}
}