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;
9use crate::error::Result;
10use crate::types::SrcinfoData;
11
12#[cfg(feature = "aur")]
13use crate::aur::utils::percent_encode;
14
15/// What: Parse dependencies from .SRCINFO content.
16///
17/// Inputs:
18/// - `srcinfo`: Raw .SRCINFO file content.
19///
20/// Output:
21/// - Returns a tuple of (depends, makedepends, checkdepends, optdepends) vectors.
22///
23/// Details:
24/// - Parses key-value pairs from .SRCINFO format.
25/// - Handles array fields that can appear multiple times.
26/// - Filters out virtual packages (.so files).
27/// - Deduplicates dependencies (returns unique list).
28/// - Handles architecture-specific dependencies (e.g., `depends_x86_64`).
29#[allow(clippy::case_sensitive_file_extension_comparisons)]
30#[must_use]
31pub fn parse_srcinfo_deps(srcinfo: &str) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
32    let mut depends = Vec::new();
33    let mut makedepends = Vec::new();
34    let mut checkdepends = Vec::new();
35    let mut optdepends = Vec::new();
36
37    // Use HashSet for deduplication
38    let mut seen_depends = HashSet::new();
39    let mut seen_makedepends = HashSet::new();
40    let mut seen_checkdepends = HashSet::new();
41    let mut seen_optdepends = HashSet::new();
42
43    for line in srcinfo.lines() {
44        let line = line.trim();
45        if line.is_empty() || line.starts_with('#') {
46            continue;
47        }
48
49        // .SRCINFO format: key = value (tab-indented)
50        if let Some((key, value)) = line.split_once('=') {
51            let key = key.trim();
52            let value = value.trim();
53
54            // Filter out virtual packages (.so files)
55            let value_lower = value.to_lowercase();
56            if value_lower.ends_with(".so")
57                || value_lower.contains(".so.")
58                || value_lower.contains(".so=")
59            {
60                continue;
61            }
62
63            // Handle architecture-specific dependencies by merging into main arrays
64            let base_key = key
65                .find('_')
66                .map_or(key, |underscore_pos| &key[..underscore_pos]);
67
68            match base_key {
69                "depends" => {
70                    if seen_depends.insert(value.to_string()) {
71                        depends.push(value.to_string());
72                    }
73                }
74                "makedepends" => {
75                    if seen_makedepends.insert(value.to_string()) {
76                        makedepends.push(value.to_string());
77                    }
78                }
79                "checkdepends" => {
80                    if seen_checkdepends.insert(value.to_string()) {
81                        checkdepends.push(value.to_string());
82                    }
83                }
84                "optdepends" => {
85                    if seen_optdepends.insert(value.to_string()) {
86                        optdepends.push(value.to_string());
87                    }
88                }
89                _ => {}
90            }
91        }
92    }
93
94    (depends, makedepends, checkdepends, optdepends)
95}
96
97/// What: Parse conflicts from .SRCINFO content.
98///
99/// Inputs:
100/// - `srcinfo`: Raw .SRCINFO file content.
101///
102/// Output:
103/// - Returns a vector of conflicting package names (without version constraints).
104///
105/// Details:
106/// - Parses "conflicts" key-value pairs from .SRCINFO format.
107/// - Handles array fields that can appear multiple times.
108/// - Filters out virtual packages (.so files) and extracts package names from version constraints.
109/// - Deduplicates conflicts (returns unique list).
110#[allow(clippy::case_sensitive_file_extension_comparisons)]
111#[must_use]
112pub fn parse_srcinfo_conflicts(srcinfo: &str) -> Vec<String> {
113    let mut conflicts = Vec::new();
114    let mut seen = HashSet::new();
115
116    for line in srcinfo.lines() {
117        let line = line.trim();
118        if line.is_empty() || line.starts_with('#') {
119            continue;
120        }
121
122        // .SRCINFO format: key = value
123        if let Some((key, value)) = line.split_once('=') {
124            let key = key.trim();
125            let value = value.trim();
126
127            // Handle architecture-specific conflicts
128            let base_key = key
129                .find('_')
130                .map_or(key, |underscore_pos| &key[..underscore_pos]);
131
132            if base_key == "conflicts" {
133                // Filter out virtual packages (.so files)
134                let value_lower = value.to_lowercase();
135                if value_lower.ends_with(".so")
136                    || value_lower.contains(".so.")
137                    || value_lower.contains(".so=")
138                {
139                    continue;
140                }
141                // Extract package name (remove version constraints if present)
142                let spec = parse_dep_spec(value);
143                if !spec.name.is_empty() && seen.insert(spec.name.clone()) {
144                    conflicts.push(spec.name);
145                }
146            }
147        }
148    }
149
150    conflicts
151}
152
153/// What: Parse full .SRCINFO content into structured data.
154///
155/// Inputs:
156/// - `content`: Raw .SRCINFO file content.
157///
158/// Output:
159/// - Returns `SrcinfoData` with all parsed fields populated.
160///
161/// Details:
162/// - Parses all fields from .SRCINFO format including pkgbase, pkgname, pkgver, pkgrel.
163/// - Extracts all dependency types (depends, makedepends, checkdepends, optdepends).
164/// - Extracts conflicts, provides, and replaces arrays.
165/// - For split packages (multiple pkgname), uses the first pkgname found.
166/// - Handles architecture-specific dependencies by merging into main arrays.
167/// - Returns default `SrcinfoData` with empty fields if content is malformed.
168#[must_use]
169pub fn parse_srcinfo(content: &str) -> SrcinfoData {
170    let mut data = SrcinfoData::default();
171    let mut pkgname_found = false;
172
173    // Parse dependencies and conflicts
174    let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(content);
175    data.depends = depends;
176    data.makedepends = makedepends;
177    data.checkdepends = checkdepends;
178    data.optdepends = optdepends;
179    data.conflicts = parse_srcinfo_conflicts(content);
180
181    // Parse other fields
182    let mut seen_provides = HashSet::new();
183    let mut seen_replaces = HashSet::new();
184
185    for line in content.lines() {
186        let line = line.trim();
187        if line.is_empty() || line.starts_with('#') {
188            continue;
189        }
190
191        if let Some((key, value)) = line.split_once('=') {
192            let key = key.trim();
193            let value = value.trim();
194
195            // Handle architecture-specific fields by stripping suffix
196            let base_key = key
197                .find('_')
198                .map_or(key, |underscore_pos| &key[..underscore_pos]);
199
200            match base_key {
201                "pkgbase" => {
202                    if data.pkgbase.is_empty() {
203                        data.pkgbase = value.to_string();
204                    }
205                }
206                "pkgname" => {
207                    // For split packages, use the first pkgname found
208                    if !pkgname_found {
209                        data.pkgname = value.to_string();
210                        pkgname_found = true;
211                    }
212                }
213                "pkgver" => {
214                    if data.pkgver.is_empty() {
215                        data.pkgver = value.to_string();
216                    }
217                }
218                "pkgrel" => {
219                    if data.pkgrel.is_empty() {
220                        data.pkgrel = value.to_string();
221                    }
222                }
223                "provides" => {
224                    if seen_provides.insert(value.to_string()) {
225                        data.provides.push(value.to_string());
226                    }
227                }
228                "replaces" => {
229                    if seen_replaces.insert(value.to_string()) {
230                        data.replaces.push(value.to_string());
231                    }
232                }
233                _ => {}
234            }
235        }
236    }
237
238    data
239}
240
241/// What: Fetch .SRCINFO content for an AUR package using async HTTP.
242///
243/// Inputs:
244/// - `client`: Reqwest HTTP client.
245/// - `name`: AUR package name.
246///
247/// Output:
248/// - Returns .SRCINFO content as a string, or an error if fetch fails.
249///
250/// # Errors
251/// - Returns `Err` when HTTP request fails (network error or client error)
252/// - Returns `Err` when HTTP response status is not successful
253/// - Returns `Err` when response body cannot be read
254/// - Returns `Err` when response is empty or contains HTML error page
255/// - Returns `Err` when response does not appear to be valid .SRCINFO format
256///
257/// Details:
258/// - Uses reqwest for async fetching with built-in timeout handling.
259/// - Validates that the response is not empty, not HTML, and contains .SRCINFO format markers.
260/// - Requires the `aur` feature to be enabled.
261#[cfg(feature = "aur")]
262pub async fn fetch_srcinfo(client: &reqwest::Client, name: &str) -> Result<String> {
263    use crate::error::ArchToolkitError;
264
265    let url = format!(
266        "https://aur.archlinux.org/cgit/aur.git/plain/.SRCINFO?h={}",
267        percent_encode(name)
268    );
269    tracing::debug!("Fetching .SRCINFO from: {}", url);
270
271    let response = client
272        .get(&url)
273        .send()
274        .await
275        .map_err(ArchToolkitError::Network)?;
276
277    if !response.status().is_success() {
278        return Err(ArchToolkitError::InvalidInput(format!(
279            "HTTP request failed with status: {}",
280            response.status()
281        )));
282    }
283
284    let text = response.text().await.map_err(ArchToolkitError::Network)?;
285
286    if text.trim().is_empty() {
287        return Err(ArchToolkitError::EmptyInput {
288            field: "srcinfo_content".to_string(),
289            message: "Empty .SRCINFO content".to_string(),
290        });
291    }
292
293    // Check if we got an HTML error page instead of .SRCINFO content
294    if text.trim_start().starts_with("<html") || text.trim_start().starts_with("<!DOCTYPE") {
295        return Err(ArchToolkitError::Parse(
296            "Received HTML error page instead of .SRCINFO".to_string(),
297        ));
298    }
299
300    // Validate that it looks like .SRCINFO format (should have pkgbase or pkgname)
301    if !text.contains("pkgbase =") && !text.contains("pkgname =") {
302        return Err(ArchToolkitError::Parse(
303            "Response does not appear to be valid .SRCINFO format".to_string(),
304        ));
305    }
306
307    Ok(text)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_parse_srcinfo_deps() {
316        let srcinfo = r"
317pkgbase = test-package
318pkgname = test-package
319pkgver = 1.0.0
320pkgrel = 1
321depends = foo
322depends = bar>=1.2.3
323makedepends = make
324makedepends = gcc
325checkdepends = check
326optdepends = optional: optional-package
327depends = libfoo.so=1-64
328";
329
330        let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(srcinfo);
331
332        // Should have 2 depends (foo and bar>=1.2.3), libfoo.so should be filtered
333        assert_eq!(depends.len(), 2);
334        assert!(depends.contains(&"foo".to_string()));
335        assert!(depends.contains(&"bar>=1.2.3".to_string()));
336
337        // Should have 2 makedepends
338        assert_eq!(makedepends.len(), 2);
339        assert!(makedepends.contains(&"make".to_string()));
340        assert!(makedepends.contains(&"gcc".to_string()));
341
342        // Should have 1 checkdepends
343        assert_eq!(checkdepends.len(), 1);
344        assert!(checkdepends.contains(&"check".to_string()));
345
346        // Should have 1 optdepends (with "optional:" prefix)
347        assert_eq!(optdepends.len(), 1);
348        assert!(optdepends.contains(&"optional: optional-package".to_string()));
349    }
350
351    #[test]
352    fn test_parse_srcinfo_deps_deduplicates() {
353        let srcinfo = r"
354depends = glibc
355depends = gtk3
356depends = glibc
357depends = nss
358";
359
360        let (depends, _, _, _) = parse_srcinfo_deps(srcinfo);
361        assert_eq!(depends.len(), 3, "Should deduplicate dependencies");
362        assert!(depends.contains(&"glibc".to_string()));
363        assert!(depends.contains(&"gtk3".to_string()));
364        assert!(depends.contains(&"nss".to_string()));
365    }
366
367    #[test]
368    fn test_parse_srcinfo_deps_arch_specific() {
369        let srcinfo = r"
370depends = common-dep
371depends_x86_64 = arch-specific-dep
372depends_aarch64 = arm-dep
373";
374
375        let (depends, _, _, _) = parse_srcinfo_deps(srcinfo);
376        // All architecture-specific deps should be merged
377        assert!(depends.contains(&"common-dep".to_string()));
378        assert!(depends.contains(&"arch-specific-dep".to_string()));
379        assert!(depends.contains(&"arm-dep".to_string()));
380    }
381
382    #[test]
383    fn test_parse_srcinfo_conflicts() {
384        let srcinfo = r"
385pkgbase = test-package
386pkgname = test-package
387pkgver = 1.0.0
388pkgrel = 1
389conflicts = conflicting-pkg1
390conflicts = conflicting-pkg2>=2.0
391conflicts = libfoo.so=1-64
392";
393
394        let conflicts = parse_srcinfo_conflicts(srcinfo);
395
396        // Should have 2 conflicts (conflicting-pkg1 and conflicting-pkg2), libfoo.so should be filtered
397        assert_eq!(conflicts.len(), 2);
398        assert!(conflicts.contains(&"conflicting-pkg1".to_string()));
399        assert!(conflicts.contains(&"conflicting-pkg2".to_string()));
400    }
401
402    #[test]
403    fn test_parse_srcinfo_conflicts_empty() {
404        let srcinfo = r"
405pkgbase = test-package
406pkgname = test-package
407pkgver = 1.0.0
408";
409
410        let conflicts = parse_srcinfo_conflicts(srcinfo);
411        assert!(conflicts.is_empty());
412    }
413
414    #[test]
415    fn test_parse_srcinfo_conflicts_deduplicates() {
416        let srcinfo = r"
417conflicts = pkg1
418conflicts = pkg2
419conflicts = pkg1
420conflicts = pkg3
421";
422
423        let conflicts = parse_srcinfo_conflicts(srcinfo);
424        assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
425        assert!(conflicts.contains(&"pkg1".to_string()));
426        assert!(conflicts.contains(&"pkg2".to_string()));
427        assert!(conflicts.contains(&"pkg3".to_string()));
428    }
429
430    #[test]
431    fn test_parse_srcinfo_full() {
432        let srcinfo = r"
433pkgbase = test-package
434pkgname = test-package
435pkgver = 1.0.0
436pkgrel = 1
437depends = glibc
438depends = python>=3.12
439makedepends = make
440checkdepends = check
441optdepends = optional: optional-package
442conflicts = conflicting-pkg
443provides = provided-pkg
444replaces = replaced-pkg
445";
446
447        let data = parse_srcinfo(srcinfo);
448
449        assert_eq!(data.pkgbase, "test-package");
450        assert_eq!(data.pkgname, "test-package");
451        assert_eq!(data.pkgver, "1.0.0");
452        assert_eq!(data.pkgrel, "1");
453        assert_eq!(data.depends.len(), 2);
454        assert!(data.depends.contains(&"glibc".to_string()));
455        assert!(data.depends.contains(&"python>=3.12".to_string()));
456        assert_eq!(data.makedepends.len(), 1);
457        assert!(data.makedepends.contains(&"make".to_string()));
458        assert_eq!(data.checkdepends.len(), 1);
459        assert!(data.checkdepends.contains(&"check".to_string()));
460        assert_eq!(data.optdepends.len(), 1);
461        assert!(
462            data.optdepends
463                .contains(&"optional: optional-package".to_string())
464        );
465        assert_eq!(data.conflicts.len(), 1);
466        assert!(data.conflicts.contains(&"conflicting-pkg".to_string()));
467        assert_eq!(data.provides.len(), 1);
468        assert!(data.provides.contains(&"provided-pkg".to_string()));
469        assert_eq!(data.replaces.len(), 1);
470        assert!(data.replaces.contains(&"replaced-pkg".to_string()));
471    }
472
473    #[test]
474    fn test_parse_srcinfo_split_packages() {
475        let srcinfo = r"
476pkgbase = split-package
477pkgname = split-package-base
478pkgname = split-package-gui
479pkgver = 1.0.0
480pkgrel = 1
481";
482
483        let data = parse_srcinfo(srcinfo);
484        // Should use first pkgname found
485        assert_eq!(data.pkgname, "split-package-base");
486        assert_eq!(data.pkgbase, "split-package");
487    }
488
489    #[test]
490    fn test_parse_srcinfo_comments_and_blank_lines() {
491        let srcinfo = r"
492# This is a comment
493pkgbase = test-package
494
495pkgname = test-package
496# Another comment
497pkgver = 1.0.0
498";
499
500        let data = parse_srcinfo(srcinfo);
501        assert_eq!(data.pkgbase, "test-package");
502        assert_eq!(data.pkgname, "test-package");
503        assert_eq!(data.pkgver, "1.0.0");
504    }
505
506    #[test]
507    fn test_parse_srcinfo_empty() {
508        let data = parse_srcinfo("");
509        assert_eq!(data.pkgbase, "");
510        assert_eq!(data.pkgname, "");
511        assert_eq!(data.pkgver, "");
512        assert_eq!(data.pkgrel, "");
513        assert!(data.depends.is_empty());
514        assert!(data.makedepends.is_empty());
515        assert!(data.checkdepends.is_empty());
516        assert!(data.optdepends.is_empty());
517        assert!(data.conflicts.is_empty());
518        assert!(data.provides.is_empty());
519        assert!(data.replaces.is_empty());
520    }
521
522    #[test]
523    fn test_parse_srcinfo_malformed() {
524        // Missing equals signs, invalid format
525        let srcinfo = r"
526pkgbase test-package
527invalid line
528";
529
530        let data = parse_srcinfo(srcinfo);
531        // Should handle gracefully, pkgbase won't be set
532        assert_eq!(data.pkgbase, "");
533    }
534}