Skip to main content

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}