Skip to main content

provenant/parsers/
podspec.rs

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