Skip to main content

arch_toolkit/deps/
source.rs

1//! Dependency source determination utilities.
2//!
3//! This module provides functions to determine where a dependency package comes from
4//! (official repository, AUR, or local) and to identify critical system packages.
5
6use crate::types::dependency::DependencySource;
7use std::collections::HashSet;
8use std::hash::BuildHasher;
9use std::process::{Command, Stdio};
10
11/// What: Infer the origin repository for a dependency currently under analysis.
12///
13/// Inputs:
14/// - `name`: Candidate dependency package name.
15/// - `installed`: Set of locally installed package names used to detect presence.
16///
17/// Output:
18/// - Returns a tuple with the determined `DependencySource` and a flag indicating core membership.
19///
20/// Details:
21/// - Prefers inspecting `pacman -Qi` metadata when the package is installed; otherwise defaults to heuristics.
22/// - For installed packages: uses `pacman -Qi` to read the "Repository" field.
23/// - For uninstalled packages: uses `pacman -Si` to check if it exists in official repositories.
24/// - Handles local packages (repo = "local" or empty) specially.
25/// - Downgrades gracefully to official classifications when the repository field cannot be read.
26/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
27/// - Returns reasonable defaults when pacman is unavailable (graceful degradation).
28///
29/// # Example
30///
31/// ```no_run
32/// use arch_toolkit::deps::determine_dependency_source;
33/// use std::collections::HashSet;
34///
35/// let installed = HashSet::from(["glibc".to_string()]);
36/// let (source, is_core) = determine_dependency_source("glibc", &installed);
37/// println!("Source: {:?}, Is core: {}", source, is_core);
38/// ```
39pub fn determine_dependency_source<S: BuildHasher>(
40    name: &str,
41    installed: &HashSet<String, S>,
42) -> (DependencySource, bool) {
43    if !installed.contains(name) {
44        // Not installed - check if it exists in official repos first
45        // Only default to AUR if it's not found in official repos
46        let output = Command::new("pacman")
47            .args(["-Si", name])
48            .env("LC_ALL", "C")
49            .env("LANG", "C")
50            .stdin(Stdio::null())
51            .stdout(Stdio::piped())
52            .stderr(Stdio::null())
53            .output();
54
55        if let Ok(output) = output
56            && output.status.success()
57        {
58            // Package exists in official repos - determine which repo
59            let text = String::from_utf8_lossy(&output.stdout);
60            for line in text.lines() {
61                if line.starts_with("Repository")
62                    && let Some(colon_pos) = line.find(':')
63                {
64                    let repo = line[colon_pos + 1..].trim().to_lowercase();
65                    let is_core = repo == "core";
66                    return (DependencySource::Official { repo }, is_core);
67                }
68            }
69            // Found in official repos but couldn't determine repo - assume extra
70            return (
71                DependencySource::Official {
72                    repo: "extra".to_string(),
73                },
74                false,
75            );
76        }
77        // Not found in official repos - this could be:
78        // 1. A binary/script provided by a package (not a package itself) - should be Missing
79        // 2. A virtual package (.so file) - should be filtered out earlier
80        // 3. A real AUR package - but we can't distinguish without checking AUR
81        //
82        // IMPORTANT: We don't try AUR here because:
83        // - Most dependencies are from official repos or are binaries/scripts
84        // - Trying AUR for every unknown dependency causes unnecessary API calls
85        // - Real AUR packages should be explicitly specified by the user, not discovered as dependencies
86        // - If it's truly an AUR dependency, it will be marked as Missing and the user can handle it
87        tracing::debug!(
88            "Package {} not found in official repos and not installed - will be marked as Missing (skipping AUR check)",
89            name
90        );
91        // Return AUR but the resolve logic should check if it exists before trying API
92        return (DependencySource::Aur, false);
93    }
94
95    // Package is installed - check which repository it came from
96    let output = Command::new("pacman")
97        .args(["-Qi", name])
98        .env("LC_ALL", "C")
99        .env("LANG", "C")
100        .stdin(Stdio::null())
101        .stdout(Stdio::piped())
102        .stderr(Stdio::piped())
103        .output();
104
105    match output {
106        Ok(output) if output.status.success() => {
107            let text = String::from_utf8_lossy(&output.stdout);
108            // Look for "Repository" field in pacman -Qi output
109            for line in text.lines() {
110                if line.starts_with("Repository")
111                    && let Some(colon_pos) = line.find(':')
112                {
113                    let repo = line[colon_pos + 1..].trim().to_lowercase();
114                    let is_core = repo == "core";
115                    // Handle local packages specially
116                    if repo == "local" || repo.is_empty() {
117                        return (DependencySource::Local, false);
118                    }
119                    return (DependencySource::Official { repo }, is_core);
120                }
121            }
122        }
123        _ => {
124            // Fallback: try pacman -Q to see if it's installed
125            // If we can't determine repo, assume it's from an official repo
126            tracing::debug!(
127                "Could not determine repository for {}, assuming official",
128                name
129            );
130        }
131    }
132
133    // Default: assume official repository (most installed packages are)
134    let is_core = is_system_package(name);
135    (
136        DependencySource::Official {
137            repo: if is_core {
138                "core".to_string()
139            } else {
140                "extra".to_string()
141            },
142        },
143        is_core,
144    )
145}
146
147/// What: Identify whether a dependency belongs to a curated list of critical system packages.
148///
149/// Inputs:
150/// - `name`: Package name to compare against the predefined system set.
151///
152/// Output:
153/// - `true` when the package is considered a core system component; otherwise `false`.
154///
155/// Details:
156/// - Used to highlight packages whose removal or downgrade should be discouraged.
157/// - Checks against a curated list of critical system packages.
158/// - Uses exact string matching (case-sensitive).
159///
160/// # Example
161///
162/// ```no_run
163/// use arch_toolkit::deps::is_system_package;
164///
165/// if is_system_package("glibc") {
166///     println!("glibc is a critical system package");
167/// }
168/// ```
169#[must_use]
170pub fn is_system_package(name: &str) -> bool {
171    // List of critical system packages
172    let system_packages = [
173        "glibc",
174        "linux",
175        "systemd",
176        "pacman",
177        "bash",
178        "coreutils",
179        "gcc",
180        "binutils",
181        "filesystem",
182        "util-linux",
183        "shadow",
184        "sed",
185        "grep",
186    ];
187    system_packages.contains(&name)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    /// What: Confirm `is_system_package` recognizes curated critical packages.
196    ///
197    /// Inputs:
198    /// - `names`: Sample package names including system and non-system entries.
199    ///
200    /// Output:
201    /// - Returns `true` for known core packages and `false` for unrelated software.
202    ///
203    /// Details:
204    /// - Exercises both positive (glibc, linux) and negative (firefox) cases to validate membership.
205    fn test_is_system_package_detects_core() {
206        assert!(is_system_package("glibc"));
207        assert!(is_system_package("linux"));
208        assert!(is_system_package("systemd"));
209        assert!(is_system_package("pacman"));
210        assert!(is_system_package("bash"));
211        assert!(is_system_package("coreutils"));
212        assert!(is_system_package("gcc"));
213        assert!(is_system_package("binutils"));
214        assert!(is_system_package("filesystem"));
215        assert!(is_system_package("util-linux"));
216        assert!(is_system_package("shadow"));
217        assert!(is_system_package("sed"));
218        assert!(is_system_package("grep"));
219        assert!(!is_system_package("firefox"));
220        assert!(!is_system_package("vim"));
221        assert!(!is_system_package("nonexistent"));
222        assert!(!is_system_package(""));
223    }
224
225    #[test]
226    /// What: Test `determine_dependency_source` with installed core package.
227    ///
228    /// Inputs:
229    /// - Mock pacman -Qi output for a core package.
230    ///
231    /// Output:
232    /// - Returns `(DependencySource::Official { repo: "core" }, true)`.
233    ///
234    /// Details:
235    /// - Tests parsing of pacman -Qi output for installed core packages.
236    fn test_determine_dependency_source_installed_core() {
237        // This test would require mocking Command, which is complex
238        // Instead, we test the parsing logic separately
239        let sample_output = "Repository      : core\nName            : glibc\n";
240        let mut found_repo = None;
241        for line in sample_output.lines() {
242            if line.starts_with("Repository")
243                && let Some(colon_pos) = line.find(':')
244            {
245                let repo = line[colon_pos + 1..].trim().to_lowercase();
246                let is_core = repo == "core";
247                found_repo = Some((repo, is_core));
248                break;
249            }
250        }
251        assert_eq!(found_repo, Some(("core".to_string(), true)));
252    }
253
254    #[test]
255    /// What: Test `determine_dependency_source` with installed extra package.
256    ///
257    /// Inputs:
258    /// - Mock pacman -Qi output for an extra package.
259    ///
260    /// Output:
261    /// - Returns `(DependencySource::Official { repo: "extra" }, false)`.
262    ///
263    /// Details:
264    /// - Tests parsing of pacman -Qi output for installed extra packages.
265    fn test_determine_dependency_source_installed_extra() {
266        let sample_output = "Repository      : extra\nName            : firefox\n";
267        let mut found_repo = None;
268        for line in sample_output.lines() {
269            if line.starts_with("Repository")
270                && let Some(colon_pos) = line.find(':')
271            {
272                let repo = line[colon_pos + 1..].trim().to_lowercase();
273                let is_core = repo == "core";
274                found_repo = Some((repo, is_core));
275                break;
276            }
277        }
278        assert_eq!(found_repo, Some(("extra".to_string(), false)));
279    }
280
281    #[test]
282    /// What: Test `determine_dependency_source` with installed local package.
283    ///
284    /// Inputs:
285    /// - Mock pacman -Qi output for a local package.
286    ///
287    /// Output:
288    /// - Returns `(DependencySource::Local, false)`.
289    ///
290    /// Details:
291    /// - Tests parsing of pacman -Qi output for local packages.
292    fn test_determine_dependency_source_installed_local() {
293        let sample_output = "Repository      : local\nName            : custom-package\n";
294        let mut found_repo = None;
295        for line in sample_output.lines() {
296            if line.starts_with("Repository")
297                && let Some(colon_pos) = line.find(':')
298            {
299                let repo = line[colon_pos + 1..].trim().to_lowercase();
300                if repo == "local" || repo.is_empty() {
301                    found_repo = Some("local");
302                    break;
303                }
304            }
305        }
306        assert_eq!(found_repo, Some("local"));
307    }
308
309    #[test]
310    /// What: Test `determine_dependency_source` with uninstalled official package.
311    ///
312    /// Inputs:
313    /// - Mock pacman -Si output for an official package.
314    ///
315    /// Output:
316    /// - Returns `(DependencySource::Official { repo }, is_core)`.
317    ///
318    /// Details:
319    /// - Tests parsing of pacman -Si output for uninstalled official packages.
320    fn test_determine_dependency_source_not_installed_official() {
321        let sample_output = "Repository      : extra\nName            : firefox\n";
322        let mut found_repo = None;
323        for line in sample_output.lines() {
324            if line.starts_with("Repository")
325                && let Some(colon_pos) = line.find(':')
326            {
327                let repo = line[colon_pos + 1..].trim().to_lowercase();
328                let is_core = repo == "core";
329                found_repo = Some((repo, is_core));
330                break;
331            }
332        }
333        assert_eq!(found_repo, Some(("extra".to_string(), false)));
334    }
335
336    #[test]
337    /// What: Test `determine_dependency_source` fallback behavior.
338    ///
339    /// Inputs:
340    /// - Package name that triggers fallback logic.
341    ///
342    /// Output:
343    /// - Returns reasonable defaults based on `is_system_package()`.
344    ///
345    /// Details:
346    /// - Tests fallback when pacman commands fail or repository cannot be determined.
347    fn test_determine_dependency_source_fallback() {
348        // Test fallback logic: if is_system_package returns true, should default to core
349        let is_core = is_system_package("glibc");
350        assert!(is_core);
351        let expected_repo = if is_core { "core" } else { "extra" };
352        assert_eq!(expected_repo, "core");
353
354        // Test fallback logic: if is_system_package returns false, should default to extra
355        let is_core = is_system_package("firefox");
356        assert!(!is_core);
357        let expected_repo = if is_core { "core" } else { "extra" };
358        assert_eq!(expected_repo, "extra");
359    }
360
361    #[test]
362    /// What: Test parsing repository field from pacman output.
363    ///
364    /// Inputs:
365    /// - Various pacman output formats.
366    ///
367    /// Output:
368    /// - Correctly extracts repository name and determines if core.
369    ///
370    /// Details:
371    /// - Tests edge cases in parsing pacman output.
372    fn test_parse_repository_field() {
373        let test_cases = vec![
374            ("Repository      : core", ("core", true)),
375            ("Repository      : extra", ("extra", false)),
376            ("Repository      : community", ("community", false)),
377            ("Repository      : local", ("local", false)),
378            ("Repository: core", ("core", true)),
379            ("Repository : extra", ("extra", false)),
380        ];
381
382        for (input, (expected_repo, expected_is_core)) in test_cases {
383            if let Some(colon_pos) = input.find(':') {
384                let repo = input[colon_pos + 1..].trim().to_lowercase();
385                let is_core = repo == "core";
386                assert_eq!(repo, expected_repo);
387                assert_eq!(is_core, expected_is_core);
388            }
389        }
390    }
391
392    // Integration tests that require pacman - these are ignored by default
393    #[test]
394    #[ignore = "Requires pacman to be available"]
395    /// What: Test `determine_dependency_source` with real pacman.
396    ///
397    /// Inputs:
398    /// - Real pacman database.
399    ///
400    /// Output:
401    /// - Correctly determines source for installed packages.
402    ///
403    /// Details:
404    /// - Integration test that requires pacman to be available.
405    fn test_determine_dependency_source_integration() {
406        use crate::deps::get_installed_packages;
407        // Test with a package that should be installed (pacman itself)
408        if let Ok(installed) = get_installed_packages()
409            && installed.contains("pacman")
410        {
411            let (source, is_core) = determine_dependency_source("pacman", &installed);
412            // pacman should be from core repository
413            match source {
414                DependencySource::Official { repo } => {
415                    assert_eq!(repo, "core");
416                    assert!(is_core);
417                }
418                _ => panic!("pacman should be from official core repository"),
419            }
420        }
421    }
422}