arch_toolkit/sandbox/analyze.rs
1//! Dependency delta analysis: compare declared dependencies against the host.
2
3use std::collections::{HashMap, HashSet};
4use std::hash::BuildHasher;
5
6use crate::deps::{
7 get_foreign_packages, get_installed_versions, is_package_installed_or_provided, parse_dep_spec,
8 parse_pkgbuild_deps, parse_srcinfo_deps, version_satisfies,
9};
10use crate::types::sandbox::{DependencyDelta, SandboxInfo};
11
12/// What: Analyze a package's dependencies from PKGBUILD content.
13///
14/// Inputs:
15/// - `package_name`: Package name for the report.
16/// - `pkgbuild_text`: PKGBUILD content.
17/// - `installed`: Set of installed package names (see `deps::get_installed_packages`).
18/// - `provided`: Set of names provided by installed packages (see `deps::get_provided_packages`).
19///
20/// Output:
21/// - `SandboxInfo` with per-category dependency deltas.
22///
23/// Details:
24/// - Parses dependency arrays via `deps::parse_pkgbuild_deps` (Phase 2 parser)
25/// and delegates to [`analyze_dependencies`] per category.
26///
27/// # Example
28///
29/// ```
30/// use arch_toolkit::sandbox::analyze_pkgbuild;
31/// use std::collections::HashSet;
32///
33/// let pkgbuild = "depends=('glibc')\nmakedepends=('rust>=1.70')";
34/// let installed: HashSet<String> = HashSet::from(["glibc".to_string()]);
35/// let provided = HashSet::new();
36/// let info = analyze_pkgbuild("demo", pkgbuild, &installed, &provided);
37/// assert_eq!(info.missing_packages(), ["rust>=1.70"]);
38/// ```
39#[must_use]
40pub fn analyze_pkgbuild<S: BuildHasher>(
41 package_name: &str,
42 pkgbuild_text: &str,
43 installed: &HashSet<String, S>,
44 provided: &HashSet<String, S>,
45) -> SandboxInfo {
46 let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild_text);
47 build_info(
48 package_name,
49 &depends,
50 &makedepends,
51 &checkdepends,
52 &optdepends,
53 installed,
54 provided,
55 )
56}
57
58/// What: Analyze a package's dependencies from .SRCINFO content.
59///
60/// Inputs:
61/// - `package_name`: Package name for the report.
62/// - `srcinfo_text`: .SRCINFO content.
63/// - `installed`: Set of installed package names.
64/// - `provided`: Set of names provided by installed packages.
65///
66/// Output:
67/// - `SandboxInfo` with per-category dependency deltas.
68///
69/// Details:
70/// - Parses dependency fields via `deps::parse_srcinfo_deps` (Phase 2 parser)
71/// and delegates to [`analyze_dependencies`] per category.
72/// - .SRCINFO is preferred over PKGBUILD when both are available (it is
73/// machine-generated and unambiguous); fetch it via `deps::fetch_srcinfo`
74/// when the `aur` feature is enabled.
75///
76/// # Example
77///
78/// ```
79/// use arch_toolkit::sandbox::analyze_srcinfo;
80/// use std::collections::HashSet;
81///
82/// let srcinfo = "pkgbase = demo\n\tdepends = glibc\n\tmakedepends = cmake";
83/// let installed: HashSet<String> = HashSet::from(["glibc".to_string(), "cmake".to_string()]);
84/// let provided = HashSet::new();
85/// let info = analyze_srcinfo("demo", srcinfo, &installed, &provided);
86/// assert!(info.is_ready_to_build());
87/// ```
88#[must_use]
89pub fn analyze_srcinfo<S: BuildHasher>(
90 package_name: &str,
91 srcinfo_text: &str,
92 installed: &HashSet<String, S>,
93 provided: &HashSet<String, S>,
94) -> SandboxInfo {
95 let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(srcinfo_text);
96 build_info(
97 package_name,
98 &depends,
99 &makedepends,
100 &checkdepends,
101 &optdepends,
102 installed,
103 provided,
104 )
105}
106
107/// What: Assemble a `SandboxInfo` from parsed dependency arrays.
108///
109/// Inputs:
110/// - `package_name`: Package name for the report.
111/// - Parsed dependency arrays per category.
112/// - `installed` / `provided`: Host package sets.
113///
114/// Output:
115/// - `SandboxInfo` with all categories analyzed.
116///
117/// Details:
118/// - Shared tail of the PKGBUILD and .SRCINFO entry points.
119fn build_info<S: BuildHasher>(
120 package_name: &str,
121 depends: &[String],
122 makedepends: &[String],
123 checkdepends: &[String],
124 optdepends: &[String],
125 installed: &HashSet<String, S>,
126 provided: &HashSet<String, S>,
127) -> SandboxInfo {
128 // Query host state once for all four categories (two subprocesses total,
129 // instead of two per dependency).
130 let host = HostState::query();
131 SandboxInfo {
132 package_name: package_name.to_string(),
133 depends: analyze_with_host(depends, installed, provided, &host),
134 makedepends: analyze_with_host(makedepends, installed, provided, &host),
135 checkdepends: analyze_with_host(checkdepends, installed, provided, &host),
136 optdepends: analyze_with_host(optdepends, installed, provided, &host),
137 }
138}
139
140/// What: Batched host package state shared across dependency categories.
141///
142/// Inputs:
143/// - Built by [`HostState::query`] from `pacman -Q` and `pacman -Qqm`.
144///
145/// Details:
146/// - Replaces per-dependency `pacman -Q <name>` / `pacman -Qi <name>` calls,
147/// which multiplied into a subprocess storm on long dependency lists.
148struct HostState {
149 /// Installed package versions (revision suffix stripped).
150 versions: HashMap<String, String>,
151 /// Foreign (`Repository: local`) package names.
152 foreign: HashSet<String>,
153}
154
155impl HostState {
156 /// What: Query installed versions and foreign packages in two subprocesses.
157 ///
158 /// Output:
159 /// - Populated state; empty maps when pacman is unavailable (graceful degradation).
160 fn query() -> Self {
161 Self {
162 versions: get_installed_versions(),
163 foreign: get_foreign_packages(),
164 }
165 }
166}
167
168/// What: Analyze dependency specs against the host environment.
169///
170/// Inputs:
171/// - `deps`: Dependency specs as declared (may include version requirements
172/// or optdepends `pkg: description` annotations).
173/// - `installed`: Set of installed package names.
174/// - `provided`: Set of names provided by installed packages.
175///
176/// Output:
177/// - One `DependencyDelta` per spec (local packages are skipped).
178///
179/// Details:
180/// - Membership is checked via `deps::is_package_installed_or_provided`.
181/// - Version constraints are parsed with `deps::parse_dep_spec` and checked
182/// with `deps::version_satisfies` against the `pacman -Q` version —
183/// an improvement over Pacsea, which passed the full spec as the
184/// requirement (never failing the check).
185/// - Installed local packages are filtered out, matching Pacsea (they are
186/// not relevant for build-preflight analysis).
187/// - Host state (versions, foreign packages) is queried in two batched pacman
188/// invocations up front and degrades gracefully when pacman is unavailable.
189#[must_use]
190pub fn analyze_dependencies<S: BuildHasher>(
191 deps: &[String],
192 installed: &HashSet<String, S>,
193 provided: &HashSet<String, S>,
194) -> Vec<DependencyDelta> {
195 analyze_with_host(deps, installed, provided, &HostState::query())
196}
197
198/// What: Analyze dependency specs against pre-queried host state.
199///
200/// Inputs:
201/// - `deps` / `installed` / `provided`: As in [`analyze_dependencies`].
202/// - `host`: Batched installed-version and foreign-package state.
203///
204/// Output:
205/// - One `DependencyDelta` per spec (foreign/local packages are skipped).
206///
207/// Details:
208/// - Shared core of [`analyze_dependencies`] and [`build_info`]; performs no
209/// subprocess calls except the lazy `pacman -Qqo` provides check for names
210/// missing from the installed set.
211fn analyze_with_host<S: BuildHasher>(
212 deps: &[String],
213 installed: &HashSet<String, S>,
214 provided: &HashSet<String, S>,
215 host: &HostState,
216) -> Vec<DependencyDelta> {
217 deps.iter()
218 .filter_map(|dep_spec| {
219 let pkg_name = extract_package_name(dep_spec);
220 let is_installed = is_package_installed_or_provided(&pkg_name, installed, provided);
221
222 // Skip local packages — not relevant for sandbox analysis
223 if is_installed && host.foreign.contains(&pkg_name) {
224 return None;
225 }
226
227 let installed_version = if is_installed {
228 host.versions.get(&pkg_name).cloned()
229 } else {
230 None
231 };
232
233 // Check the declared constraint against the installed version.
234 // Strip any optdepends description before parsing the spec.
235 let spec_only = dep_spec
236 .split_once(": ")
237 .map_or(dep_spec.as_str(), |(spec, _desc)| spec);
238 let version_req = parse_dep_spec(spec_only).version_req;
239 let version_satisfied = installed_version
240 .as_ref()
241 .is_some_and(|version| version_satisfies(version, &version_req));
242
243 Some(DependencyDelta {
244 name: dep_spec.clone(),
245 is_installed,
246 installed_version,
247 version_satisfied,
248 })
249 })
250 .collect()
251}
252
253/// What: Extract the bare package name from a dependency specification.
254///
255/// Inputs:
256/// - `dep_spec`: Spec like `foo>=1.2`, `bar`, or `baz: enables feature X`.
257///
258/// Output:
259/// - Package name without version requirements or optdepends description.
260///
261/// Details:
262/// - Handles the optdepends `package: description` form first, then strips
263/// version operators via `deps::parse_dep_spec`.
264///
265/// # Example
266///
267/// ```
268/// use arch_toolkit::sandbox::extract_package_name;
269///
270/// assert_eq!(extract_package_name("python>=3.12"), "python");
271/// assert_eq!(extract_package_name("cups: printing support"), "cups");
272/// assert_eq!(extract_package_name("glibc"), "glibc");
273/// ```
274#[must_use]
275pub fn extract_package_name(dep_spec: &str) -> String {
276 let spec_only = dep_spec
277 .split_once(':')
278 .map_or(dep_spec, |(before, _)| before);
279 parse_dep_spec(spec_only.trim()).name
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn sets(installed: &[&str]) -> (HashSet<String>, HashSet<String>) {
287 (
288 installed.iter().map(ToString::to_string).collect(),
289 HashSet::new(),
290 )
291 }
292
293 #[test]
294 /// What: Verify deltas report installed and missing dependencies.
295 ///
296 /// Inputs:
297 /// - Specs with one installed and one missing package.
298 ///
299 /// Output:
300 /// - Correct `is_installed` flags; missing entries have no version.
301 ///
302 /// Details:
303 /// - Membership comes from the caller-provided installed set.
304 fn membership() {
305 let (installed, provided) = sets(&["glibc"]);
306 let deps = vec![
307 "glibc".to_string(),
308 "definitely-not-installed-xyz".to_string(),
309 ];
310 let deltas = analyze_dependencies(&deps, &installed, &provided);
311 assert_eq!(deltas.len(), 2);
312 assert!(deltas[0].is_installed);
313 assert!(!deltas[1].is_installed);
314 assert!(deltas[1].installed_version.is_none());
315 assert!(!deltas[1].version_satisfied);
316 }
317
318 #[test]
319 /// What: Verify provided packages count as installed.
320 ///
321 /// Inputs:
322 /// - Package present only in the provided set.
323 ///
324 /// Output:
325 /// - Delta marked installed.
326 ///
327 /// Details:
328 /// - Uses a fixture-only virtual name so host pacman state cannot make the test pass accidentally.
329 fn provided_counts_as_installed() {
330 let installed: HashSet<String> = HashSet::new();
331 let provided: HashSet<String> = HashSet::from(["arch-toolkit-virtual-fixture".to_string()]);
332 let deltas = analyze_dependencies(
333 &["arch-toolkit-virtual-fixture".to_string()],
334 &installed,
335 &provided,
336 );
337 assert!(deltas[0].is_installed);
338 }
339
340 #[test]
341 /// What: Verify name extraction across spec forms.
342 ///
343 /// Inputs:
344 /// - Version-constrained, plain, and optdepends-annotated specs.
345 ///
346 /// Output:
347 /// - Bare package names.
348 ///
349 /// Details:
350 /// - Mirrors Pacsea's `extract_package_name` behavior.
351 fn name_extraction() {
352 assert_eq!(extract_package_name("python>=3.12"), "python");
353 assert_eq!(extract_package_name("qt6-base<7"), "qt6-base");
354 assert_eq!(extract_package_name("libfoo=1.0"), "libfoo");
355 assert_eq!(extract_package_name("cups: printing support"), "cups");
356 assert_eq!(extract_package_name(" glibc "), "glibc");
357 }
358
359 #[test]
360 /// What: Verify full analysis from PKGBUILD text.
361 ///
362 /// Inputs:
363 /// - PKGBUILD with depends/makedepends/optdepends and a partial installed set.
364 ///
365 /// Output:
366 /// - Correct categories, missing list, and readiness flag.
367 ///
368 /// Details:
369 /// - Exercises the Phase 2 parser integration end to end.
370 fn pkgbuild_analysis() {
371 let pkgbuild = r"
372depends=('glibc' 'missing-dep-xyz')
373makedepends=('cmake')
374optdepends=('cups: printing support')
375";
376 let (installed, provided) = sets(&["glibc", "cmake"]);
377 let info = analyze_pkgbuild("demo", pkgbuild, &installed, &provided);
378 assert_eq!(info.package_name, "demo");
379 assert_eq!(info.depends.len(), 2);
380 assert_eq!(info.makedepends.len(), 1);
381 assert_eq!(info.optdepends.len(), 1);
382 assert_eq!(info.missing_packages(), ["missing-dep-xyz"]);
383 assert!(!info.is_ready_to_build());
384 }
385
386 #[test]
387 /// What: Verify full analysis from .SRCINFO text.
388 ///
389 /// Inputs:
390 /// - .SRCINFO with all build deps present in the installed set.
391 ///
392 /// Output:
393 /// - Ready-to-build report with no missing packages.
394 ///
395 /// Details:
396 /// - Missing optdepends must not affect readiness.
397 fn srcinfo_analysis() {
398 let srcinfo = "pkgbase = demo\n\tdepends = glibc\n\tmakedepends = rust\n\toptdepends = cups: printing";
399 let (installed, provided) = sets(&["glibc", "rust"]);
400 let info = analyze_srcinfo("demo", srcinfo, &installed, &provided);
401 assert!(info.is_ready_to_build());
402 assert!(info.missing_packages().is_empty());
403 assert_eq!(info.optdepends.len(), 1);
404 }
405
406 #[test]
407 /// What: Verify empty input produces an empty, ready report.
408 ///
409 /// Inputs:
410 /// - Empty PKGBUILD text.
411 ///
412 /// Output:
413 /// - No deltas in any category; ready to build.
414 ///
415 /// Details:
416 /// - Degenerate inputs must not panic.
417 fn empty_input() {
418 let (installed, provided) = sets(&[]);
419 let info = analyze_pkgbuild("empty", "", &installed, &provided);
420 assert!(info.depends.is_empty());
421 assert!(info.is_ready_to_build());
422 }
423}