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}