Skip to main content

provenant/parsers/
cargo.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Parser for Cargo.toml manifest files.
8//!
9//! Extracts package metadata, dependencies, and license information from
10//! Rust Cargo.toml files.
11//!
12//! # Supported Formats
13//! - Cargo.toml (manifest)
14//!
15//! # Key Features
16//! - Dependency extraction with feature flags and optional dependencies
17//! - `is_pinned` analysis (exact version vs range specifiers)
18//! - Package URL (purl) generation
19//! - Workspace inheritance detection (stores `"workspace"` markers in extra_data)
20//!
21//! # Implementation Notes
22//! - Uses toml crate for parsing
23//! - Version pinning: `"1.0.0"` is pinned, `"^1.0.0"` is not
24//! - Graceful error handling with `warn!()` logs
25//! - Direct dependencies: all in manifest are direct (no lockfile)
26
27use crate::models::{DatasourceId, Dependency, FileReference, PackageData, PackageType, Party};
28use crate::parser_warn as warn;
29use crate::parsers::utils::{
30    MAX_ITERATION_COUNT, RecursionGuard, read_file_to_string, split_name_email, truncate_field,
31};
32use packageurl::PackageUrl;
33use std::path::Path;
34use toml::Value;
35
36use super::PackageParser;
37use super::license_normalization::{
38    DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
39    normalize_spdx_expression,
40};
41
42const FIELD_PACKAGE: &str = "package";
43const FIELD_NAME: &str = "name";
44const FIELD_VERSION: &str = "version";
45const FIELD_LICENSE: &str = "license";
46const FIELD_LICENSE_FILE: &str = "license-file";
47const FIELD_AUTHORS: &str = "authors";
48const FIELD_REPOSITORY: &str = "repository";
49const FIELD_HOMEPAGE: &str = "homepage";
50const FIELD_DEPENDENCIES: &str = "dependencies";
51const FIELD_DEV_DEPENDENCIES: &str = "dev-dependencies";
52const FIELD_DEV_DEPENDENCIES_LEGACY: &str = "dev_dependencies";
53const FIELD_BUILD_DEPENDENCIES: &str = "build-dependencies";
54const FIELD_BUILD_DEPENDENCIES_LEGACY: &str = "build_dependencies";
55const FIELD_DESCRIPTION: &str = "description";
56const FIELD_KEYWORDS: &str = "keywords";
57const FIELD_CATEGORIES: &str = "categories";
58const FIELD_RUST_VERSION: &str = "rust-version";
59const FIELD_EDITION: &str = "edition";
60const FIELD_README: &str = "readme";
61const FIELD_PUBLISH: &str = "publish";
62
63/// Rust Cargo.toml manifest parser.
64///
65/// Extracts package metadata including dependencies (regular, dev, build),
66/// license information, and crate-specific fields.
67pub struct CargoParser;
68
69impl PackageParser for CargoParser {
70    const PACKAGE_TYPE: PackageType = PackageType::Cargo;
71
72    fn extract_packages(path: &Path) -> Vec<PackageData> {
73        let toml_content = match read_cargo_toml(path) {
74            Ok(content) => content,
75            Err(e) => {
76                warn!("Failed to read or parse Cargo.toml at {:?}: {}", path, e);
77                return vec![default_package_data()];
78            }
79        };
80
81        let package = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table());
82
83        let name = package
84            .and_then(|p| p.get(FIELD_NAME))
85            .and_then(|v| v.as_str())
86            .map(|s| truncate_field(s.to_string()));
87
88        let version = package
89            .and_then(|p| p.get(FIELD_VERSION))
90            .and_then(|v| v.as_str())
91            .map(|s| truncate_field(s.to_string()));
92
93        let raw_license = package
94            .and_then(|p| p.get(FIELD_LICENSE))
95            .and_then(|v| v.as_str())
96            .map(|s| truncate_field(s.to_string()));
97        // Cargo's legacy dual-license syntax uses `/` as the OR separator (e.g.
98        // `Apache-2.0/MIT`), deprecated in favour of SPDX `OR` but still present
99        // in older crates. SPDX license ids never contain `/`, so treat it as an
100        // OR separator before SPDX normalization while keeping the raw statement
101        // source-faithful.
102        let normalized_license = raw_license
103            .as_deref()
104            .map(normalize_cargo_legacy_license_separator);
105        let file_references = extract_file_references(&toml_content);
106        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
107            normalized_license
108                .as_deref()
109                .and_then(normalize_spdx_expression)
110                .map(|normalized| {
111                    build_declared_license_data(
112                        normalized,
113                        DeclaredLicenseMatchMetadata::single_line(
114                            raw_license.as_deref().unwrap_or_default(),
115                        ),
116                    )
117                })
118                .unwrap_or_else(empty_declared_license_data);
119
120        let extracted_license_statement = raw_license.clone();
121
122        let dependencies = extract_dependencies_for_scopes(&toml_content, &[FIELD_DEPENDENCIES]);
123        let dev_dependencies = extract_dependencies_for_scopes(
124            &toml_content,
125            &[FIELD_DEV_DEPENDENCIES, FIELD_DEV_DEPENDENCIES_LEGACY],
126        );
127        let build_dependencies = extract_dependencies_for_scopes(
128            &toml_content,
129            &[FIELD_BUILD_DEPENDENCIES, FIELD_BUILD_DEPENDENCIES_LEGACY],
130        );
131
132        let purl = create_package_url(&name, &version);
133
134        let homepage_url = package
135            .and_then(|p| p.get(FIELD_HOMEPAGE))
136            .and_then(|v| v.as_str())
137            .map(|s| truncate_field(s.to_string()))
138            .or_else(|| {
139                name.as_ref()
140                    .map(|n| format!("https://crates.io/crates/{}", n))
141            });
142
143        let repository_url = package
144            .and_then(|p| p.get(FIELD_REPOSITORY))
145            .and_then(|v| v.as_str())
146            .map(|s| truncate_field(s.to_string()));
147        let download_url = None;
148
149        let api_data_url = generate_cargo_api_url(&name, &version);
150
151        let repository_homepage_url = name
152            .as_ref()
153            .map(|n| format!("https://crates.io/crates/{}", n));
154
155        let repository_download_url = match (&name, &version) {
156            (Some(n), Some(v)) => Some(format!(
157                "https://crates.io/api/v1/crates/{}/{}/download",
158                n, v
159            )),
160            _ => None,
161        };
162
163        let description = package
164            .and_then(|p| p.get(FIELD_DESCRIPTION))
165            .and_then(|v| v.as_str())
166            .map(|s| truncate_field(s.trim().to_string()));
167
168        let keywords = extract_keywords_and_categories(&toml_content);
169
170        let extra_data = extract_extra_data(&toml_content);
171        let is_private = package
172            .and_then(|p| p.get(FIELD_PUBLISH))
173            .is_some_and(|value| matches!(value, Value::Boolean(false)));
174        vec![PackageData {
175            package_type: Some(Self::PACKAGE_TYPE),
176            namespace: None,
177            name,
178            version,
179            qualifiers: None,
180            subpath: None,
181            primary_language: Some("Rust".to_string()),
182            description,
183            release_date: None,
184            parties: extract_parties(&toml_content),
185            keywords,
186            homepage_url,
187            download_url,
188            size: None,
189            sha1: None,
190            md5: None,
191            sha256: None,
192            sha512: None,
193            bug_tracking_url: None,
194            code_view_url: None,
195            vcs_url: repository_url,
196            copyright: None,
197            holder: None,
198            declared_license_expression,
199            declared_license_expression_spdx,
200            license_detections,
201            other_license_expression: None,
202            other_license_expression_spdx: None,
203            other_license_detections: Vec::new(),
204            extracted_license_statement,
205            notice_text: None,
206            source_packages: Vec::new(),
207            file_references,
208            is_private,
209            is_virtual: false,
210            extra_data,
211            dependencies: [dependencies, dev_dependencies, build_dependencies].concat(),
212            repository_homepage_url,
213            repository_download_url,
214            api_data_url,
215            datasource_id: Some(DatasourceId::CargoToml),
216            purl,
217        }]
218    }
219
220    fn is_match(path: &Path) -> bool {
221        path.file_name()
222            .and_then(|name| name.to_str())
223            .is_some_and(|name| name.eq_ignore_ascii_case("cargo.toml"))
224    }
225
226    fn metadata() -> Vec<super::metadata::ParserMetadata> {
227        vec![super::metadata::ParserMetadata {
228            description: "Rust Cargo.toml manifest",
229            file_patterns: &["**/Cargo.toml", "**/cargo.toml"],
230            package_type: "cargo",
231            primary_language: "Rust",
232            documentation_url: Some("https://doc.rust-lang.org/cargo/reference/manifest.html"),
233        }]
234    }
235}
236
237/// Datasource-preserving fallback row for a Cargo.toml that cannot be read or
238/// parsed, so a malformed manifest stays distinguishable from "no manifest".
239fn default_package_data() -> PackageData {
240    PackageData {
241        package_type: Some(CargoParser::PACKAGE_TYPE),
242        primary_language: Some("Rust".to_string()),
243        datasource_id: Some(DatasourceId::CargoToml),
244        ..Default::default()
245    }
246}
247
248/// Convert Cargo's legacy `/` dual-license separator to SPDX `OR`. SPDX license
249/// ids never contain `/`, so any `/` in a Cargo `license` field is the
250/// deprecated OR separator (e.g. `Apache-2.0/MIT` -> `Apache-2.0 OR MIT`).
251fn normalize_cargo_legacy_license_separator(license: &str) -> String {
252    if !license.contains('/') {
253        return license.to_string();
254    }
255    license
256        .split('/')
257        .map(str::trim)
258        .filter(|part| !part.is_empty())
259        .collect::<Vec<_>>()
260        .join(" OR ")
261}
262
263/// Reads and parses a TOML file
264fn read_cargo_toml(path: &Path) -> Result<Value, String> {
265    let content =
266        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
267
268    toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
269}
270
271fn generate_cargo_api_url(name: &Option<String>, _version: &Option<String>) -> Option<String> {
272    const REGISTRY: &str = "https://crates.io/api/v1/crates";
273    name.as_ref().map(|name| format!("{}/{}", REGISTRY, name))
274}
275
276fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
277    name.as_ref().and_then(|name| {
278        let mut package_url = match PackageUrl::new(CargoParser::PACKAGE_TYPE.as_str(), name) {
279            Ok(p) => p,
280            Err(e) => {
281                warn!(
282                    "Failed to create PackageUrl for cargo package '{}': {}",
283                    name, e
284                );
285                return None;
286            }
287        };
288
289        if let Some(v) = version
290            && let Err(e) = package_url.with_version(v)
291        {
292            warn!(
293                "Failed to set version '{}' for cargo package '{}': {}",
294                v, name, e
295            );
296            return None;
297        }
298
299        Some(package_url.to_string())
300    })
301}
302
303/// Extracts party information from the `authors` field
304fn extract_parties(toml_content: &Value) -> Vec<Party> {
305    let mut parties = Vec::new();
306
307    if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table())
308        && let Some(authors) = package.get(FIELD_AUTHORS).and_then(|v| v.as_array())
309    {
310        for author in authors.iter().take(MAX_ITERATION_COUNT) {
311            if let Some(author_str) = author.as_str() {
312                let (name, email) = split_name_email(author_str);
313                parties.push(Party {
314                    r#type: None,
315                    role: Some("author".to_string()),
316                    name,
317                    email,
318                    url: None,
319                    organization: None,
320                    organization_url: None,
321                    timezone: None,
322                });
323            }
324        }
325        if authors.len() > MAX_ITERATION_COUNT {
326            warn!(
327                "Authors array has {} entries, capping at MAX_ITERATION_COUNT ({})",
328                authors.len(),
329                MAX_ITERATION_COUNT
330            );
331        }
332    }
333
334    parties
335}
336
337/// Determines if a Cargo version specifier is pinned to an exact version.
338///
339/// A version is considered pinned if it specifies an exact version (full semver)
340/// without range operators. Examples:
341/// - Pinned: "1.0.0", "0.8.1"
342/// - NOT pinned: "0.8" (allows patch), "^1.0.0", "~1.0.0", ">=1.0.0", "*"
343fn is_cargo_version_pinned(version_str: &str) -> bool {
344    let trimmed = version_str.trim();
345
346    // Empty version is not pinned
347    if trimmed.is_empty() {
348        return false;
349    }
350
351    // Check for range operators that indicate unpinned versions
352    if trimmed.contains('^')
353        || trimmed.contains('~')
354        || trimmed.contains('>')
355        || trimmed.contains('<')
356        || trimmed.contains('*')
357        || trimmed.contains('=')
358    {
359        return false;
360    }
361
362    // Count dots to check if it's a full semver (major.minor.patch)
363    // Pinned versions must have at least 2 dots (e.g., "1.0.0")
364    // Partial versions like "0.8" or "1" are not pinned
365    trimmed.matches('.').count() >= 2
366}
367
368fn extract_dependencies(toml_content: &Value, scope: &str) -> Vec<Dependency> {
369    use serde_json::json;
370
371    let mut dependencies = Vec::new();
372
373    // Determine is_runtime based on scope
374    let is_runtime = !scope.ends_with("dev-dependencies") && !scope.ends_with("build-dependencies");
375
376    if let Some(deps_table) = toml_content.get(scope).and_then(|v| v.as_table()) {
377        if deps_table.len() > MAX_ITERATION_COUNT {
378            warn!(
379                "Dependency table '{}' has {} entries, capping at MAX_ITERATION_COUNT ({})",
380                scope,
381                deps_table.len(),
382                MAX_ITERATION_COUNT
383            );
384        }
385        for (name, value) in deps_table.iter().take(MAX_ITERATION_COUNT) {
386            let (extracted_requirement, is_optional, extra_data_map, is_pinned) = match value {
387                Value::String(version_str) => {
388                    // Simple string version: "1.0"
389                    let pinned = is_cargo_version_pinned(version_str);
390                    (
391                        Some(version_str.to_string()),
392                        false,
393                        std::collections::HashMap::new(),
394                        pinned,
395                    )
396                }
397                Value::Table(table) => {
398                    // Complex table format: { version = "1.0", optional = true, features = [...] }
399                    let version = table
400                        .get("version")
401                        .and_then(|v| v.as_str())
402                        .map(String::from);
403
404                    let pinned = version.as_ref().is_some_and(|v| is_cargo_version_pinned(v));
405
406                    let is_optional = table
407                        .get("optional")
408                        .and_then(|v| v.as_bool())
409                        .unwrap_or(false);
410
411                    let mut extra_data = std::collections::HashMap::new();
412
413                    // Extract all table fields into extra_data
414                    for (key, val) in table {
415                        match key.as_str() {
416                            "version" => {
417                                // Store version in extra_data
418                                if let Some(v) = val.as_str() {
419                                    extra_data.insert("version".to_string(), json!(v));
420                                }
421                            }
422                            "features" => {
423                                // Extract features array
424                                if let Some(features_array) = val.as_array() {
425                                    let features: Vec<String> = features_array
426                                        .iter()
427                                        .filter_map(|f| f.as_str().map(String::from))
428                                        .collect();
429                                    extra_data.insert("features".to_string(), json!(features));
430                                }
431                            }
432                            "optional" => {
433                                // Skip optional flag, it's handled separately
434                            }
435                            _ => {
436                                // Store other fields (workspace, path, git, branch, tag, rev, etc.)
437                                if let Some(s) = val.as_str() {
438                                    extra_data.insert(key.clone(), json!(s));
439                                } else if let Some(b) = val.as_bool() {
440                                    extra_data.insert(key.clone(), json!(b));
441                                } else if let Some(i) = val.as_integer() {
442                                    extra_data.insert(key.clone(), json!(i));
443                                }
444                            }
445                        }
446                    }
447
448                    (version, is_optional, extra_data, pinned)
449                }
450                _ => {
451                    // Unknown format, skip
452                    continue;
453                }
454            };
455
456            // Only create dependency if we have a version or it's a table with other data
457            if extracted_requirement.is_some() || !extra_data_map.is_empty() {
458                let purl = match PackageUrl::new(CargoParser::PACKAGE_TYPE.as_str(), name) {
459                    Ok(p) => p.to_string(),
460                    Err(e) => {
461                        warn!(
462                            "Failed to create PackageUrl for cargo dependency '{}': {}",
463                            name, e
464                        );
465                        continue; // Skip this dependency
466                    }
467                };
468
469                dependencies.push(Dependency {
470                    purl: Some(purl),
471                    extracted_requirement,
472                    scope: Some(scope.to_string()),
473                    is_runtime: Some(is_runtime),
474                    is_optional: Some(is_optional),
475                    is_pinned: Some(is_pinned),
476                    is_direct: Some(true),
477                    resolved_package: None,
478                    extra_data: if extra_data_map.is_empty() {
479                        None
480                    } else {
481                        Some(extra_data_map)
482                    },
483                });
484            }
485        }
486    }
487
488    dependencies
489}
490
491fn extract_dependencies_for_scopes(toml_content: &Value, scopes: &[&str]) -> Vec<Dependency> {
492    scopes
493        .iter()
494        .flat_map(|scope| extract_dependencies(toml_content, scope))
495        .collect()
496}
497
498/// Extracts keywords and categories, merging them into a single keywords array
499fn extract_keywords_and_categories(toml_content: &Value) -> Vec<String> {
500    let mut keywords = Vec::new();
501
502    if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table()) {
503        if let Some(kw_array) = package.get(FIELD_KEYWORDS).and_then(|v| v.as_array()) {
504            if kw_array.len() > MAX_ITERATION_COUNT {
505                warn!(
506                    "Keywords array has {} entries, capping at MAX_ITERATION_COUNT ({})",
507                    kw_array.len(),
508                    MAX_ITERATION_COUNT
509                );
510            }
511            for kw in kw_array.iter().take(MAX_ITERATION_COUNT) {
512                if let Some(kw_str) = kw.as_str() {
513                    keywords.push(truncate_field(kw_str.to_string()));
514                }
515            }
516        }
517
518        if let Some(cat_array) = package.get(FIELD_CATEGORIES).and_then(|v| v.as_array()) {
519            if cat_array.len() > MAX_ITERATION_COUNT {
520                warn!(
521                    "Categories array has {} entries, capping at MAX_ITERATION_COUNT ({})",
522                    cat_array.len(),
523                    MAX_ITERATION_COUNT
524                );
525            }
526            for cat in cat_array.iter().take(MAX_ITERATION_COUNT) {
527                if let Some(cat_str) = cat.as_str() {
528                    keywords.push(truncate_field(cat_str.to_string()));
529                }
530            }
531        }
532    }
533
534    keywords
535}
536
537fn extract_file_references(toml_content: &Value) -> Vec<FileReference> {
538    let mut file_references = Vec::new();
539
540    if let Some(package) = toml_content
541        .get(FIELD_PACKAGE)
542        .and_then(|value| value.as_table())
543    {
544        for path in [
545            package
546                .get(FIELD_LICENSE_FILE)
547                .and_then(|value| value.as_str()),
548            package.get(FIELD_README).and_then(|value| value.as_str()),
549        ]
550        .into_iter()
551        .flatten()
552        {
553            if file_references
554                .iter()
555                .any(|reference: &FileReference| reference.path == path)
556            {
557                continue;
558            }
559
560            file_references.push(FileReference {
561                path: path.to_string(),
562                size: None,
563                sha1: None,
564                md5: None,
565                sha256: None,
566                sha512: None,
567                extra_data: None,
568            });
569        }
570    }
571
572    file_references
573}
574
575fn toml_to_json(value: &toml::Value, guard: &mut RecursionGuard<()>) -> serde_json::Value {
576    if guard.descend() {
577        warn!("TOML nesting depth exceeded, returning Null");
578        return serde_json::Value::Null;
579    }
580    let result = match value {
581        toml::Value::String(s) => serde_json::json!(s),
582        toml::Value::Integer(i) => serde_json::json!(i),
583        toml::Value::Float(f) => serde_json::json!(f),
584        toml::Value::Boolean(b) => serde_json::json!(b),
585        toml::Value::Array(a) => {
586            serde_json::Value::Array(a.iter().map(|v| toml_to_json(v, guard)).collect())
587        }
588        toml::Value::Table(t) => {
589            let map: serde_json::Map<String, serde_json::Value> = t
590                .iter()
591                .map(|(k, v)| (k.clone(), toml_to_json(v, guard)))
592                .collect();
593            serde_json::Value::Object(map)
594        }
595        toml::Value::Datetime(d) => serde_json::json!(d.to_string()),
596    };
597    guard.ascend();
598    result
599}
600
601/// Extracts extra_data fields (rust-version, edition, documentation, license-file, workspace)
602fn extract_extra_data(
603    toml_content: &Value,
604) -> Option<std::collections::HashMap<String, serde_json::Value>> {
605    use serde_json::json;
606    let mut extra_data = std::collections::HashMap::new();
607
608    if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table()) {
609        if package.len() > MAX_ITERATION_COUNT {
610            warn!(
611                "Package table has {} entries, exceeding MAX_ITERATION_COUNT ({})",
612                package.len(),
613                MAX_ITERATION_COUNT
614            );
615        }
616        if let Some(rust_version_value) = package.get(FIELD_RUST_VERSION) {
617            if let Some(rust_version_str) = rust_version_value.as_str() {
618                extra_data.insert("rust_version".to_string(), json!(rust_version_str));
619            } else if rust_version_value
620                .as_table()
621                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
622            {
623                extra_data.insert("rust-version".to_string(), json!("workspace"));
624            }
625        }
626
627        // Extract edition (or detect workspace inheritance)
628        if let Some(edition_value) = package.get(FIELD_EDITION) {
629            if let Some(edition_str) = edition_value.as_str() {
630                extra_data.insert("rust_edition".to_string(), json!(edition_str));
631            } else if edition_value
632                .as_table()
633                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
634            {
635                extra_data.insert("edition".to_string(), json!("workspace"));
636            }
637        }
638
639        // Extract documentation URL
640        if let Some(documentation) = package.get("documentation").and_then(|v| v.as_str()) {
641            extra_data.insert("documentation_url".to_string(), json!(documentation));
642        }
643
644        // Extract license-file path
645        if let Some(license_file) = package.get(FIELD_LICENSE_FILE).and_then(|v| v.as_str()) {
646            extra_data.insert("license_file".to_string(), json!(license_file));
647        }
648
649        if let Some(readme_value) = package.get(FIELD_README) {
650            if let Some(readme_file) = readme_value.as_str() {
651                extra_data.insert("readme_file".to_string(), json!(readme_file));
652            } else if let Some(readme_enabled) = readme_value.as_bool() {
653                extra_data.insert("readme".to_string(), json!(readme_enabled));
654            } else if readme_value
655                .as_table()
656                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
657            {
658                extra_data.insert("readme".to_string(), json!("workspace"));
659            }
660        }
661
662        if let Some(publish_value) = package.get(FIELD_PUBLISH) {
663            extra_data.insert(
664                "publish".to_string(),
665                toml_to_json(publish_value, &mut RecursionGuard::depth_only()),
666            );
667        }
668
669        // Check for workspace inheritance markers for other fields
670        // version
671        if let Some(version_value) = package.get(FIELD_VERSION)
672            && version_value
673                .as_table()
674                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
675        {
676            extra_data.insert("version".to_string(), json!("workspace"));
677        }
678
679        // license
680        if let Some(license_value) = package.get(FIELD_LICENSE)
681            && license_value
682                .as_table()
683                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
684        {
685            extra_data.insert("license".to_string(), json!("workspace"));
686        }
687
688        // homepage
689        if let Some(homepage_value) = package.get(FIELD_HOMEPAGE)
690            && homepage_value
691                .as_table()
692                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
693        {
694            extra_data.insert("homepage".to_string(), json!("workspace"));
695        }
696
697        // repository
698        if let Some(repository_value) = package.get(FIELD_REPOSITORY)
699            && repository_value
700                .as_table()
701                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
702        {
703            extra_data.insert("repository".to_string(), json!("workspace"));
704        }
705
706        // categories
707        if let Some(categories_value) = package.get(FIELD_CATEGORIES)
708            && categories_value
709                .as_table()
710                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
711        {
712            extra_data.insert("categories".to_string(), json!("workspace"));
713        }
714
715        // authors
716        if let Some(authors_value) = package.get(FIELD_AUTHORS)
717            && authors_value
718                .as_table()
719                .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
720        {
721            extra_data.insert("authors".to_string(), json!("workspace"));
722        }
723    }
724
725    // Extract workspace table if it exists
726    if let Some(workspace_value) = toml_content.get("workspace") {
727        extra_data.insert(
728            "workspace".to_string(),
729            toml_to_json(workspace_value, &mut RecursionGuard::depth_only()),
730        );
731    }
732
733    if extra_data.is_empty() {
734        None
735    } else {
736        Some(extra_data)
737    }
738}