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