From 0ef72692e670893135248536eb26704f0970f636 Mon Sep 17 00:00:00 2001 From: pisum-sativum Date: Sat, 6 Jun 2026 04:11:18 +0530 Subject: [PATCH 1/2] Implement cross-tab package multi-select and batch actions --- src/managers/apt.rs | 32 +++++++++++++++++----- src/managers/arch.rs | 36 ++++++++++++++++++------- src/managers/brew.rs | 34 ++++++++++++++++++------ src/managers/mod.rs | 63 ++++++++++++++++++++++++++++++-------------- src/ui/app.rs | 58 +++++++++++++++++++++++++++++++--------- src/ui/draw.rs | 36 ++++++++++++++++++++++++- 6 files changed, 202 insertions(+), 57 deletions(-) diff --git a/src/managers/apt.rs b/src/managers/apt.rs index 834b98b..e8a480d 100644 --- a/src/managers/apt.rs +++ b/src/managers/apt.rs @@ -140,10 +140,17 @@ impl PackageManager for AptManager { fn install( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } let mut args = vec!["apt", "install", "-y"]; - let pkg_refs: Vec<&str> = pkgs.iter().map(|s| s.as_str()).collect(); + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); args.extend(pkg_refs); crate::execute_external_command(terminal, "sudo", &args)?; Ok(()) @@ -152,10 +159,17 @@ impl PackageManager for AptManager { fn remove( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } let mut args = vec!["apt", "remove", "-y"]; - let pkg_refs: Vec<&str> = pkgs.iter().map(|s| s.as_str()).collect(); + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); args.extend(pkg_refs); crate::execute_external_command(terminal, "sudo", &args)?; Ok(()) @@ -164,13 +178,17 @@ impl PackageManager for AptManager { fn update_packages( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { - if pkgs.is_empty() { + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { return Ok(()); } let mut args = vec!["apt", "install", "--only-upgrade", "-y"]; - let pkg_refs: Vec<&str> = pkgs.iter().map(|s| s.as_str()).collect(); + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); args.extend(pkg_refs); crate::execute_external_command(terminal, "sudo", &args)?; Ok(()) diff --git a/src/managers/arch.rs b/src/managers/arch.rs index e6dc618..9df86f7 100644 --- a/src/managers/arch.rs +++ b/src/managers/arch.rs @@ -103,15 +103,22 @@ impl PackageManager for ArchManager { fn install( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } if !self.aur_helper.is_empty() { // yay (or configured AUR helper) can transparently install both // official-repo and AUR packages; aur_install strips any "aur/" prefix. - yay::aur_install(terminal, pkgs, &self.aur_helper)?; + yay::aur_install(terminal, &names, &self.aur_helper)?; } else { // No AUR helper available — install via pacman only. - pacman::pacman_install(terminal, pkgs)?; + pacman::pacman_install(terminal, &names)?; } Ok(()) } @@ -119,27 +126,38 @@ impl PackageManager for ArchManager { fn remove( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { - pacman::pacman_remove(terminal, pkgs) + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } + pacman::pacman_remove(terminal, &names) } fn update_packages( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { - if pkgs.is_empty() { + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { return Ok(()); } if !self.aur_helper.is_empty() { // When an AUR helper is available, route everything through it — // it handles both official-repo and AUR packages transparently. // aur_install already strips any "aur/" prefix before invoking the helper. - yay::aur_install(terminal, pkgs, &self.aur_helper)?; + yay::aur_install(terminal, &names, &self.aur_helper)?; } else { // No AUR helper; treat all selected packages as official-repo. - pacman::pacman_install(terminal, pkgs)?; + pacman::pacman_install(terminal, &names)?; } Ok(()) } diff --git a/src/managers/brew.rs b/src/managers/brew.rs index ce4557b..2eaa3e8 100644 --- a/src/managers/brew.rs +++ b/src/managers/brew.rs @@ -147,7 +147,7 @@ impl PackageManager for BrewManager { let name = parts.next()?.trim().to_string(); let version_info = parts.next().unwrap_or("").trim().to_string(); Some(Package { - provider: "brew".to_string(), + provider: "brew/update".to_string(), name, version: version_info, description: String::new(), @@ -220,10 +220,17 @@ impl PackageManager for BrewManager { fn install( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } let mut args = vec!["install"]; - let pkg_refs: Vec<&str> = pkgs.iter().map(|s| s.as_str()).collect(); + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); args.extend(pkg_refs); crate::execute_external_command(terminal, "brew", &args)?; Ok(()) @@ -232,10 +239,17 @@ impl PackageManager for BrewManager { fn remove( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } let mut args = vec!["uninstall"]; - let pkg_refs: Vec<&str> = pkgs.iter().map(|s| s.as_str()).collect(); + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); args.extend(pkg_refs); crate::execute_external_command(terminal, "brew", &args)?; Ok(()) @@ -244,13 +258,17 @@ impl PackageManager for BrewManager { fn update_packages( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { - if pkgs.is_empty() { + let names: HashSet = pkgs.iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { return Ok(()); } let mut args = vec!["upgrade"]; - let pkg_refs: Vec<&str> = pkgs.iter().map(|s| s.as_str()).collect(); + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); args.extend(pkg_refs); crate::execute_external_command(terminal, "brew", &args)?; Ok(()) diff --git a/src/managers/mod.rs b/src/managers/mod.rs index 73eded9..9a3b90e 100644 --- a/src/managers/mod.rs +++ b/src/managers/mod.rs @@ -27,17 +27,17 @@ pub trait PackageManager: Send + Sync { fn install( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box>; fn remove( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box>; fn update_packages( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box>; fn system_upgrade( &self, @@ -93,10 +93,7 @@ impl PackageManager for CombinedManager { fn get_details(&self, pkg: &str, provider: &str) -> Option> { for m in &self.managers { - // Check if this manager's name/prefix matches the provider - if provider.to_lowercase().contains(&m.name().to_lowercase()) - || m.name().to_lowercase().contains(&provider.to_lowercase()) - { + if manager_handles_provider(m.name(), provider) { return m.get_details(pkg, provider); } } @@ -112,12 +109,17 @@ impl PackageManager for CombinedManager { fn install( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { for m in &self.managers { - // This is tricky; we'd need to know which package belongs to which manager. - // For now, let's assume the manager's internal logic handles its own packages. - m.install(terminal, pkgs)?; + let manager_pkgs: HashSet<(String, String)> = pkgs + .iter() + .filter(|(_, provider)| manager_handles_provider(m.name(), provider)) + .cloned() + .collect(); + if !manager_pkgs.is_empty() { + m.install(terminal, &manager_pkgs)?; + } } Ok(()) } @@ -125,10 +127,17 @@ impl PackageManager for CombinedManager { fn remove( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { for m in &self.managers { - m.remove(terminal, pkgs)?; + let manager_pkgs: HashSet<(String, String)> = pkgs + .iter() + .filter(|(_, provider)| manager_handles_provider(m.name(), provider)) + .cloned() + .collect(); + if !manager_pkgs.is_empty() { + m.remove(terminal, &manager_pkgs)?; + } } Ok(()) } @@ -136,15 +145,14 @@ impl PackageManager for CombinedManager { fn update_packages( &self, terminal: &mut DefaultTerminal, - pkgs: &HashSet, + pkgs: &HashSet<(String, String)>, ) -> Result<(), Box> { - // Partition `pkgs` by manager: only send a package to the backend that - // owns it (determined by intersecting with each manager's installed set). - // This prevents backends from attempting to upgrade packages they don't manage. for m in &self.managers { - let installed = m.get_installed(); - let manager_pkgs: HashSet = - pkgs.iter().filter(|p| installed.contains(*p)).cloned().collect(); + let manager_pkgs: HashSet<(String, String)> = pkgs + .iter() + .filter(|(_, provider)| manager_handles_provider(m.name(), provider)) + .cloned() + .collect(); if !manager_pkgs.is_empty() { m.update_packages(terminal, &manager_pkgs)?; } @@ -268,3 +276,18 @@ pub fn parse_alternating_lines(lines: &[&str], manager: String, query: &str) -> res } + +pub fn manager_handles_provider(manager_name: &str, provider: &str) -> bool { + let m_lower = manager_name.to_lowercase(); + let p_lower = provider.to_lowercase(); + if p_lower.contains("pacman") || p_lower.contains("aur") || p_lower.contains("yay") { + m_lower.contains("arch") || m_lower.contains("pacman") || m_lower.contains("yay") + } else if p_lower.contains("brew") || p_lower.contains("homebrew") { + m_lower.contains("brew") + } else if p_lower.contains("apt") { + m_lower.contains("apt") || m_lower.contains("debian") || m_lower.contains("ubuntu") + } else { + p_lower.contains(&m_lower) || m_lower.contains(&p_lower) + } +} + diff --git a/src/ui/app.rs b/src/ui/app.rs index d89b46f..65264e3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -39,7 +39,7 @@ pub struct App { pub current_tab: Tab, pub packages: Vec, pub checked: Vec, - pub selected_names: HashSet, + pub selected_names: HashSet<(String, String)>, pub installed_packages: HashSet, pub selected: usize, pub list_state: ListState, @@ -273,9 +273,9 @@ impl App { } let mut to_remove = HashSet::new(); - for name in &self.selected_names { + for (name, provider) in &self.selected_names { if self.installed_packages.contains(name) { - to_remove.insert(name.clone()); + to_remove.insert((name.clone(), provider.clone())); } } @@ -291,15 +291,15 @@ impl App { terminal: &mut DefaultTerminal, ) -> Result<(), Box> { // Prefer explicitly checked packages; fall back to the highlighted item. - let mut to_update: HashSet = self.selected_names.clone(); + let mut to_update: HashSet<(String, String)> = self.selected_names.clone(); if to_update.is_empty() && let Some(pkg) = self.packages.get(self.selected) { - to_update.insert(pkg.name.clone()); + to_update.insert((pkg.name.clone(), pkg.provider.clone())); } // Only update packages that are actually installed; avoids invoking // backend-specific upgrade commands on packages the system doesn't own. - to_update.retain(|name| self.installed_packages.contains(name)); + to_update.retain(|(name, _)| self.installed_packages.contains(name)); if !to_update.is_empty() { self.manager.update_packages(terminal, &to_update)?; } @@ -545,7 +545,7 @@ impl App { self.checked = self .packages .iter() - .map(|p| self.selected_names.contains(&p.name)) + .map(|p| self.selected_names.contains(&(p.name.clone(), p.provider.clone()))) .collect(); self.selected = 0; @@ -659,18 +659,21 @@ impl App { } else if is_key(key.code, &keys.install) { let _ = self.run_command(terminal); self.installed_packages = self.manager.get_installed(); + self.selected_names.retain(|(name, _)| !self.installed_packages.contains(name)); if let Tab::Installed = self.current_tab { self.reset_tab_state(); } } else if is_key(key.code, &keys.remove) { let _ = self.run_remove_command(terminal); self.installed_packages = self.manager.get_installed(); + self.selected_names.retain(|(name, _)| self.installed_packages.contains(name)); if let Tab::Installed = self.current_tab { self.reset_tab_state(); } } else if is_key(key.code, &keys.update) { let _ = self.run_update_command(terminal); self.installed_packages = self.manager.get_installed(); + self.selected_names.retain(|(_, provider)| !provider.contains("/update")); // Reset both Updates and Installed tabs so their lists // reflect the post-update state immediately. match self.current_tab { @@ -696,12 +699,13 @@ impl App { } else if !self.packages.is_empty() { let pkg = &self.packages[self.selected]; let name = pkg.name.clone(); + let provider = pkg.provider.clone(); let is_checked = !self.checked[self.selected]; self.checked[self.selected] = is_checked; if is_checked { - self.selected_names.insert(name); + self.selected_names.insert((name, provider)); } else { - self.selected_names.remove(&name); + self.selected_names.remove(&(name, provider)); } } } else if is_key(key.code, &keys.search_edit) { @@ -874,7 +878,36 @@ impl App { // Tab switching if mouse_event.row >= 1 && mouse_event.row <= 3 { let col = mouse_event.column.saturating_sub(1); - let tab_titles = ["Search", "Installed", "Updates", "Settings"]; + + let search_count = self.selected_names.iter() + .filter(|(name, provider)| !provider.contains("/update") && !self.installed_packages.contains(name)) + .count(); + let installed_count = self.selected_names.iter() + .filter(|(name, provider)| !provider.contains("/update") && self.installed_packages.contains(name)) + .count(); + let updates_count = self.selected_names.iter() + .filter(|(_, provider)| provider.contains("/update")) + .count(); + + let search_title = if search_count > 0 { + format!("Search [{}]", search_count) + } else { + "Search".to_string() + }; + + let installed_title = if installed_count > 0 { + format!("Installed [{}]", installed_count) + } else { + "Installed".to_string() + }; + + let updates_title = if updates_count > 0 { + format!("Updates [{}]", updates_count) + } else { + "Updates".to_string() + }; + + let tab_titles = [search_title, installed_title, updates_title, "Settings".to_string()]; let mut current_x = 0; for (i, title) in tab_titles.iter().enumerate() { let width = title.len() as u16; @@ -941,12 +974,13 @@ impl App { if mouse_event.column < 5 { let name = self.packages[real_idx].name.clone(); + let provider = self.packages[real_idx].provider.clone(); let is_checked = !self.checked[real_idx]; self.checked[real_idx] = is_checked; if is_checked { - self.selected_names.insert(name); + self.selected_names.insert((name, provider)); } else { - self.selected_names.remove(&name); + self.selected_names.remove(&(name, provider)); } } } diff --git a/src/ui/draw.rs b/src/ui/draw.rs index c55c00d..69efe18 100644 --- a/src/ui/draw.rs +++ b/src/ui/draw.rs @@ -202,7 +202,41 @@ fn draw_tabs(frame: &mut Frame, app: &App, area: Rect, theme: &crate::config::Th let highlight_color = app.config.get_color(&theme.highlight_color); let border_type = get_border_type(&app.config.settings.border_style); - let tab_titles = vec!["Search", "Installed", "Updates", "Settings"]; + let search_count = app.selected_names.iter() + .filter(|(name, provider)| !provider.contains("/update") && !app.installed_packages.contains(name)) + .count(); + let installed_count = app.selected_names.iter() + .filter(|(name, provider)| !provider.contains("/update") && app.installed_packages.contains(name)) + .count(); + let updates_count = app.selected_names.iter() + .filter(|(_, provider)| provider.contains("/update")) + .count(); + + let search_title = if search_count > 0 { + format!("Search [{}]", search_count) + } else { + "Search".to_string() + }; + + let installed_title = if installed_count > 0 { + format!("Installed [{}]", installed_count) + } else { + "Installed".to_string() + }; + + let updates_title = if updates_count > 0 { + format!("Updates [{}]", updates_count) + } else { + "Updates".to_string() + }; + + let tab_titles = vec![ + search_title, + installed_title, + updates_title, + "Settings".to_string(), + ]; + let tabs = ratatui::widgets::Tabs::new(tab_titles) .block( Block::bordered() From 71916531982776da4b8e763870c7aed2d15d9837 Mon Sep 17 00:00:00 2001 From: pisum-sativum Date: Tue, 9 Jun 2026 18:37:09 +0530 Subject: [PATCH 2/2] feat(managers): add Zypper package manager for openSUSE support Implements the PackageManager trait for openSUSE Leap and Tumbleweed using the zypper CLI tool. Key implementation details: - Uses --xmlout mode for search and installed-packages listing to ensure reliable, structured parsing instead of fragile text parsing - Custom minimal XML attribute extractor avoids adding a new dependency - Exit code 100 (updates available) is correctly treated as non-error in get_updates() - system_upgrade() maps to zypper dup (distribution upgrade) - refresh_databases() maps to zypper refresh - Provider routing added to manager_handles_provider() for zypper, opensuse, and suse provider strings - Availability detection uses which zypper in get_available_managers() Closes #57 --- src/managers/mod.rs | 11 ++ src/managers/zypper.rs | 308 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 src/managers/zypper.rs diff --git a/src/managers/mod.rs b/src/managers/mod.rs index 9a3b90e..e10b5f8 100644 --- a/src/managers/mod.rs +++ b/src/managers/mod.rs @@ -3,6 +3,7 @@ pub mod arch; pub mod brew; pub mod pacman; pub mod yay; +pub mod zypper; use ratatui::DefaultTerminal; use std::collections::{HashMap, HashSet}; @@ -197,6 +198,10 @@ pub fn get_available_managers() -> Vec { available.push("apt".to_string()); } + if std::process::Command::new("which").arg("zypper").output().map(|o| o.status.success()).unwrap_or(false) { + available.push("zypper".to_string()); + } + available } @@ -219,6 +224,10 @@ pub fn get_system_manager(config: &crate::config::Config) -> Box bool { m_lower.contains("brew") } else if p_lower.contains("apt") { m_lower.contains("apt") || m_lower.contains("debian") || m_lower.contains("ubuntu") + } else if p_lower.contains("zypper") || p_lower.contains("opensuse") || p_lower.contains("suse") { + m_lower.contains("zypper") || m_lower.contains("opensuse") || m_lower.contains("suse") } else { p_lower.contains(&m_lower) || m_lower.contains(&p_lower) } diff --git a/src/managers/zypper.rs b/src/managers/zypper.rs new file mode 100644 index 0000000..9b8a2b0 --- /dev/null +++ b/src/managers/zypper.rs @@ -0,0 +1,308 @@ +use crate::managers::{Package, PackageManager}; +use ratatui::DefaultTerminal; +use std::collections::{HashMap, HashSet}; +use std::process::Command; + +pub struct ZypperManager; + +/// Parse zypper XML output for `search` and `packages` commands. +/// Zypper XML search results have the form: +/// ```xml +/// +/// ``` +fn parse_xml_solvables(xml: &str, provider: &str, query: &str) -> Vec { + let mut packages = Vec::new(); + + for line in xml.lines() { + let line = line.trim(); + if !line.starts_with("(line: &'a str, attr: &str) -> Option { + let needle = format!("{}=\"", attr); + if let Some(start) = line.find(&needle) { + let rest = &line[start + needle.len()..]; + let end = rest.find('"')?; + return Some(rest[..end].to_string()); + } + // Try single-quoted variant + let needle_sq = format!("{}='", attr); + if let Some(start) = line.find(&needle_sq) { + let rest = &line[start + needle_sq.len()..]; + let end = rest.find('\'')?; + return Some(rest[..end].to_string()); + } + None +} + +impl PackageManager for ZypperManager { + fn name(&self) -> &str { + "Zypper (openSUSE)" + } + + // ------------------------------------------------------------------------- + // search + // ------------------------------------------------------------------------- + fn search(&self, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + + // Check cache + { + let cache = crate::managers::SEARCH_CACHE.lock().unwrap(); + if let Some(cached) = cache.get(query) { + return cached.clone(); + } + } + + let output = Command::new("zypper") + .args(["--xmlout", "search", query]) + .output() + .ok(); + + let mut packages = if let Some(out) = output { + // zypper exits with 0 (no results) or non-zero on real errors; + // any XML in stdout is still valid regardless of exit code here. + let xml = String::from_utf8_lossy(&out.stdout); + let mut pkgs = parse_xml_solvables(&xml, "zypper", query); + pkgs.retain(|p| p.score > 0.01); + pkgs.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + pkgs + } else { + Vec::new() + }; + + let config = crate::config::Config::load(); + packages.truncate(config.settings.max_search_results); + + // Update cache + { + let mut cache = crate::managers::SEARCH_CACHE.lock().unwrap(); + cache.insert(query.to_string(), packages.clone()); + } + + packages + } + + // ------------------------------------------------------------------------- + // get_installed (names only, used for highlighting) + // ------------------------------------------------------------------------- + fn get_installed(&self) -> HashSet { + let output = Command::new("zypper") + .args(["--xmlout", "packages", "--installed-only"]) + .output() + .ok(); + + if let Some(out) = output { + let xml = String::from_utf8_lossy(&out.stdout); + parse_xml_solvables(&xml, "zypper", "") + .into_iter() + .map(|p| p.name) + .collect() + } else { + HashSet::new() + } + } + + // ------------------------------------------------------------------------- + // get_installed_details + // ------------------------------------------------------------------------- + fn get_installed_details(&self) -> Vec { + let output = Command::new("zypper") + .args(["--xmlout", "packages", "--installed-only"]) + .output() + .ok(); + + if let Some(out) = output { + let xml = String::from_utf8_lossy(&out.stdout); + let mut pkgs = parse_xml_solvables(&xml, "zypper/local", ""); + pkgs.iter_mut().for_each(|p| p.score = 1.0); + pkgs + } else { + Vec::new() + } + } + + // ------------------------------------------------------------------------- + // get_updates + // Exit code 100 means "updates available" — that is NOT an error for zypper. + // ------------------------------------------------------------------------- + fn get_updates(&self) -> Vec { + let output = Command::new("zypper") + .args(["--xmlout", "list-updates"]) + .output() + .ok(); + + if let Some(out) = output { + // Exit code 100 = updates available (treat like success) + let code = out.status.code().unwrap_or(0); + if code != 0 && code != 100 { + return Vec::new(); + } + let xml = String::from_utf8_lossy(&out.stdout); + let mut pkgs = parse_xml_solvables(&xml, "zypper/update", ""); + pkgs.iter_mut().for_each(|p| { + p.score = 1.0; + if p.description.is_empty() { + p.description = "Update available".to_string(); + } + }); + pkgs + } else { + Vec::new() + } + } + + // ------------------------------------------------------------------------- + // get_details (plain-text `info` sub-command; XML info is verbose) + // ------------------------------------------------------------------------- + fn get_details(&self, pkg: &str, _provider: &str) -> Option> { + // Check cache first + { + let cache = crate::managers::DETAILS_CACHE.lock().unwrap(); + if let Some(cached) = cache.get(pkg) { + return Some(cached.clone()); + } + } + + let pure_name = pkg.split('/').next_back().unwrap_or(pkg); + + let output = Command::new("zypper") + .args(["info", pure_name]) + .output() + .ok()?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let mut info = HashMap::new(); + info.insert("Info".to_string(), stdout); + + // Update cache + { + let mut cache = crate::managers::DETAILS_CACHE.lock().unwrap(); + cache.insert(pkg.to_string(), info.clone()); + } + + Some(info) + } + + // ------------------------------------------------------------------------- + // install + // ------------------------------------------------------------------------- + fn install( + &self, + terminal: &mut DefaultTerminal, + pkgs: &HashSet<(String, String)>, + ) -> Result<(), Box> { + let names: HashSet = pkgs + .iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } + let mut args = vec!["zypper", "install", "-y"]; + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); + args.extend(pkg_refs); + crate::execute_external_command(terminal, "sudo", &args)?; + Ok(()) + } + + // ------------------------------------------------------------------------- + // remove + // ------------------------------------------------------------------------- + fn remove( + &self, + terminal: &mut DefaultTerminal, + pkgs: &HashSet<(String, String)>, + ) -> Result<(), Box> { + let names: HashSet = pkgs + .iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } + let mut args = vec!["zypper", "remove", "-y"]; + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); + args.extend(pkg_refs); + crate::execute_external_command(terminal, "sudo", &args)?; + Ok(()) + } + + // ------------------------------------------------------------------------- + // update_packages (install a specific version == upgrade selected pkgs) + // ------------------------------------------------------------------------- + fn update_packages( + &self, + terminal: &mut DefaultTerminal, + pkgs: &HashSet<(String, String)>, + ) -> Result<(), Box> { + let names: HashSet = pkgs + .iter() + .filter(|(_, provider)| super::manager_handles_provider(self.name(), provider)) + .map(|(name, _)| name.clone()) + .collect(); + if names.is_empty() { + return Ok(()); + } + let mut args = vec!["zypper", "update", "-y"]; + let pkg_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); + args.extend(pkg_refs); + crate::execute_external_command(terminal, "sudo", &args)?; + Ok(()) + } + + // ------------------------------------------------------------------------- + // system_upgrade → zypper dup (distribution upgrade) + // ------------------------------------------------------------------------- + fn system_upgrade( + &self, + terminal: &mut DefaultTerminal, + ) -> Result<(), Box> { + crate::execute_external_command(terminal, "sudo", &["zypper", "dup", "-y"])?; + Ok(()) + } + + // ------------------------------------------------------------------------- + // refresh_databases → zypper refresh + // ------------------------------------------------------------------------- + fn refresh_databases( + &self, + terminal: &mut DefaultTerminal, + ) -> Result<(), Box> { + crate::execute_external_command(terminal, "sudo", &["zypper", "refresh"])?; + Ok(()) + } +}