1use 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
43pub 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
397fn 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
408fn 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
445fn extract_multiline_description(lines: &[&str], start_index: usize) -> Option<String> {
447 let start_line = lines.get(start_index)?;
448
449 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
481fn 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
551fn 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
569fn 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
581fn 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
601fn 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 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
637fn 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
647fn 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)); 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}