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