Skip to main content

arch_toolkit/deps/
parse.rs

1//! Parsing utilities for dependency specifications.
2//!
3//! This module provides functions to parse:
4//! - Dependency specification strings (e.g., "python>=3.12")
5//! - Pacman -Si output for dependencies and conflicts
6
7use std::collections::HashSet;
8
9use crate::types::DependencySpec;
10
11/// English labels that indicate the "Depends On" field in pacman output.
12/// Hardcoded to avoid i18n dependencies.
13const DEPENDS_LABELS: &[&str] = &["Depends On"];
14
15/// Labels that indicate "None" (no dependencies/conflicts).
16const NONE_LABELS: &[&str] = &["None"];
17
18/// Labels that indicate the "Conflicts With" field in pacman output.
19const CONFLICTS_LABELS: &[&str] = &["Conflicts With"];
20
21/// Common English words to filter out from dependency parsing.
22/// These are not valid package names and appear in description text.
23const COMMON_WORDS: &[&str] = &[
24    "for", "to", "with", "is", "that", "using", "usually", "bundled", "bindings", "tooling", "the",
25    "and", "or", "in", "on", "at", "by", "from", "as", "if", "when", "where", "which", "what",
26    "how", "why",
27];
28
29/// What: Split a dependency specification into name and version requirement.
30///
31/// Inputs:
32/// - `spec`: Dependency string from pacman (e.g., "python>=3.12", "glibc").
33///
34/// Output:
35/// - Returns `DependencySpec` with name and `version_req` fields.
36///
37/// Details:
38/// - Searches for version operators in precedence order: <=, >=, =, <, >
39/// - Returns empty `version_req` when no operator is present.
40/// - Trims whitespace from both name and version.
41///
42/// # Examples
43///
44/// ```
45/// use arch_toolkit::deps::parse_dep_spec;
46///
47/// let spec = parse_dep_spec("python>=3.12");
48/// assert_eq!(spec.name, "python");
49/// assert_eq!(spec.version_req, ">=3.12");
50///
51/// let spec = parse_dep_spec("glibc");
52/// assert_eq!(spec.name, "glibc");
53/// assert!(spec.version_req.is_empty());
54/// ```
55#[must_use]
56pub fn parse_dep_spec(spec: &str) -> DependencySpec {
57    // Check operators in precedence order (multi-char before single-char)
58    for op in ["<=", ">=", "=", "<", ">"] {
59        if let Some(pos) = spec.find(op) {
60            return DependencySpec {
61                name: spec[..pos].trim().to_string(),
62                version_req: spec[pos..].trim().to_string(),
63            };
64        }
65    }
66    DependencySpec::new(spec.trim())
67}
68
69/// What: Check if a token looks like a valid package name.
70///
71/// Inputs:
72/// - `token`: A whitespace-separated token from pacman output.
73///
74/// Output:
75/// - Returns `true` if token appears to be a valid package name.
76///
77/// Details:
78/// - Filters out .so files (virtual packages)
79/// - Filters out common English words
80/// - Filters out tokens shorter than 2 characters
81/// - Filters out tokens starting with non-alphanumeric (except - and _)
82/// - Filters out tokens ending with colons
83/// - Requires at least one alphanumeric character
84fn is_valid_package_token(token: &str) -> bool {
85    if token.is_empty() || token.len() < 2 {
86        return false;
87    }
88
89    // Filter out .so files (virtual packages)
90    // Note: We already convert to lowercase, so case-sensitive comparison is safe here
91    let lower = token.to_lowercase();
92    #[allow(clippy::case_sensitive_file_extension_comparisons)]
93    {
94        if lower.ends_with(".so") || lower.contains(".so.") || lower.contains(".so=") {
95            return false;
96        }
97    }
98
99    // Filter out common words
100    if COMMON_WORDS.contains(&lower.as_str()) {
101        return false;
102    }
103
104    // Check first character
105    let Some(first_char) = token.chars().next() else {
106        return false;
107    };
108    if !first_char.is_alphanumeric() && first_char != '-' && first_char != '_' {
109        return false;
110    }
111
112    // Filter out tokens ending with colons
113    if token.ends_with(':') {
114        return false;
115    }
116
117    // Must contain at least one alphanumeric character
118    token.chars().any(char::is_alphanumeric)
119}
120
121/// What: Collect continuation lines for a pacman -Si field.
122///
123/// Inputs:
124/// - `lines`: All lines from the pacman output
125/// - `start_index`: Index of the field line (e.g., "Depends On")
126/// - `field_value`: Initial value after the colon on the field line
127///
128/// Output:
129/// - Returns concatenated string with all continuation lines appended
130///
131/// Details:
132/// - Continuation lines are indented and don't have field names
133/// - Stops when hitting a new field, empty line, or non-continuation line
134fn collect_continuation_lines(lines: &[&str], start_index: usize, field_value: &str) -> String {
135    let mut result = field_value.to_string();
136
137    for continuation_line in lines.iter().skip(start_index + 1) {
138        // Stop if we hit an empty line
139        if continuation_line.trim().is_empty() {
140            break;
141        }
142
143        // Check if this is a continuation line (starts with whitespace, no field name)
144        let trimmed = continuation_line.trim_start();
145        if continuation_line.starts_with(char::is_whitespace) {
146            // This is a continuation line - append it
147            result.push(' ');
148            result.push_str(trimmed);
149        } else if trimmed.contains(':') && !trimmed.starts_with(char::is_whitespace) {
150            // This is a new field, stop collecting
151            break;
152        } else {
153            // Not a continuation, stop
154            break;
155        }
156    }
157
158    result
159}
160
161/// What: Extract dependency specifications from pacman -Si "Depends On" field.
162///
163/// Inputs:
164/// - `text`: Raw stdout from `pacman -Si` for a package.
165///
166/// Output:
167/// - Returns vector of dependency specification strings (without .so virtual packages).
168///
169/// Details:
170/// - Scans for lines starting with "Depends On"
171/// - Handles multi-line dependencies (continuation lines are indented)
172/// - Splits dependencies on whitespace
173/// - Filters out .so virtual packages
174/// - Filters out common words and invalid tokens
175/// - Deduplicates dependencies (returns unique list)
176/// - Returns empty vector if no dependencies or "None"
177///
178/// # Examples
179///
180/// ```
181/// use arch_toolkit::deps::parse_pacman_si_deps;
182///
183/// let output = "Name            : firefox\nDepends On      : glibc gtk3 python>=3.10\n";
184/// let deps = parse_pacman_si_deps(output);
185/// assert_eq!(deps, vec!["glibc", "gtk3", "python>=3.10"]);
186///
187/// let output = "Name            : base\nDepends On      : None\n";
188/// let deps = parse_pacman_si_deps(output);
189/// assert!(deps.is_empty());
190/// ```
191#[must_use]
192pub fn parse_pacman_si_deps(text: &str) -> Vec<String> {
193    let lines: Vec<&str> = text.lines().collect();
194
195    for (i, line) in lines.iter().enumerate() {
196        // Check if line starts with any known "Depends On" label
197        let is_depends_line = DEPENDS_LABELS.iter().any(|label| line.starts_with(label))
198            || (line.contains("Depends") && line.contains("On"));
199
200        if is_depends_line && let Some(colon_pos) = line.find(':') {
201            // Start with text after colon on the "Depends On" line
202            let initial_value = line[colon_pos + 1..].trim();
203
204            // Collect continuation lines (indented lines without field names)
205            let deps_str = collect_continuation_lines(&lines, i, initial_value);
206
207            // Check for "None" equivalent
208            let deps_str_trimmed = deps_str.trim();
209            if deps_str_trimmed.is_empty()
210                || NONE_LABELS
211                    .iter()
212                    .any(|label| deps_str_trimmed.eq_ignore_ascii_case(label))
213            {
214                return Vec::new();
215            }
216
217            // Split, filter, deduplicate, and collect
218            let mut seen = HashSet::new();
219            return deps_str_trimmed
220                .split_whitespace()
221                .map(str::trim)
222                .filter(|s| is_valid_package_token(s))
223                .filter_map(|s| {
224                    // Deduplicate: only add if not seen before
225                    if seen.insert(s) {
226                        Some(s.to_string())
227                    } else {
228                        None
229                    }
230                })
231                .collect();
232        }
233    }
234    Vec::new()
235}
236
237/// What: Extract conflict specifications from pacman -Si "Conflicts With" field.
238///
239/// Inputs:
240/// - `text`: Raw stdout from `pacman -Si` for a package.
241///
242/// Output:
243/// - Returns vector of package names that conflict (without version constraints).
244///
245/// Details:
246/// - Scans for lines starting with "Conflicts With"
247/// - Handles multi-line conflicts (continuation lines are indented)
248/// - Splits conflicts on whitespace
249/// - Removes version constraints from package names
250/// - Filters out .so virtual packages and invalid tokens
251/// - Deduplicates conflicts (returns unique list)
252/// - Returns empty vector if no conflicts or "None"
253///
254/// # Examples
255///
256/// ```
257/// use arch_toolkit::deps::parse_pacman_si_conflicts;
258///
259/// let output = "Name            : vim\nConflicts With : gvim vi\n";
260/// let conflicts = parse_pacman_si_conflicts(output);
261/// assert_eq!(conflicts, vec!["gvim", "vi"]);
262///
263/// let output = "Name            : base\nConflicts With : None\n";
264/// let conflicts = parse_pacman_si_conflicts(output);
265/// assert!(conflicts.is_empty());
266/// ```
267#[must_use]
268pub fn parse_pacman_si_conflicts(text: &str) -> Vec<String> {
269    let lines: Vec<&str> = text.lines().collect();
270
271    for (i, line) in lines.iter().enumerate() {
272        // Check if line starts with any known "Conflicts With" label
273        let is_conflicts_line = CONFLICTS_LABELS.iter().any(|label| line.starts_with(label))
274            || (line.contains("Conflicts") && line.contains("With"));
275
276        if is_conflicts_line && let Some(colon_pos) = line.find(':') {
277            // Start with text after colon on the "Conflicts With" line
278            let initial_value = line[colon_pos + 1..].trim();
279
280            // Collect continuation lines (indented lines without field names)
281            let conflicts_str = collect_continuation_lines(&lines, i, initial_value);
282
283            // Check for "None" equivalent
284            let conflicts_str_trimmed = conflicts_str.trim();
285            if conflicts_str_trimmed.is_empty()
286                || NONE_LABELS
287                    .iter()
288                    .any(|label| conflicts_str_trimmed.eq_ignore_ascii_case(label))
289            {
290                return Vec::new();
291            }
292
293            // Split, filter, extract package name (remove version), deduplicate, and collect
294            let mut seen = HashSet::new();
295            return conflicts_str_trimmed
296                .split_whitespace()
297                .map(str::trim)
298                .filter(|s| is_valid_package_token(s))
299                .map(|s| parse_dep_spec(s).name)
300                .filter_map(|name| {
301                    // Deduplicate: only add if not seen before
302                    if seen.insert(name.clone()) {
303                        Some(name)
304                    } else {
305                        None
306                    }
307                })
308                .collect();
309        }
310    }
311    Vec::new()
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    // === parse_dep_spec tests ===
319
320    #[test]
321    fn parse_dep_spec_no_version() {
322        let spec = parse_dep_spec("glibc");
323        assert_eq!(spec.name, "glibc");
324        assert!(spec.version_req.is_empty());
325        assert!(!spec.has_version_req());
326    }
327
328    #[test]
329    fn parse_dep_spec_greater_equal() {
330        let spec = parse_dep_spec("python>=3.12");
331        assert_eq!(spec.name, "python");
332        assert_eq!(spec.version_req, ">=3.12");
333    }
334
335    #[test]
336    fn parse_dep_spec_less_equal() {
337        let spec = parse_dep_spec("openssl<=1.1.1");
338        assert_eq!(spec.name, "openssl");
339        assert_eq!(spec.version_req, "<=1.1.1");
340    }
341
342    #[test]
343    fn parse_dep_spec_equal() {
344        let spec = parse_dep_spec("firefox=121.0");
345        assert_eq!(spec.name, "firefox");
346        assert_eq!(spec.version_req, "=121.0");
347    }
348
349    #[test]
350    fn parse_dep_spec_greater() {
351        let spec = parse_dep_spec("rust>1.70");
352        assert_eq!(spec.name, "rust");
353        assert_eq!(spec.version_req, ">1.70");
354    }
355
356    #[test]
357    fn parse_dep_spec_less() {
358        let spec = parse_dep_spec("cmake<4.0");
359        assert_eq!(spec.name, "cmake");
360        assert_eq!(spec.version_req, "<4.0");
361    }
362
363    #[test]
364    fn parse_dep_spec_with_whitespace() {
365        let spec = parse_dep_spec("  python >= 3.12  ");
366        assert_eq!(spec.name, "python");
367        assert_eq!(spec.version_req, ">= 3.12");
368    }
369
370    #[test]
371    fn parse_dep_spec_complex_version() {
372        let spec = parse_dep_spec("qt5-base>=5.15.10-1");
373        assert_eq!(spec.name, "qt5-base");
374        assert_eq!(spec.version_req, ">=5.15.10-1");
375    }
376
377    // === is_valid_package_token tests ===
378
379    #[test]
380    fn is_valid_package_token_valid() {
381        assert!(is_valid_package_token("glibc"));
382        assert!(is_valid_package_token("qt5-base"));
383        assert!(is_valid_package_token("python3"));
384        assert!(is_valid_package_token("lib32-glibc"));
385    }
386
387    #[test]
388    fn is_valid_package_token_so_files() {
389        assert!(!is_valid_package_token("libedit.so"));
390        assert!(!is_valid_package_token("libgit2.so.1"));
391        assert!(!is_valid_package_token("libfoo.so=0-64"));
392    }
393
394    #[test]
395    fn is_valid_package_token_common_words() {
396        assert!(!is_valid_package_token("for"));
397        assert!(!is_valid_package_token("with"));
398        assert!(!is_valid_package_token("the"));
399    }
400
401    #[test]
402    fn is_valid_package_token_short() {
403        assert!(!is_valid_package_token("a"));
404        assert!(!is_valid_package_token(""));
405    }
406
407    #[test]
408    fn is_valid_package_token_invalid_start() {
409        assert!(!is_valid_package_token("(test)"));
410        assert!(!is_valid_package_token("[optional]"));
411    }
412
413    #[test]
414    fn is_valid_package_token_colon_ending() {
415        assert!(!is_valid_package_token("error:"));
416    }
417
418    // === parse_pacman_si_deps tests ===
419
420    #[test]
421    fn parse_pacman_si_deps_basic() {
422        let text = "Name            : firefox\nDepends On      : glibc gtk3 nss\n";
423        let deps = parse_pacman_si_deps(text);
424        assert_eq!(deps.len(), 3);
425        assert!(deps.contains(&"glibc".to_string()));
426        assert!(deps.contains(&"gtk3".to_string()));
427        assert!(deps.contains(&"nss".to_string()));
428    }
429
430    #[test]
431    fn parse_pacman_si_deps_with_versions() {
432        let text = "Depends On      : python>=3.10 rust>=1.70\n";
433        let deps = parse_pacman_si_deps(text);
434        assert_eq!(deps.len(), 2);
435        assert!(deps.contains(&"python>=3.10".to_string()));
436        assert!(deps.contains(&"rust>=1.70".to_string()));
437    }
438
439    #[test]
440    fn parse_pacman_si_deps_none() {
441        let text = "Depends On      : None\n";
442        let deps = parse_pacman_si_deps(text);
443        assert!(deps.is_empty());
444    }
445
446    #[test]
447    fn parse_pacman_si_deps_empty() {
448        let text = "Depends On      :\n";
449        let deps = parse_pacman_si_deps(text);
450        assert!(deps.is_empty());
451    }
452
453    #[test]
454    fn parse_pacman_si_deps_filters_so() {
455        let text = "Depends On      : glibc libedit.so libgit2.so.1 nss\n";
456        let deps = parse_pacman_si_deps(text);
457        assert_eq!(deps.len(), 2);
458        assert!(deps.contains(&"glibc".to_string()));
459        assert!(deps.contains(&"nss".to_string()));
460    }
461
462    #[test]
463    fn parse_pacman_si_deps_no_depends_line() {
464        let text = "Name            : firefox\nVersion         : 121.0\n";
465        let deps = parse_pacman_si_deps(text);
466        assert!(deps.is_empty());
467    }
468
469    #[test]
470    fn parse_pacman_si_deps_deduplicates() {
471        let text = "Depends On      : glibc gtk3 glibc nss gtk3\n";
472        let deps = parse_pacman_si_deps(text);
473        assert_eq!(deps.len(), 3, "Should deduplicate dependencies");
474        assert!(deps.contains(&"glibc".to_string()));
475        assert!(deps.contains(&"gtk3".to_string()));
476        assert!(deps.contains(&"nss".to_string()));
477    }
478
479    #[test]
480    fn parse_pacman_si_deps_multiline() {
481        let text = "Name            : firefox\nDepends On      : glibc gtk3 libpulse nss\n                  libxt libxss libxcomposite\n                  libx11 libxcb\n";
482        let deps = parse_pacman_si_deps(text);
483        assert_eq!(deps.len(), 9);
484        assert!(deps.contains(&"glibc".to_string()));
485        assert!(deps.contains(&"gtk3".to_string()));
486        assert!(deps.contains(&"libpulse".to_string()));
487        assert!(deps.contains(&"nss".to_string()));
488        assert!(deps.contains(&"libxt".to_string()));
489        assert!(deps.contains(&"libxss".to_string()));
490        assert!(deps.contains(&"libxcomposite".to_string()));
491        assert!(deps.contains(&"libx11".to_string()));
492        assert!(deps.contains(&"libxcb".to_string()));
493    }
494
495    // === parse_pacman_si_conflicts tests ===
496
497    #[test]
498    fn parse_pacman_si_conflicts_basic() {
499        let text = "Conflicts With  : conflicting-pkg1 conflicting-pkg2\n";
500        let conflicts = parse_pacman_si_conflicts(text);
501        assert_eq!(conflicts.len(), 2);
502        assert!(conflicts.contains(&"conflicting-pkg1".to_string()));
503        assert!(conflicts.contains(&"conflicting-pkg2".to_string()));
504    }
505
506    #[test]
507    fn parse_pacman_si_conflicts_with_versions() {
508        let text = "Conflicts With  : old-pkg<2.0 new-pkg>=3.0\n";
509        let conflicts = parse_pacman_si_conflicts(text);
510        assert_eq!(conflicts.len(), 2);
511        assert!(conflicts.contains(&"old-pkg".to_string()));
512        assert!(conflicts.contains(&"new-pkg".to_string()));
513    }
514
515    #[test]
516    fn parse_pacman_si_conflicts_none() {
517        let text = "Conflicts With  : None\n";
518        let conflicts = parse_pacman_si_conflicts(text);
519        assert!(conflicts.is_empty());
520    }
521
522    #[test]
523    fn parse_pacman_si_conflicts_empty() {
524        let text = "Conflicts With  :\n";
525        let conflicts = parse_pacman_si_conflicts(text);
526        assert!(conflicts.is_empty());
527    }
528
529    #[test]
530    fn parse_pacman_si_conflicts_no_conflicts_line() {
531        let text = "Name            : firefox\nVersion         : 121.0\n";
532        let conflicts = parse_pacman_si_conflicts(text);
533        assert!(conflicts.is_empty());
534    }
535
536    #[test]
537    fn parse_pacman_si_conflicts_deduplicates() {
538        let text = "Conflicts With  : pkg1 pkg2 pkg1 pkg3\n";
539        let conflicts = parse_pacman_si_conflicts(text);
540        assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
541        assert!(conflicts.contains(&"pkg1".to_string()));
542        assert!(conflicts.contains(&"pkg2".to_string()));
543        assert!(conflicts.contains(&"pkg3".to_string()));
544    }
545}