Skip to main content

arch_toolkit/deps/
srcinfo.rs

1//! Parser for AUR .SRCINFO files.
2//!
3//! This module provides functions for parsing .SRCINFO files, which are
4//! machine-readable metadata files generated from PKGBUILD files for AUR packages.
5
6use std::collections::HashSet;
7
8use crate::deps::parse::parse_dep_spec;
9#[cfg(feature = "aur")]
10use crate::error::Result;
11use crate::types::dependency::SrcinfoData;
12
13#[cfg(feature = "aur")]
14use crate::aur::utils::percent_encode;
15
16/// Maximum accepted AUR `.SRCINFO` response body size in bytes.
17#[cfg(feature = "aur")]
18const MAX_AUR_SRCINFO_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
19
20/// What: Store one split-package output for graph-only `.SRCINFO` resolution.
21///
22/// Inputs:
23/// - Package-output dependency, provider, conflict, and replacement fields.
24///
25/// Output:
26/// - Retains a selected split package's metadata for the injected graph resolver.
27///
28/// Details:
29/// - This internal projection keeps the legacy public `SrcinfoData` struct source-compatible while
30///   preserving exact package-output ownership for graph traversal.
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub(super) struct SrcinfoPackage {
33    /// Selected package output name.
34    pub(super) name: String,
35    /// Runtime dependency specifications.
36    pub(super) depends: Vec<String>,
37    /// Build dependency specifications.
38    pub(super) makedepends: Vec<String>,
39    /// Check dependency specifications.
40    pub(super) checkdepends: Vec<String>,
41    /// Optional dependency specifications.
42    pub(super) optdepends: Vec<String>,
43    /// Package and virtual conflict specifications.
44    pub(super) conflicts: Vec<String>,
45    /// Virtual provider specifications.
46    pub(super) provides: Vec<String>,
47    /// Replacement specifications.
48    pub(super) replaces: Vec<String>,
49}
50
51/// What: Store graph-specific header and split-package `.SRCINFO` metadata.
52///
53/// Inputs:
54/// - Package-base header fields and selected package-output projections.
55///
56/// Output:
57/// - Supplies epoch/pkgver/pkgrel and split package metadata to graph resolution.
58///
59/// Details:
60/// - This internal representation is separate from the legacy public aggregate parser output to
61///   avoid breaking callers that construct `SrcinfoData` with a struct literal.
62#[derive(Clone, Debug, Default)]
63pub(super) struct GraphSrcinfoData {
64    /// Package-base name.
65    pub(super) pkgbase: String,
66    /// Package epoch, if declared.
67    pub(super) epoch: String,
68    /// Package version.
69    pub(super) pkgver: String,
70    /// Package release.
71    pub(super) pkgrel: String,
72    /// Individual split-package outputs.
73    pub(super) packages: Vec<SrcinfoPackage>,
74}
75
76/// What: Parse dependencies from .SRCINFO content.
77///
78/// Inputs:
79/// - `srcinfo`: Raw .SRCINFO file content.
80///
81/// Output:
82/// - Returns a tuple of (depends, makedepends, checkdepends, optdepends) vectors.
83///
84/// Details:
85/// - Parses key-value pairs from .SRCINFO format.
86/// - Handles array fields that can appear multiple times.
87/// - Filters out virtual packages (.so files).
88/// - Deduplicates dependencies (returns unique list).
89/// - Handles architecture-specific dependencies (e.g., `depends_x86_64`).
90#[allow(clippy::case_sensitive_file_extension_comparisons)]
91#[must_use]
92pub fn parse_srcinfo_deps(srcinfo: &str) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
93    let mut depends = Vec::new();
94    let mut makedepends = Vec::new();
95    let mut checkdepends = Vec::new();
96    let mut optdepends = Vec::new();
97
98    // Use HashSet for deduplication
99    let mut seen_depends = HashSet::new();
100    let mut seen_makedepends = HashSet::new();
101    let mut seen_checkdepends = HashSet::new();
102    let mut seen_optdepends = HashSet::new();
103
104    for line in srcinfo.lines() {
105        let line = line.trim();
106        if line.is_empty() || line.starts_with('#') {
107            continue;
108        }
109
110        // .SRCINFO format: key = value (tab-indented)
111        if let Some((key, value)) = line.split_once('=') {
112            let key = key.trim();
113            let value = value.trim();
114
115            // Filter out virtual packages (.so files)
116            let value_lower = value.to_lowercase();
117            if value_lower.ends_with(".so")
118                || value_lower.contains(".so.")
119                || value_lower.contains(".so=")
120            {
121                continue;
122            }
123
124            // Handle architecture-specific dependencies by merging into main arrays
125            let base_key = key
126                .find('_')
127                .map_or(key, |underscore_pos| &key[..underscore_pos]);
128
129            match base_key {
130                "depends" if seen_depends.insert(value.to_string()) => {
131                    depends.push(value.to_string());
132                }
133                "makedepends" if seen_makedepends.insert(value.to_string()) => {
134                    makedepends.push(value.to_string());
135                }
136                "checkdepends" if seen_checkdepends.insert(value.to_string()) => {
137                    checkdepends.push(value.to_string());
138                }
139                "optdepends" if seen_optdepends.insert(value.to_string()) => {
140                    optdepends.push(value.to_string());
141                }
142                _ => {}
143            }
144        }
145    }
146
147    (depends, makedepends, checkdepends, optdepends)
148}
149
150/// What: Parse conflicts from .SRCINFO content.
151///
152/// Inputs:
153/// - `srcinfo`: Raw .SRCINFO file content.
154///
155/// Output:
156/// - Returns a vector of conflicting package names (without version constraints).
157///
158/// Details:
159/// - Parses "conflicts" key-value pairs from .SRCINFO format.
160/// - Handles array fields that can appear multiple times.
161/// - Filters out virtual packages (.so files) and extracts package names from version constraints.
162/// - Deduplicates conflicts (returns unique list).
163#[allow(clippy::case_sensitive_file_extension_comparisons)]
164#[must_use]
165pub fn parse_srcinfo_conflicts(srcinfo: &str) -> Vec<String> {
166    let mut conflicts = Vec::new();
167    let mut seen = HashSet::new();
168
169    for line in srcinfo.lines() {
170        let line = line.trim();
171        if line.is_empty() || line.starts_with('#') {
172            continue;
173        }
174
175        // .SRCINFO format: key = value
176        if let Some((key, value)) = line.split_once('=') {
177            let key = key.trim();
178            let value = value.trim();
179
180            // Handle architecture-specific conflicts
181            let base_key = key
182                .find('_')
183                .map_or(key, |underscore_pos| &key[..underscore_pos]);
184
185            if base_key == "conflicts" {
186                // Filter out virtual packages (.so files)
187                let value_lower = value.to_lowercase();
188                if value_lower.ends_with(".so")
189                    || value_lower.contains(".so.")
190                    || value_lower.contains(".so=")
191                {
192                    continue;
193                }
194                // Extract package name (remove version constraints if present)
195                let spec = parse_dep_spec(value);
196                if !spec.name.is_empty() && seen.insert(spec.name.clone()) {
197                    conflicts.push(spec.name);
198                }
199            }
200        }
201    }
202
203    conflicts
204}
205
206/// What: Normalize a `.SRCINFO` key by removing an architecture suffix.
207///
208/// Inputs:
209/// - `key`: A raw `.SRCINFO` key such as `depends_x86_64`.
210///
211/// Output:
212/// - Returns the key family such as `depends`.
213///
214/// Details:
215/// - `.SRCINFO` uses underscore suffixes for architecture-specific dependency fields.
216fn srcinfo_base_key(key: &str) -> &str {
217    key.find('_').map_or(key, |position| &key[..position])
218}
219
220/// What: Add a metadata value once while retaining first-seen source order.
221///
222/// Inputs:
223/// - `values`: Destination metadata values.
224/// - `value`: Metadata value to insert.
225///
226/// Output:
227/// - Updates `values` only when the value was not present.
228///
229/// Details:
230/// - Retaining source order keeps split-package fixture results deterministic before graph sorting.
231fn push_srcinfo_value(values: &mut Vec<String>, value: &str) {
232    if !values.iter().any(|existing| existing == value) {
233        values.push(value.to_string());
234    }
235}
236
237/// What: Add one dependency-related `.SRCINFO` field to a package projection.
238///
239/// Inputs:
240/// - `package`: Package-base or package-output projection to update.
241/// - `key`: Normalized `.SRCINFO` field family.
242/// - `value`: Trimmed field value.
243///
244/// Output:
245/// - Updates the matching dependency, provider, conflict, or replacement collection.
246///
247/// Details:
248/// - Values are intentionally not filtered: graph resolution must retain virtual `.so` provides
249///   and dependencies even though legacy flat parser helpers retain their existing filtering.
250fn apply_package_field(package: &mut SrcinfoPackage, key: &str, value: &str) {
251    match key {
252        "depends" => push_srcinfo_value(&mut package.depends, value),
253        "makedepends" => push_srcinfo_value(&mut package.makedepends, value),
254        "checkdepends" => push_srcinfo_value(&mut package.checkdepends, value),
255        "optdepends" => push_srcinfo_value(&mut package.optdepends, value),
256        "conflicts" => push_srcinfo_value(&mut package.conflicts, value),
257        "provides" => push_srcinfo_value(&mut package.provides, value),
258        "replaces" => push_srcinfo_value(&mut package.replaces, value),
259        _ => {}
260    }
261}
262
263/// What: Merge shared package-base fields into one split-package output.
264///
265/// Inputs:
266/// - `package`: Split-package output to enrich.
267/// - `base`: Shared package-base dependency metadata.
268///
269/// Output:
270/// - Updates `package` with every unique base-level field.
271///
272/// Details:
273/// - Package-output fields retain their values and base fields are appended only when absent.
274fn merge_package_base(package: &mut SrcinfoPackage, base: &SrcinfoPackage) {
275    for (target, shared) in [
276        (&mut package.depends, &base.depends),
277        (&mut package.makedepends, &base.makedepends),
278        (&mut package.checkdepends, &base.checkdepends),
279        (&mut package.optdepends, &base.optdepends),
280        (&mut package.conflicts, &base.conflicts),
281        (&mut package.provides, &base.provides),
282        (&mut package.replaces, &base.replaces),
283    ] {
284        for value in shared {
285            push_srcinfo_value(target, value);
286        }
287    }
288}
289
290/// What: Parse lossless package-output dependency metadata from a `.SRCINFO` document.
291///
292/// Inputs:
293/// - `content`: Raw `.SRCINFO` text.
294///
295/// Output:
296/// - Returns one package projection for every `pkgname` section.
297///
298/// Details:
299/// - Package-base metadata is merged into each output. Unlike legacy helpers, virtual entries are
300///   retained so a graph provider can verify provider identity and conflicts.
301fn parse_srcinfo_packages(content: &str) -> Vec<SrcinfoPackage> {
302    let mut base = SrcinfoPackage::default();
303    let mut packages = Vec::new();
304    let mut current_package = None;
305
306    for raw_line in content.lines() {
307        let line = raw_line.trim();
308        if line.is_empty() || line.starts_with('#') {
309            continue;
310        }
311        let Some((raw_key, raw_value)) = line.split_once('=') else {
312            continue;
313        };
314        let key = srcinfo_base_key(raw_key.trim());
315        let value = raw_value.trim();
316        if key == "pkgname" {
317            packages.push(SrcinfoPackage {
318                name: value.to_string(),
319                ..SrcinfoPackage::default()
320            });
321            current_package = Some(packages.len() - 1);
322            continue;
323        }
324        if let Some(index) = current_package {
325            apply_package_field(&mut packages[index], key, value);
326        } else {
327            apply_package_field(&mut base, key, value);
328        }
329    }
330
331    for package in &mut packages {
332        merge_package_base(package, &base);
333    }
334    packages
335}
336
337/// What: Parse graph-specific package-base and split-package `.SRCINFO` metadata.
338///
339/// Inputs:
340/// - `content`: Raw `.SRCINFO` text.
341///
342/// Output:
343/// - Returns package base, epoch/pkgver/pkgrel, and lossless split-package projections.
344///
345/// Details:
346/// - The graph resolver uses this internal parser while legacy callers retain the existing
347///   aggregate `parse_srcinfo` contract and its public `SrcinfoData` shape.
348pub(super) fn parse_srcinfo_graph(content: &str) -> GraphSrcinfoData {
349    let mut data = GraphSrcinfoData {
350        packages: parse_srcinfo_packages(content),
351        ..GraphSrcinfoData::default()
352    };
353    for raw_line in content.lines() {
354        let line = raw_line.trim();
355        if line.is_empty() || line.starts_with('#') {
356            continue;
357        }
358        let Some((raw_key, raw_value)) = line.split_once('=') else {
359            continue;
360        };
361        let key = srcinfo_base_key(raw_key.trim());
362        let value = raw_value.trim();
363        match key {
364            "pkgbase" if data.pkgbase.is_empty() => data.pkgbase = value.to_string(),
365            "epoch" if data.epoch.is_empty() => data.epoch = value.to_string(),
366            "pkgver" if data.pkgver.is_empty() => data.pkgver = value.to_string(),
367            "pkgrel" if data.pkgrel.is_empty() => data.pkgrel = value.to_string(),
368            _ => {}
369        }
370    }
371    data
372}
373
374/// What: Parse full .SRCINFO content into structured data.
375///
376/// Inputs:
377/// - `content`: Raw .SRCINFO file content.
378///
379/// Output:
380/// - Returns `SrcinfoData` with aggregate fields populated.
381///
382/// Details:
383/// - Parses all fields including pkgbase, pkgname, pkgver, pkgrel and package arrays.
384/// - Existing aggregate fields retain their historical first-name/merged-array behavior.
385/// - Graph-only split-package selection is kept internal to preserve public struct-literal compatibility.
386/// - Returns default `SrcinfoData` with empty fields if content is malformed.
387#[must_use]
388pub fn parse_srcinfo(content: &str) -> SrcinfoData {
389    let mut data = SrcinfoData::default();
390    let mut pkgname_found = false;
391
392    // Parse dependencies and conflicts
393    let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(content);
394    data.depends = depends;
395    data.makedepends = makedepends;
396    data.checkdepends = checkdepends;
397    data.optdepends = optdepends;
398    data.conflicts = parse_srcinfo_conflicts(content);
399
400    // Parse other fields
401    let mut seen_provides = HashSet::new();
402    let mut seen_replaces = HashSet::new();
403
404    for line in content.lines() {
405        let line = line.trim();
406        if line.is_empty() || line.starts_with('#') {
407            continue;
408        }
409
410        if let Some((key, value)) = line.split_once('=') {
411            let key = key.trim();
412            let value = value.trim();
413
414            // Handle architecture-specific fields by stripping suffix
415            let base_key = key
416                .find('_')
417                .map_or(key, |underscore_pos| &key[..underscore_pos]);
418
419            match base_key {
420                "pkgbase"
421                    if data.pkgbase.is_empty() => {
422                        data.pkgbase = value.to_string();
423                    }
424                "pkgname"
425                    // For split packages, use the first pkgname found
426                    if !pkgname_found => {
427                        data.pkgname = value.to_string();
428                        pkgname_found = true;
429                    }
430                "pkgver"
431                    if data.pkgver.is_empty() => {
432                        data.pkgver = value.to_string();
433                    }
434                "pkgrel"
435                    if data.pkgrel.is_empty() => {
436                        data.pkgrel = value.to_string();
437                    }
438                "provides"
439                    if seen_provides.insert(value.to_string()) => {
440                        data.provides.push(value.to_string());
441                    }
442                "replaces"
443                    if seen_replaces.insert(value.to_string()) => {
444                        data.replaces.push(value.to_string());
445                    }
446                _ => {}
447            }
448        }
449    }
450
451    data
452}
453
454/// What: Fetch .SRCINFO content for an AUR package using async HTTP.
455///
456/// Inputs:
457/// - `client`: Reqwest HTTP client.
458/// - `name`: AUR package name.
459///
460/// Output:
461/// - Returns .SRCINFO content as a string, or an error if fetch fails.
462///
463/// # Errors
464/// - Returns `Err` when HTTP request fails (network error or client error)
465/// - Returns `Err` when HTTP response status is not successful
466/// - Returns `Err` when response body cannot be read
467/// - Returns `Err` when response is empty or contains HTML error page
468/// - Returns `Err` when response does not appear to be valid .SRCINFO format
469///
470/// Details:
471/// - Uses reqwest for async fetching with built-in timeout handling.
472/// - Validates that the response is not empty, not HTML, and contains .SRCINFO format markers.
473/// - Requires the `aur` feature to be enabled.
474#[cfg(feature = "aur")]
475pub async fn fetch_srcinfo(client: &reqwest::Client, name: &str) -> Result<String> {
476    let url = format!(
477        "https://aur.archlinux.org/cgit/aur.git/plain/.SRCINFO?h={}",
478        percent_encode(name)
479    );
480    fetch_srcinfo_from_url(client, name, &url).await
481}
482
483/// What: Fetch and validate one bounded `.SRCINFO` document from a selected URL.
484///
485/// Inputs:
486/// - `client`: Reqwest HTTP client retaining caller timeout and transport policy.
487/// - `name`: AUR package name retained in every status, body, and parse error.
488/// - `url`: Request URL selected by the public AUR endpoint wrapper or a local test.
489///
490/// Output:
491/// - Validated `.SRCINFO` text within [`MAX_AUR_SRCINFO_RESPONSE_BYTES`].
492///
493/// Details:
494/// - Streams without executing, sourcing, expanding, or logging response content.
495/// - The URL remains private to avoid logging a full untrusted value.
496#[cfg(feature = "aur")]
497async fn fetch_srcinfo_from_url(client: &reqwest::Client, name: &str, url: &str) -> Result<String> {
498    use crate::error::ArchToolkitError;
499
500    tracing::debug!(package = %name, "fetching AUR .SRCINFO");
501    let response = client
502        .get(url)
503        .send()
504        .await
505        .map_err(ArchToolkitError::Network)?;
506    let status = response.status();
507    if !status.is_success() {
508        return Err(ArchToolkitError::InvalidInput(format!(
509            "AUR .SRCINFO fetch failed for package '{name}' with status {status}"
510        )));
511    }
512
513    let resource_label = format!("AUR .SRCINFO for package '{name}'");
514    let text = crate::http::read_bounded_response_text(
515        response,
516        MAX_AUR_SRCINFO_RESPONSE_BYTES,
517        &resource_label,
518        |error| {
519            ArchToolkitError::Parse(format!(
520                "{resource_label} response body read failed: {error}"
521            ))
522        },
523    )
524    .await?;
525
526    if text.trim().is_empty() {
527        return Err(ArchToolkitError::EmptyInput {
528            field: format!("AUR .SRCINFO response for package '{name}'"),
529            message: "response body was empty".to_string(),
530        });
531    }
532    if text.trim_start().starts_with("<html") || text.trim_start().starts_with("<!DOCTYPE") {
533        return Err(ArchToolkitError::Parse(format!(
534            "AUR .SRCINFO fetch for package '{name}' received an HTML error page"
535        )));
536    }
537    if !text.contains("pkgbase =") && !text.contains("pkgname =") {
538        return Err(ArchToolkitError::Parse(format!(
539            "AUR .SRCINFO response for package '{name}' is not valid .SRCINFO format"
540        )));
541    }
542
543    Ok(text)
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    #[cfg(feature = "aur")]
550    use crate::error::ArchToolkitError;
551    #[cfg(feature = "aur")]
552    use wiremock::matchers::{method, path};
553    #[cfg(feature = "aur")]
554    use wiremock::{Mock, MockServer, ResponseTemplate};
555
556    #[test]
557    fn test_parse_srcinfo_deps() {
558        let srcinfo = r"
559pkgbase = test-package
560pkgname = test-package
561pkgver = 1.0.0
562pkgrel = 1
563depends = foo
564depends = bar>=1.2.3
565makedepends = make
566makedepends = gcc
567checkdepends = check
568optdepends = optional: optional-package
569depends = libfoo.so=1-64
570";
571
572        let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(srcinfo);
573
574        // Should have 2 depends (foo and bar>=1.2.3), libfoo.so should be filtered
575        assert_eq!(depends.len(), 2);
576        assert!(depends.contains(&"foo".to_string()));
577        assert!(depends.contains(&"bar>=1.2.3".to_string()));
578
579        // Should have 2 makedepends
580        assert_eq!(makedepends.len(), 2);
581        assert!(makedepends.contains(&"make".to_string()));
582        assert!(makedepends.contains(&"gcc".to_string()));
583
584        // Should have 1 checkdepends
585        assert_eq!(checkdepends.len(), 1);
586        assert!(checkdepends.contains(&"check".to_string()));
587
588        // Should have 1 optdepends (with "optional:" prefix)
589        assert_eq!(optdepends.len(), 1);
590        assert!(optdepends.contains(&"optional: optional-package".to_string()));
591    }
592
593    #[test]
594    fn test_parse_srcinfo_deps_deduplicates() {
595        let srcinfo = r"
596depends = glibc
597depends = gtk3
598depends = glibc
599depends = nss
600";
601
602        let (depends, _, _, _) = parse_srcinfo_deps(srcinfo);
603        assert_eq!(depends.len(), 3, "Should deduplicate dependencies");
604        assert!(depends.contains(&"glibc".to_string()));
605        assert!(depends.contains(&"gtk3".to_string()));
606        assert!(depends.contains(&"nss".to_string()));
607    }
608
609    #[test]
610    fn test_parse_srcinfo_deps_arch_specific() {
611        let srcinfo = r"
612depends = common-dep
613depends_x86_64 = arch-specific-dep
614depends_aarch64 = arm-dep
615";
616
617        let (depends, _, _, _) = parse_srcinfo_deps(srcinfo);
618        // All architecture-specific deps should be merged
619        assert!(depends.contains(&"common-dep".to_string()));
620        assert!(depends.contains(&"arch-specific-dep".to_string()));
621        assert!(depends.contains(&"arm-dep".to_string()));
622    }
623
624    #[test]
625    fn test_parse_srcinfo_conflicts() {
626        let srcinfo = r"
627pkgbase = test-package
628pkgname = test-package
629pkgver = 1.0.0
630pkgrel = 1
631conflicts = conflicting-pkg1
632conflicts = conflicting-pkg2>=2.0
633conflicts = libfoo.so=1-64
634";
635
636        let conflicts = parse_srcinfo_conflicts(srcinfo);
637
638        // Should have 2 conflicts (conflicting-pkg1 and conflicting-pkg2), libfoo.so should be filtered
639        assert_eq!(conflicts.len(), 2);
640        assert!(conflicts.contains(&"conflicting-pkg1".to_string()));
641        assert!(conflicts.contains(&"conflicting-pkg2".to_string()));
642    }
643
644    #[test]
645    fn test_parse_srcinfo_conflicts_empty() {
646        let srcinfo = r"
647pkgbase = test-package
648pkgname = test-package
649pkgver = 1.0.0
650";
651
652        let conflicts = parse_srcinfo_conflicts(srcinfo);
653        assert!(conflicts.is_empty());
654    }
655
656    #[test]
657    fn test_parse_srcinfo_conflicts_deduplicates() {
658        let srcinfo = r"
659conflicts = pkg1
660conflicts = pkg2
661conflicts = pkg1
662conflicts = pkg3
663";
664
665        let conflicts = parse_srcinfo_conflicts(srcinfo);
666        assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
667        assert!(conflicts.contains(&"pkg1".to_string()));
668        assert!(conflicts.contains(&"pkg2".to_string()));
669        assert!(conflicts.contains(&"pkg3".to_string()));
670    }
671
672    #[test]
673    fn test_parse_srcinfo_full() {
674        let srcinfo = r"
675pkgbase = test-package
676pkgname = test-package
677pkgver = 1.0.0
678pkgrel = 1
679depends = glibc
680depends = python>=3.12
681makedepends = make
682checkdepends = check
683optdepends = optional: optional-package
684conflicts = conflicting-pkg
685provides = provided-pkg
686replaces = replaced-pkg
687";
688
689        let data = parse_srcinfo(srcinfo);
690
691        assert_eq!(data.pkgbase, "test-package");
692        assert_eq!(data.pkgname, "test-package");
693        assert_eq!(data.pkgver, "1.0.0");
694        assert_eq!(data.pkgrel, "1");
695        assert_eq!(data.depends.len(), 2);
696        assert!(data.depends.contains(&"glibc".to_string()));
697        assert!(data.depends.contains(&"python>=3.12".to_string()));
698        assert_eq!(data.makedepends.len(), 1);
699        assert!(data.makedepends.contains(&"make".to_string()));
700        assert_eq!(data.checkdepends.len(), 1);
701        assert!(data.checkdepends.contains(&"check".to_string()));
702        assert_eq!(data.optdepends.len(), 1);
703        assert!(
704            data.optdepends
705                .contains(&"optional: optional-package".to_string())
706        );
707        assert_eq!(data.conflicts.len(), 1);
708        assert!(data.conflicts.contains(&"conflicting-pkg".to_string()));
709        assert_eq!(data.provides.len(), 1);
710        assert!(data.provides.contains(&"provided-pkg".to_string()));
711        assert_eq!(data.replaces.len(), 1);
712        assert!(data.replaces.contains(&"replaced-pkg".to_string()));
713    }
714
715    /// What: Verify split package outputs retain selected and inherited metadata.
716    ///
717    /// Inputs:
718    /// - A fixture with package-base dependencies and two split package outputs.
719    ///
720    /// Output:
721    /// - Confirms legacy first-package fields remain while `packages` preserves both outputs.
722    ///
723    /// Details:
724    /// - Shared base dependencies must be inherited without leaking one output's dependencies into
725    ///   another selected split package.
726    #[test]
727    fn test_parse_srcinfo_split_packages() {
728        let srcinfo = r"
729pkgbase = split-package
730depends = shared-base
731pkgname = split-package-base
732depends = base-only
733pkgname = split-package-gui
734depends = gui-only
735provides = virtual-gui=1
736pkgver = 1.0.0
737pkgrel = 1
738";
739
740        let data = parse_srcinfo(srcinfo);
741        assert_eq!(data.pkgname, "split-package-base");
742        assert_eq!(data.pkgbase, "split-package");
743        let graph_data = parse_srcinfo_graph(srcinfo);
744        assert_eq!(graph_data.packages.len(), 2);
745        let base = graph_data
746            .packages
747            .iter()
748            .find(|package| package.name == "split-package-base");
749        let gui = graph_data
750            .packages
751            .iter()
752            .find(|package| package.name == "split-package-gui");
753        assert!(base.is_some_and(|package| {
754            package.depends == vec!["base-only".to_string(), "shared-base".to_string()]
755        }));
756        assert!(gui.is_some_and(|package| {
757            package.depends == vec!["gui-only".to_string(), "shared-base".to_string()]
758                && package.provides == vec!["virtual-gui=1".to_string()]
759        }));
760    }
761
762    #[test]
763    fn test_parse_srcinfo_comments_and_blank_lines() {
764        let srcinfo = r"
765# This is a comment
766pkgbase = test-package
767
768pkgname = test-package
769# Another comment
770pkgver = 1.0.0
771";
772
773        let data = parse_srcinfo(srcinfo);
774        assert_eq!(data.pkgbase, "test-package");
775        assert_eq!(data.pkgname, "test-package");
776        assert_eq!(data.pkgver, "1.0.0");
777    }
778
779    #[test]
780    fn test_parse_srcinfo_empty() {
781        let data = parse_srcinfo("");
782        assert_eq!(data.pkgbase, "");
783        assert_eq!(data.pkgname, "");
784        assert_eq!(data.pkgver, "");
785        assert_eq!(data.pkgrel, "");
786        assert!(data.depends.is_empty());
787        assert!(data.makedepends.is_empty());
788        assert!(data.checkdepends.is_empty());
789        assert!(data.optdepends.is_empty());
790        assert!(data.conflicts.is_empty());
791        assert!(data.provides.is_empty());
792        assert!(data.replaces.is_empty());
793    }
794
795    #[test]
796    fn test_parse_srcinfo_malformed() {
797        // Missing equals signs, invalid format
798        let srcinfo = r"
799pkgbase test-package
800invalid line
801";
802
803        let data = parse_srcinfo(srcinfo);
804        // Should handle gracefully, pkgbase won't be set
805        assert_eq!(data.pkgbase, "");
806    }
807
808    #[cfg(feature = "aur")]
809    #[tokio::test]
810    /// What: Reject an oversized AUR `.SRCINFO` response.
811    ///
812    /// Inputs:
813    /// - A local body one byte above the named 10 MiB ceiling.
814    ///
815    /// Output:
816    /// - Contextual `InputTooLong` identifying `.SRCINFO` and package `yay`.
817    ///
818    /// Details:
819    /// - The inert bytes are bounded before metadata format validation.
820    async fn oversized_aur_srcinfo_response_is_rejected() {
821        let server = MockServer::start().await;
822        Mock::given(method("GET"))
823            .and(path("/.SRCINFO"))
824            .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![
825                b'x';
826                MAX_AUR_SRCINFO_RESPONSE_BYTES
827                    + 1
828            ]))
829            .mount(&server)
830            .await;
831
832        let error = fetch_srcinfo_from_url(
833            &reqwest::Client::new(),
834            "yay",
835            &format!("{}/.SRCINFO", server.uri()),
836        )
837        .await
838        .expect_err("oversized .SRCINFO response must fail");
839        let message = error.to_string();
840
841        assert!(matches!(
842            error,
843            ArchToolkitError::InputTooLong {
844                max_length: MAX_AUR_SRCINFO_RESPONSE_BYTES,
845                ..
846            }
847        ));
848        assert!(message.contains(".SRCINFO"));
849        assert!(message.contains("yay"));
850    }
851
852    #[cfg(feature = "aur")]
853    #[tokio::test]
854    /// What: Preserve `.SRCINFO` package context for status and invalid bodies.
855    ///
856    /// Inputs:
857    /// - Local 404, empty, malformed text, and HTML responses.
858    ///
859    /// Output:
860    /// - An actionable error naming `.SRCINFO` and package `yay` for each response.
861    ///
862    /// Details:
863    /// - Empty and format checks remain after the bounded strict UTF-8 read.
864    async fn aur_srcinfo_status_empty_and_malformed_errors_are_contextual() {
865        for (path_value, template) in [
866            ("/status", ResponseTemplate::new(404)),
867            ("/empty", ResponseTemplate::new(200)),
868            (
869                "/malformed",
870                ResponseTemplate::new(200).set_body_string("not metadata"),
871            ),
872            (
873                "/html",
874                ResponseTemplate::new(200).set_body_string("<!DOCTYPE html>error"),
875            ),
876        ] {
877            let server = MockServer::start().await;
878            Mock::given(method("GET"))
879                .and(path(path_value))
880                .respond_with(template)
881                .mount(&server)
882                .await;
883
884            let error = fetch_srcinfo_from_url(
885                &reqwest::Client::new(),
886                "yay",
887                &format!("{}{path_value}", server.uri()),
888            )
889            .await
890            .expect_err("invalid .SRCINFO response must fail");
891            let message = error.to_string();
892
893            assert!(message.contains(".SRCINFO"));
894            assert!(message.contains("yay"));
895        }
896    }
897
898    #[cfg(feature = "aur")]
899    #[tokio::test]
900    /// What: Return a normal bounded `.SRCINFO` fixture unchanged.
901    ///
902    /// Inputs:
903    /// - A local valid metadata document for package `yay`.
904    ///
905    /// Output:
906    /// - Exact source text ready for caller-controlled parsing.
907    ///
908    /// Details:
909    /// - The fetch path validates markers but never executes metadata content.
910    async fn normal_aur_srcinfo_fixture_is_read() {
911        let server = MockServer::start().await;
912        let srcinfo = "pkgbase = yay\npkgname = yay\npkgver = 1\n";
913        Mock::given(method("GET"))
914            .and(path("/.SRCINFO"))
915            .respond_with(ResponseTemplate::new(200).set_body_string(srcinfo))
916            .mount(&server)
917            .await;
918
919        let body = fetch_srcinfo_from_url(
920            &reqwest::Client::new(),
921            "yay",
922            &format!("{}/.SRCINFO", server.uri()),
923        )
924        .await
925        .expect("normal .SRCINFO fixture");
926
927        assert_eq!(body, srcinfo);
928    }
929}