1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
26use crate::parser_warn as warn;
27use crate::parsers::utils::{
28 MAX_ITERATION_COUNT, RecursionGuard, read_file_to_string, truncate_field,
29};
30use packageurl::PackageUrl;
31use std::path::Path;
32use toml::Value;
33
34use super::PackageParser;
35use super::license_normalization::{
36 DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
37 normalize_spdx_expression,
38};
39use super::metadata::ParserMetadata;
40
41const FIELD_NAME: &str = "name";
42const FIELD_UUID: &str = "uuid";
43const FIELD_VERSION: &str = "version";
44const FIELD_LICENSE: &str = "license";
45const FIELD_AUTHOR: &str = "author";
46const FIELD_AUTHORS: &str = "authors";
47const FIELD_REPOSITORY: &str = "repository";
48const FIELD_DEPS: &str = "deps";
49const FIELD_COMPAT: &str = "compat";
50const FIELD_TARGETS: &str = "targets";
51const FIELD_HOMEPAGE: &str = "homepage";
52
53pub struct JuliaProjectTomlParser;
54
55impl PackageParser for JuliaProjectTomlParser {
56 const PACKAGE_TYPE: PackageType = PackageType::Julia;
57
58 fn metadata() -> Vec<ParserMetadata> {
59 vec![ParserMetadata {
60 description: "Julia Project.toml manifest",
61 file_patterns: &["**/Project.toml"],
62 package_type: "julia",
63 primary_language: "Julia",
64 documentation_url: Some("https://pkgdocs.julialang.org/v1/toml-files/"),
65 }]
66 }
67
68 fn extract_packages(path: &Path) -> Vec<PackageData> {
69 let toml_content = match read_julia_toml(path) {
70 Ok(content) => content,
71 Err(e) => {
72 warn!("Failed to read or parse Project.toml at {:?}: {}", path, e);
73 return vec![default_project_package_data()];
74 }
75 };
76
77 let name = toml_content
78 .get(FIELD_NAME)
79 .and_then(|v| v.as_str())
80 .map(|s| truncate_field(s.to_string()));
81
82 let version = toml_content
83 .get(FIELD_VERSION)
84 .and_then(|v| v.as_str())
85 .map(|s| truncate_field(s.to_string()));
86
87 let raw_license = toml_content
88 .get(FIELD_LICENSE)
89 .and_then(|v| v.as_str())
90 .map(|s| truncate_field(s.to_string()));
91
92 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
93 raw_license
94 .as_deref()
95 .and_then(normalize_spdx_expression)
96 .map(|normalized| {
97 build_declared_license_data(
98 normalized,
99 DeclaredLicenseMatchMetadata::single_line(
100 raw_license.as_deref().unwrap_or_default(),
101 ),
102 )
103 })
104 .unwrap_or_else(empty_declared_license_data);
105
106 let extracted_license_statement = raw_license.clone().map(truncate_field);
107
108 let dependencies = extract_project_dependencies(&toml_content);
109
110 let purl = create_package_url(&name, &version);
111
112 let repository_url = toml_content
113 .get(FIELD_REPOSITORY)
114 .and_then(|v| v.as_str())
115 .map(|s| truncate_field(s.to_string()));
116
117 let homepage_url = toml_content
118 .get(FIELD_HOMEPAGE)
119 .and_then(|v| v.as_str())
120 .map(|s| truncate_field(s.to_string()));
121
122 let description = None;
123
124 let extra_data = extract_project_extra_data(&toml_content);
125
126 let is_private = false;
127
128 vec![PackageData {
129 package_type: Some(Self::PACKAGE_TYPE),
130 namespace: None,
131 name,
132 version,
133 qualifiers: None,
134 subpath: None,
135 primary_language: Some("Julia".to_string()),
136 description,
137 release_date: None,
138 parties: extract_parties(&toml_content),
139 keywords: Vec::new(),
140 homepage_url,
141 download_url: None,
142 size: None,
143 sha1: None,
144 md5: None,
145 sha256: None,
146 sha512: None,
147 bug_tracking_url: None,
148 code_view_url: None,
149 vcs_url: repository_url,
150 copyright: None,
151 holder: None,
152 declared_license_expression,
153 declared_license_expression_spdx,
154 license_detections,
155 other_license_expression: None,
156 other_license_expression_spdx: None,
157 other_license_detections: Vec::new(),
158 extracted_license_statement,
159 notice_text: None,
160 source_packages: Vec::new(),
161 file_references: Vec::new(),
162 is_private,
163 is_virtual: false,
164 extra_data,
165 dependencies,
166 repository_homepage_url: None,
167 repository_download_url: None,
168 api_data_url: None,
169 datasource_id: Some(DatasourceId::JuliaProjectToml),
170 purl,
171 }]
172 }
173
174 fn is_match(path: &Path) -> bool {
175 path.file_name()
176 .and_then(|name| name.to_str())
177 .is_some_and(|name| name.eq_ignore_ascii_case("Project.toml"))
178 }
179}
180
181pub struct JuliaManifestTomlParser;
182
183impl PackageParser for JuliaManifestTomlParser {
184 const PACKAGE_TYPE: PackageType = PackageType::Julia;
185
186 fn metadata() -> Vec<ParserMetadata> {
187 vec![ParserMetadata {
188 description: "Julia Manifest.toml resolved dependencies",
189 file_patterns: &["**/Manifest.toml"],
190 package_type: "julia",
191 primary_language: "Julia",
192 documentation_url: Some("https://pkgdocs.julialang.org/v1/toml-files/"),
193 }]
194 }
195
196 fn extract_packages(path: &Path) -> Vec<PackageData> {
197 let toml_content = match read_julia_toml(path) {
198 Ok(content) => content,
199 Err(e) => {
200 warn!("Failed to read or parse Manifest.toml at {:?}: {}", path, e);
201 return vec![];
202 }
203 };
204
205 extract_manifest_packages(&toml_content)
206 }
207
208 fn is_match(path: &Path) -> bool {
209 path.file_name()
210 .and_then(|name| name.to_str())
211 .is_some_and(|name| name.eq_ignore_ascii_case("Manifest.toml"))
212 }
213}
214
215fn read_julia_toml(path: &Path) -> Result<Value, String> {
216 let content =
217 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
218 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
219}
220
221fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
222 name.as_ref().and_then(|name| {
223 let mut package_url = match PackageUrl::new(PackageType::Julia.as_str(), name) {
224 Ok(p) => p,
225 Err(e) => {
226 warn!(
227 "Failed to create PackageUrl for julia package '{}': {}",
228 name, e
229 );
230 return None;
231 }
232 };
233
234 if let Some(v) = version
235 && let Err(e) = package_url.with_version(v)
236 {
237 warn!(
238 "Failed to set version '{}' for julia package '{}': {}",
239 v, name, e
240 );
241 return None;
242 }
243
244 Some(truncate_field(package_url.to_string()))
245 })
246}
247
248fn extract_parties(toml_content: &Value) -> Vec<Party> {
249 use std::collections::HashSet;
250
251 let mut parties = Vec::new();
252 let mut seen = HashSet::new();
253
254 if let Some(authors) = toml_content.get(FIELD_AUTHORS).and_then(|v| v.as_array()) {
255 for author in authors.iter().take(MAX_ITERATION_COUNT) {
256 push_author_party(author, &mut parties, &mut seen);
257 }
258 }
259
260 if let Some(author_value) = toml_content.get(FIELD_AUTHOR) {
261 match author_value {
262 Value::Array(authors) => {
263 for author in authors.iter().take(MAX_ITERATION_COUNT) {
264 push_author_party(author, &mut parties, &mut seen);
265 }
266 }
267 other => push_author_party(other, &mut parties, &mut seen),
268 }
269 }
270
271 parties
272}
273
274fn push_author_party(
275 value: &Value,
276 parties: &mut Vec<Party>,
277 seen: &mut std::collections::HashSet<String>,
278) {
279 let Some(author_str) = value.as_str() else {
280 return;
281 };
282
283 let author_name = truncate_field(author_str.trim().to_string());
284 if author_name.is_empty() || !seen.insert(author_name.clone()) {
285 return;
286 }
287
288 parties.push(Party {
289 r#type: None,
290 role: Some("author".to_string()),
291 name: Some(author_name),
292 email: None,
293 url: None,
294 organization: None,
295 organization_url: None,
296 timezone: None,
297 });
298}
299
300fn extract_project_dependencies(toml_content: &Value) -> Vec<Dependency> {
301 let mut dependencies = Vec::new();
302
303 let deps_table = match toml_content.get(FIELD_DEPS).and_then(|v| v.as_table()) {
304 Some(table) => table,
305 None => return dependencies,
306 };
307
308 let compat_table = toml_content.get(FIELD_COMPAT).and_then(|v| v.as_table());
309
310 for (dep_name, dep_value) in deps_table.iter().take(MAX_ITERATION_COUNT) {
311 let uuid = dep_value.as_str().map(String::from);
312
313 let extracted_requirement = compat_table
314 .and_then(|ct| ct.get(dep_name))
315 .and_then(|v| v.as_str())
316 .map(|s| truncate_field(s.to_string()));
317
318 let is_pinned = extracted_requirement
319 .as_deref()
320 .is_some_and(is_julia_version_pinned);
321
322 let purl = match PackageUrl::new(PackageType::Julia.as_str(), dep_name) {
323 Ok(p) => truncate_field(p.to_string()),
324 Err(e) => {
325 warn!(
326 "Failed to create PackageUrl for julia dependency '{}': {}",
327 dep_name, e
328 );
329 continue;
330 }
331 };
332
333 let mut extra_data_map = std::collections::HashMap::new();
334 if let Some(ref uuid_val) = uuid {
335 extra_data_map.insert("uuid".to_string(), serde_json::json!(uuid_val));
336 }
337
338 dependencies.push(Dependency {
339 purl: Some(purl),
340 extracted_requirement,
341 scope: Some("dependencies".to_string()),
342 is_runtime: Some(true),
343 is_optional: None,
344 is_pinned: Some(is_pinned),
345 is_direct: Some(true),
346 resolved_package: None,
347 extra_data: if extra_data_map.is_empty() {
348 None
349 } else {
350 Some(extra_data_map)
351 },
352 });
353 }
354
355 dependencies
356}
357
358fn extract_manifest_packages(toml_content: &Value) -> Vec<PackageData> {
359 let mut packages = Vec::new();
360
361 let deps_table = match toml_content.get(FIELD_DEPS).and_then(|v| v.as_table()) {
362 Some(table) => table,
363 None => return packages,
364 };
365
366 for (dep_name, dep_value) in deps_table.iter().take(MAX_ITERATION_COUNT) {
367 let dep_entries = match dep_value.as_array() {
368 Some(entries) => entries,
369 None => continue,
370 };
371
372 for dep_entry in dep_entries.iter().take(MAX_ITERATION_COUNT) {
373 let name = Some(truncate_field(dep_name.clone()));
374
375 let uuid = dep_entry
376 .get(FIELD_UUID)
377 .and_then(|v| v.as_str())
378 .map(String::from);
379
380 let version = dep_entry
381 .get(FIELD_VERSION)
382 .and_then(|v| v.as_str())
383 .map(|s| truncate_field(s.to_string()));
384
385 let purl = create_package_url(&name, &version);
386
387 let tree_hash = dep_entry
388 .get("git-tree-sha1")
389 .and_then(|v| v.as_str())
390 .map(String::from);
391
392 let source_url = dep_entry
393 .get("url")
394 .and_then(|v| v.as_str())
395 .map(|s| truncate_field(s.to_string()));
396
397 let mut extra_data_map = std::collections::HashMap::new();
398 if let Some(ref uuid_val) = uuid {
399 extra_data_map.insert("uuid".to_string(), serde_json::json!(uuid_val));
400 }
401 if let Some(ref tree_hash_val) = tree_hash {
402 extra_data_map.insert("tree_hash".to_string(), serde_json::json!(tree_hash_val));
403 }
404 if let Some(ref source_url_val) = source_url {
405 extra_data_map.insert("url".to_string(), serde_json::json!(source_url_val));
406 }
407
408 packages.push(PackageData {
409 package_type: Some(PackageType::Julia),
410 namespace: None,
411 name,
412 version,
413 qualifiers: None,
414 subpath: None,
415 primary_language: Some("Julia".to_string()),
416 description: None,
417 release_date: None,
418 parties: Vec::new(),
419 keywords: Vec::new(),
420 homepage_url: None,
421 download_url: None,
422 size: None,
423 sha1: None,
424 md5: None,
425 sha256: None,
426 sha512: None,
427 bug_tracking_url: None,
428 code_view_url: None,
429 vcs_url: source_url,
430 copyright: None,
431 holder: None,
432 declared_license_expression: None,
433 declared_license_expression_spdx: None,
434 license_detections: Vec::new(),
435 other_license_expression: None,
436 other_license_expression_spdx: None,
437 other_license_detections: Vec::new(),
438 extracted_license_statement: None,
439 notice_text: None,
440 source_packages: Vec::new(),
441 file_references: Vec::new(),
442 is_private: false,
443 is_virtual: false,
444 extra_data: if extra_data_map.is_empty() {
445 None
446 } else {
447 Some(extra_data_map)
448 },
449 dependencies: Vec::new(),
450 repository_homepage_url: None,
451 repository_download_url: None,
452 api_data_url: None,
453 datasource_id: Some(DatasourceId::JuliaManifestToml),
454 purl,
455 });
456 }
457 }
458
459 packages
460}
461
462fn extract_project_extra_data(
463 toml_content: &Value,
464) -> Option<std::collections::HashMap<String, serde_json::Value>> {
465 use serde_json::json;
466 let mut extra_data = std::collections::HashMap::new();
467
468 if let Some(uuid) = toml_content.get(FIELD_UUID).and_then(|v| v.as_str()) {
469 extra_data.insert("uuid".to_string(), json!(uuid));
470 }
471
472 if let Some(targets) = toml_content.get(FIELD_TARGETS) {
473 extra_data.insert("targets".to_string(), toml_to_json(targets));
474 }
475
476 if let Some(compat) = toml_content.get(FIELD_COMPAT) {
477 extra_data.insert("compat".to_string(), toml_to_json(compat));
478 }
479
480 if let Some(deps) = toml_content.get(FIELD_DEPS) {
481 extra_data.insert("deps".to_string(), toml_to_json(deps));
482 }
483
484 if let Some(extras) = toml_content.get("extras") {
485 extra_data.insert("extras".to_string(), toml_to_json(extras));
486 }
487
488 if let Some(sources) = toml_content.get("sources") {
489 extra_data.insert("sources".to_string(), toml_to_json(sources));
490 }
491
492 if extra_data.is_empty() {
493 None
494 } else {
495 Some(extra_data)
496 }
497}
498
499fn toml_to_json(value: &toml::Value) -> serde_json::Value {
500 toml_to_json_inner(value, &mut RecursionGuard::depth_only())
501}
502
503fn toml_to_json_inner(value: &toml::Value, guard: &mut RecursionGuard<()>) -> serde_json::Value {
504 if guard.descend() {
505 warn!("Recursion depth exceeded in toml_to_json, returning Null");
506 return serde_json::Value::Null;
507 }
508
509 let result = match value {
510 toml::Value::String(s) => serde_json::json!(s),
511 toml::Value::Integer(i) => serde_json::json!(i),
512 toml::Value::Float(f) => serde_json::json!(f),
513 toml::Value::Boolean(b) => serde_json::json!(b),
514 toml::Value::Array(a) => {
515 serde_json::Value::Array(a.iter().map(|v| toml_to_json_inner(v, guard)).collect())
516 }
517 toml::Value::Table(t) => {
518 let map: serde_json::Map<String, serde_json::Value> = t
519 .iter()
520 .map(|(k, v)| (k.clone(), toml_to_json_inner(v, guard)))
521 .collect();
522 serde_json::Value::Object(map)
523 }
524 toml::Value::Datetime(d) => serde_json::json!(d.to_string()),
525 };
526 guard.ascend();
527 result
528}
529
530fn default_project_package_data() -> PackageData {
531 PackageData {
532 package_type: Some(PackageType::Julia),
533 datasource_id: Some(DatasourceId::JuliaProjectToml),
534 ..Default::default()
535 }
536}
537
538fn is_julia_version_pinned(version_str: &str) -> bool {
539 let trimmed = version_str.trim();
540 if trimmed.is_empty() {
541 return false;
542 }
543 if trimmed.contains('^')
544 || trimmed.contains('~')
545 || trimmed.contains('>')
546 || trimmed.contains('<')
547 || trimmed.contains('*')
548 {
549 return false;
550 }
551 trimmed.matches('.').count() >= 2
552}