pacsea/logic/repos/foreign_overlap.rs
1//! Foreign (AUR) packages that also exist in a given sync repository.
2
3use std::collections::HashSet;
4use std::process::Command;
5
6use crate::install::shell_single_quote;
7use crate::logic::privilege::{PrivilegeTool, build_privilege_command};
8
9/// What: One foreign package whose name exists in a sync database.
10///
11/// Inputs:
12/// - Built by [`compute_foreign_repo_overlap`].
13///
14/// Output:
15/// - Display and migration planning.
16///
17/// Details:
18/// - `version` is the installed foreign version from `pacman -Qm` (not `-Qmq`: `-q` omits versions).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ForeignRepoOverlapEntry {
21 /// Package name (`pkgname`).
22 pub name: String,
23 /// Installed version string (`ver-rel`).
24 pub version: String,
25}
26
27/// What: Outcome of comparing foreign installs to a sync repository’s package names.
28///
29/// Inputs:
30/// - Produced by [`analyze_foreign_repo_overlap`].
31///
32/// Output:
33/// - `entries` for the overlap wizard; counts for user-facing follow-up toasts.
34///
35/// Details:
36/// - `sync_pkg_name_count == 0` can mean an unknown repo (treated as empty), a failed list, or a repo
37/// with no packages; see [`sync_repo_pkgnames_or_empty_if_repo_missing`].
38#[derive(Debug, Clone)]
39pub struct ForeignRepoOverlapAnalysis {
40 /// Overlapping foreign packages (non-empty when the wizard should open).
41 pub entries: Vec<ForeignRepoOverlapEntry>,
42 /// Count of rows from `pacman -Qm` before intersection.
43 pub foreign_pkg_count: usize,
44 /// Distinct package names from `pacman -Sl <repo>` (0 when listing failed as “unknown repo” or empty).
45 pub sync_pkg_name_count: usize,
46}
47
48/// What: Run `pacman -Qm` and parse foreign package names and versions.
49///
50/// Inputs:
51/// - None (uses host `pacman`).
52///
53/// Output:
54/// - Vector of `(pkgname, version)` or error message. `version` is the remainder of the line after the
55/// package name (typically `pkgver-pkgrel` from `pacman -Qm`, but may be empty for name-only lines).
56///
57/// Details:
58/// - Uses `-Qm` without `-q` so each line normally includes `pkgver-pkgrel`. `pacman -Qmq` only prints
59/// names (see `pacman(8)` `--quiet`), which would make overlap detection see zero foreign packages.
60/// - Returns empty vector when no foreign packages exist.
61/// - Skips blank lines. Non-blank lines always yield an entry: the first whitespace-separated token is
62/// the package name; any further tokens are joined with spaces into `version`, which is empty when the
63/// line contains only a name (no version field to skip—overlap logic still keys on `pkgname`).
64///
65/// # Errors
66///
67/// - Returns an error when `pacman` cannot be executed or exits non-zero.
68pub fn list_foreign_packages() -> Result<Vec<(String, String)>, String> {
69 let out = Command::new("pacman")
70 .args(["-Qm"])
71 .output()
72 .map_err(|e| format!("pacman -Qm failed to run: {e}"))?;
73 if !out.status.success() {
74 let stderr = String::from_utf8_lossy(&out.stderr);
75 return Err(format!(
76 "pacman -Qm failed (status {}): {stderr}",
77 out.status
78 ));
79 }
80 let text = String::from_utf8_lossy(&out.stdout);
81 let mut rows = Vec::new();
82 for line in text.lines() {
83 let line = line.trim();
84 if line.is_empty() {
85 continue;
86 }
87 let mut parts = line.split_whitespace();
88 let Some(name) = parts.next().map(str::to_string) else {
89 continue;
90 };
91 let version = parts.collect::<Vec<_>>().join(" ");
92 rows.push((name, version));
93 }
94 Ok(rows)
95}
96
97/// What: Collect `pkgname` values from `pacman -Sl <repo>`.
98///
99/// Inputs:
100/// - `repo`: Lowercase `[repo]` name (e.g. `chaotic-aur`).
101///
102/// Output:
103/// - Set of package names or error.
104///
105/// Details:
106/// - Ignores header lines and malformed rows.
107///
108/// # Errors
109///
110/// - Returns an error when `pacman` cannot be executed, exits non-zero, or the repo is unknown.
111pub fn sync_repo_pkgnames(repo: &str) -> Result<HashSet<String>, String> {
112 let repo = repo.trim();
113 if repo.is_empty() {
114 return Err("Repository name is empty.".to_string());
115 }
116 let out = Command::new("pacman")
117 .args(["-Sl", repo])
118 .output()
119 .map_err(|e| format!("pacman -Sl failed to run: {e}"))?;
120 if !out.status.success() {
121 let stderr = String::from_utf8_lossy(&out.stderr);
122 return Err(format!(
123 "pacman -Sl {repo} failed (sync database missing or invalid?): {stderr}"
124 ));
125 }
126 let text = String::from_utf8_lossy(&out.stdout);
127 let mut set = HashSet::new();
128 for line in text.lines() {
129 let line = line.trim();
130 if line.is_empty() || line.starts_with('[') {
131 continue;
132 }
133 let mut parts = line.split_whitespace();
134 let _r = parts.next();
135 let Some(pkg) = parts.next() else {
136 continue;
137 };
138 set.insert(pkg.to_string());
139 }
140 Ok(set)
141}
142
143/// What: Detect pacman stderr/English/German messages meaning the repository is not configured.
144///
145/// Inputs:
146/// - `combined`: Lowercased `pacman -Sl` error text (stdout+stderr merged is unnecessary; caller passes stderr body).
147///
148/// Output:
149/// - `true` when the failure is consistent with an unknown or unavailable repo name.
150///
151/// Details:
152/// - After a Pacsea apply disables the last managed repo, `pacman -Sl <name>` fails because the section
153/// is gone; overlap detection should treat that as “no sync packages” instead of surfacing a connection alert.
154fn sync_sl_failure_is_unknown_repository(combined: &str) -> bool {
155 let lower = combined.to_lowercase();
156 lower.contains("not found")
157 || lower.contains("nicht gefunden")
158 || lower.contains("wurde nicht gefunden")
159 || lower.contains("could not find")
160 || lower.contains("unable to find")
161 || lower.contains("unknown repository")
162 || lower.contains("repository not found")
163 || lower.contains("no package database")
164}
165
166/// What: Like [`sync_repo_pkgnames`] but returns an empty set when the repo is unknown to pacman.
167///
168/// Inputs:
169/// - `repo`: Lowercase `[repo]` name passed to `pacman -Sl`.
170///
171/// Output:
172/// - Package name set, or empty when pacman reports the repository is missing.
173///
174/// Details:
175/// - Propagates other `pacman -Sl` failures (permissions, corrupted DB, etc.).
176fn sync_repo_pkgnames_or_empty_if_repo_missing(repo: &str) -> Result<HashSet<String>, String> {
177 match sync_repo_pkgnames(repo) {
178 Ok(s) => Ok(s),
179 Err(msg) => {
180 if sync_sl_failure_is_unknown_repository(&msg) {
181 Ok(HashSet::new())
182 } else {
183 Err(msg)
184 }
185 }
186 }
187}
188
189/// What: Build overlap analysis from a foreign list and a sync repo’s package-name set.
190///
191/// Inputs:
192/// - `foreign`: `(pkgname, ver-rel)` rows (typically from `pacman -Qm`).
193/// - `repo_names`: Names from `pacman -Sl <repo>`.
194///
195/// Output:
196/// - [`ForeignRepoOverlapAnalysis`] with sorted overlap entries and counts.
197///
198/// Details:
199/// - `foreign_pkg_count` is `foreign.len()`; `sync_pkg_name_count` is `repo_names.len()`.
200fn overlap_analysis_from_foreign_and_repo_names(
201 foreign: Vec<(String, String)>,
202 repo_names: &HashSet<String>,
203) -> ForeignRepoOverlapAnalysis {
204 let foreign_pkg_count = foreign.len();
205 let sync_pkg_name_count = repo_names.len();
206 let mut entries: Vec<ForeignRepoOverlapEntry> = foreign
207 .into_iter()
208 .filter(|(n, _)| repo_names.contains(n))
209 .map(|(name, version)| ForeignRepoOverlapEntry { name, version })
210 .collect();
211 entries.sort_by(|a, b| a.name.cmp(&b.name));
212 ForeignRepoOverlapAnalysis {
213 entries,
214 foreign_pkg_count,
215 sync_pkg_name_count,
216 }
217}
218
219/// What: Compare foreign installs to `pacman -Sl <repo>` with counts for UI diagnostics.
220///
221/// Inputs:
222/// - `repo`: Pacman repository name (same as `pacman -Sl` argument; usually lowercase).
223///
224/// Output:
225/// - [`ForeignRepoOverlapAnalysis`] with sorted overlap entries and counts.
226///
227/// Details:
228/// - Read-only; does not mutate the system.
229///
230/// # Errors
231///
232/// - Propagates failures from [`list_foreign_packages`] or [`sync_repo_pkgnames_or_empty_if_repo_missing`]
233/// when `pacman -Sl` fails for reasons other than an unknown/missing repository.
234pub fn analyze_foreign_repo_overlap(repo: &str) -> Result<ForeignRepoOverlapAnalysis, String> {
235 analyze_foreign_repo_overlap_with_qm_snapshot(repo, None)
236}
237
238/// What: Compare foreign installs to `pacman -Sl <repo>`, using an optional pre-apply `pacman -Qm` snapshot.
239///
240/// Inputs:
241/// - `repo`: Pacman repository name for `pacman -Sl`.
242/// - `pre_apply_foreign_snapshot`: When `Some`, foreign rows captured when repo apply was **queued**
243/// (before privileged commands). When `None`, calls [`list_foreign_packages`] at analysis time.
244///
245/// Output:
246/// - Same as [`analyze_foreign_repo_overlap`].
247///
248/// Details:
249/// - After a repo is enabled, pacman may reclassify installs so they no longer appear in `-Qm`; the
250/// snapshot preserves the pre-enable foreign set without toggling repositories in `pacman.conf`.
251///
252/// # Errors
253///
254/// - Propagates failures from [`list_foreign_packages`] when `pre_apply_foreign_snapshot` is `None`,
255/// or from [`sync_repo_pkgnames_or_empty_if_repo_missing`].
256pub fn analyze_foreign_repo_overlap_with_qm_snapshot(
257 repo: &str,
258 pre_apply_foreign_snapshot: Option<&[(String, String)]>,
259) -> Result<ForeignRepoOverlapAnalysis, String> {
260 let foreign: Vec<(String, String)> = if let Some(rows) = pre_apply_foreign_snapshot {
261 rows.to_vec()
262 } else {
263 list_foreign_packages()?
264 };
265 let repo_names = sync_repo_pkgnames_or_empty_if_repo_missing(repo)?;
266 Ok(overlap_analysis_from_foreign_and_repo_names(
267 foreign,
268 &repo_names,
269 ))
270}
271
272/// What: Foreign packages installed whose `pkgname` exists in `repo`'s sync DB.
273///
274/// Inputs:
275/// - `repo`: Pacman repository name (same as `pacman -Sl` argument).
276///
277/// Output:
278/// - Sorted overlap entries for stable UI.
279///
280/// Details:
281/// - Read-only; does not mutate the system.
282///
283/// # Errors
284///
285/// - Propagates failures from [`list_foreign_packages`] or [`sync_repo_pkgnames_or_empty_if_repo_missing`].
286pub fn compute_foreign_repo_overlap(repo: &str) -> Result<Vec<ForeignRepoOverlapEntry>, String> {
287 Ok(analyze_foreign_repo_overlap(repo)?.entries)
288}
289
290/// What: Build privileged migrate commands: remove foreign packages then install from sync.
291///
292/// Inputs:
293/// - `tool`: Active privilege backend.
294/// - `dry_run`: When `true`, emit `echo DRY RUN: ...` only.
295/// - `pkgs`: Package names to remove and reinstall (same `pkgname`).
296///
297/// Output:
298/// - `(summary_lines, commands)` for [`crate::install::ExecutorRequest::Update`].
299///
300/// Details:
301/// - Single transaction chain `pacman -Rns` then `pacman -S` for all names.
302/// - `-Rns` may remove dependents; UI must warn users before calling.
303///
304/// # Errors
305///
306/// - Returns an error when `pkgs` is empty or privilege command construction fails.
307pub fn build_foreign_to_sync_migrate_bundle(
308 tool: PrivilegeTool,
309 dry_run: bool,
310 pkgs: &[String],
311) -> Result<(Vec<String>, Vec<String>), String> {
312 if pkgs.is_empty() {
313 return Err("No packages selected for migration.".to_string());
314 }
315 let joined = pkgs.join(" ");
316 let summary_lines = vec![
317 format!("Remove foreign packages: {joined}"),
318 format!("Install from sync repositories: {joined}"),
319 ];
320 let inner =
321 format!("pacman -Rns --noconfirm {joined} && pacman -S --needed --noconfirm {joined}");
322 let cmd = if dry_run {
323 let quoted = shell_single_quote(&inner);
324 format!("echo DRY RUN: {quoted}")
325 } else {
326 build_privilege_command(tool, &inner)
327 };
328 Ok((summary_lines, vec![cmd]))
329}
330
331#[cfg(test)]
332mod tests {
333 #[test]
334 fn parse_qm_lines_include_name_only_rows() {
335 let text = "foo 1.0-1\n\nbar\n";
336 let mut rows = Vec::new();
337 for line in text.lines() {
338 let line = line.trim();
339 if line.is_empty() {
340 continue;
341 }
342 let mut parts = line.split_whitespace();
343 let name = parts.next().expect("test line has name").to_string();
344 let version = parts.collect::<Vec<_>>().join(" ");
345 rows.push((name, version));
346 }
347 assert_eq!(rows.len(), 2);
348 assert_eq!(rows[0].0, "foo");
349 assert_eq!(rows[0].1, "1.0-1");
350 assert_eq!(rows[1].0, "bar");
351 assert!(rows[1].1.is_empty());
352 }
353
354 #[test]
355 fn sl_parsing_collects_second_column() {
356 let line = "chaotic-aur discord 0.0.45-1.1";
357 let mut parts = line.split_whitespace();
358 let _r = parts.next();
359 let pkg = parts.next().expect("test sl line has pkg");
360 assert_eq!(pkg, "discord");
361 }
362
363 #[test]
364 fn unknown_repo_sl_errors_are_recognized_en_de() {
365 assert!(super::sync_sl_failure_is_unknown_repository(
366 "pacman -Sl chaotic-aur failed: error: repository 'chaotic-aur' was not found."
367 ));
368 assert!(super::sync_sl_failure_is_unknown_repository(
369 "Fehler: Das Repositorium »chaotic-aur« wurde nicht gefunden."
370 ));
371 assert!(!super::sync_sl_failure_is_unknown_repository(
372 "pacman -Sl failed to run: No such file or directory (os error 2)"
373 ));
374 }
375
376 #[test]
377 /// What: Verify pre-apply foreign rows intersect sync name set as used after repo apply.
378 ///
379 /// Inputs:
380 /// - Synthetic foreign list and `HashSet` of repo pkgnames.
381 ///
382 /// Output:
383 /// - Analysis counts and entries match intersection expectations.
384 ///
385 /// Details:
386 /// - Mirrors [`super::overlap_analysis_from_foreign_and_repo_names`] without calling pacman.
387 fn overlap_analysis_intersects_snapshot_foreign_with_repo_names() {
388 use std::collections::HashSet;
389
390 let foreign = vec![
391 ("waypaper-git".to_string(), "2.7-1".to_string()),
392 ("only-foreign".to_string(), "1-1".to_string()),
393 ];
394 let mut repo = HashSet::new();
395 repo.insert("waypaper-git".to_string());
396 let a = super::overlap_analysis_from_foreign_and_repo_names(foreign, &repo);
397 assert_eq!(a.foreign_pkg_count, 2);
398 assert_eq!(a.sync_pkg_name_count, 1);
399 assert_eq!(a.entries.len(), 1);
400 assert_eq!(a.entries[0].name, "waypaper-git");
401 assert_eq!(a.entries[0].version, "2.7-1");
402 }
403}