arch_toolkit/deps/
pkgbuild.rs

1//! Parser for PKGBUILD files.
2//!
3//! This module provides functions for parsing PKGBUILD files, which are
4//! bash scripts that define how Arch Linux packages are built.
5//!
6//! The parser extracts dependency arrays (depends, makedepends, checkdepends, optdepends)
7//! and conflicts from PKGBUILD content, handling both single-line and multi-line
8//! bash array syntax.
9
10use std::collections::HashSet;
11
12use crate::deps::parse::parse_dep_spec;
13
14/// What: Parse dependencies from PKGBUILD content.
15///
16/// Inputs:
17/// - `pkgbuild`: Raw PKGBUILD file content.
18///
19/// Output:
20/// - Returns a tuple of (depends, makedepends, checkdepends, optdepends) vectors.
21///
22/// Details:
23/// - Parses bash array syntax: `depends=('foo' 'bar>=1.2')` (single-line)
24/// - Also handles `depends+=` patterns used in functions like `package()`
25/// - Handles both quoted and unquoted dependencies
26/// - Also handles multi-line arrays:
27///   ```text
28///   depends=(
29///       'foo'
30///       'bar>=1.2'
31///   )
32///   ```
33/// - Filters out .so files (virtual packages) and invalid package names
34/// - Only parses specific dependency fields (depends, makedepends, checkdepends, optdepends)
35/// - Deduplicates dependencies (returns unique list)
36#[allow(clippy::case_sensitive_file_extension_comparisons)]
37#[must_use]
38pub fn parse_pkgbuild_deps(pkgbuild: &str) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
39    let mut depends = Vec::new();
40    let mut makedepends = Vec::new();
41    let mut checkdepends = Vec::new();
42    let mut optdepends = Vec::new();
43
44    // Use HashSet for deduplication
45    let mut seen_depends = HashSet::new();
46    let mut seen_makedepends = HashSet::new();
47    let mut seen_checkdepends = HashSet::new();
48    let mut seen_optdepends = HashSet::new();
49
50    let lines: Vec<&str> = pkgbuild.lines().collect();
51    let mut i = 0;
52
53    while i < lines.len() {
54        let line = lines[i].trim();
55        i += 1;
56
57        if line.is_empty() || line.starts_with('#') {
58            continue;
59        }
60
61        // Parse array declarations: depends=('foo' 'bar') or depends=( or depends+=('foo' 'bar')
62        if let Some((key, value)) = line.split_once('=') {
63            let key = key.trim();
64            let value = value.trim();
65
66            // Handle both depends= and depends+= patterns
67            let base_key = key.strip_suffix('+').map_or(key, |stripped| stripped);
68
69            // Only parse specific dependency fields, ignore other PKGBUILD fields
70            if !matches!(
71                base_key,
72                "depends" | "makedepends" | "checkdepends" | "optdepends"
73            ) {
74                continue;
75            }
76
77            // Check if this is an array declaration
78            if value.starts_with('(') {
79                let deps = find_matching_closing_paren(value).map_or_else(
80                    || {
81                        // Multi-line array: depends=(
82                        //     'foo'
83                        //     'bar'
84                        // )
85                        let mut array_lines = Vec::new();
86                        // Collect lines until we find the closing parenthesis
87                        while i < lines.len() {
88                            let next_line = lines[i].trim();
89                            i += 1;
90
91                            // Skip empty lines and comments
92                            if next_line.is_empty() || next_line.starts_with('#') {
93                                continue;
94                            }
95
96                            // Check if this line closes the array
97                            if next_line == ")" {
98                                break;
99                            }
100
101                            // Check if this line contains a closing parenthesis (may be on same line as content)
102                            if let Some(paren_pos) = next_line.find(')') {
103                                // Extract content before the closing paren
104                                let content_before_paren = &next_line[..paren_pos].trim();
105                                if !content_before_paren.is_empty() {
106                                    array_lines.push((*content_before_paren).to_string());
107                                }
108                                break;
109                            }
110
111                            // Add this line to the array content
112                            array_lines.push(next_line.to_string());
113                        }
114
115                        // Parse all collected lines as array content
116                        // Ensure proper spacing between items (each line should be a separate item)
117                        let array_content = array_lines
118                            .iter()
119                            .map(|s| s.trim())
120                            .filter(|s| !s.is_empty())
121                            .collect::<Vec<_>>()
122                            .join(" ");
123                        parse_array_content(&array_content)
124                    },
125                    |closing_paren_pos| {
126                        // Single-line array (may have content after closing paren): depends=('foo' 'bar') or depends+=('foo' 'bar') other_code
127                        let array_content = &value[1..closing_paren_pos];
128                        parse_array_content(array_content)
129                    },
130                );
131
132                // Filter out invalid dependencies (.so files, invalid names, etc.)
133                let filtered_deps: Vec<String> = deps
134                    .into_iter()
135                    .filter_map(|dep| {
136                        let dep_trimmed = dep.trim();
137                        if dep_trimmed.is_empty() {
138                            return None;
139                        }
140
141                        if is_valid_dependency(dep_trimmed) {
142                            Some(dep_trimmed.to_string())
143                        } else {
144                            None
145                        }
146                    })
147                    .collect();
148
149                // Add dependencies to the appropriate vector (using base_key to handle both = and +=)
150                // Deduplicate using HashSet
151                match base_key {
152                    "depends" => {
153                        for dep in filtered_deps {
154                            if seen_depends.insert(dep.clone()) {
155                                depends.push(dep);
156                            }
157                        }
158                    }
159                    "makedepends" => {
160                        for dep in filtered_deps {
161                            if seen_makedepends.insert(dep.clone()) {
162                                makedepends.push(dep);
163                            }
164                        }
165                    }
166                    "checkdepends" => {
167                        for dep in filtered_deps {
168                            if seen_checkdepends.insert(dep.clone()) {
169                                checkdepends.push(dep);
170                            }
171                        }
172                    }
173                    "optdepends" => {
174                        for dep in filtered_deps {
175                            if seen_optdepends.insert(dep.clone()) {
176                                optdepends.push(dep);
177                            }
178                        }
179                    }
180                    _ => {}
181                }
182            }
183        }
184    }
185
186    (depends, makedepends, checkdepends, optdepends)
187}
188
189/// What: Parse conflicts from PKGBUILD content.
190///
191/// Inputs:
192/// - `pkgbuild`: Raw PKGBUILD file content.
193///
194/// Output:
195/// - Returns a vector of conflicting package names (without version constraints).
196///
197/// Details:
198/// - Parses bash array syntax: `conflicts=('foo' 'bar')` (single-line)
199/// - Also handles `conflicts+=` patterns used in functions like `package()`
200/// - Handles both quoted and unquoted conflicts
201/// - Also handles multi-line arrays:
202///   ```text
203///   conflicts=(
204///       'foo'
205///       'bar'
206///   )
207///   ```
208/// - Filters out .so files (virtual packages) and invalid package names
209/// - Extracts package names from version constraints (e.g., "jujutsu-git>=1.0" -> "jujutsu-git")
210/// - Deduplicates conflicts (returns unique list)
211#[allow(clippy::case_sensitive_file_extension_comparisons)]
212#[must_use]
213pub fn parse_pkgbuild_conflicts(pkgbuild: &str) -> Vec<String> {
214    let mut conflicts = Vec::new();
215    let mut seen = HashSet::new();
216
217    let lines: Vec<&str> = pkgbuild.lines().collect();
218    let mut i = 0;
219
220    while i < lines.len() {
221        let line = lines[i].trim();
222        i += 1;
223
224        if line.is_empty() || line.starts_with('#') {
225            continue;
226        }
227
228        // Parse array declarations: conflicts=('foo' 'bar') or conflicts=( or conflicts+=('foo' 'bar')
229        if let Some((key, value)) = line.split_once('=') {
230            let key = key.trim();
231            let value = value.trim();
232
233            // Handle both conflicts= and conflicts+= patterns
234            let base_key = key.strip_suffix('+').map_or(key, |stripped| stripped);
235
236            // Only parse conflicts field
237            if base_key != "conflicts" {
238                continue;
239            }
240
241            // Check if this is an array declaration
242            if value.starts_with('(') {
243                let conflict_deps = find_matching_closing_paren(value).map_or_else(
244                    || {
245                        // Multi-line array: conflicts=(
246                        //     'foo'
247                        //     'bar'
248                        // )
249                        let mut array_lines = Vec::new();
250                        // Collect lines until we find the closing parenthesis
251                        while i < lines.len() {
252                            let next_line = lines[i].trim();
253                            i += 1;
254
255                            // Skip empty lines and comments
256                            if next_line.is_empty() || next_line.starts_with('#') {
257                                continue;
258                            }
259
260                            // Check if this line closes the array
261                            if next_line == ")" {
262                                break;
263                            }
264
265                            // Check if this line contains a closing parenthesis (may be on same line as content)
266                            if let Some(paren_pos) = next_line.find(')') {
267                                // Extract content before the closing paren
268                                let content_before_paren = &next_line[..paren_pos].trim();
269                                if !content_before_paren.is_empty() {
270                                    array_lines.push((*content_before_paren).to_string());
271                                }
272                                break;
273                            }
274
275                            // Add this line to the array content
276                            array_lines.push(next_line.to_string());
277                        }
278
279                        // Parse all collected lines as array content
280                        let array_content = array_lines
281                            .iter()
282                            .map(|s| s.trim())
283                            .filter(|s| !s.is_empty())
284                            .collect::<Vec<_>>()
285                            .join(" ");
286                        parse_array_content(&array_content)
287                    },
288                    |closing_paren_pos| {
289                        // Single-line array (may have content after closing paren): conflicts=('foo' 'bar') or conflicts+=('foo' 'bar') other_code
290                        let array_content = &value[1..closing_paren_pos];
291                        parse_array_content(array_content)
292                    },
293                );
294
295                // Filter out invalid conflicts (.so files, invalid names, etc.)
296                let filtered_conflicts: Vec<String> = conflict_deps
297                    .into_iter()
298                    .filter_map(|conflict| {
299                        let conflict_trimmed = conflict.trim();
300                        if conflict_trimmed.is_empty() {
301                            return None;
302                        }
303
304                        if is_valid_dependency(conflict_trimmed) {
305                            // Extract package name (remove version constraints if present)
306                            // Use a simple approach: split on version operators
307                            let spec = parse_dep_spec(conflict_trimmed);
308                            if !spec.name.is_empty() && seen.insert(spec.name.clone()) {
309                                Some(spec.name)
310                            } else {
311                                None
312                            }
313                        } else {
314                            None
315                        }
316                    })
317                    .collect();
318
319                // Add conflicts to the vector (using base_key to handle both = and +=)
320                conflicts.extend(filtered_conflicts);
321            }
322        }
323    }
324
325    conflicts
326}
327
328/// What: Find the position of the matching closing parenthesis in a string.
329///
330/// Inputs:
331/// - `s`: String starting with an opening parenthesis.
332///
333/// Output:
334/// - `Some(position)` if a matching closing parenthesis is found, `None` otherwise.
335///
336/// Details:
337/// - Handles nested parentheses and quoted strings.
338fn find_matching_closing_paren(s: &str) -> Option<usize> {
339    let mut depth = 0;
340    let mut in_quotes = false;
341    let mut quote_char = '\0';
342
343    for (pos, ch) in s.char_indices() {
344        match ch {
345            '\'' | '"' => {
346                if !in_quotes {
347                    in_quotes = true;
348                    quote_char = ch;
349                } else if ch == quote_char {
350                    in_quotes = false;
351                    quote_char = '\0';
352                }
353            }
354            '(' if !in_quotes => {
355                depth += 1;
356            }
357            ')' if !in_quotes => {
358                depth -= 1;
359                if depth == 0 {
360                    return Some(pos);
361                }
362            }
363            _ => {}
364        }
365    }
366    None
367}
368
369/// What: Parse quoted and unquoted strings from bash array content.
370///
371/// Inputs:
372/// - `content`: Array content string (e.g., "'foo' 'bar>=1.2'" or "libcairo.so libdbus-1.so").
373///
374/// Output:
375/// - Vector of dependency strings.
376///
377/// Details:
378/// - Handles both quoted ('foo') and unquoted (foo) dependencies.
379/// - Splits on whitespace for unquoted values.
380fn parse_array_content(content: &str) -> Vec<String> {
381    let mut deps = Vec::new();
382    let mut in_quotes = false;
383    let mut quote_char = '\0';
384    let mut current = String::new();
385
386    for ch in content.chars() {
387        match ch {
388            '\'' | '"' => {
389                if !in_quotes {
390                    in_quotes = true;
391                    quote_char = ch;
392                } else if ch == quote_char {
393                    if !current.is_empty() {
394                        deps.push(current.clone());
395                        current.clear();
396                    }
397                    in_quotes = false;
398                    quote_char = '\0';
399                } else {
400                    current.push(ch);
401                }
402            }
403            _ if in_quotes => {
404                current.push(ch);
405            }
406            ch if ch.is_whitespace() => {
407                // Whitespace outside quotes - end current unquoted value
408                if !current.is_empty() {
409                    deps.push(current.clone());
410                    current.clear();
411                }
412            }
413            _ => {
414                // Non-whitespace character outside quotes - add to current value
415                current.push(ch);
416            }
417        }
418    }
419
420    // Handle unclosed quote or trailing unquoted value
421    if !current.is_empty() {
422        deps.push(current);
423    }
424
425    deps
426}
427
428/// What: Check if a dependency string is valid (not a .so file, has valid format).
429///
430/// Inputs:
431/// - `dep`: Dependency string to validate.
432///
433/// Output:
434/// - Returns `true` if the dependency appears to be valid, `false` otherwise.
435///
436/// Details:
437/// - Filters out .so files (virtual packages)
438/// - Filters out names ending with ) (parsing errors)
439/// - Filters out names that don't start with alphanumeric or underscore
440/// - Filters out names that are too short (< 2 characters)
441/// - Requires at least one alphanumeric character
442fn is_valid_dependency(dep: &str) -> bool {
443    // Filter out .so files (virtual packages)
444    let dep_lower = dep.to_lowercase();
445    if std::path::Path::new(&dep_lower)
446        .extension()
447        .is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
448        || dep_lower.contains(".so.")
449        || dep_lower.contains(".so=")
450    {
451        return false;
452    }
453
454    // Filter out names ending with ) - this is a parsing error
455    // But first check if it's actually a valid name with version constraint ending in )
456    // like "package>=1.0)" which would be a parsing error
457    if dep.ends_with(')') {
458        // Check if it might be a valid version constraint that accidentally ends with )
459        // If it contains version operators before the ), it's likely a parsing error
460        if dep.contains(">=") || dep.contains("<=") || dep.contains("==") {
461            // This looks like "package>=1.0)" which is invalid
462            return false;
463        }
464        // Otherwise, it might be "package)" which is also invalid
465        return false;
466    }
467
468    // Filter out names that don't look like package names
469    // Package names should start with alphanumeric or underscore
470    let Some(first_char) = dep.chars().next() else {
471        return false;
472    };
473    if !first_char.is_alphanumeric() && first_char != '_' {
474        return false;
475    }
476
477    // Filter out names that are too short
478    if dep.len() < 2 {
479        return false;
480    }
481
482    // Filter out names containing invalid characters (but allow version operators)
483    // Allow: alphanumeric, dash, underscore, and version operators (>=, <=, ==, >, <)
484    let has_valid_chars = dep
485        .chars()
486        .any(|c| c.is_alphanumeric() || c == '-' || c == '_');
487    if !has_valid_chars {
488        return false;
489    }
490
491    true
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    // === parse_pkgbuild_deps tests ===
499
500    #[test]
501    fn test_parse_pkgbuild_deps_basic() {
502        let pkgbuild = r"
503pkgname=test-package
504pkgver=1.0.0
505depends=('foo' 'bar>=1.2')
506makedepends=('make' 'gcc')
507";
508
509        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
510
511        assert_eq!(depends.len(), 2);
512        assert!(depends.contains(&"foo".to_string()));
513        assert!(depends.contains(&"bar>=1.2".to_string()));
514
515        assert_eq!(makedepends.len(), 2);
516        assert!(makedepends.contains(&"make".to_string()));
517        assert!(makedepends.contains(&"gcc".to_string()));
518
519        assert_eq!(checkdepends.len(), 0);
520        assert_eq!(optdepends.len(), 0);
521    }
522
523    #[test]
524    fn test_parse_pkgbuild_deps_append() {
525        let pkgbuild = r#"
526pkgname=test-package
527pkgver=1.0.0
528package() {
529    depends+=(foo bar)
530    cd $_pkgname
531    make DESTDIR="$pkgdir" PREFIX=/usr install
532}
533"#;
534
535        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
536
537        assert_eq!(depends.len(), 2);
538        assert!(depends.contains(&"foo".to_string()));
539        assert!(depends.contains(&"bar".to_string()));
540
541        assert_eq!(makedepends.len(), 0);
542        assert_eq!(checkdepends.len(), 0);
543        assert_eq!(optdepends.len(), 0);
544    }
545
546    #[test]
547    fn test_parse_pkgbuild_deps_unquoted() {
548        let pkgbuild = r"
549pkgname=test-package
550depends=(foo bar libcairo.so libdbus-1.so)
551";
552
553        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
554
555        // .so files should be filtered out
556        assert_eq!(depends.len(), 2);
557        assert!(depends.contains(&"foo".to_string()));
558        assert!(depends.contains(&"bar".to_string()));
559
560        assert_eq!(makedepends.len(), 0);
561        assert_eq!(checkdepends.len(), 0);
562        assert_eq!(optdepends.len(), 0);
563    }
564
565    #[test]
566    fn test_parse_pkgbuild_deps_multiline() {
567        let pkgbuild = r"
568pkgname=test-package
569depends=(
570    'foo'
571    'bar>=1.2'
572    'baz'
573)
574";
575
576        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
577
578        assert_eq!(depends.len(), 3);
579        assert!(depends.contains(&"foo".to_string()));
580        assert!(depends.contains(&"bar>=1.2".to_string()));
581        assert!(depends.contains(&"baz".to_string()));
582
583        assert_eq!(makedepends.len(), 0);
584        assert_eq!(checkdepends.len(), 0);
585        assert_eq!(optdepends.len(), 0);
586    }
587
588    #[test]
589    fn test_parse_pkgbuild_deps_makedepends_append() {
590        let pkgbuild = r"
591pkgname=test-package
592build() {
593    makedepends+=(cmake ninja)
594    cmake -B build
595}
596";
597
598        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
599
600        assert_eq!(makedepends.len(), 2);
601        assert!(makedepends.contains(&"cmake".to_string()));
602        assert!(makedepends.contains(&"ninja".to_string()));
603
604        assert_eq!(depends.len(), 0);
605        assert_eq!(checkdepends.len(), 0);
606        assert_eq!(optdepends.len(), 0);
607    }
608
609    #[test]
610    fn test_parse_pkgbuild_deps_jujutsu_git_scenario() {
611        let pkgbuild = r"
612pkgname=jujutsu-git
613pkgver=0.1.0
614pkgdesc=Git-compatible VCS that is both simple and powerful
615url=https://github.com/martinvonz/jj
616license=(Apache-2.0)
617arch=(i686 x86_64 armv6h armv7h)
618depends=(
619    glibc
620    libc.so
621    libm.so
622)
623makedepends=(
624    libgit2
625    libgit2.so
626    libssh2
627    libssh2.so)
628    openssh
629    git)
630cargo
631checkdepends=()
632optdepends=()
633source=($pkgname::git+$url)
634";
635
636        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
637
638        // depends should only contain glibc, .so files filtered out
639        assert_eq!(depends.len(), 1);
640        assert!(depends.contains(&"glibc".to_string()));
641
642        // makedepends should contain libgit2, libssh2
643        // .so files are filtered out
644        // Note: openssh, git), and cargo are after the array closes, so they're not part of makedepends
645        assert_eq!(makedepends.len(), 2);
646        assert!(makedepends.contains(&"libgit2".to_string()));
647        assert!(makedepends.contains(&"libssh2".to_string()));
648
649        assert_eq!(checkdepends.len(), 0);
650        assert_eq!(optdepends.len(), 0);
651    }
652
653    #[test]
654    fn test_parse_pkgbuild_deps_ignore_other_fields() {
655        let pkgbuild = r"
656pkgname=test-package
657pkgver=1.0.0
658pkgdesc=Test package description
659url=https://example.com
660license=(MIT)
661arch=(x86_64)
662source=($pkgname-$pkgver.tar.gz)
663depends=(foo bar)
664makedepends=(make)
665";
666
667        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
668
669        // Only depends and makedepends should be parsed
670        assert_eq!(depends.len(), 2);
671        assert!(depends.contains(&"foo".to_string()));
672        assert!(depends.contains(&"bar".to_string()));
673
674        assert_eq!(makedepends.len(), 1);
675        assert!(makedepends.contains(&"make".to_string()));
676
677        assert_eq!(checkdepends.len(), 0);
678        assert_eq!(optdepends.len(), 0);
679    }
680
681    #[test]
682    fn test_parse_pkgbuild_deps_filter_invalid_names() {
683        // Test filtering of invalid names (using single-line format for reliability)
684        let pkgbuild = r"
685depends=('valid-package' 'invalid)' '=invalid' 'a' 'valid>=1.0')
686";
687
688        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps(pkgbuild);
689
690        // Only valid package names should remain
691        // Note: 'invalid)' should be filtered out (ends with ))
692        // Note: '=invalid' should be filtered out (starts with =)
693        // Note: 'a' should be filtered out (too short)
694        // So we should have: valid-package and valid>=1.0
695        assert_eq!(depends.len(), 2);
696        assert!(depends.contains(&"valid-package".to_string()));
697        assert!(depends.contains(&"valid>=1.0".to_string()));
698
699        assert_eq!(makedepends.len(), 0);
700        assert_eq!(checkdepends.len(), 0);
701        assert_eq!(optdepends.len(), 0);
702    }
703
704    #[test]
705    fn test_parse_pkgbuild_deps_deduplicates() {
706        let pkgbuild = r"
707depends=('foo' 'bar' 'foo' 'baz' 'bar')
708";
709
710        let (depends, _, _, _) = parse_pkgbuild_deps(pkgbuild);
711        assert_eq!(depends.len(), 3, "Should deduplicate dependencies");
712        assert!(depends.contains(&"foo".to_string()));
713        assert!(depends.contains(&"bar".to_string()));
714        assert!(depends.contains(&"baz".to_string()));
715    }
716
717    #[test]
718    fn test_parse_pkgbuild_deps_empty() {
719        let (depends, makedepends, checkdepends, optdepends) = parse_pkgbuild_deps("");
720        assert_eq!(depends.len(), 0);
721        assert_eq!(makedepends.len(), 0);
722        assert_eq!(checkdepends.len(), 0);
723        assert_eq!(optdepends.len(), 0);
724    }
725
726    #[test]
727    fn test_parse_pkgbuild_deps_comments_and_blank_lines() {
728        let pkgbuild = r"
729# This is a comment
730pkgname=test-package
731
732depends=(foo bar)
733# Another comment
734makedepends=(make)
735";
736
737        let (depends, makedepends, _, _) = parse_pkgbuild_deps(pkgbuild);
738        assert_eq!(depends.len(), 2);
739        assert!(depends.contains(&"foo".to_string()));
740        assert!(depends.contains(&"bar".to_string()));
741        assert_eq!(makedepends.len(), 1);
742        assert!(makedepends.contains(&"make".to_string()));
743    }
744
745    #[test]
746    fn test_parse_pkgbuild_deps_mixed_quoted_unquoted() {
747        let pkgbuild = r"
748depends=('quoted' unquoted 'another-quoted' unquoted2)
749";
750
751        let (depends, _, _, _) = parse_pkgbuild_deps(pkgbuild);
752        assert_eq!(depends.len(), 4);
753        assert!(depends.contains(&"quoted".to_string()));
754        assert!(depends.contains(&"unquoted".to_string()));
755        assert!(depends.contains(&"another-quoted".to_string()));
756        assert!(depends.contains(&"unquoted2".to_string()));
757    }
758
759    // === parse_pkgbuild_conflicts tests ===
760
761    #[test]
762    fn test_parse_pkgbuild_conflicts_basic() {
763        let pkgbuild = r"
764pkgname=jujutsu-git
765pkgver=0.1.0
766conflicts=('jujutsu')
767";
768
769        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
770
771        assert_eq!(conflicts.len(), 1);
772        assert!(conflicts.contains(&"jujutsu".to_string()));
773    }
774
775    #[test]
776    fn test_parse_pkgbuild_conflicts_multiline() {
777        let pkgbuild = r"
778pkgname=pacsea-git
779pkgver=0.1.0
780conflicts=(
781    'pacsea'
782    'pacsea-bin'
783)
784";
785
786        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
787
788        assert_eq!(conflicts.len(), 2);
789        assert!(conflicts.contains(&"pacsea".to_string()));
790        assert!(conflicts.contains(&"pacsea-bin".to_string()));
791    }
792
793    #[test]
794    fn test_parse_pkgbuild_conflicts_with_versions() {
795        let pkgbuild = r"
796pkgname=test-package
797conflicts=('old-pkg<2.0' 'new-pkg>=3.0')
798";
799
800        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
801
802        assert_eq!(conflicts.len(), 2);
803        assert!(conflicts.contains(&"old-pkg".to_string()));
804        assert!(conflicts.contains(&"new-pkg".to_string()));
805    }
806
807    #[test]
808    fn test_parse_pkgbuild_conflicts_filter_so() {
809        let pkgbuild = r"
810pkgname=test-package
811conflicts=('foo' 'libcairo.so' 'bar' 'libdbus-1.so=1-64')
812";
813
814        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
815
816        // .so files should be filtered out
817        assert_eq!(conflicts.len(), 2);
818        assert!(conflicts.contains(&"foo".to_string()));
819        assert!(conflicts.contains(&"bar".to_string()));
820    }
821
822    #[test]
823    fn test_parse_pkgbuild_conflicts_deduplicates() {
824        let pkgbuild = r"
825conflicts=('pkg1' 'pkg2' 'pkg1' 'pkg3')
826";
827
828        let conflicts = parse_pkgbuild_conflicts(pkgbuild);
829        assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
830        assert!(conflicts.contains(&"pkg1".to_string()));
831        assert!(conflicts.contains(&"pkg2".to_string()));
832        assert!(conflicts.contains(&"pkg3".to_string()));
833    }
834
835    #[test]
836    fn test_parse_pkgbuild_conflicts_empty() {
837        let conflicts = parse_pkgbuild_conflicts("");
838        assert!(conflicts.is_empty());
839    }
840}