Skip to main content

provenant/parsers/
podspec.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for CocoaPods .podspec manifest files.
7//!
8//! Extracts package metadata and dependencies from .podspec files which define
9//! CocoaPods package specifications using Ruby DSL syntax.
10//!
11//! # Supported Formats
12//! - *.podspec (CocoaPods package specification files)
13//! - .podspec files (same format, different naming convention)
14//!
15//! # Key Features
16//! - Metadata extraction (name, version, summary, description, license)
17//! - Author/contributor information parsing with email handling
18//! - Homepage and source repository URL extraction
19//! - Dependency declaration parsing with version constraints
20//! - Support for development dependencies
21//! - Regex-based Ruby DSL parsing (no full Ruby AST required)
22//!
23//! # Implementation Notes
24//! - Uses regex for pattern matching in Ruby DSL syntax
25//! - Supports multi-line string values and Ruby hash syntax
26//! - Dependency version constraints are parsed from DSL
27//! - Graceful error handling with `warn!()` logs on parse failures
28
29use std::path::Path;
30use std::sync::LazyLock;
31
32use crate::parser_warn as warn;
33use md5::{Digest, Md5};
34use packageurl::PackageUrl;
35use regex::Regex;
36
37use super::metadata::ParserMetadata;
38use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
39use crate::parsers::PackageParser;
40use crate::parsers::license_normalization::{
41    DeclaredLicenseMatchMetadata, build_declared_license_data_from_pair,
42    normalize_spdx_declared_license,
43};
44use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
45
46/// Parses CocoaPods specification files (.podspec).
47///
48/// Extracts package metadata from .podspec files using regex-based Ruby DSL parsing.
49///
50/// # Extracted Fields
51/// - Name, version, summary, description
52/// - Homepage, license, source URLs
53/// - Author information (including author hashes)
54/// - Dependencies with version constraints
55///
56/// # Heredoc Support
57/// Handles multiline descriptions: `s.description = <<-DESC ... DESC`
58pub struct PodspecParser;
59
60impl PackageParser for PodspecParser {
61    const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
62
63    fn metadata() -> Vec<ParserMetadata> {
64        vec![ParserMetadata {
65            description: "CocoaPods podspec file",
66            file_patterns: &["**/*.podspec"],
67            package_type: "cocoapods",
68            primary_language: "Objective-C",
69            documentation_url: Some("https://guides.cocoapods.org/syntax/podspec.html"),
70        }]
71    }
72
73    fn is_match(path: &Path) -> bool {
74        path.extension().is_some_and(|ext| {
75            ext == "podspec"
76                && path
77                    .file_name()
78                    .is_some_and(|name| !name.to_string_lossy().ends_with(".json.podspec"))
79        })
80    }
81
82    fn extract_packages(path: &Path) -> Vec<PackageData> {
83        let content = match read_file_to_string(path, None) {
84            Ok(c) => c,
85            Err(e) => {
86                warn!("Failed to read {:?}: {}", path, e);
87                return vec![default_package_data()];
88            }
89        };
90
91        let raw_name = extract_raw_field(&content, &NAME_PATTERN);
92        let raw_version = extract_raw_field(&content, &VERSION_PATTERN);
93        let name = extract_metadata_field(&content, &NAME_PATTERN).map(truncate_field);
94        let version = extract_metadata_field(&content, &VERSION_PATTERN).map(truncate_field);
95        let summary = extract_metadata_field(&content, &SUMMARY_PATTERN).map(truncate_field);
96        let description =
97            merge_summary_and_description(summary.as_deref(), extract_description(&content))
98                .map(truncate_field);
99        let homepage_url = extract_metadata_field(&content, &HOMEPAGE_PATTERN).map(truncate_field);
100        let license = extract_license_statement(&content).map(truncate_field);
101        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
102            normalize_podspec_declared_license(&content, license.as_deref());
103        let source = extract_source_url(&content).map(truncate_field);
104        let authors = extract_authors(&content);
105
106        let parties = authors
107            .into_iter()
108            .map(|(name, email)| Party {
109                r#type: Some(PartyType::Person),
110                name: Some(truncate_field(name)),
111                email: email.map(truncate_field),
112                url: None,
113                role: Some("author".to_string()),
114                organization: None,
115                organization_url: None,
116                timezone: None,
117            })
118            .collect();
119
120        let dependencies = extract_dependencies(&content);
121        let mut extra_data = serde_json::Map::new();
122        let has_dynamic_identity_placeholders = raw_name
123            .as_deref()
124            .is_some_and(looks_like_nonliteral_podspec_expression)
125            && raw_version
126                .as_deref()
127                .is_some_and(looks_like_nonliteral_podspec_expression);
128        if has_dynamic_identity_placeholders {
129            extra_data.insert(
130                "dynamic_identity_placeholders".to_string(),
131                serde_json::Value::Bool(true),
132            );
133        }
134        if let Some(raw_license) = extract_field(&content, &LICENSE_PATTERN)
135            && let Some(license_file) = extract_ruby_hash_file(&raw_license)
136        {
137            extra_data.insert(
138                "license_file".to_string(),
139                serde_json::Value::String(license_file),
140            );
141        }
142        let repository_homepage_url = name
143            .as_ref()
144            .map(|n| format!("https://cocoapods.org/pods/{}", n));
145        let repository_download_url = match (source.as_deref(), version.as_deref()) {
146            (Some(vcs_url), Some(version_str)) => get_repo_base_url(vcs_url)
147                .map(|base| format!("{}/archive/refs/tags/{}.zip", base, version_str)),
148            _ => None,
149        };
150        let code_view_url = match (source.as_deref(), version.as_deref()) {
151            (Some(vcs_url), Some(version_str)) => {
152                get_repo_base_url(vcs_url).map(|base| format!("{}/tree/{}", base, version_str))
153            }
154            _ => None,
155        };
156        let bug_tracking_url = source
157            .as_deref()
158            .and_then(get_repo_base_url)
159            .map(|base| format!("{}/issues/", base));
160        let api_data_url = match (name.as_deref(), version.as_deref()) {
161            (Some(name_str), Some(version_str)) => get_hashed_path(name_str).map(|hashed| {
162                format!(
163                    "https://raw.githubusercontent.com/CocoaPods/Specs/blob/master/Specs/{}/{}/{}/{}.podspec.json",
164                    hashed, name_str, version_str, name_str
165                )
166            }),
167            _ => None,
168        };
169        let purl = if let Some(name_str) = &name {
170            let purl_result = PackageUrl::new(Self::PACKAGE_TYPE.as_str(), name_str)
171                .or_else(|_| PackageUrl::new("generic", name_str));
172            match purl_result {
173                Ok(mut purl) => {
174                    if let Some(version_str) = &version {
175                        let _ = purl.with_version(version_str);
176                    }
177                    Some(truncate_field(purl.to_string()))
178                }
179                Err(_) => None,
180            }
181        } else {
182            None
183        };
184
185        vec![PackageData {
186            package_type: Some(Self::PACKAGE_TYPE),
187            namespace: None,
188            name,
189            version,
190            qualifiers: None,
191            subpath: None,
192            primary_language: Some("Objective-C".to_string()),
193            description,
194            release_date: None,
195            parties,
196            keywords: Vec::new(),
197            homepage_url,
198            download_url: None,
199            size: None,
200            sha1: None,
201            md5: None,
202            sha256: None,
203            sha512: None,
204            bug_tracking_url,
205            code_view_url,
206            vcs_url: source,
207            copyright: None,
208            holder: None,
209            declared_license_expression,
210            declared_license_expression_spdx,
211            license_detections,
212            other_license_expression: None,
213            other_license_expression_spdx: None,
214            other_license_detections: Vec::new(),
215            extracted_license_statement: license,
216            notice_text: None,
217            source_packages: Vec::new(),
218            file_references: Vec::new(),
219            extra_data: (!extra_data.is_empty()).then_some(extra_data.into_iter().collect()),
220            dependencies,
221            repository_homepage_url,
222            repository_download_url,
223            api_data_url,
224            datasource_id: Some(DatasourceId::CocoapodsPodspec),
225            purl,
226            is_private: false,
227            is_virtual: false,
228        }]
229    }
230}
231
232fn default_package_data() -> PackageData {
233    PackageData {
234        package_type: Some(PodspecParser::PACKAGE_TYPE),
235        primary_language: Some("Objective-C".to_string()),
236        datasource_id: Some(DatasourceId::CocoapodsPodspec),
237        ..Default::default()
238    }
239}
240
241static NAME_PATTERN: LazyLock<Regex> =
242    LazyLock::new(|| Regex::new(r"\.name\s*=\s*(.+)").expect("valid regex"));
243static VERSION_PATTERN: LazyLock<Regex> =
244    LazyLock::new(|| Regex::new(r"\.version\s*=\s*(.+)").expect("valid regex"));
245static SUMMARY_PATTERN: LazyLock<Regex> =
246    LazyLock::new(|| Regex::new(r"\.summary\s*=\s*(.+)").expect("valid regex"));
247static DESCRIPTION_PATTERN: LazyLock<Regex> =
248    LazyLock::new(|| Regex::new(r"\.description\s*=\s*(.+)").expect("valid regex"));
249static HOMEPAGE_PATTERN: LazyLock<Regex> =
250    LazyLock::new(|| Regex::new(r"\.homepage\s*=\s*(.+)").expect("valid regex"));
251static LICENSE_PATTERN: LazyLock<Regex> =
252    LazyLock::new(|| Regex::new(r"\.license\s*=\s*(.+)").expect("valid regex"));
253static SOURCE_PATTERN: LazyLock<Regex> =
254    LazyLock::new(|| Regex::new(r"\.source\s*=\s*(.+)").expect("valid regex"));
255static AUTHOR_PATTERN: LazyLock<Regex> =
256    LazyLock::new(|| Regex::new(r"\.authors?\s*=\s*(.+)").expect("valid regex"));
257static SOURCE_GIT_PATTERN: LazyLock<Regex> =
258    LazyLock::new(|| Regex::new(r#":git\s*=>\s*['\"]([^'\"]+)['\"]"#).expect("valid regex"));
259static SOURCE_GIT_DYNAMIC_PATTERN: LazyLock<Regex> =
260    LazyLock::new(|| Regex::new(r#":git\s*(?:=>|=)\s*([^,}]+)"#).expect("valid regex"));
261static SOURCE_HTTP_PATTERN: LazyLock<Regex> =
262    LazyLock::new(|| Regex::new(r#":http\s*=>\s*['\"]([^'\"]+)['\"]"#).expect("valid regex"));
263
264static DEPENDENCY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
265    Regex::new(
266    r#"(?:s\.)?(?:dependency|add_dependency|add_(?:runtime|development)_dependency)\s+['"]([^'"]+)['"](?:\s*,\s*(.+))?"#
267).expect("valid regex")
268});
269
270fn extract_license_statement(content: &str) -> Option<String> {
271    extract_field(content, &LICENSE_PATTERN)
272        .map(|value| normalize_ruby_hash_literal(&value))
273        .and_then(|value| normalize_podspec_dynamic_metadata_value(&value))
274}
275
276fn normalize_podspec_declared_license(
277    content: &str,
278    extracted_license_statement: Option<&str>,
279) -> (
280    Option<String>,
281    Option<String>,
282    Vec<crate::models::LicenseDetection>,
283) {
284    let Some(raw_license) = extract_raw_field(content, &LICENSE_PATTERN) else {
285        return super::license_normalization::empty_declared_license_data();
286    };
287    if looks_like_nonliteral_podspec_expression(&raw_license) {
288        return build_declared_license_data_from_pair(
289            "unknown".to_string(),
290            "LicenseRef-scancode-unknown".to_string(),
291            DeclaredLicenseMatchMetadata::single_line(
292                extracted_license_statement.unwrap_or(raw_license.as_str()),
293            ),
294        );
295    }
296    let normalized_candidate = if raw_license.contains("=>") || raw_license.contains('=') {
297        extract_ruby_hash_type(&raw_license)
298            .map(|license_type| canonicalize_cocoapods_license_type(&license_type))
299    } else {
300        extracted_license_statement.map(canonicalize_cocoapods_license_type)
301    };
302
303    normalize_spdx_declared_license(normalized_candidate.as_deref())
304}
305
306fn extract_ruby_hash_file(raw_license: &str) -> Option<String> {
307    let normalized = raw_license.replace("=>", "=");
308    let file_regex = Regex::new(r#":file\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
309    file_regex
310        .captures(&normalized)
311        .and_then(|caps| caps.get(1))
312        .map(|value| value.as_str().trim().to_string())
313        .filter(|value| !value.is_empty())
314}
315
316fn canonicalize_cocoapods_license_type(value: &str) -> String {
317    match value.trim() {
318        "Apache License, Version 2.0" => "Apache-2.0".to_string(),
319        other => other.to_string(),
320    }
321}
322
323fn extract_ruby_hash_type(raw_license: &str) -> Option<String> {
324    let normalized = raw_license.replace("=>", "=");
325    let type_regex = Regex::new(r#":type\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
326    type_regex
327        .captures(&normalized)
328        .and_then(|caps| caps.get(1))
329        .map(|value| value.as_str().trim().to_string())
330        .filter(|value| !value.is_empty())
331}
332
333fn normalize_ruby_hash_literal(value: &str) -> String {
334    if !value.contains('=') && !value.contains("=>") {
335        return value.to_string();
336    }
337
338    value
339        .replace("=>", "=")
340        .replace(['\'', '"'], "")
341        .split_whitespace()
342        .collect::<Vec<_>>()
343        .join(" ")
344}
345
346fn normalize_podspec_dynamic_metadata_value(value: &str) -> Option<String> {
347    let trimmed = value.trim();
348    if trimmed.is_empty() {
349        return None;
350    }
351
352    if looks_like_podspec_dynamic_metadata_value(trimmed) {
353        let normalized = trimmed
354            .chars()
355            .filter(|c| c.is_ascii_alphanumeric())
356            .collect::<String>()
357            .to_ascii_lowercase();
358        return (!normalized.is_empty()).then_some(normalized);
359    }
360
361    Some(trimmed.to_string())
362}
363
364fn looks_like_podspec_dynamic_metadata_value(value: &str) -> bool {
365    value.contains("['") || value.contains("[\"")
366}
367
368fn looks_like_nonliteral_podspec_expression(value: &str) -> bool {
369    let trimmed = value.trim();
370    if trimmed.is_empty() {
371        return false;
372    }
373
374    if trimmed.contains("=>") || trimmed.starts_with('{') {
375        return false;
376    }
377
378    if matches!(trimmed.chars().next(), Some('\'' | '"'))
379        || trimmed.starts_with("%q{")
380        || trimmed.starts_with("%Q{")
381    {
382        return false;
383    }
384
385    if trimmed
386        .chars()
387        .all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '_' | '+'))
388    {
389        return false;
390    }
391
392    true
393}
394
395fn extract_metadata_field(content: &str, pattern: &Regex) -> Option<String> {
396    extract_field(content, pattern)
397        .and_then(|value| normalize_podspec_dynamic_metadata_value(&value))
398}
399
400fn extract_raw_field(content: &str, pattern: &Regex) -> Option<String> {
401    for line in content.lines().take(MAX_ITERATION_COUNT) {
402        let cleaned_line = pre_process(line);
403        if let Some(value) = pattern.captures(&cleaned_line).and_then(|caps| caps.get(1)) {
404            return Some(value.as_str().trim().to_string());
405        }
406    }
407    None
408}
409
410/// Extract a single field using a regex pattern
411fn extract_field(content: &str, pattern: &Regex) -> Option<String> {
412    for line in content.lines().take(MAX_ITERATION_COUNT) {
413        let cleaned_line = pre_process(line);
414        if let Some(value) = pattern.captures(&cleaned_line).and_then(|caps| caps.get(1)) {
415            return Some(clean_string(value.as_str()));
416        }
417    }
418    None
419}
420
421/// Extract description, handling multiline heredoc format
422fn extract_description(content: &str) -> Option<String> {
423    let lines: Vec<&str> = content.lines().take(MAX_ITERATION_COUNT).collect();
424
425    for (i, line) in lines.iter().enumerate() {
426        let cleaned = pre_process(line);
427        if let Some(value) = DESCRIPTION_PATTERN
428            .captures(&cleaned)
429            .and_then(|caps| caps.get(1))
430        {
431            let value_str = value.as_str();
432
433            if value_str.contains("<<-") {
434                return extract_multiline_description(&lines, i);
435            } else {
436                return normalize_podspec_dynamic_metadata_value(&clean_string(value_str));
437            }
438        }
439    }
440    None
441}
442
443fn merge_summary_and_description(
444    summary: Option<&str>,
445    description: Option<String>,
446) -> Option<String> {
447    match (
448        summary.map(str::trim).filter(|s| !s.is_empty()),
449        description,
450    ) {
451        (Some(summary), Some(description)) if description.starts_with(summary) => Some(description),
452        (Some(summary), Some(description)) => Some(format!("{}\n{}", summary, description)),
453        (Some(summary), None) => Some(summary.to_string()),
454        (None, description) => description,
455    }
456}
457
458/// Extract multiline description in heredoc format
459fn extract_multiline_description(lines: &[&str], start_index: usize) -> Option<String> {
460    let start_line = lines.get(start_index)?;
461
462    // Extract the delimiter (e.g., "DESC" from "<<-DESC")
463    let delimiter = start_line
464        .split("<<-")
465        .nth(1)?
466        .trim()
467        .trim_matches(|c| c == '"' || c == '\'');
468
469    let mut description_lines = Vec::new();
470    let mut found_start = false;
471
472    for line in lines.iter().take(MAX_ITERATION_COUNT).skip(start_index) {
473        if !found_start && line.contains("<<-") {
474            found_start = true;
475            continue;
476        }
477
478        if found_start {
479            let trimmed = line.trim();
480            if trimmed == delimiter {
481                break;
482            }
483            description_lines.push(*line);
484        }
485    }
486
487    if description_lines.is_empty() {
488        None
489    } else {
490        Some(description_lines.join("\n").trim().to_string())
491    }
492}
493
494/// Extract authors (can be single or multiple)
495fn extract_authors(content: &str) -> Vec<(String, Option<String>)> {
496    let mut authors = Vec::new();
497
498    for line in content.lines().take(MAX_ITERATION_COUNT) {
499        let cleaned_line = pre_process(line);
500        if let Some(value) = AUTHOR_PATTERN
501            .captures(&cleaned_line)
502            .and_then(|caps| caps.get(1))
503        {
504            let value_str = value.as_str();
505
506            if value_str.contains("=>") {
507                for part in value_str.split(',') {
508                    if let Some((name, email)) = parse_author_hash_entry(part) {
509                        authors.push((name, Some(email)));
510                    }
511                }
512            } else {
513                let cleaned = clean_string(value_str);
514                let Some(cleaned) = normalize_podspec_dynamic_metadata_value(&cleaned) else {
515                    continue;
516                };
517                let (name, email) = parse_author_string(&cleaned);
518                authors.push((name, email));
519            }
520        }
521    }
522
523    authors
524}
525
526fn extract_source_url(content: &str) -> Option<String> {
527    for line in content.lines().take(MAX_ITERATION_COUNT) {
528        let cleaned_line = pre_process(line);
529        let Some(value) = SOURCE_PATTERN
530            .captures(&cleaned_line)
531            .and_then(|caps| caps.get(1))
532            .map(|m| m.as_str())
533        else {
534            continue;
535        };
536
537        if let Some(caps) = SOURCE_GIT_PATTERN.captures(value)
538            && let Some(url) = caps.get(1)
539        {
540            return Some(clean_string(url.as_str()));
541        }
542
543        if let Some(caps) = SOURCE_GIT_DYNAMIC_PATTERN.captures(value)
544            && let Some(url) = caps.get(1)
545        {
546            let cleaned = clean_string(url.as_str());
547            if !cleaned.is_empty() {
548                return Some(cleaned);
549            }
550        }
551
552        if let Some(caps) = SOURCE_HTTP_PATTERN.captures(value)
553            && let Some(url) = caps.get(1)
554        {
555            return Some(clean_string(url.as_str()));
556        }
557
558        return Some(clean_string(value));
559    }
560
561    None
562}
563
564/// Parse author from hash entry format: "Name" => "email"
565fn parse_author_hash_entry(entry: &str) -> Option<(String, String)> {
566    let parts: Vec<&str> = entry.split("=>").collect();
567    if parts.len() == 2 {
568        let name = clean_string(parts[0].trim())
569            .trim()
570            .trim_matches(['\'', '"'])
571            .to_string();
572        let email = clean_string(parts[1].trim())
573            .trim()
574            .trim_matches(['\'', '"'])
575            .to_string();
576        Some((name, email))
577    } else {
578        None
579    }
580}
581
582/// Parse author from string, extracting email if present
583fn parse_author_string(author: &str) -> (String, Option<String>) {
584    if let Some(email_start) = author.find('<')
585        && let Some(email_end) = author.find('>')
586    {
587        let name = author[..email_start].trim().to_string();
588        let email = author[email_start + 1..email_end].trim().to_string();
589        return (name, Some(email));
590    }
591    (author.to_string(), None)
592}
593
594/// Extract dependencies from podspec
595fn extract_dependencies(content: &str) -> Vec<Dependency> {
596    let mut dependencies = Vec::new();
597
598    for line in content.lines().take(MAX_ITERATION_COUNT) {
599        let cleaned_line = pre_process(line);
600        if let Some(caps) = DEPENDENCY_PATTERN.captures(&cleaned_line) {
601            let method = caps.get(0).map(|m| m.as_str()).unwrap_or("");
602            let name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
603            let version_req = caps.get(2).map(|m| clean_string(m.as_str()));
604
605            if let Some(dep) = create_dependency(name, version_req, method) {
606                dependencies.push(dep);
607            }
608        }
609    }
610
611    dependencies
612}
613
614/// Create a Dependency from name and version requirement
615fn create_dependency(name: &str, version_req: Option<String>, method: &str) -> Option<Dependency> {
616    if name.is_empty() {
617        return None;
618    }
619
620    let purl = PackageUrl::new("cocoapods", name).ok()?;
621
622    // Determine if version is pinned (exact version)
623    let is_pinned = version_req
624        .as_ref()
625        .map(|v| !v.contains(&['~', '>', '<', '='][..]))
626        .unwrap_or(false);
627
628    let is_development = method.contains("add_development_dependency");
629
630    Some(Dependency {
631        purl: Some(truncate_field(purl.to_string())),
632        extracted_requirement: version_req.map(truncate_field),
633        scope: Some(
634            if is_development {
635                "development"
636            } else {
637                "runtime"
638            }
639            .to_string(),
640        ),
641        is_runtime: Some(!is_development),
642        is_optional: Some(is_development),
643        is_pinned: Some(is_pinned),
644        is_direct: Some(true),
645        resolved_package: None,
646        extra_data: None,
647    })
648}
649
650/// Pre-process a line by removing comments and trimming
651fn pre_process(line: &str) -> String {
652    let line = if let Some(comment_pos) = line.find('#') {
653        &line[..comment_pos]
654    } else {
655        line
656    };
657    line.trim().to_string()
658}
659
660/// Clean a string value by removing quotes and special characters
661fn clean_string(s: &str) -> String {
662    let after_removing_special_patterns = s.trim().replace("%q", "").replace(".freeze", "");
663
664    after_removing_special_patterns
665        .trim_matches(|c| {
666            c == '\''
667                || c == '"'
668                || c == '{'
669                || c == '}'
670                || c == '['
671                || c == ']'
672                || c == '<'
673                || c == '>'
674        })
675        .trim()
676        .to_string()
677}
678
679fn get_repo_base_url(vcs_url: &str) -> Option<String> {
680    if vcs_url.is_empty() {
681        return None;
682    }
683
684    if vcs_url.ends_with(".git") {
685        Some(vcs_url.trim_end_matches(".git").to_string())
686    } else {
687        Some(vcs_url.to_string())
688    }
689}
690
691fn get_hashed_path(name: &str) -> Option<String> {
692    if name.is_empty() {
693        return None;
694    }
695
696    let mut hasher = Md5::new();
697    hasher.update(name.as_bytes());
698    let hash_str = hex::encode(hasher.finalize());
699
700    Some(format!(
701        "{}/{}/{}",
702        &hash_str[0..1],
703        &hash_str[1..2],
704        &hash_str[2..3]
705    ))
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn test_is_match() {
714        assert!(PodspecParser::is_match(Path::new("AFNetworking.podspec")));
715        assert!(PodspecParser::is_match(Path::new("project/MyLib.podspec")));
716        assert!(!PodspecParser::is_match(Path::new(
717            "AFNetworking.podspec.json"
718        )));
719        assert!(!PodspecParser::is_match(Path::new("Podfile")));
720        assert!(!PodspecParser::is_match(Path::new("Podfile.lock")));
721    }
722
723    #[test]
724    fn test_clean_string() {
725        assert_eq!(clean_string("'AFNetworking'"), "AFNetworking");
726        assert_eq!(clean_string("\"AFNetworking\""), "AFNetworking");
727        assert_eq!(clean_string("'test'.freeze"), "test");
728        assert_eq!(clean_string("%q{test}"), "test");
729    }
730
731    #[test]
732    fn test_extract_simple_field() {
733        let content = r#"
734Pod::Spec.new do |s|
735  s.name = "AFNetworking"
736  s.version = "4.0.1"
737end
738"#;
739        assert_eq!(
740            extract_metadata_field(content, &NAME_PATTERN),
741            Some("AFNetworking".to_string())
742        );
743        assert_eq!(
744            extract_metadata_field(content, &VERSION_PATTERN),
745            Some("4.0.1".to_string())
746        );
747    }
748
749    #[test]
750    fn test_extract_dynamic_metadata_field_uses_stable_placeholder() {
751        let content = r#"
752Pod::Spec.new do |s|
753  s.name = package['name']
754  s.version = package['version']
755  s.summary = package['description']
756  s.homepage = package['repository']['url']
757end
758"#;
759
760        assert_eq!(
761            extract_metadata_field(content, &NAME_PATTERN),
762            Some("packagename".to_string())
763        );
764        assert_eq!(
765            extract_metadata_field(content, &VERSION_PATTERN),
766            Some("packageversion".to_string())
767        );
768        assert_eq!(
769            extract_metadata_field(content, &SUMMARY_PATTERN),
770            Some("packagedescription".to_string())
771        );
772        assert_eq!(
773            extract_metadata_field(content, &HOMEPAGE_PATTERN),
774            Some("packagerepositoryurl".to_string())
775        );
776    }
777
778    #[test]
779    fn test_detects_nonliteral_podspec_expressions_generically() {
780        assert!(looks_like_nonliteral_podspec_expression("package['name']"));
781        assert!(looks_like_nonliteral_podspec_expression("pod_name"));
782        assert!(looks_like_nonliteral_podspec_expression(
783            "SpecMetadata.version"
784        ));
785        assert!(!looks_like_nonliteral_podspec_expression("'Demo'"));
786        assert!(!looks_like_nonliteral_podspec_expression("\"1.0.0\""));
787        assert!(!looks_like_nonliteral_podspec_expression("1.0.0"));
788    }
789
790    #[test]
791    fn test_extract_multiline_description() {
792        let content = r#"
793Pod::Spec.new do |s|
794  s.description = <<-DESC
795    A delightful networking library.
796    Features include:
797    - Modern API
798  DESC
799end
800"#;
801        let desc = extract_description(content);
802        assert!(desc.is_some());
803        let desc_text = desc.unwrap();
804        assert!(desc_text.contains("delightful networking"));
805        assert!(desc_text.contains("Modern API"));
806    }
807
808    #[test]
809    fn test_extract_dependency() {
810        let content = r#"
811Pod::Spec.new do |s|
812  s.dependency "AFNetworking", "~> 4.0"
813  s.dependency "Alamofire"
814end
815"#;
816        let deps = extract_dependencies(content);
817        assert_eq!(deps.len(), 2);
818
819        assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
820        assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
821        assert_eq!(deps[0].is_pinned, Some(false)); // Contains ~
822
823        assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
824        assert_eq!(deps[1].extracted_requirement, None);
825    }
826
827    #[test]
828    fn test_extract_runtime_and_development_dependency_scopes() {
829        let content = r#"
830Pod::Spec.new do |s|
831  s.add_dependency 'AFNetworking', '~> 4.0'
832  s.add_runtime_dependency 'Alamofire', '~> 5.0'
833  s.add_development_dependency 'Quick', '~> 7.0'
834end
835"#;
836
837        let deps = extract_dependencies(content);
838        assert_eq!(deps.len(), 3);
839
840        assert_eq!(deps[0].scope.as_deref(), Some("runtime"));
841        assert_eq!(deps[0].is_runtime, Some(true));
842        assert_eq!(deps[0].is_optional, Some(false));
843
844        assert_eq!(deps[1].scope.as_deref(), Some("runtime"));
845        assert_eq!(deps[1].is_runtime, Some(true));
846        assert_eq!(deps[1].is_optional, Some(false));
847
848        assert_eq!(deps[2].scope.as_deref(), Some("development"));
849        assert_eq!(deps[2].is_runtime, Some(false));
850        assert_eq!(deps[2].is_optional, Some(true));
851    }
852
853    #[test]
854    fn test_parse_author_string() {
855        assert_eq!(
856            parse_author_string("John Doe <john@example.com>"),
857            ("John Doe".to_string(), Some("john@example.com".to_string()))
858        );
859        assert_eq!(
860            parse_author_string("Jane Smith"),
861            ("Jane Smith".to_string(), None)
862        );
863    }
864
865    #[test]
866    fn test_normalize_podspec_license_string() {
867        let content = r#"
868Pod::Spec.new do |s|
869  s.license = 'Apache License, Version 2.0'
870end
871"#;
872
873        let extracted = extract_license_statement(content);
874        let (declared, declared_spdx, detections) =
875            normalize_podspec_declared_license(content, extracted.as_deref());
876
877        assert_eq!(declared.as_deref(), Some("apache-2.0"));
878        assert_eq!(declared_spdx.as_deref(), Some("Apache-2.0"));
879        assert_eq!(detections.len(), 1);
880    }
881
882    #[test]
883    fn test_normalize_podspec_hash_type_only() {
884        let content = r#"
885Pod::Spec.new do |s|
886  s.license = { :type => 'MIT', :file => 'LICENSE' }
887end
888"#;
889
890        let extracted = extract_license_statement(content);
891        let (declared, declared_spdx, detections) =
892            normalize_podspec_declared_license(content, extracted.as_deref());
893
894        assert_eq!(declared.as_deref(), Some("mit"));
895        assert_eq!(declared_spdx.as_deref(), Some("MIT"));
896        assert_eq!(detections.len(), 1);
897    }
898
899    #[test]
900    fn test_podspec_license_hash_preserves_license_file_reference() {
901        let content = r#"
902Pod::Spec.new do |s|
903  s.name = "Demo"
904  s.version = "1.0.0"
905  s.license = { :type => 'MIT', :file => 'LICENSE.txt' }
906end
907"#;
908
909        let temp_dir = tempfile::tempdir().unwrap();
910        let file_path = temp_dir.path().join("Demo.podspec");
911        std::fs::write(&file_path, content).unwrap();
912
913        let package_data = PodspecParser::extract_first_package(&file_path);
914        assert_eq!(package_data.license_detections.len(), 1);
915        assert_eq!(
916            package_data.license_detections[0].matches[0]
917                .referenced_filenames
918                .as_ref(),
919            Some(&vec!["LICENSE.txt".to_string()])
920        );
921    }
922
923    #[test]
924    fn test_extract_packages_marks_dynamic_identity_placeholders_for_nonliteral_fields() {
925        let content = r#"
926Pod::Spec.new do |s|
927
928  s.name           = pod_name
929  s.version        = pod_version
930  s.summary        = package['description']
931  s.homepage       = homepage_url
932  s.license        = license_name
933  s.author         = author_name
934  s.source         = { :git => homepage_url, :tag => 'v#{pod_version}' }
935
936  s.requires_arc   = true
937  s.ios.deployment_target = '8.0'
938  s.tvos.deployment_target = '9.0'
939
940  s.preserve_paths = 'README.md', 'package.json', 'index.js'
941  s.source_files   = 'ios/*.{h,m}'
942
943  s.dependency 'React-Core'
944
945end
946"#;
947
948        let temp_dir = tempfile::tempdir().unwrap();
949        let file_path = temp_dir.path().join("dynamic-identity.podspec");
950        std::fs::write(&file_path, content).unwrap();
951
952        let package_data = PodspecParser::extract_first_package(&file_path);
953
954        assert_eq!(package_data.name.as_deref(), Some("pod_name"));
955        assert_eq!(package_data.version.as_deref(), Some("pod_version"));
956        assert_eq!(
957            package_data.description.as_deref(),
958            Some("packagedescription")
959        );
960        assert_eq!(package_data.homepage_url.as_deref(), Some("homepage_url"));
961        assert_eq!(
962            package_data.extracted_license_statement.as_deref(),
963            Some("license_name")
964        );
965        assert_eq!(
966            package_data.declared_license_expression.as_deref(),
967            Some("unknown")
968        );
969        assert_eq!(
970            package_data.declared_license_expression_spdx.as_deref(),
971            Some("LicenseRef-scancode-unknown")
972        );
973        assert_eq!(package_data.license_detections.len(), 1);
974        assert_eq!(package_data.vcs_url.as_deref(), Some("homepage_url"));
975        assert_eq!(
976            package_data.repository_homepage_url.as_deref(),
977            Some("https://cocoapods.org/pods/pod_name")
978        );
979        assert_eq!(
980            package_data.repository_download_url.as_deref(),
981            Some("homepage_url/archive/refs/tags/pod_version.zip")
982        );
983        assert_eq!(
984            package_data.code_view_url.as_deref(),
985            Some("homepage_url/tree/pod_version")
986        );
987        assert_eq!(
988            package_data.bug_tracking_url.as_deref(),
989            Some("homepage_url/issues/")
990        );
991        assert_eq!(
992            package_data.purl.as_deref(),
993            Some("pkg:cocoapods/pod_name@pod_version")
994        );
995        assert_eq!(package_data.parties.len(), 1);
996        assert_eq!(package_data.parties[0].name.as_deref(), Some("author_name"));
997        assert_eq!(
998            package_data
999                .extra_data
1000                .as_ref()
1001                .and_then(|data| data.get("dynamic_identity_placeholders"))
1002                .and_then(|value| value.as_bool()),
1003            Some(true)
1004        );
1005        assert_eq!(package_data.dependencies.len(), 1);
1006        assert_eq!(
1007            package_data.dependencies[0].purl.as_deref(),
1008            Some("pkg:cocoapods/React-Core")
1009        );
1010    }
1011}