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