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 crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
36use crate::parsers::PackageParser;
37use crate::parsers::license_normalization::{
38    DeclaredLicenseMatchMetadata, build_declared_license_data_from_pair,
39    normalize_spdx_declared_license,
40};
41use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
42
43/// Parses CocoaPods specification files (.podspec).
44///
45/// Extracts package metadata from .podspec files using regex-based Ruby DSL parsing.
46///
47/// # Extracted Fields
48/// - Name, version, summary, description
49/// - Homepage, license, source URLs
50/// - Author information (including author hashes)
51/// - Dependencies with version constraints
52///
53/// # Heredoc Support
54/// Handles multiline descriptions: `s.description = <<-DESC ... DESC`
55pub struct PodspecParser;
56
57impl PackageParser for PodspecParser {
58    const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
59
60    fn is_match(path: &Path) -> bool {
61        path.extension().is_some_and(|ext| {
62            ext == "podspec"
63                && path
64                    .file_name()
65                    .is_some_and(|name| !name.to_string_lossy().ends_with(".json.podspec"))
66        })
67    }
68
69    fn extract_packages(path: &Path) -> Vec<PackageData> {
70        let content = match read_file_to_string(path, None) {
71            Ok(c) => c,
72            Err(e) => {
73                warn!("Failed to read {:?}: {}", path, e);
74                return vec![default_package_data()];
75            }
76        };
77
78        let raw_name = extract_raw_field(&content, &NAME_PATTERN);
79        let raw_version = extract_raw_field(&content, &VERSION_PATTERN);
80        let name = extract_metadata_field(&content, &NAME_PATTERN).map(truncate_field);
81        let version = extract_metadata_field(&content, &VERSION_PATTERN).map(truncate_field);
82        let summary = extract_metadata_field(&content, &SUMMARY_PATTERN).map(truncate_field);
83        let description =
84            merge_summary_and_description(summary.as_deref(), extract_description(&content))
85                .map(truncate_field);
86        let homepage_url = extract_metadata_field(&content, &HOMEPAGE_PATTERN).map(truncate_field);
87        let license = extract_license_statement(&content).map(truncate_field);
88        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
89            normalize_podspec_declared_license(&content, license.as_deref());
90        let source = extract_source_url(&content).map(truncate_field);
91        let authors = extract_authors(&content);
92
93        let parties = authors
94            .into_iter()
95            .map(|(name, email)| Party {
96                r#type: Some("person".to_string()),
97                name: Some(truncate_field(name)),
98                email: email.map(truncate_field),
99                url: None,
100                role: Some("author".to_string()),
101                organization: None,
102                organization_url: None,
103                timezone: None,
104            })
105            .collect();
106
107        let dependencies = extract_dependencies(&content);
108        let mut extra_data = serde_json::Map::new();
109        let has_dynamic_identity_placeholders = raw_name
110            .as_deref()
111            .is_some_and(looks_like_nonliteral_podspec_expression)
112            && raw_version
113                .as_deref()
114                .is_some_and(looks_like_nonliteral_podspec_expression);
115        if has_dynamic_identity_placeholders {
116            extra_data.insert(
117                "dynamic_identity_placeholders".to_string(),
118                serde_json::Value::Bool(true),
119            );
120        }
121        if let Some(raw_license) = extract_field(&content, &LICENSE_PATTERN)
122            && let Some(license_file) = extract_ruby_hash_file(&raw_license)
123        {
124            extra_data.insert(
125                "license_file".to_string(),
126                serde_json::Value::String(license_file),
127            );
128        }
129        let repository_homepage_url = name
130            .as_ref()
131            .map(|n| format!("https://cocoapods.org/pods/{}", n));
132        let repository_download_url = match (source.as_deref(), version.as_deref()) {
133            (Some(vcs_url), Some(version_str)) => get_repo_base_url(vcs_url)
134                .map(|base| format!("{}/archive/refs/tags/{}.zip", base, version_str)),
135            _ => None,
136        };
137        let code_view_url = match (source.as_deref(), version.as_deref()) {
138            (Some(vcs_url), Some(version_str)) => {
139                get_repo_base_url(vcs_url).map(|base| format!("{}/tree/{}", base, version_str))
140            }
141            _ => None,
142        };
143        let bug_tracking_url = source
144            .as_deref()
145            .and_then(get_repo_base_url)
146            .map(|base| format!("{}/issues/", base));
147        let api_data_url = match (name.as_deref(), version.as_deref()) {
148            (Some(name_str), Some(version_str)) => get_hashed_path(name_str).map(|hashed| {
149                format!(
150                    "https://raw.githubusercontent.com/CocoaPods/Specs/blob/master/Specs/{}/{}/{}/{}.podspec.json",
151                    hashed, name_str, version_str, name_str
152                )
153            }),
154            _ => None,
155        };
156        let purl = if let Some(name_str) = &name {
157            let purl_result = PackageUrl::new(Self::PACKAGE_TYPE.as_str(), name_str)
158                .or_else(|_| PackageUrl::new("generic", name_str));
159            match purl_result {
160                Ok(mut purl) => {
161                    if let Some(version_str) = &version {
162                        let _ = purl.with_version(version_str);
163                    }
164                    Some(truncate_field(purl.to_string()))
165                }
166                Err(_) => None,
167            }
168        } else {
169            None
170        };
171
172        vec![PackageData {
173            package_type: Some(Self::PACKAGE_TYPE),
174            namespace: None,
175            name,
176            version,
177            qualifiers: None,
178            subpath: None,
179            primary_language: Some("Objective-C".to_string()),
180            description,
181            release_date: None,
182            parties,
183            keywords: Vec::new(),
184            homepage_url,
185            download_url: None,
186            size: None,
187            sha1: None,
188            md5: None,
189            sha256: None,
190            sha512: None,
191            bug_tracking_url,
192            code_view_url,
193            vcs_url: source,
194            copyright: None,
195            holder: None,
196            declared_license_expression,
197            declared_license_expression_spdx,
198            license_detections,
199            other_license_expression: None,
200            other_license_expression_spdx: None,
201            other_license_detections: Vec::new(),
202            extracted_license_statement: license,
203            notice_text: None,
204            source_packages: Vec::new(),
205            file_references: Vec::new(),
206            extra_data: (!extra_data.is_empty()).then_some(extra_data.into_iter().collect()),
207            dependencies,
208            repository_homepage_url,
209            repository_download_url,
210            api_data_url,
211            datasource_id: Some(DatasourceId::CocoapodsPodspec),
212            purl,
213            is_private: false,
214            is_virtual: false,
215        }]
216    }
217}
218
219fn default_package_data() -> PackageData {
220    PackageData {
221        package_type: Some(PodspecParser::PACKAGE_TYPE),
222        primary_language: Some("Objective-C".to_string()),
223        datasource_id: Some(DatasourceId::CocoapodsPodspec),
224        ..Default::default()
225    }
226}
227
228static NAME_PATTERN: LazyLock<Regex> =
229    LazyLock::new(|| Regex::new(r"\.name\s*=\s*(.+)").expect("valid regex"));
230static VERSION_PATTERN: LazyLock<Regex> =
231    LazyLock::new(|| Regex::new(r"\.version\s*=\s*(.+)").expect("valid regex"));
232static SUMMARY_PATTERN: LazyLock<Regex> =
233    LazyLock::new(|| Regex::new(r"\.summary\s*=\s*(.+)").expect("valid regex"));
234static DESCRIPTION_PATTERN: LazyLock<Regex> =
235    LazyLock::new(|| Regex::new(r"\.description\s*=\s*(.+)").expect("valid regex"));
236static HOMEPAGE_PATTERN: LazyLock<Regex> =
237    LazyLock::new(|| Regex::new(r"\.homepage\s*=\s*(.+)").expect("valid regex"));
238static LICENSE_PATTERN: LazyLock<Regex> =
239    LazyLock::new(|| Regex::new(r"\.license\s*=\s*(.+)").expect("valid regex"));
240static SOURCE_PATTERN: LazyLock<Regex> =
241    LazyLock::new(|| Regex::new(r"\.source\s*=\s*(.+)").expect("valid regex"));
242static AUTHOR_PATTERN: LazyLock<Regex> =
243    LazyLock::new(|| Regex::new(r"\.authors?\s*=\s*(.+)").expect("valid regex"));
244static SOURCE_GIT_PATTERN: LazyLock<Regex> =
245    LazyLock::new(|| Regex::new(r#":git\s*=>\s*['\"]([^'\"]+)['\"]"#).expect("valid regex"));
246static SOURCE_GIT_DYNAMIC_PATTERN: LazyLock<Regex> =
247    LazyLock::new(|| Regex::new(r#":git\s*(?:=>|=)\s*([^,}]+)"#).expect("valid regex"));
248static SOURCE_HTTP_PATTERN: LazyLock<Regex> =
249    LazyLock::new(|| Regex::new(r#":http\s*=>\s*['\"]([^'\"]+)['\"]"#).expect("valid regex"));
250
251static DEPENDENCY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
252    Regex::new(
253    r#"(?:s\.)?(?:dependency|add_dependency|add_(?:runtime|development)_dependency)\s+['"]([^'"]+)['"](?:\s*,\s*(.+))?"#
254).expect("valid regex")
255});
256
257fn extract_license_statement(content: &str) -> Option<String> {
258    extract_field(content, &LICENSE_PATTERN)
259        .map(|value| normalize_ruby_hash_literal(&value))
260        .and_then(|value| normalize_podspec_dynamic_metadata_value(&value))
261}
262
263fn normalize_podspec_declared_license(
264    content: &str,
265    extracted_license_statement: Option<&str>,
266) -> (
267    Option<String>,
268    Option<String>,
269    Vec<crate::models::LicenseDetection>,
270) {
271    let Some(raw_license) = extract_raw_field(content, &LICENSE_PATTERN) else {
272        return super::license_normalization::empty_declared_license_data();
273    };
274    if looks_like_nonliteral_podspec_expression(&raw_license) {
275        return build_declared_license_data_from_pair(
276            "unknown".to_string(),
277            "LicenseRef-scancode-unknown".to_string(),
278            DeclaredLicenseMatchMetadata::single_line(
279                extracted_license_statement.unwrap_or(raw_license.as_str()),
280            ),
281        );
282    }
283    let normalized_candidate = if raw_license.contains("=>") || raw_license.contains('=') {
284        extract_ruby_hash_type(&raw_license)
285            .map(|license_type| canonicalize_cocoapods_license_type(&license_type))
286    } else {
287        extracted_license_statement.map(canonicalize_cocoapods_license_type)
288    };
289
290    normalize_spdx_declared_license(normalized_candidate.as_deref())
291}
292
293fn extract_ruby_hash_file(raw_license: &str) -> Option<String> {
294    let normalized = raw_license.replace("=>", "=");
295    let file_regex = Regex::new(r#":file\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
296    file_regex
297        .captures(&normalized)
298        .and_then(|caps| caps.get(1))
299        .map(|value| value.as_str().trim().to_string())
300        .filter(|value| !value.is_empty())
301}
302
303fn canonicalize_cocoapods_license_type(value: &str) -> String {
304    match value.trim() {
305        "Apache License, Version 2.0" => "Apache-2.0".to_string(),
306        other => other.to_string(),
307    }
308}
309
310fn extract_ruby_hash_type(raw_license: &str) -> Option<String> {
311    let normalized = raw_license.replace("=>", "=");
312    let type_regex = Regex::new(r#":type\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
313    type_regex
314        .captures(&normalized)
315        .and_then(|caps| caps.get(1))
316        .map(|value| value.as_str().trim().to_string())
317        .filter(|value| !value.is_empty())
318}
319
320fn normalize_ruby_hash_literal(value: &str) -> String {
321    if !value.contains('=') && !value.contains("=>") {
322        return value.to_string();
323    }
324
325    value
326        .replace("=>", "=")
327        .replace(['\'', '"'], "")
328        .split_whitespace()
329        .collect::<Vec<_>>()
330        .join(" ")
331}
332
333fn normalize_podspec_dynamic_metadata_value(value: &str) -> Option<String> {
334    let trimmed = value.trim();
335    if trimmed.is_empty() {
336        return None;
337    }
338
339    if looks_like_podspec_dynamic_metadata_value(trimmed) {
340        let normalized = trimmed
341            .chars()
342            .filter(|c| c.is_ascii_alphanumeric())
343            .collect::<String>()
344            .to_ascii_lowercase();
345        return (!normalized.is_empty()).then_some(normalized);
346    }
347
348    Some(trimmed.to_string())
349}
350
351fn looks_like_podspec_dynamic_metadata_value(value: &str) -> bool {
352    value.contains("['") || value.contains("[\"")
353}
354
355fn looks_like_nonliteral_podspec_expression(value: &str) -> bool {
356    let trimmed = value.trim();
357    if trimmed.is_empty() {
358        return false;
359    }
360
361    if trimmed.contains("=>") || trimmed.starts_with('{') {
362        return false;
363    }
364
365    if matches!(trimmed.chars().next(), Some('\'' | '"'))
366        || trimmed.starts_with("%q{")
367        || trimmed.starts_with("%Q{")
368    {
369        return false;
370    }
371
372    if trimmed
373        .chars()
374        .all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '_' | '+'))
375    {
376        return false;
377    }
378
379    true
380}
381
382fn extract_metadata_field(content: &str, pattern: &Regex) -> Option<String> {
383    extract_field(content, pattern)
384        .and_then(|value| normalize_podspec_dynamic_metadata_value(&value))
385}
386
387fn extract_raw_field(content: &str, pattern: &Regex) -> Option<String> {
388    for line in content.lines().take(MAX_ITERATION_COUNT) {
389        let cleaned_line = pre_process(line);
390        if let Some(value) = pattern.captures(&cleaned_line).and_then(|caps| caps.get(1)) {
391            return Some(value.as_str().trim().to_string());
392        }
393    }
394    None
395}
396
397/// Extract a single field using a regex pattern
398fn extract_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(clean_string(value.as_str()));
403        }
404    }
405    None
406}
407
408/// Extract description, handling multiline heredoc format
409fn extract_description(content: &str) -> Option<String> {
410    let lines: Vec<&str> = content.lines().take(MAX_ITERATION_COUNT).collect();
411
412    for (i, line) in lines.iter().enumerate() {
413        let cleaned = pre_process(line);
414        if let Some(value) = DESCRIPTION_PATTERN
415            .captures(&cleaned)
416            .and_then(|caps| caps.get(1))
417        {
418            let value_str = value.as_str();
419
420            if value_str.contains("<<-") {
421                return extract_multiline_description(&lines, i);
422            } else {
423                return normalize_podspec_dynamic_metadata_value(&clean_string(value_str));
424            }
425        }
426    }
427    None
428}
429
430fn merge_summary_and_description(
431    summary: Option<&str>,
432    description: Option<String>,
433) -> Option<String> {
434    match (
435        summary.map(str::trim).filter(|s| !s.is_empty()),
436        description,
437    ) {
438        (Some(summary), Some(description)) if description.starts_with(summary) => Some(description),
439        (Some(summary), Some(description)) => Some(format!("{}\n{}", summary, description)),
440        (Some(summary), None) => Some(summary.to_string()),
441        (None, description) => description,
442    }
443}
444
445/// Extract multiline description in heredoc format
446fn extract_multiline_description(lines: &[&str], start_index: usize) -> Option<String> {
447    let start_line = lines.get(start_index)?;
448
449    // Extract the delimiter (e.g., "DESC" from "<<-DESC")
450    let delimiter = start_line
451        .split("<<-")
452        .nth(1)?
453        .trim()
454        .trim_matches(|c| c == '"' || c == '\'');
455
456    let mut description_lines = Vec::new();
457    let mut found_start = false;
458
459    for line in lines.iter().take(MAX_ITERATION_COUNT).skip(start_index) {
460        if !found_start && line.contains("<<-") {
461            found_start = true;
462            continue;
463        }
464
465        if found_start {
466            let trimmed = line.trim();
467            if trimmed == delimiter {
468                break;
469            }
470            description_lines.push(*line);
471        }
472    }
473
474    if description_lines.is_empty() {
475        None
476    } else {
477        Some(description_lines.join("\n").trim().to_string())
478    }
479}
480
481/// Extract authors (can be single or multiple)
482fn extract_authors(content: &str) -> Vec<(String, Option<String>)> {
483    let mut authors = Vec::new();
484
485    for line in content.lines().take(MAX_ITERATION_COUNT) {
486        let cleaned_line = pre_process(line);
487        if let Some(value) = AUTHOR_PATTERN
488            .captures(&cleaned_line)
489            .and_then(|caps| caps.get(1))
490        {
491            let value_str = value.as_str();
492
493            if value_str.contains("=>") {
494                for part in value_str.split(',') {
495                    if let Some((name, email)) = parse_author_hash_entry(part) {
496                        authors.push((name, Some(email)));
497                    }
498                }
499            } else {
500                let cleaned = clean_string(value_str);
501                let Some(cleaned) = normalize_podspec_dynamic_metadata_value(&cleaned) else {
502                    continue;
503                };
504                let (name, email) = parse_author_string(&cleaned);
505                authors.push((name, email));
506            }
507        }
508    }
509
510    authors
511}
512
513fn extract_source_url(content: &str) -> Option<String> {
514    for line in content.lines().take(MAX_ITERATION_COUNT) {
515        let cleaned_line = pre_process(line);
516        let Some(value) = SOURCE_PATTERN
517            .captures(&cleaned_line)
518            .and_then(|caps| caps.get(1))
519            .map(|m| m.as_str())
520        else {
521            continue;
522        };
523
524        if let Some(caps) = SOURCE_GIT_PATTERN.captures(value)
525            && let Some(url) = caps.get(1)
526        {
527            return Some(clean_string(url.as_str()));
528        }
529
530        if let Some(caps) = SOURCE_GIT_DYNAMIC_PATTERN.captures(value)
531            && let Some(url) = caps.get(1)
532        {
533            let cleaned = clean_string(url.as_str());
534            if !cleaned.is_empty() {
535                return Some(cleaned);
536            }
537        }
538
539        if let Some(caps) = SOURCE_HTTP_PATTERN.captures(value)
540            && let Some(url) = caps.get(1)
541        {
542            return Some(clean_string(url.as_str()));
543        }
544
545        return Some(clean_string(value));
546    }
547
548    None
549}
550
551/// Parse author from hash entry format: "Name" => "email"
552fn parse_author_hash_entry(entry: &str) -> Option<(String, String)> {
553    let parts: Vec<&str> = entry.split("=>").collect();
554    if parts.len() == 2 {
555        let name = clean_string(parts[0].trim())
556            .trim()
557            .trim_matches(['\'', '"'])
558            .to_string();
559        let email = clean_string(parts[1].trim())
560            .trim()
561            .trim_matches(['\'', '"'])
562            .to_string();
563        Some((name, email))
564    } else {
565        None
566    }
567}
568
569/// Parse author from string, extracting email if present
570fn parse_author_string(author: &str) -> (String, Option<String>) {
571    if let Some(email_start) = author.find('<')
572        && let Some(email_end) = author.find('>')
573    {
574        let name = author[..email_start].trim().to_string();
575        let email = author[email_start + 1..email_end].trim().to_string();
576        return (name, Some(email));
577    }
578    (author.to_string(), None)
579}
580
581/// Extract dependencies from podspec
582fn extract_dependencies(content: &str) -> Vec<Dependency> {
583    let mut dependencies = Vec::new();
584
585    for line in content.lines().take(MAX_ITERATION_COUNT) {
586        let cleaned_line = pre_process(line);
587        if let Some(caps) = DEPENDENCY_PATTERN.captures(&cleaned_line) {
588            let method = caps.get(0).map(|m| m.as_str()).unwrap_or("");
589            let name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
590            let version_req = caps.get(2).map(|m| clean_string(m.as_str()));
591
592            if let Some(dep) = create_dependency(name, version_req, method) {
593                dependencies.push(dep);
594            }
595        }
596    }
597
598    dependencies
599}
600
601/// Create a Dependency from name and version requirement
602fn create_dependency(name: &str, version_req: Option<String>, method: &str) -> Option<Dependency> {
603    if name.is_empty() {
604        return None;
605    }
606
607    let purl = PackageUrl::new("cocoapods", name).ok()?;
608
609    // Determine if version is pinned (exact version)
610    let is_pinned = version_req
611        .as_ref()
612        .map(|v| !v.contains(&['~', '>', '<', '='][..]))
613        .unwrap_or(false);
614
615    let is_development = method.contains("add_development_dependency");
616
617    Some(Dependency {
618        purl: Some(truncate_field(purl.to_string())),
619        extracted_requirement: version_req.map(truncate_field),
620        scope: Some(
621            if is_development {
622                "development"
623            } else {
624                "runtime"
625            }
626            .to_string(),
627        ),
628        is_runtime: Some(!is_development),
629        is_optional: Some(is_development),
630        is_pinned: Some(is_pinned),
631        is_direct: Some(true),
632        resolved_package: None,
633        extra_data: None,
634    })
635}
636
637/// Pre-process a line by removing comments and trimming
638fn pre_process(line: &str) -> String {
639    let line = if let Some(comment_pos) = line.find('#') {
640        &line[..comment_pos]
641    } else {
642        line
643    };
644    line.trim().to_string()
645}
646
647/// Clean a string value by removing quotes and special characters
648fn clean_string(s: &str) -> String {
649    let after_removing_special_patterns = s.trim().replace("%q", "").replace(".freeze", "");
650
651    after_removing_special_patterns
652        .trim_matches(|c| {
653            c == '\''
654                || c == '"'
655                || c == '{'
656                || c == '}'
657                || c == '['
658                || c == ']'
659                || c == '<'
660                || c == '>'
661        })
662        .trim()
663        .to_string()
664}
665
666fn get_repo_base_url(vcs_url: &str) -> Option<String> {
667    if vcs_url.is_empty() {
668        return None;
669    }
670
671    if vcs_url.ends_with(".git") {
672        Some(vcs_url.trim_end_matches(".git").to_string())
673    } else {
674        Some(vcs_url.to_string())
675    }
676}
677
678fn get_hashed_path(name: &str) -> Option<String> {
679    if name.is_empty() {
680        return None;
681    }
682
683    let mut hasher = Md5::new();
684    hasher.update(name.as_bytes());
685    let hash_str = hex::encode(hasher.finalize());
686
687    Some(format!(
688        "{}/{}/{}",
689        &hash_str[0..1],
690        &hash_str[1..2],
691        &hash_str[2..3]
692    ))
693}
694
695crate::register_parser!(
696    "CocoaPods podspec file",
697    &["**/*.podspec"],
698    "cocoapods",
699    "Objective-C",
700    Some("https://guides.cocoapods.org/syntax/podspec.html"),
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}