Skip to main content

provenant/parsers/
cargo.rs

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