Skip to main content

provenant/parsers/
julia.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for Julia Project.toml and Manifest.toml files.
5//!
6//! Extracts package metadata, dependencies, and license information from
7//! Julia package manager (Pkg.jl) manifest files.
8//!
9//! # Supported Formats
10//! - Project.toml (package metadata)
11//! - Manifest.toml (resolved dependency tree)
12//!
13//! # Key Features
14//! - Dependency extraction with UUID tracking
15//! - `is_pinned` analysis based on Manifest.toml resolved versions
16//! - Package URL (purl) generation
17//! - Compat section version constraint extraction
18//!
19//! # Implementation Notes
20//! - Uses toml crate for parsing
21//! - Julia packages are identified by UUID
22//! - Project.toml `[deps]` lists direct dependencies by name → UUID
23//! - Manifest.toml `[[deps]]` entries contain resolved version + tree SHA
24
25use 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}