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