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