use crate::utils::package_manager::PackageManagerImpl;
use anyhow::{Context, Result};
use std::process::Command;
use std::sync::mpsc;
use std::thread;
use tracing::{info, warn};
#[derive(Debug, Clone)]
pub struct DiscoveredPackage {
pub package_name: String,
pub binary_name: Option<String>,
pub description: Option<String>,
pub manager: DiscoverySource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiscoverySource {
Homebrew,
Pacman,
Apt,
Dnf,
Yum,
Snap,
Cargo,
Npm,
Pip,
Pip3,
Gem,
}
impl DiscoverySource {
#[must_use]
pub fn display_name(&self) -> &'static str {
match self {
DiscoverySource::Homebrew => "Homebrew",
DiscoverySource::Pacman => "Pacman",
DiscoverySource::Apt => "APT",
DiscoverySource::Dnf => "DNF",
DiscoverySource::Yum => "YUM",
DiscoverySource::Snap => "Snap",
DiscoverySource::Cargo => "Cargo",
DiscoverySource::Npm => "NPM",
DiscoverySource::Pip => "pip",
DiscoverySource::Pip3 => "pip3",
DiscoverySource::Gem => "Gem",
}
}
#[must_use]
pub fn to_package_manager(&self) -> crate::utils::profile_manifest::PackageManager {
use crate::utils::profile_manifest::PackageManager;
match self {
DiscoverySource::Homebrew => PackageManager::Brew,
DiscoverySource::Pacman => PackageManager::Pacman,
DiscoverySource::Apt => PackageManager::Apt,
DiscoverySource::Dnf => PackageManager::Dnf,
DiscoverySource::Yum => PackageManager::Yum,
DiscoverySource::Snap => PackageManager::Snap,
DiscoverySource::Cargo => PackageManager::Cargo,
DiscoverySource::Npm => PackageManager::Npm,
DiscoverySource::Pip => PackageManager::Pip,
DiscoverySource::Pip3 => PackageManager::Pip3,
DiscoverySource::Gem => PackageManager::Gem,
}
}
#[must_use]
pub fn from_package_manager(
manager: &crate::utils::profile_manifest::PackageManager,
) -> Option<Self> {
use crate::utils::profile_manifest::PackageManager;
match manager {
PackageManager::Brew => Some(DiscoverySource::Homebrew),
PackageManager::Pacman => Some(DiscoverySource::Pacman),
PackageManager::Apt => Some(DiscoverySource::Apt),
PackageManager::Dnf => Some(DiscoverySource::Dnf),
PackageManager::Yum => Some(DiscoverySource::Yum),
PackageManager::Snap => Some(DiscoverySource::Snap),
PackageManager::Cargo => Some(DiscoverySource::Cargo),
PackageManager::Npm => Some(DiscoverySource::Npm),
PackageManager::Pip => Some(DiscoverySource::Pip),
PackageManager::Pip3 => Some(DiscoverySource::Pip3),
PackageManager::Gem => Some(DiscoverySource::Gem),
PackageManager::Custom => None, }
}
#[must_use]
pub fn supports_discovery(&self) -> bool {
match self {
DiscoverySource::Homebrew
| DiscoverySource::Pacman
| DiscoverySource::Apt
| DiscoverySource::Dnf
| DiscoverySource::Yum
| DiscoverySource::Snap
| DiscoverySource::Cargo
| DiscoverySource::Npm
| DiscoverySource::Pip
| DiscoverySource::Pip3
| DiscoverySource::Gem => true,
}
}
}
#[derive(Debug, Clone)]
pub enum DiscoveryStatus {
Started(DiscoverySource),
Complete {
source: DiscoverySource,
packages: Vec<DiscoveredPackage>,
},
Failed {
source: DiscoverySource,
error: String,
},
NoSourcesAvailable,
}
pub trait PackageDiscoverer: Send + Sync {
fn is_available(&self) -> bool;
fn source(&self) -> DiscoverySource;
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>>;
fn detect_binary_name(&self, package_name: &str) -> Option<String>;
}
pub struct HomebrewDiscoverer;
impl PackageDiscoverer for HomebrewDiscoverer {
fn is_available(&self) -> bool {
PackageManagerImpl::brew_command()
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Homebrew
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering Homebrew packages...");
let output = PackageManagerImpl::brew_command()
.args(["leaves", "--installed-on-request"])
.output()
.context("Failed to run brew leaves")?;
if !output.status.success() {
let output = PackageManagerImpl::brew_command()
.arg("leaves")
.output()
.context("Failed to run brew leaves")?;
if !output.status.success() {
anyhow::bail!("brew leaves failed");
}
return self.parse_leaves_output(&output.stdout);
}
self.parse_leaves_output(&output.stdout)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
let check = Command::new("which").arg(package_name).output().ok()?;
if check.status.success() {
return Some(package_name.to_string());
}
let output = PackageManagerImpl::brew_command()
.args(["list", "--formula", package_name])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("/bin/") {
if let Some(binary) = line.split('/').next_back() {
if !binary.is_empty() {
return Some(binary.to_string());
}
}
}
}
}
Some(package_name.to_string())
}
}
impl HomebrewDiscoverer {
fn parse_leaves_output(&self, stdout: &[u8]) -> Result<Vec<DiscoveredPackage>> {
let stdout = String::from_utf8_lossy(stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
let package_name = line.trim();
if package_name.is_empty() {
continue;
}
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Homebrew,
});
}
info!("Discovered {} Homebrew packages", packages.len());
Ok(packages)
}
}
pub struct PacmanDiscoverer;
impl PackageDiscoverer for PacmanDiscoverer {
fn is_available(&self) -> bool {
Command::new("pacman")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Pacman
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering Pacman packages...");
let output = Command::new("pacman")
.args(["-Qe", "-q"]) .output()
.context("Failed to run pacman -Qe")?;
if !output.status.success() {
anyhow::bail!("pacman -Qe failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
let package_name = line.trim();
if package_name.is_empty() {
continue;
}
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Pacman,
});
}
info!("Discovered {} Pacman packages", packages.len());
Ok(packages)
}
#[allow(dead_code)]
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
let check = Command::new("which").arg(package_name).output().ok()?;
if check.status.success() {
return Some(package_name.to_string());
}
let output = Command::new("pacman")
.args(["-Ql", package_name])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("/usr/bin/") || line.contains("/bin/") {
if let Some(binary) = line.split_whitespace().last() {
if let Some(name) = binary.split('/').next_back() {
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
}
}
Some(package_name.to_string())
}
}
pub struct AptDiscoverer;
impl PackageDiscoverer for AptDiscoverer {
fn is_available(&self) -> bool {
Command::new("apt-mark")
.arg("--help")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Apt
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering APT packages...");
let output = Command::new("apt-mark")
.arg("showmanual")
.output()
.context("Failed to run apt-mark showmanual")?;
if !output.status.success() {
anyhow::bail!("apt-mark showmanual failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
let package_name = line.trim();
if package_name.is_empty() {
continue;
}
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Apt,
});
}
info!("Discovered {} APT packages", packages.len());
Ok(packages)
}
#[allow(dead_code)]
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
let check = Command::new("which").arg(package_name).output().ok()?;
if check.status.success() {
return Some(package_name.to_string());
}
let output = Command::new("dpkg")
.args(["-L", package_name])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("/usr/bin/") || line.contains("/bin/") {
if let Some(name) = line.split('/').next_back() {
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
}
Some(package_name.to_string())
}
}
pub struct YumDiscoverer;
impl PackageDiscoverer for YumDiscoverer {
fn is_available(&self) -> bool {
Command::new("yum")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Yum
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering YUM packages...");
let output = Command::new("yum")
.args(["list", "installed"])
.output()
.context("Failed to run yum list installed")?;
if !output.status.success() {
anyhow::bail!("yum list installed failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let package_full = parts[0];
let package_name = package_full.split('.').next().unwrap_or(package_full);
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Yum,
});
}
info!("Discovered {} YUM packages", packages.len());
Ok(packages)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
Some(package_name.to_string())
}
}
pub struct DnfDiscoverer;
impl PackageDiscoverer for DnfDiscoverer {
fn is_available(&self) -> bool {
Command::new("dnf")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Dnf
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering DNF packages...");
let output = Command::new("dnf")
.args(["list", "installed"])
.output()
.context("Failed to run dnf list installed")?;
if !output.status.success() {
anyhow::bail!("dnf list installed failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let package_full = parts[0];
let package_name = package_full.split('.').next().unwrap_or(package_full);
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Dnf,
});
}
info!("Discovered {} DNF packages", packages.len());
Ok(packages)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
Some(package_name.to_string())
}
}
pub struct SnapDiscoverer;
impl PackageDiscoverer for SnapDiscoverer {
fn is_available(&self) -> bool {
Command::new("snap")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Snap
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering Snap packages...");
let output = Command::new("snap")
.arg("list")
.output()
.context("Failed to run snap list")?;
if !output.status.success() {
anyhow::bail!("snap list failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let package_name = parts[0];
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Snap,
});
}
info!("Discovered {} Snap packages", packages.len());
Ok(packages)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
Some(package_name.to_string())
}
}
pub struct CargoDiscoverer;
impl PackageDiscoverer for CargoDiscoverer {
fn is_available(&self) -> bool {
Command::new("cargo")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Cargo
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering Cargo packages...");
let output = Command::new("cargo")
.args(["install", "--list"])
.output()
.context("Failed to run cargo install --list")?;
if !output.status.success() {
anyhow::bail!("cargo install --list failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
if !line.starts_with(' ') && !line.is_empty() {
let parts: Vec<&str> = line.split_whitespace().collect();
if let Some(package_name) = parts.first() {
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Cargo,
});
}
}
}
info!("Discovered {} Cargo packages", packages.len());
Ok(packages)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
Some(package_name.to_string())
}
}
pub struct NpmDiscoverer;
impl PackageDiscoverer for NpmDiscoverer {
fn is_available(&self) -> bool {
Command::new("npm")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Npm
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering NPM packages...");
let output = Command::new("npm")
.args(["list", "-g", "--depth=0", "--parseable"])
.output()
.context("Failed to run npm list -g")?;
if !output.status.success() {
warn!("npm list returned non-zero status, continuing anyway");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
if let Some(pos) = line.rfind("/node_modules/") {
let package_part = &line[pos + 14..];
if !package_part.is_empty() {
let package_name = package_part.to_string();
packages.push(DiscoveredPackage {
package_name: package_name.clone(),
binary_name: Some(package_name), description: None,
manager: DiscoverySource::Npm,
});
}
} else if let Some(package_name) = line.split('/').next_back() {
if !package_name.is_empty()
&& package_name != "lib"
&& package_name != "node_modules"
{
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Npm,
});
}
}
}
info!("Discovered {} NPM packages", packages.len());
Ok(packages)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
Some(package_name.to_string())
}
}
pub struct PipDiscoverer;
impl PackageDiscoverer for PipDiscoverer {
fn is_available(&self) -> bool {
Command::new("pip")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Pip
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering pip packages...");
let output = Command::new("pip")
.args(["list", "--format=freeze"])
.output()
.context("Failed to run pip list")?;
if !output.status.success() {
anyhow::bail!("pip list failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
if let Some(package_name) = line.split("==").next() {
if !package_name.is_empty() {
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Pip,
});
}
}
}
info!("Discovered {} pip packages", packages.len());
Ok(packages)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
Some(package_name.to_string())
}
}
pub struct Pip3Discoverer;
impl PackageDiscoverer for Pip3Discoverer {
fn is_available(&self) -> bool {
Command::new("pip3")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Pip3
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering pip3 packages...");
let output = Command::new("pip3")
.args(["list", "--format=freeze"])
.output()
.context("Failed to run pip3 list")?;
if !output.status.success() {
anyhow::bail!("pip3 list failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
if let Some(package_name) = line.split("==").next() {
if !package_name.is_empty() {
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Pip3,
});
}
}
}
info!("Discovered {} pip3 packages", packages.len());
Ok(packages)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
Some(package_name.to_string())
}
}
pub struct GemDiscoverer;
impl PackageDiscoverer for GemDiscoverer {
fn is_available(&self) -> bool {
Command::new("gem")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn source(&self) -> DiscoverySource {
DiscoverySource::Gem
}
fn discover_packages(&self) -> Result<Vec<DiscoveredPackage>> {
info!("Discovering gem packages...");
let output = Command::new("gem")
.arg("list")
.output()
.context("Failed to run gem list")?;
if !output.status.success() {
anyhow::bail!("gem list failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut packages = Vec::new();
for line in stdout.lines() {
if let Some(package_name) = line.split_whitespace().next() {
if !package_name.is_empty() && !line.starts_with("***") {
packages.push(DiscoveredPackage {
package_name: package_name.to_string(),
binary_name: Some(package_name.to_string()),
description: None,
manager: DiscoverySource::Gem,
});
}
}
}
info!("Discovered {} gem packages", packages.len());
Ok(packages)
}
fn detect_binary_name(&self, package_name: &str) -> Option<String> {
Some(package_name.to_string())
}
}
pub struct PackageDiscoveryService {
discoverers: Vec<Box<dyn PackageDiscoverer>>,
}
impl Default for PackageDiscoveryService {
fn default() -> Self {
Self::new()
}
}
impl PackageDiscoveryService {
#[must_use]
pub fn new() -> Self {
let discoverers: Vec<Box<dyn PackageDiscoverer>> = vec![
Box::new(HomebrewDiscoverer),
Box::new(PacmanDiscoverer),
Box::new(AptDiscoverer),
Box::new(YumDiscoverer),
Box::new(DnfDiscoverer),
Box::new(SnapDiscoverer),
Box::new(CargoDiscoverer),
Box::new(NpmDiscoverer),
Box::new(PipDiscoverer),
Box::new(Pip3Discoverer),
Box::new(GemDiscoverer),
];
Self { discoverers }
}
#[must_use]
pub fn available_sources(&self) -> Vec<DiscoverySource> {
self.discoverers
.iter()
.filter(|d| d.is_available())
.map(|d| d.source())
.collect()
}
pub fn discover_from(&self, source: DiscoverySource) -> Result<Vec<DiscoveredPackage>> {
if !source.supports_discovery() {
return Ok(Vec::new()); }
for discoverer in &self.discoverers {
if discoverer.source() == source {
return discoverer.discover_packages();
}
}
Ok(Vec::new())
}
pub fn discover_all(&self) -> Vec<DiscoveredPackage> {
let mut all_packages = Vec::new();
for discoverer in &self.discoverers {
if discoverer.is_available() {
match discoverer.discover_packages() {
Ok(packages) => all_packages.extend(packages),
Err(e) => {
warn!(
"Failed to discover packages from {:?}: {}",
discoverer.source(),
e
);
}
}
}
}
all_packages
}
#[must_use]
pub fn discover_async() -> mpsc::Receiver<DiscoveryStatus> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let service = PackageDiscoveryService::new();
let sources = service.available_sources();
if sources.is_empty() {
let _ = tx.send(DiscoveryStatus::NoSourcesAvailable);
return;
}
let source = sources[0];
let _ = tx.send(DiscoveryStatus::Started(source));
match service.discover_from(source) {
Ok(packages) => {
let _ = tx.send(DiscoveryStatus::Complete { source, packages });
}
Err(e) => {
let _ = tx.send(DiscoveryStatus::Failed {
source,
error: e.to_string(),
});
}
}
});
rx
}
#[must_use]
pub fn discover_source_async(source: DiscoverySource) -> mpsc::Receiver<DiscoveryStatus> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let service = PackageDiscoveryService::new();
let _ = tx.send(DiscoveryStatus::Started(source));
match service.discover_from(source) {
Ok(packages) => {
let _ = tx.send(DiscoveryStatus::Complete { source, packages });
}
Err(e) => {
let _ = tx.send(DiscoveryStatus::Failed {
source,
error: e.to_string(),
});
}
}
});
rx
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discovery_source_display_name() {
assert_eq!(DiscoverySource::Homebrew.display_name(), "Homebrew");
assert_eq!(DiscoverySource::Pacman.display_name(), "Pacman");
assert_eq!(DiscoverySource::Apt.display_name(), "APT");
}
#[test]
fn test_service_creation() {
let service = PackageDiscoveryService::new();
let _ = service.available_sources();
}
}