#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceInfo {
RemoteScript {
url: String,
},
LocalScript {
path: String,
},
Package {
name: String,
},
Flatpak {
app_id: String,
},
Snap {
name: String,
},
System {
manager: String,
name: String,
},
App {
query: String,
},
Desktop {
id: String,
},
}
pub const SYSTEM_MANAGERS: &[&str] = &[
"apt", "dnf", "yum", "pacman", "apk", "zypper", "xbps", "eopkg", "nix", "brew", "pkg", "emerge",
];
pub fn parse_source(source: &str) -> SourceInfo {
let trimmed = source.trim();
if trimmed.contains('|')
&& let Some(url) = extract_url_from_pipe(trimmed)
{
return SourceInfo::RemoteScript { url };
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return SourceInfo::RemoteScript {
url: trimmed.to_string(),
};
}
if let Some((prefix, value)) = trimmed.split_once(':')
&& !value.is_empty()
{
let manager = normalize_manager(prefix);
if let Some(m) = manager {
return SourceInfo::System {
manager: m.to_string(),
name: value.trim().to_string(),
};
}
match prefix.to_ascii_lowercase().as_str() {
"flatpak" => {
return SourceInfo::Flatpak {
app_id: value.trim().to_string(),
};
}
"snap" => {
return SourceInfo::Snap {
name: value.trim().to_string(),
};
}
"app" => {
return SourceInfo::App {
query: value.trim().to_string(),
};
}
"desktop" => {
return SourceInfo::Desktop {
id: value.trim().to_string(),
};
}
_ => {}
}
}
if trimmed.starts_with("./") || trimmed.starts_with('/') || trimmed.ends_with(".sh") {
return SourceInfo::LocalScript {
path: trimmed.to_string(),
};
}
if trimmed.ends_with(".desktop") && !trimmed.contains(' ') && !trimmed.contains('/') {
return SourceInfo::Desktop {
id: trimmed.to_string(),
};
}
if looks_like_flatpak_id(trimmed) {
return SourceInfo::Flatpak {
app_id: trimmed.to_string(),
};
}
SourceInfo::Package {
name: trimmed.to_string(),
}
}
pub fn is_wrap_source(info: &SourceInfo) -> bool {
matches!(
info,
SourceInfo::Flatpak { .. }
| SourceInfo::Snap { .. }
| SourceInfo::System { .. }
| SourceInfo::App { .. }
| SourceInfo::Desktop { .. }
)
}
pub fn source_kind(info: &SourceInfo) -> &'static str {
match info {
SourceInfo::RemoteScript { .. } => "remote script",
SourceInfo::LocalScript { .. } => "local script",
SourceInfo::Package { .. } => "package",
SourceInfo::Flatpak { .. } => "flatpak",
SourceInfo::Snap { .. } => "snap",
SourceInfo::System { .. } => "system",
SourceInfo::App { .. } => "app",
SourceInfo::Desktop { .. } => "desktop",
}
}
fn normalize_manager(prefix: &str) -> Option<&'static str> {
let lower = prefix.to_ascii_lowercase();
let canonical = match lower.as_str() {
"apt-get" => "apt",
"yum" => "dnf",
"xbps-install" => "xbps",
"nix-env" => "nix",
_ => lower.as_str(),
};
SYSTEM_MANAGERS.iter().find(|m| **m == canonical).copied()
}
fn looks_like_flatpak_id(s: &str) -> bool {
if s.contains(' ') || s.contains('/') || s.contains(':') {
return false;
}
let parts: Vec<&str> = s.split('.').collect();
if parts.len() < 3 {
return false;
}
if !matches!(
parts[0].to_ascii_lowercase().as_str(),
"com" | "org" | "io" | "net" | "dev" | "app" | "page" | "info" | "me" | "one"
) {
return false;
}
parts.iter().all(|p| {
!p.is_empty()
&& p.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
})
}
fn extract_url_from_pipe(cmd: &str) -> Option<String> {
let parts: Vec<&str> = cmd.split('|').collect();
let fetch_cmd = parts.first()?.trim();
let tokens: Vec<&str> = fetch_cmd.split_whitespace().collect();
for (i, token) in tokens.iter().enumerate() {
if token.starts_with("http://") || token.starts_with("https://") {
return Some(token.to_string());
}
if (*token == "-fsSL"
|| *token == "-sSL"
|| *token == "-fsL"
|| *token == "-sL"
|| *token == "-L")
&& let Some(next) = tokens.get(i + 1)
&& (next.starts_with("http://") || next.starts_with("https://"))
{
return Some(next.to_string());
}
}
for token in &tokens {
if token.starts_with("http://") || token.starts_with("https://") {
return Some(token.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_wrap_prefixes() {
assert_eq!(
parse_source("flatpak:com.discordapp.Discord"),
SourceInfo::Flatpak {
app_id: "com.discordapp.Discord".to_string()
}
);
assert_eq!(
parse_source("snap:discord"),
SourceInfo::Snap {
name: "discord".to_string()
}
);
assert_eq!(
parse_source("apt:firefox"),
SourceInfo::System {
manager: "apt".to_string(),
name: "firefox".to_string()
}
);
assert_eq!(
parse_source("pacman:firefox"),
SourceInfo::System {
manager: "pacman".to_string(),
name: "firefox".to_string()
}
);
assert_eq!(
parse_source("apt-get:vim"),
SourceInfo::System {
manager: "apt".to_string(),
name: "vim".to_string()
}
);
assert_eq!(
parse_source("app:discord"),
SourceInfo::App {
query: "discord".to_string()
}
);
assert_eq!(
parse_source("desktop:discord"),
SourceInfo::Desktop {
id: "discord".to_string()
}
);
assert_eq!(
parse_source("pkg:gh"),
SourceInfo::System {
manager: "pkg".to_string(),
name: "gh".to_string()
}
);
}
#[test]
fn detects_bare_flatpak_ids_and_desktop_files() {
assert_eq!(
parse_source("com.discordapp.Discord"),
SourceInfo::Flatpak {
app_id: "com.discordapp.Discord".to_string()
}
);
assert_eq!(
parse_source("org.mozilla.firefox"),
SourceInfo::Flatpak {
app_id: "org.mozilla.firefox".to_string()
}
);
assert_eq!(
parse_source("discord.desktop"),
SourceInfo::Desktop {
id: "discord.desktop".to_string()
}
);
assert_eq!(
parse_source("discord.app"),
SourceInfo::Package {
name: "discord.app".to_string()
}
);
}
#[test]
fn keeps_legacy_sources_unchanged() {
assert!(matches!(
parse_source("curl -fsSL https://example.com/install | bash"),
SourceInfo::RemoteScript { .. }
));
assert!(matches!(
parse_source("https://example.com/install.sh"),
SourceInfo::RemoteScript { .. }
));
assert!(matches!(
parse_source("./install.sh"),
SourceInfo::LocalScript { .. }
));
assert!(matches!(
parse_source("/tmp/install.sh"),
SourceInfo::LocalScript { .. }
));
assert_eq!(
parse_source("gh"),
SourceInfo::Package {
name: "gh".to_string()
}
);
assert_eq!(
parse_source("weird:"),
SourceInfo::Package {
name: "weird:".to_string()
}
);
}
#[test]
fn wrap_predicate_and_kind() {
assert!(is_wrap_source(&SourceInfo::Flatpak {
app_id: "com.x.Y".to_string()
}));
assert!(is_wrap_source(&SourceInfo::App {
query: "x".to_string()
}));
assert!(!is_wrap_source(&SourceInfo::Package {
name: "x".to_string()
}));
assert_eq!(
source_kind(&SourceInfo::Flatpak {
app_id: "x".to_string()
}),
"flatpak"
);
}
}