Skip to main content

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}