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