use std::collections::HashSet;
use std::process::Command;
use crate::install::shell_single_quote;
use crate::logic::privilege::{PrivilegeTool, build_privilege_command};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForeignRepoOverlapEntry {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone)]
pub struct ForeignRepoOverlapAnalysis {
pub entries: Vec<ForeignRepoOverlapEntry>,
pub foreign_pkg_count: usize,
pub sync_pkg_name_count: usize,
}
pub fn list_foreign_packages() -> Result<Vec<(String, String)>, String> {
let out = Command::new("pacman")
.args(["-Qm"])
.output()
.map_err(|e| format!("pacman -Qm failed to run: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!(
"pacman -Qm failed (status {}): {stderr}",
out.status
));
}
let text = String::from_utf8_lossy(&out.stdout);
let mut rows = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let Some(name) = parts.next().map(str::to_string) else {
continue;
};
let version = parts.collect::<Vec<_>>().join(" ");
rows.push((name, version));
}
Ok(rows)
}
pub fn sync_repo_pkgnames(repo: &str) -> Result<HashSet<String>, String> {
let repo = repo.trim();
if repo.is_empty() {
return Err("Repository name is empty.".to_string());
}
let out = Command::new("pacman")
.args(["-Sl", repo])
.output()
.map_err(|e| format!("pacman -Sl failed to run: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!(
"pacman -Sl {repo} failed (sync database missing or invalid?): {stderr}"
));
}
let text = String::from_utf8_lossy(&out.stdout);
let mut set = HashSet::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('[') {
continue;
}
let mut parts = line.split_whitespace();
let _r = parts.next();
let Some(pkg) = parts.next() else {
continue;
};
set.insert(pkg.to_string());
}
Ok(set)
}
fn sync_sl_failure_is_unknown_repository(combined: &str) -> bool {
let lower = combined.to_lowercase();
lower.contains("not found")
|| lower.contains("nicht gefunden")
|| lower.contains("wurde nicht gefunden")
|| lower.contains("could not find")
|| lower.contains("unable to find")
|| lower.contains("unknown repository")
|| lower.contains("repository not found")
|| lower.contains("no package database")
}
fn sync_repo_pkgnames_or_empty_if_repo_missing(repo: &str) -> Result<HashSet<String>, String> {
match sync_repo_pkgnames(repo) {
Ok(s) => Ok(s),
Err(msg) => {
if sync_sl_failure_is_unknown_repository(&msg) {
Ok(HashSet::new())
} else {
Err(msg)
}
}
}
}
fn overlap_analysis_from_foreign_and_repo_names(
foreign: Vec<(String, String)>,
repo_names: &HashSet<String>,
) -> ForeignRepoOverlapAnalysis {
let foreign_pkg_count = foreign.len();
let sync_pkg_name_count = repo_names.len();
let mut entries: Vec<ForeignRepoOverlapEntry> = foreign
.into_iter()
.filter(|(n, _)| repo_names.contains(n))
.map(|(name, version)| ForeignRepoOverlapEntry { name, version })
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
ForeignRepoOverlapAnalysis {
entries,
foreign_pkg_count,
sync_pkg_name_count,
}
}
pub fn analyze_foreign_repo_overlap(repo: &str) -> Result<ForeignRepoOverlapAnalysis, String> {
analyze_foreign_repo_overlap_with_qm_snapshot(repo, None)
}
pub fn analyze_foreign_repo_overlap_with_qm_snapshot(
repo: &str,
pre_apply_foreign_snapshot: Option<&[(String, String)]>,
) -> Result<ForeignRepoOverlapAnalysis, String> {
let foreign: Vec<(String, String)> = if let Some(rows) = pre_apply_foreign_snapshot {
rows.to_vec()
} else {
list_foreign_packages()?
};
let repo_names = sync_repo_pkgnames_or_empty_if_repo_missing(repo)?;
Ok(overlap_analysis_from_foreign_and_repo_names(
foreign,
&repo_names,
))
}
pub fn compute_foreign_repo_overlap(repo: &str) -> Result<Vec<ForeignRepoOverlapEntry>, String> {
Ok(analyze_foreign_repo_overlap(repo)?.entries)
}
pub fn build_foreign_to_sync_migrate_bundle(
tool: PrivilegeTool,
dry_run: bool,
pkgs: &[String],
) -> Result<(Vec<String>, Vec<String>), String> {
if pkgs.is_empty() {
return Err("No packages selected for migration.".to_string());
}
let joined = pkgs.join(" ");
let summary_lines = vec![
format!("Remove foreign packages: {joined}"),
format!("Install from sync repositories: {joined}"),
];
let inner =
format!("pacman -Rns --noconfirm {joined} && pacman -S --needed --noconfirm {joined}");
let cmd = if dry_run {
let quoted = shell_single_quote(&inner);
format!("echo DRY RUN: {quoted}")
} else {
build_privilege_command(tool, &inner)
};
Ok((summary_lines, vec![cmd]))
}
#[cfg(test)]
mod tests {
#[test]
fn parse_qm_lines_include_name_only_rows() {
let text = "foo 1.0-1\n\nbar\n";
let mut rows = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let name = parts.next().expect("test line has name").to_string();
let version = parts.collect::<Vec<_>>().join(" ");
rows.push((name, version));
}
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, "foo");
assert_eq!(rows[0].1, "1.0-1");
assert_eq!(rows[1].0, "bar");
assert!(rows[1].1.is_empty());
}
#[test]
fn sl_parsing_collects_second_column() {
let line = "chaotic-aur discord 0.0.45-1.1";
let mut parts = line.split_whitespace();
let _r = parts.next();
let pkg = parts.next().expect("test sl line has pkg");
assert_eq!(pkg, "discord");
}
#[test]
fn unknown_repo_sl_errors_are_recognized_en_de() {
assert!(super::sync_sl_failure_is_unknown_repository(
"pacman -Sl chaotic-aur failed: error: repository 'chaotic-aur' was not found."
));
assert!(super::sync_sl_failure_is_unknown_repository(
"Fehler: Das Repositorium »chaotic-aur« wurde nicht gefunden."
));
assert!(!super::sync_sl_failure_is_unknown_repository(
"pacman -Sl failed to run: No such file or directory (os error 2)"
));
}
#[test]
fn overlap_analysis_intersects_snapshot_foreign_with_repo_names() {
use std::collections::HashSet;
let foreign = vec![
("waypaper-git".to_string(), "2.7-1".to_string()),
("only-foreign".to_string(), "1-1".to_string()),
];
let mut repo = HashSet::new();
repo.insert("waypaper-git".to_string());
let a = super::overlap_analysis_from_foreign_and_repo_names(foreign, &repo);
assert_eq!(a.foreign_pkg_count, 2);
assert_eq!(a.sync_pkg_name_count, 1);
assert_eq!(a.entries.len(), 1);
assert_eq!(a.entries[0].name, "waypaper-git");
assert_eq!(a.entries[0].version, "2.7-1");
}
}