Skip to main content

provenant/parsers/
podspec.rs

1//! Parser for CocoaPods .podspec manifest files.
2//!
3//! Extracts package metadata and dependencies from .podspec files which define
4//! CocoaPods package specifications using Ruby DSL syntax.
5//!
6//! # Supported Formats
7//! - *.podspec (CocoaPods package specification files)
8//! - .podspec files (same format, different naming convention)
9//!
10//! # Key Features
11//! - Metadata extraction (name, version, summary, description, license)
12//! - Author/contributor information parsing with email handling
13//! - Homepage and source repository URL extraction
14//! - Dependency declaration parsing with version constraints
15//! - Support for development dependencies
16//! - Regex-based Ruby DSL parsing (no full Ruby AST required)
17//!
18//! # Implementation Notes
19//! - Uses regex for pattern matching in Ruby DSL syntax
20//! - Supports multi-line string values and Ruby hash syntax
21//! - Dependency version constraints are parsed from DSL
22//! - Graceful error handling with `warn!()` logs on parse failures
23
24use std::fs;
25use std::path::Path;
26
27use crate::parser_warn as warn;
28use lazy_static::lazy_static;
29use md5::{Digest, Md5};
30use packageurl::PackageUrl;
31use regex::Regex;
32
33use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
34use crate::parsers::PackageParser;
35use crate::parsers::license_normalization::normalize_spdx_declared_license;
36
37/// Parses CocoaPods specification files (.podspec).
38///
39/// Extracts package metadata from .podspec files using regex-based Ruby DSL parsing.
40///
41/// # Extracted Fields
42/// - Name, version, summary, description
43/// - Homepage, license, source URLs
44/// - Author information (including author hashes)
45/// - Dependencies with version constraints
46///
47/// # Heredoc Support
48/// Handles multiline descriptions: `s.description = <<-DESC ... DESC`
49pub struct PodspecParser;
50
51impl PackageParser for PodspecParser {
52    const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
53
54    fn is_match(path: &Path) -> bool {
55        path.extension().is_some_and(|ext| {
56            ext == "podspec"
57                && path
58                    .file_name()
59                    .is_some_and(|name| !name.to_string_lossy().ends_with(".json.podspec"))
60        })
61    }
62
63    fn extract_packages(path: &Path) -> Vec<PackageData> {
64        let content = match fs::read_to_string(path) {
65            Ok(c) => c,
66            Err(e) => {
67                warn!("Failed to read {:?}: {}", path, e);
68                return vec![default_package_data()];
69            }
70        };
71
72        let name = extract_field(&content, &NAME_PATTERN);
73        let version = extract_field(&content, &VERSION_PATTERN);
74        let summary = extract_field(&content, &SUMMARY_PATTERN);
75        let description =
76            merge_summary_and_description(summary.as_deref(), extract_description(&content));
77        let homepage_url = extract_field(&content, &HOMEPAGE_PATTERN);
78        let license = extract_license_statement(&content);
79        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
80            normalize_podspec_declared_license(&content, license.as_deref());
81        let source = extract_source_url(&content);
82        let authors = extract_authors(&content);
83
84        let parties = authors
85            .into_iter()
86            .map(|(name, email)| Party {
87                r#type: Some("person".to_string()),
88                name: Some(name),
89                email,
90                url: None,
91                role: Some("author".to_string()),
92                organization: None,
93                organization_url: None,
94                timezone: None,
95            })
96            .collect();
97
98        let dependencies = extract_dependencies(&content);
99        let mut extra_data = serde_json::Map::new();
100        if let Some(raw_license) = extract_field(&content, &LICENSE_PATTERN)
101            && let Some(license_file) = extract_ruby_hash_file(&raw_license)
102        {
103            extra_data.insert(
104                "license_file".to_string(),
105                serde_json::Value::String(license_file),
106            );
107        }
108        let repository_homepage_url = name
109            .as_ref()
110            .map(|n| format!("https://cocoapods.org/pods/{}", n));
111        let repository_download_url = match (source.as_deref(), version.as_deref()) {
112            (Some(vcs_url), Some(version_str)) => get_repo_base_url(vcs_url)
113                .map(|base| format!("{}/archive/refs/tags/{}.zip", base, version_str)),
114            _ => None,
115        };
116        let code_view_url = match (source.as_deref(), version.as_deref()) {
117            (Some(vcs_url), Some(version_str)) => {
118                get_repo_base_url(vcs_url).map(|base| format!("{}/tree/{}", base, version_str))
119            }
120            _ => None,
121        };
122        let bug_tracking_url = source
123            .as_deref()
124            .and_then(get_repo_base_url)
125            .map(|base| format!("{}/issues/", base));
126        let api_data_url = match (name.as_deref(), version.as_deref()) {
127            (Some(name_str), Some(version_str)) => get_hashed_path(name_str).map(|hashed| {
128                format!(
129                    "https://raw.githubusercontent.com/CocoaPods/Specs/blob/master/Specs/{}/{}/{}/{}.podspec.json",
130                    hashed, name_str, version_str, name_str
131                )
132            }),
133            _ => None,
134        };
135        let purl = if let Some(name_str) = &name {
136            let mut purl = PackageUrl::new(Self::PACKAGE_TYPE.as_str(), name_str)
137                .unwrap_or_else(|_| PackageUrl::new("generic", name_str).unwrap());
138            if let Some(version_str) = &version {
139                let _ = purl.with_version(version_str);
140            }
141            Some(purl.to_string())
142        } else {
143            None
144        };
145
146        vec![PackageData {
147            package_type: Some(Self::PACKAGE_TYPE),
148            namespace: None,
149            name,
150            version,
151            qualifiers: None,
152            subpath: None,
153            primary_language: Some("Objective-C".to_string()),
154            description,
155            release_date: None,
156            parties,
157            keywords: Vec::new(),
158            homepage_url,
159            download_url: None,
160            size: None,
161            sha1: None,
162            md5: None,
163            sha256: None,
164            sha512: None,
165            bug_tracking_url,
166            code_view_url,
167            vcs_url: source,
168            copyright: None,
169            holder: None,
170            declared_license_expression,
171            declared_license_expression_spdx,
172            license_detections,
173            other_license_expression: None,
174            other_license_expression_spdx: None,
175            other_license_detections: Vec::new(),
176            extracted_license_statement: license,
177            notice_text: None,
178            source_packages: Vec::new(),
179            file_references: Vec::new(),
180            extra_data: (!extra_data.is_empty()).then_some(extra_data.into_iter().collect()),
181            dependencies,
182            repository_homepage_url,
183            repository_download_url,
184            api_data_url,
185            datasource_id: Some(DatasourceId::CocoapodsPodspec),
186            purl,
187            is_private: false,
188            is_virtual: false,
189        }]
190    }
191}
192
193fn default_package_data() -> PackageData {
194    PackageData {
195        package_type: Some(PodspecParser::PACKAGE_TYPE),
196        primary_language: Some("Objective-C".to_string()),
197        datasource_id: Some(DatasourceId::CocoapodsPodspec),
198        ..Default::default()
199    }
200}
201
202lazy_static! {
203    // Regex patterns matching Python reference implementation
204    static ref NAME_PATTERN: Regex = Regex::new(r"\.name\s*=\s*(.+)").unwrap();
205    static ref VERSION_PATTERN: Regex = Regex::new(r"\.version\s*=\s*(.+)").unwrap();
206    static ref SUMMARY_PATTERN: Regex = Regex::new(r"\.summary\s*=\s*(.+)").unwrap();
207    static ref DESCRIPTION_PATTERN: Regex = Regex::new(r"\.description\s*=\s*(.+)").unwrap();
208    static ref HOMEPAGE_PATTERN: Regex = Regex::new(r"\.homepage\s*=\s*(.+)").unwrap();
209    static ref LICENSE_PATTERN: Regex = Regex::new(r"\.license\s*=\s*(.+)").unwrap();
210    static ref SOURCE_PATTERN: Regex = Regex::new(r"\.source\s*=\s*(.+)").unwrap();
211    static ref AUTHOR_PATTERN: Regex = Regex::new(r"\.authors?\s*=\s*(.+)").unwrap();
212    static ref SOURCE_GIT_PATTERN: Regex = Regex::new(r#":git\s*=>\s*['\"]([^'\"]+)['\"]"#).unwrap();
213    static ref SOURCE_HTTP_PATTERN: Regex = Regex::new(r#":http\s*=>\s*['\"]([^'\"]+)['\"]"#).unwrap();
214
215    // Dependency patterns (using pod/dependency method calls)
216    static ref DEPENDENCY_PATTERN: Regex = Regex::new(
217        r#"(?:s\.)?(?:dependency|add_dependency|add_(?:runtime|development)_dependency)\s+['"]([^'"]+)['"](?:\s*,\s*(.+))?"#
218    ).unwrap();
219}
220
221fn extract_license_statement(content: &str) -> Option<String> {
222    extract_field(content, &LICENSE_PATTERN).map(|value| normalize_ruby_hash_literal(&value))
223}
224
225fn normalize_podspec_declared_license(
226    content: &str,
227    extracted_license_statement: Option<&str>,
228) -> (
229    Option<String>,
230    Option<String>,
231    Vec<crate::models::LicenseDetection>,
232) {
233    let Some(raw_license) = extract_field(content, &LICENSE_PATTERN) else {
234        return super::license_normalization::empty_declared_license_data();
235    };
236    let normalized_candidate = if raw_license.contains("=>") || raw_license.contains('=') {
237        extract_ruby_hash_type(&raw_license)
238            .map(|license_type| canonicalize_cocoapods_license_type(&license_type))
239    } else {
240        extracted_license_statement.map(canonicalize_cocoapods_license_type)
241    };
242
243    normalize_spdx_declared_license(normalized_candidate.as_deref())
244}
245
246fn extract_ruby_hash_file(raw_license: &str) -> Option<String> {
247    let normalized = raw_license.replace("=>", "=");
248    let file_regex = Regex::new(r#":file\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
249    file_regex
250        .captures(&normalized)
251        .and_then(|caps| caps.get(1))
252        .map(|value| value.as_str().trim().to_string())
253        .filter(|value| !value.is_empty())
254}
255
256fn canonicalize_cocoapods_license_type(value: &str) -> String {
257    match value.trim() {
258        "Apache License, Version 2.0" => "Apache-2.0".to_string(),
259        other => other.to_string(),
260    }
261}
262
263fn extract_ruby_hash_type(raw_license: &str) -> Option<String> {
264    let normalized = raw_license.replace("=>", "=");
265    let type_regex = Regex::new(r#":type\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
266    type_regex
267        .captures(&normalized)
268        .and_then(|caps| caps.get(1))
269        .map(|value| value.as_str().trim().to_string())
270        .filter(|value| !value.is_empty())
271}
272
273fn normalize_ruby_hash_literal(value: &str) -> String {
274    if !value.contains('=') && !value.contains("=>") {
275        return value.to_string();
276    }
277
278    value
279        .replace("=>", "=")
280        .replace(['\'', '"'], "")
281        .split_whitespace()
282        .collect::<Vec<_>>()
283        .join(" ")
284}
285
286/// Extract a single field using a regex pattern
287fn extract_field(content: &str, pattern: &Regex) -> Option<String> {
288    for line in content.lines() {
289        let cleaned_line = pre_process(line);
290        if let Some(value) = pattern.captures(&cleaned_line).and_then(|caps| caps.get(1)) {
291            return Some(clean_string(value.as_str()));
292        }
293    }
294    None
295}
296
297/// Extract description, handling multiline heredoc format
298fn extract_description(content: &str) -> Option<String> {
299    let lines: Vec<&str> = content.lines().collect();
300
301    for (i, line) in lines.iter().enumerate() {
302        let cleaned = pre_process(line);
303        if let Some(value) = DESCRIPTION_PATTERN
304            .captures(&cleaned)
305            .and_then(|caps| caps.get(1))
306        {
307            let value_str = value.as_str();
308
309            if value_str.contains("<<-") {
310                return extract_multiline_description(&lines, i);
311            } else {
312                return Some(clean_string(value_str));
313            }
314        }
315    }
316    None
317}
318
319fn merge_summary_and_description(
320    summary: Option<&str>,
321    description: Option<String>,
322) -> Option<String> {
323    match (
324        summary.map(str::trim).filter(|s| !s.is_empty()),
325        description,
326    ) {
327        (Some(summary), Some(description)) if description.starts_with(summary) => Some(description),
328        (Some(summary), Some(description)) => Some(format!("{}\n{}", summary, description)),
329        (Some(summary), None) => Some(summary.to_string()),
330        (None, description) => description,
331    }
332}
333
334/// Extract multiline description in heredoc format
335fn extract_multiline_description(lines: &[&str], start_index: usize) -> Option<String> {
336    let start_line = lines.get(start_index)?;
337
338    // Extract the delimiter (e.g., "DESC" from "<<-DESC")
339    let delimiter = start_line
340        .split("<<-")
341        .nth(1)?
342        .trim()
343        .trim_matches(|c| c == '"' || c == '\'');
344
345    let mut description_lines = Vec::new();
346    let mut found_start = false;
347
348    for line in lines.iter().skip(start_index) {
349        if !found_start && line.contains("<<-") {
350            found_start = true;
351            continue;
352        }
353
354        if found_start {
355            let trimmed = line.trim();
356            if trimmed == delimiter {
357                break;
358            }
359            description_lines.push(*line);
360        }
361    }
362
363    if description_lines.is_empty() {
364        None
365    } else {
366        Some(description_lines.join("\n").trim().to_string())
367    }
368}
369
370/// Extract authors (can be single or multiple)
371fn extract_authors(content: &str) -> Vec<(String, Option<String>)> {
372    let mut authors = Vec::new();
373
374    for line in content.lines() {
375        let cleaned_line = pre_process(line);
376        if let Some(value) = AUTHOR_PATTERN
377            .captures(&cleaned_line)
378            .and_then(|caps| caps.get(1))
379        {
380            let value_str = value.as_str();
381
382            if value_str.contains("=>") {
383                for part in value_str.split(',') {
384                    if let Some((name, email)) = parse_author_hash_entry(part) {
385                        authors.push((name, Some(email)));
386                    }
387                }
388            } else {
389                let cleaned = clean_string(value_str);
390                let (name, email) = parse_author_string(&cleaned);
391                authors.push((name, email));
392            }
393        }
394    }
395
396    authors
397}
398
399fn extract_source_url(content: &str) -> Option<String> {
400    for line in content.lines() {
401        let cleaned_line = pre_process(line);
402        let Some(value) = SOURCE_PATTERN
403            .captures(&cleaned_line)
404            .and_then(|caps| caps.get(1))
405            .map(|m| m.as_str())
406        else {
407            continue;
408        };
409
410        if let Some(caps) = SOURCE_GIT_PATTERN.captures(value)
411            && let Some(url) = caps.get(1)
412        {
413            return Some(clean_string(url.as_str()));
414        }
415
416        if let Some(caps) = SOURCE_HTTP_PATTERN.captures(value)
417            && let Some(url) = caps.get(1)
418        {
419            return Some(clean_string(url.as_str()));
420        }
421
422        return Some(clean_string(value));
423    }
424
425    None
426}
427
428/// Parse author from hash entry format: "Name" => "email"
429fn parse_author_hash_entry(entry: &str) -> Option<(String, String)> {
430    let parts: Vec<&str> = entry.split("=>").collect();
431    if parts.len() == 2 {
432        let name = clean_string(parts[0].trim())
433            .trim()
434            .trim_matches(['\'', '"'])
435            .to_string();
436        let email = clean_string(parts[1].trim())
437            .trim()
438            .trim_matches(['\'', '"'])
439            .to_string();
440        Some((name, email))
441    } else {
442        None
443    }
444}
445
446/// Parse author from string, extracting email if present
447fn parse_author_string(author: &str) -> (String, Option<String>) {
448    if let Some(email_start) = author.find('<')
449        && let Some(email_end) = author.find('>')
450    {
451        let name = author[..email_start].trim().to_string();
452        let email = author[email_start + 1..email_end].trim().to_string();
453        return (name, Some(email));
454    }
455    (author.to_string(), None)
456}
457
458/// Extract dependencies from podspec
459fn extract_dependencies(content: &str) -> Vec<Dependency> {
460    let mut dependencies = Vec::new();
461
462    for line in content.lines() {
463        let cleaned_line = pre_process(line);
464        if let Some(caps) = DEPENDENCY_PATTERN.captures(&cleaned_line) {
465            let method = caps.get(0).map(|m| m.as_str()).unwrap_or("");
466            let name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
467            let version_req = caps.get(2).map(|m| clean_string(m.as_str()));
468
469            if let Some(dep) = create_dependency(name, version_req, method) {
470                dependencies.push(dep);
471            }
472        }
473    }
474
475    dependencies
476}
477
478/// Create a Dependency from name and version requirement
479fn create_dependency(name: &str, version_req: Option<String>, method: &str) -> Option<Dependency> {
480    if name.is_empty() {
481        return None;
482    }
483
484    let purl = PackageUrl::new("cocoapods", name).ok()?;
485
486    // Determine if version is pinned (exact version)
487    let is_pinned = version_req
488        .as_ref()
489        .map(|v| !v.contains(&['~', '>', '<', '='][..]))
490        .unwrap_or(false);
491
492    let is_development = method.contains("add_development_dependency");
493
494    Some(Dependency {
495        purl: Some(purl.to_string()),
496        extracted_requirement: version_req,
497        scope: Some(
498            if is_development {
499                "development"
500            } else {
501                "runtime"
502            }
503            .to_string(),
504        ),
505        is_runtime: Some(!is_development),
506        is_optional: Some(is_development),
507        is_pinned: Some(is_pinned),
508        is_direct: Some(true),
509        resolved_package: None,
510        extra_data: None,
511    })
512}
513
514/// Pre-process a line by removing comments and trimming
515fn pre_process(line: &str) -> String {
516    let line = if let Some(comment_pos) = line.find('#') {
517        &line[..comment_pos]
518    } else {
519        line
520    };
521    line.trim().to_string()
522}
523
524/// Clean a string value by removing quotes and special characters
525fn clean_string(s: &str) -> String {
526    let after_removing_special_patterns = s.trim().replace("%q", "").replace(".freeze", "");
527
528    after_removing_special_patterns
529        .trim_matches(|c| {
530            c == '\''
531                || c == '"'
532                || c == '{'
533                || c == '}'
534                || c == '['
535                || c == ']'
536                || c == '<'
537                || c == '>'
538        })
539        .trim()
540        .to_string()
541}
542
543fn get_repo_base_url(vcs_url: &str) -> Option<String> {
544    if vcs_url.is_empty() {
545        return None;
546    }
547
548    if vcs_url.ends_with(".git") {
549        Some(vcs_url.trim_end_matches(".git").to_string())
550    } else {
551        Some(vcs_url.to_string())
552    }
553}
554
555fn get_hashed_path(name: &str) -> Option<String> {
556    if name.is_empty() {
557        return None;
558    }
559
560    let mut hasher = Md5::new();
561    hasher.update(name.as_bytes());
562    let hash_str = hex::encode(hasher.finalize());
563
564    Some(format!(
565        "{}/{}/{}",
566        &hash_str[0..1],
567        &hash_str[1..2],
568        &hash_str[2..3]
569    ))
570}
571
572crate::register_parser!(
573    "CocoaPods podspec file",
574    &["**/*.podspec"],
575    "cocoapods",
576    "Objective-C",
577    Some("https://guides.cocoapods.org/syntax/podspec.html"),
578);
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    #[test]
585    fn test_is_match() {
586        assert!(PodspecParser::is_match(Path::new("AFNetworking.podspec")));
587        assert!(PodspecParser::is_match(Path::new("project/MyLib.podspec")));
588        assert!(!PodspecParser::is_match(Path::new(
589            "AFNetworking.podspec.json"
590        )));
591        assert!(!PodspecParser::is_match(Path::new("Podfile")));
592        assert!(!PodspecParser::is_match(Path::new("Podfile.lock")));
593    }
594
595    #[test]
596    fn test_clean_string() {
597        assert_eq!(clean_string("'AFNetworking'"), "AFNetworking");
598        assert_eq!(clean_string("\"AFNetworking\""), "AFNetworking");
599        assert_eq!(clean_string("'test'.freeze"), "test");
600        assert_eq!(clean_string("%q{test}"), "test");
601    }
602
603    #[test]
604    fn test_extract_simple_field() {
605        let content = r#"
606Pod::Spec.new do |s|
607  s.name = "AFNetworking"
608  s.version = "4.0.1"
609end
610"#;
611        assert_eq!(
612            extract_field(content, &NAME_PATTERN),
613            Some("AFNetworking".to_string())
614        );
615        assert_eq!(
616            extract_field(content, &VERSION_PATTERN),
617            Some("4.0.1".to_string())
618        );
619    }
620
621    #[test]
622    fn test_extract_multiline_description() {
623        let content = r#"
624Pod::Spec.new do |s|
625  s.description = <<-DESC
626    A delightful networking library.
627    Features include:
628    - Modern API
629  DESC
630end
631"#;
632        let desc = extract_description(content);
633        assert!(desc.is_some());
634        let desc_text = desc.unwrap();
635        assert!(desc_text.contains("delightful networking"));
636        assert!(desc_text.contains("Modern API"));
637    }
638
639    #[test]
640    fn test_extract_dependency() {
641        let content = r#"
642Pod::Spec.new do |s|
643  s.dependency "AFNetworking", "~> 4.0"
644  s.dependency "Alamofire"
645end
646"#;
647        let deps = extract_dependencies(content);
648        assert_eq!(deps.len(), 2);
649
650        assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
651        assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
652        assert_eq!(deps[0].is_pinned, Some(false)); // Contains ~
653
654        assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
655        assert_eq!(deps[1].extracted_requirement, None);
656    }
657
658    #[test]
659    fn test_extract_runtime_and_development_dependency_scopes() {
660        let content = r#"
661Pod::Spec.new do |s|
662  s.add_dependency 'AFNetworking', '~> 4.0'
663  s.add_runtime_dependency 'Alamofire', '~> 5.0'
664  s.add_development_dependency 'Quick', '~> 7.0'
665end
666"#;
667
668        let deps = extract_dependencies(content);
669        assert_eq!(deps.len(), 3);
670
671        assert_eq!(deps[0].scope.as_deref(), Some("runtime"));
672        assert_eq!(deps[0].is_runtime, Some(true));
673        assert_eq!(deps[0].is_optional, Some(false));
674
675        assert_eq!(deps[1].scope.as_deref(), Some("runtime"));
676        assert_eq!(deps[1].is_runtime, Some(true));
677        assert_eq!(deps[1].is_optional, Some(false));
678
679        assert_eq!(deps[2].scope.as_deref(), Some("development"));
680        assert_eq!(deps[2].is_runtime, Some(false));
681        assert_eq!(deps[2].is_optional, Some(true));
682    }
683
684    #[test]
685    fn test_parse_author_string() {
686        assert_eq!(
687            parse_author_string("John Doe <john@example.com>"),
688            ("John Doe".to_string(), Some("john@example.com".to_string()))
689        );
690        assert_eq!(
691            parse_author_string("Jane Smith"),
692            ("Jane Smith".to_string(), None)
693        );
694    }
695
696    #[test]
697    fn test_normalize_podspec_license_string() {
698        let content = r#"
699Pod::Spec.new do |s|
700  s.license = 'Apache License, Version 2.0'
701end
702"#;
703
704        let extracted = extract_license_statement(content);
705        let (declared, declared_spdx, detections) =
706            normalize_podspec_declared_license(content, extracted.as_deref());
707
708        assert_eq!(declared.as_deref(), Some("apache-2.0"));
709        assert_eq!(declared_spdx.as_deref(), Some("Apache-2.0"));
710        assert_eq!(detections.len(), 1);
711    }
712
713    #[test]
714    fn test_normalize_podspec_hash_type_only() {
715        let content = r#"
716Pod::Spec.new do |s|
717  s.license = { :type => 'MIT', :file => 'LICENSE' }
718end
719"#;
720
721        let extracted = extract_license_statement(content);
722        let (declared, declared_spdx, detections) =
723            normalize_podspec_declared_license(content, extracted.as_deref());
724
725        assert_eq!(declared.as_deref(), Some("mit"));
726        assert_eq!(declared_spdx.as_deref(), Some("MIT"));
727        assert_eq!(detections.len(), 1);
728    }
729
730    #[test]
731    fn test_podspec_license_hash_preserves_license_file_reference() {
732        let content = r#"
733Pod::Spec.new do |s|
734  s.name = "Demo"
735  s.version = "1.0.0"
736  s.license = { :type => 'MIT', :file => 'LICENSE.txt' }
737end
738"#;
739
740        let temp_dir = tempfile::tempdir().unwrap();
741        let file_path = temp_dir.path().join("Demo.podspec");
742        std::fs::write(&file_path, content).unwrap();
743
744        let package_data = PodspecParser::extract_first_package(&file_path);
745        assert_eq!(package_data.license_detections.len(), 1);
746        assert_eq!(
747            package_data.license_detections[0].matches[0]
748                .referenced_filenames
749                .as_ref(),
750            Some(&vec!["LICENSE.txt".to_string()])
751        );
752    }
753}