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