Skip to main content

provenant/parsers/
podspec.rs

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