1use std::path::Path;
5
6use super::metadata::ParserMetadata;
7use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
8use crate::parser_warn as warn;
9use crate::parsers::utils::{CappedIterExt, read_file_to_string, truncate_field};
10use packageurl::PackageUrl;
11
12use super::PackageParser;
13
14pub struct CarthageCartfileParser;
15
16impl PackageParser for CarthageCartfileParser {
17 const PACKAGE_TYPE: PackageType = PackageType::Carthage;
18
19 fn metadata() -> Vec<ParserMetadata> {
20 vec![ParserMetadata {
21 description: "Carthage Cartfile dependency manifest",
22 file_patterns: &["**/Cartfile", "**/Cartfile.private"],
23 package_type: "carthage",
24 primary_language: "Objective-C",
25 documentation_url: Some(
26 "https://github.com/Carthage/Carthage/blob/master/Documentation/Artifacts.md",
27 ),
28 }]
29 }
30
31 fn extract_packages(path: &Path) -> Vec<PackageData> {
32 let is_private = is_private_cartfile_path(path);
33 let content = match read_file_to_string(path, None) {
34 Ok(c) => c,
35 Err(e) => {
36 warn!("Failed to read Cartfile at {:?}: {}", path, e);
37 return vec![default_cartfile_package_data(is_private)];
38 }
39 };
40
41 let dependencies = parse_cartfile_lines(&content, false);
42
43 vec![PackageData {
44 package_type: Some(Self::PACKAGE_TYPE),
45 primary_language: Some("Objective-C".to_string()),
46 is_private,
47 dependencies,
48 datasource_id: Some(DatasourceId::CarthageCartfile),
49 ..Default::default()
50 }]
51 }
52
53 fn is_match(path: &Path) -> bool {
54 path.file_name()
55 .is_some_and(|name| name == "Cartfile" || name == "Cartfile.private")
56 }
57}
58
59pub struct CarthageCartfileResolvedParser;
60
61impl PackageParser for CarthageCartfileResolvedParser {
62 const PACKAGE_TYPE: PackageType = PackageType::Carthage;
63
64 fn metadata() -> Vec<ParserMetadata> {
65 vec![ParserMetadata {
66 description: "Carthage Cartfile.resolved pinned dependencies",
67 file_patterns: &["**/Cartfile.resolved"],
68 package_type: "carthage",
69 primary_language: "Objective-C",
70 documentation_url: Some(
71 "https://github.com/Carthage/Carthage/blob/master/Documentation/Artifacts.md",
72 ),
73 }]
74 }
75
76 fn extract_packages(path: &Path) -> Vec<PackageData> {
77 let content = match read_file_to_string(path, None) {
78 Ok(c) => c,
79 Err(e) => {
80 warn!("Failed to read Cartfile.resolved at {:?}: {}", path, e);
81 return vec![default_cartfile_resolved_package_data()];
82 }
83 };
84
85 let dependencies = parse_cartfile_lines(&content, true);
86
87 vec![PackageData {
88 package_type: Some(Self::PACKAGE_TYPE),
89 primary_language: Some("Objective-C".to_string()),
90 dependencies,
91 datasource_id: Some(DatasourceId::CarthageCartfileResolved),
92 ..Default::default()
93 }]
94 }
95
96 fn is_match(path: &Path) -> bool {
97 path.file_name()
98 .is_some_and(|name| name == "Cartfile.resolved")
99 }
100}
101
102#[derive(Debug, PartialEq)]
103enum OriginType {
104 Github,
105 Git,
106 Binary,
107}
108
109struct ParsedLine {
110 origin: OriginType,
111 source: String,
112 version_spec: Option<String>,
113}
114
115fn parse_cartfile_lines(content: &str, is_resolved: bool) -> Vec<Dependency> {
116 let mut dependencies = Vec::new();
117
118 for line in content.lines().capped("Cartfile lines") {
119 let line = line.trim();
120 if line.is_empty() || line.starts_with('#') {
121 continue;
122 }
123
124 let Some(parsed) = parse_line(line) else {
125 warn!("Failed to parse Cartfile line: {}", line);
126 continue;
127 };
128
129 let purl_version = if is_resolved {
130 parsed.version_spec.as_deref()
131 } else {
132 None
133 };
134
135 let (purl, name) = match parsed.origin {
136 OriginType::Github => make_github_purl(&parsed.source, purl_version),
137 OriginType::Git => make_git_dep_info(&parsed.source),
138 OriginType::Binary => make_binary_dep_info(&parsed.source),
139 };
140
141 let extracted_requirement = match (&parsed.origin, parsed.version_spec) {
146 (OriginType::Github, version_spec) => version_spec,
147 (_, Some(version_spec)) => Some(version_spec),
148 (_, None) => Some(parsed.source.clone()),
149 }
150 .map(truncate_field);
151
152 let is_pinned = if is_resolved { Some(true) } else { None };
153
154 dependencies.push(Dependency {
155 purl: purl.map(truncate_field),
156 extracted_requirement,
157 scope: Some("dependencies".to_string()),
158 is_runtime: None,
159 is_optional: None,
160 is_pinned,
161 is_direct: Some(true),
162 resolved_package: None,
163 extra_data: name.map(|n| {
164 let mut map = std::collections::HashMap::new();
165 map.insert("name".to_string(), serde_json::json!(n));
166 map
167 }),
168 });
169 }
170
171 dependencies
172}
173
174fn parse_line(line: &str) -> Option<ParsedLine> {
175 let (origin, rest) = if let Some(rest) = line.strip_prefix("github") {
176 (OriginType::Github, rest.trim())
177 } else if let Some(rest) = line.strip_prefix("git") {
178 (OriginType::Git, rest.trim())
179 } else if let Some(rest) = line.strip_prefix("binary") {
180 (OriginType::Binary, rest.trim())
181 } else {
182 return None;
183 };
184
185 let (source, remaining) = extract_quoted_string(rest)?;
186
187 let version_spec = extract_version_spec(remaining.trim());
188
189 Some(ParsedLine {
190 origin,
191 source,
192 version_spec,
193 })
194}
195
196fn extract_quoted_string(s: &str) -> Option<(String, &str)> {
197 let s = s.trim();
198 if !s.starts_with('"') {
199 return None;
200 }
201 let rest = &s[1..];
202 let end = rest.find('"')?;
203 Some((rest[..end].to_string(), &rest[end + 1..]))
204}
205
206fn extract_version_spec(s: &str) -> Option<String> {
207 let s = strip_inline_comment(s.trim());
208 if s.is_empty() || s.starts_with('#') {
209 return None;
210 }
211
212 let spec = if let Some(rest) = s.strip_prefix("~>") {
213 format!("~> {}", rest.trim())
214 } else if let Some(rest) = s.strip_prefix(">=") {
215 format!(">= {}", rest.trim())
216 } else if let Some(rest) = s.strip_prefix("==") {
217 format!("== {}", rest.trim())
218 } else if s.starts_with('"') {
219 let (version, _) = extract_quoted_string(s)?;
220 version
221 } else {
222 s.to_string()
223 };
224
225 if spec.is_empty() { None } else { Some(spec) }
226}
227
228fn strip_inline_comment(s: &str) -> &str {
229 s.find('#').map_or(s, |i| s[..i].trim_end())
230}
231
232fn make_github_purl(source: &str, version: Option<&str>) -> (Option<String>, Option<String>) {
233 let parts: Vec<&str> = source.splitn(2, '/').collect();
234 if parts.len() != 2 {
235 warn!("Invalid GitHub source in Cartfile: {}", source);
236 return (None, Some(source.to_string()));
237 }
238
239 let namespace = parts[0];
240 let name = parts[1];
241
242 let purl = match PackageUrl::new("github", name) {
243 Ok(mut p) => {
244 if let Err(e) = p.with_namespace(namespace) {
245 warn!(
246 "Failed to set namespace for github purl '{}': {}",
247 source, e
248 );
249 return (None, Some(name.to_string()));
250 }
251 if let Some(v) = version
252 && let Err(e) = p.with_version(v)
253 {
254 warn!(
255 "Failed to set version '{}' for github purl '{}': {}",
256 v, source, e
257 );
258 }
259 Some(p.to_string())
260 }
261 Err(e) => {
262 warn!("Failed to create PackageUrl for github '{}': {}", source, e);
263 None
264 }
265 };
266
267 (purl, Some(name.to_string()))
268}
269
270fn make_git_dep_info(source: &str) -> (Option<String>, Option<String>) {
271 let name = source
272 .rsplit('/')
273 .next()
274 .map(|s| s.strip_suffix(".git").unwrap_or(s))
275 .filter(|s| !s.is_empty())
276 .map(String::from);
277
278 (None, name)
279}
280
281fn make_binary_dep_info(source: &str) -> (Option<String>, Option<String>) {
282 let name = source
283 .rsplit('/')
284 .next()
285 .and_then(|s| s.strip_suffix(".json"))
286 .filter(|s| !s.is_empty())
287 .map(String::from);
288
289 (None, name)
290}
291
292fn is_private_cartfile_path(path: &Path) -> bool {
293 path.file_name()
294 .is_some_and(|name| name == "Cartfile.private")
295}
296
297fn default_cartfile_package_data(is_private: bool) -> PackageData {
298 PackageData {
299 package_type: Some(PackageType::Carthage),
300 primary_language: Some("Objective-C".to_string()),
301 is_private,
302 datasource_id: Some(DatasourceId::CarthageCartfile),
303 ..Default::default()
304 }
305}
306
307fn default_cartfile_resolved_package_data() -> PackageData {
308 PackageData {
309 package_type: Some(PackageType::Carthage),
310 primary_language: Some("Objective-C".to_string()),
311 datasource_id: Some(DatasourceId::CarthageCartfileResolved),
312 ..Default::default()
313 }
314}