Skip to main content

provenant/output_schema/metadata/
mod.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct OutputTypeDoc {
6    pub type_name: &'static str,
7    pub json_paths: &'static [&'static str],
8    pub summary: &'static str,
9    pub fields: &'static [OutputFieldDoc],
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct OutputFieldDoc {
14    pub json_name: &'static str,
15    pub rust_field: &'static str,
16    pub value_shape: &'static str,
17    pub presence: &'static str,
18    pub meaning: &'static str,
19}
20
21mod dependency;
22mod document;
23mod file_evidence;
24mod file_info;
25mod license;
26mod package;
27mod summary;
28
29use dependency::{DEPENDENCY_FIELDS, RESOLVED_PACKAGE_FIELDS, TOP_LEVEL_DEPENDENCY_FIELDS};
30use document::{
31    EXTRA_DATA_FIELDS, HEADER_FIELDS, LICENSE_INDEX_PROVENANCE_FIELDS, OUTPUT_FIELDS,
32    SYSTEM_ENVIRONMENT_FIELDS,
33};
34use file_evidence::{AUTHOR_FIELDS, COPYRIGHT_FIELDS, EMAIL_FIELDS, HOLDER_FIELDS, URL_FIELDS};
35use file_info::FILE_INFO_FIELDS;
36use license::{
37    LICENSE_DETECTION_FIELDS, LICENSE_POLICY_ENTRY_FIELDS, LICENSE_REFERENCE_FIELDS,
38    LICENSE_RULE_REFERENCE_FIELDS, MATCH_FIELDS, TOP_LEVEL_LICENSE_DETECTION_FIELDS,
39};
40use package::{FILE_REFERENCE_FIELDS, PACKAGE_DATA_FIELDS, PACKAGE_FIELDS, PARTY_FIELDS};
41use summary::{
42    FACET_TALLIES_FIELDS, LICENSE_CLARITY_SCORE_FIELDS, SUMMARY_FIELDS, TALLIES_FIELDS,
43    TALLY_ENTRY_FIELDS,
44};
45
46const EMPTY_FIELDS: &[OutputFieldDoc] = &[];
47
48const DOCUMENTED_TYPES: &[OutputTypeDoc] = &[
49    OutputTypeDoc {
50        type_name: "Output",
51        json_paths: &["$"],
52        summary: "Top-level ScanCode-compatible output object.",
53        fields: OUTPUT_FIELDS,
54    },
55    OutputTypeDoc {
56        type_name: "OutputHeader",
57        json_paths: &["$.headers[]"],
58        summary: "Per-run metadata block for one scan invocation.",
59        fields: HEADER_FIELDS,
60    },
61    OutputTypeDoc {
62        type_name: "OutputExtraData",
63        json_paths: &["$.headers[].extra_data"],
64        summary: "Scanner-owned counts and provenance metadata nested under a header block.",
65        fields: EXTRA_DATA_FIELDS,
66    },
67    OutputTypeDoc {
68        type_name: "OutputSummary",
69        json_paths: &["$.summary"],
70        summary: "Optional codebase-level rollup emitted by summary/classification workflows.",
71        fields: SUMMARY_FIELDS,
72    },
73    OutputTypeDoc {
74        type_name: "OutputFileInfo",
75        json_paths: &["$.files[]"],
76        summary: "File or directory record on the main per-resource output surface.",
77        fields: FILE_INFO_FIELDS,
78    },
79    OutputTypeDoc {
80        type_name: "OutputPackage",
81        json_paths: &["$.packages[]"],
82        summary: "Assembled top-level package record on the public output contract.",
83        fields: PACKAGE_FIELDS,
84    },
85    OutputTypeDoc {
86        type_name: "OutputPackageData",
87        json_paths: &["$.files[].package_data[]"],
88        summary: "Raw parser-emitted package record attached to a specific file.",
89        fields: PACKAGE_DATA_FIELDS,
90    },
91    OutputTypeDoc {
92        type_name: "OutputDependency",
93        json_paths: &["$.files[].package_data[].dependencies[]"],
94        summary: "Raw dependency row preserved on parser-emitted package data.",
95        fields: DEPENDENCY_FIELDS,
96    },
97    OutputTypeDoc {
98        type_name: "OutputTopLevelDependency",
99        json_paths: &["$.dependencies[]"],
100        summary: "Hoisted top-level dependency record emitted after assembly.",
101        fields: TOP_LEVEL_DEPENDENCY_FIELDS,
102    },
103    OutputTypeDoc {
104        type_name: "OutputTopLevelLicenseDetection",
105        json_paths: &["$.license_detections[]"],
106        summary: "Grouped top-level license detection block across the scanned codebase.",
107        fields: TOP_LEVEL_LICENSE_DETECTION_FIELDS,
108    },
109    OutputTypeDoc {
110        type_name: "OutputTallies",
111        json_paths: &[
112            "$.tallies",
113            "$.tallies_of_key_files",
114            "$.files[].tallies",
115            "$.tallies_by_facet[].tallies",
116        ],
117        summary: "Tally block used on top-level, key-file, facet, and file-level tally surfaces.",
118        fields: TALLIES_FIELDS,
119    },
120    OutputTypeDoc {
121        type_name: "OutputFacetTallies",
122        json_paths: &["$.tallies_by_facet[]"],
123        summary: "Facet-specific tally wrapper for one user-defined facet label.",
124        fields: FACET_TALLIES_FIELDS,
125    },
126    OutputTypeDoc {
127        type_name: "OutputTallyEntry",
128        json_paths: &[
129            "$.summary.other_license_expressions[]",
130            "$.summary.other_holders[]",
131            "$.summary.other_languages[]",
132            "$.tallies.*[]",
133        ],
134        summary: "Single tally bucket entry used throughout summary and tally outputs.",
135        fields: TALLY_ENTRY_FIELDS,
136    },
137    OutputTypeDoc {
138        type_name: "OutputLicenseClarityScore",
139        json_paths: &["$.summary.license_clarity_score"],
140        summary: "Structured license-clarity scoring payload on the summary surface.",
141        fields: LICENSE_CLARITY_SCORE_FIELDS,
142    },
143    OutputTypeDoc {
144        type_name: "OutputSystemEnvironment",
145        json_paths: &["$.headers[].extra_data.system_environment"],
146        summary: "Recorded environment metadata for the scan runtime.",
147        fields: SYSTEM_ENVIRONMENT_FIELDS,
148    },
149    OutputTypeDoc {
150        type_name: "OutputLicenseIndexProvenance",
151        json_paths: &["$.headers[].extra_data.license_index_provenance"],
152        summary: "Provenance block for the effective license index used by the scan.",
153        fields: LICENSE_INDEX_PROVENANCE_FIELDS,
154    },
155    OutputTypeDoc {
156        type_name: "OutputParty",
157        json_paths: &[
158            "$.packages[].parties[]",
159            "$.files[].package_data[].parties[]",
160            "$.dependencies[].resolved_package.parties[]",
161        ],
162        summary: "Party record used on package and resolved-package surfaces.",
163        fields: PARTY_FIELDS,
164    },
165    OutputTypeDoc {
166        type_name: "OutputFileReference",
167        json_paths: &[
168            "$.files[].package_data[].file_references[]",
169            "$.dependencies[].resolved_package.file_references[]",
170        ],
171        summary: "Referenced-file record used on package-related surfaces.",
172        fields: FILE_REFERENCE_FIELDS,
173    },
174    OutputTypeDoc {
175        type_name: "OutputLicensePolicyEntry",
176        json_paths: &["$.files[].license_policy[]"],
177        summary: "Policy decoration entry attached to file-level license-policy output.",
178        fields: LICENSE_POLICY_ENTRY_FIELDS,
179    },
180    OutputTypeDoc {
181        type_name: "OutputAuthor",
182        json_paths: &["$.files[].authors[]"],
183        summary: "File-level author evidence record.",
184        fields: AUTHOR_FIELDS,
185    },
186    OutputTypeDoc {
187        type_name: "OutputCopyright",
188        json_paths: &["$.files[].copyrights[]"],
189        summary: "File-level copyright evidence record.",
190        fields: COPYRIGHT_FIELDS,
191    },
192    OutputTypeDoc {
193        type_name: "OutputEmail",
194        json_paths: &["$.files[].emails[]"],
195        summary: "File-level email evidence record.",
196        fields: EMAIL_FIELDS,
197    },
198    OutputTypeDoc {
199        type_name: "OutputHolder",
200        json_paths: &["$.files[].holders[]"],
201        summary: "File-level holder evidence record.",
202        fields: HOLDER_FIELDS,
203    },
204    OutputTypeDoc {
205        type_name: "OutputURL",
206        json_paths: &["$.files[].urls[]"],
207        summary: "File-level URL evidence record.",
208        fields: URL_FIELDS,
209    },
210    OutputTypeDoc {
211        type_name: "OutputLicenseDetection",
212        json_paths: &[
213            "$.files[].license_detections[]",
214            "$.files[].package_data[].license_detections[]",
215            "$.packages[].license_detections[]",
216            "$.dependencies[].resolved_package.license_detections[]",
217        ],
218        summary: "Grouped license detection record used on file, package_data, package, and resolved-package surfaces.",
219        fields: LICENSE_DETECTION_FIELDS,
220    },
221    OutputTypeDoc {
222        type_name: "OutputMatch",
223        json_paths: &[
224            "$.files[].license_clues[]",
225            "$.files[].license_detections[].matches[]",
226            "$.license_detections[].reference_matches[]",
227        ],
228        summary: "Match record used for clue output, grouped detections, and top-level representative references.",
229        fields: MATCH_FIELDS,
230    },
231    OutputTypeDoc {
232        type_name: "OutputLicenseReference",
233        json_paths: &["$.license_references[]"],
234        summary: "Top-level license reference record describing one emitted license key and its reference metadata.",
235        fields: LICENSE_REFERENCE_FIELDS,
236    },
237    OutputTypeDoc {
238        type_name: "OutputLicenseRuleReference",
239        json_paths: &["$.license_rule_references[]"],
240        summary: "Top-level license-rule reference record describing one emitted rule and its reference metadata.",
241        fields: LICENSE_RULE_REFERENCE_FIELDS,
242    },
243    OutputTypeDoc {
244        type_name: "OutputResolvedPackage",
245        json_paths: &[
246            "$.dependencies[].resolved_package",
247            "$.files[].package_data[].dependencies[].resolved_package",
248        ],
249        summary: "Resolved package payload nested under dependency rows.",
250        fields: RESOLVED_PACKAGE_FIELDS,
251    },
252    OutputTypeDoc {
253        type_name: "OutputFileType",
254        json_paths: &["$.files[].type"],
255        summary: "Serialized file-type enum used by file records.",
256        fields: EMPTY_FIELDS,
257    },
258    OutputTypeDoc {
259        type_name: "OutputPackageType",
260        json_paths: &["$.packages[].type", "$.files[].package_data[].type"],
261        summary: "Serialized package-type string newtype used by package-related records.",
262        fields: EMPTY_FIELDS,
263    },
264    OutputTypeDoc {
265        type_name: "OutputDatasourceId",
266        json_paths: &[
267            "$.packages[].datasource_ids[]",
268            "$.dependencies[].datasource_id",
269            "$.files[].package_data[].datasource_id",
270        ],
271        summary: "Serialized datasource-id string newtype used by package and dependency records.",
272        fields: EMPTY_FIELDS,
273    },
274];
275
276pub fn documented_output_types() -> &'static [OutputTypeDoc] {
277    DOCUMENTED_TYPES
278}
279
280#[cfg(test)]
281mod tests {
282    use super::documented_output_types;
283    use std::collections::BTreeSet;
284
285    use crate::output_schema::{
286        OutputAuthor, OutputCopyright, OutputDatasourceId, OutputDependency, OutputEmail,
287        OutputExtraData, OutputFileInfo, OutputFileReference, OutputFileType, OutputHeader,
288        OutputHolder, OutputLicenseDetection, OutputLicenseIndexProvenance,
289        OutputLicensePolicyEntry, OutputMatch, OutputPackage, OutputPackageData, OutputPackageType,
290        OutputParty, OutputSystemEnvironment, OutputTallies, OutputTallyEntry,
291        OutputTopLevelDependency, OutputTopLevelLicenseDetection, OutputURL,
292    };
293    use serde::Serialize;
294    use serde_json::{Map, Value};
295
296    fn metadata_field_names(type_name: &str) -> BTreeSet<&'static str> {
297        documented_output_types()
298            .iter()
299            .find(|ty| ty.type_name == type_name)
300            .unwrap_or_else(|| panic!("{} should be documented", type_name))
301            .fields
302            .iter()
303            .map(|field| field.json_name)
304            .collect()
305    }
306
307    fn serialized_object_keys<T: Serialize>(value: &T) -> BTreeSet<String> {
308        serde_json::to_value(value)
309            .expect("serialize")
310            .as_object()
311            .expect("object")
312            .keys()
313            .cloned()
314            .collect()
315    }
316
317    fn sample_match() -> OutputMatch {
318        OutputMatch {
319            license_expression: "mit".to_string(),
320            license_expression_spdx: "MIT".to_string(),
321            from_file: Some("src/lib.rs".to_string()),
322            start_line: 1,
323            end_line: 2,
324            matcher: Some("1-hash".to_string()),
325            score: 100.0,
326            matched_length: Some(42),
327            match_coverage: Some(100.0),
328            rule_relevance: Some(100),
329            rule_identifier: Some("mit_1.RULE".to_string()),
330            rule_url: Some("https://example.invalid/rule".to_string()),
331            matched_text: Some("Permission is hereby granted".to_string()),
332            matched_text_diagnostics: Some("diagnostics".to_string()),
333            referenced_filenames: Some(vec!["LICENSE".to_string()]),
334        }
335    }
336
337    fn sample_license_detection() -> OutputLicenseDetection {
338        OutputLicenseDetection {
339            license_expression: "mit".to_string(),
340            license_expression_spdx: "MIT".to_string(),
341            matches: vec![sample_match()],
342            detection_log: vec!["normalized".to_string()],
343            identifier: Some("det-1".to_string()),
344        }
345    }
346
347    fn sample_tally_entry() -> OutputTallyEntry {
348        OutputTallyEntry {
349            value: Some("mit".to_string()),
350            count: 1,
351        }
352    }
353
354    fn sample_tallies() -> OutputTallies {
355        OutputTallies {
356            detected_license_expression: vec![sample_tally_entry()],
357            copyrights: vec![sample_tally_entry()],
358            holders: vec![sample_tally_entry()],
359            authors: vec![sample_tally_entry()],
360            programming_language: vec![sample_tally_entry()],
361        }
362    }
363
364    fn sample_party() -> OutputParty {
365        OutputParty {
366            r#type: Some("person".to_string()),
367            role: Some("author".to_string()),
368            name: Some("Example Person".to_string()),
369            email: Some("person@example.invalid".to_string()),
370            url: Some("https://example.invalid/person".to_string()),
371            organization: Some("Example Org".to_string()),
372            organization_url: Some("https://example.invalid".to_string()),
373            timezone: Some("UTC".to_string()),
374        }
375    }
376
377    fn sample_file_reference() -> OutputFileReference {
378        OutputFileReference {
379            path: "LICENSE".to_string(),
380            size: Some(123),
381            sha1: Some("a".repeat(40)),
382            md5: Some("b".repeat(32)),
383            sha256: Some("c".repeat(64)),
384            sha512: Some("d".repeat(128)),
385            extra_data: Some(std::collections::HashMap::from_iter([(
386                "hint".to_string(),
387                Value::String("local".to_string()),
388            )])),
389        }
390    }
391
392    fn sample_license_policy_entry() -> OutputLicensePolicyEntry {
393        OutputLicensePolicyEntry {
394            license_key: "mit".to_string(),
395            label: "Allowed".to_string(),
396            color_code: "#00ff00".to_string(),
397            icon: "check".to_string(),
398        }
399    }
400
401    fn sample_author() -> OutputAuthor {
402        OutputAuthor {
403            author: "Example Author".to_string(),
404            start_line: 1,
405            end_line: 1,
406        }
407    }
408
409    fn sample_copyright() -> OutputCopyright {
410        OutputCopyright {
411            copyright: "Copyright 2026 Example".to_string(),
412            start_line: 1,
413            end_line: 1,
414        }
415    }
416
417    fn sample_email() -> OutputEmail {
418        OutputEmail {
419            email: "example@example.invalid".to_string(),
420            start_line: 1,
421            end_line: 1,
422        }
423    }
424
425    fn sample_holder() -> OutputHolder {
426        OutputHolder {
427            holder: "Example Holder".to_string(),
428            start_line: 1,
429            end_line: 1,
430        }
431    }
432
433    fn sample_url() -> OutputURL {
434        OutputURL {
435            url: "https://example.invalid".to_string(),
436            start_line: 1,
437            end_line: 1,
438        }
439    }
440
441    fn sample_package_data() -> OutputPackageData {
442        OutputPackageData {
443            package_type: Some(OutputPackageType::from(crate::models::PackageType::Cargo)),
444            namespace: Some("example".to_string()),
445            name: Some("crate-name".to_string()),
446            version: Some("1.2.3".to_string()),
447            qualifiers: Some(std::collections::HashMap::from_iter([(
448                "arch".to_string(),
449                "x86_64".to_string(),
450            )])),
451            subpath: Some("sub".to_string()),
452            primary_language: Some("Rust".to_string()),
453            description: Some("Example package data".to_string()),
454            release_date: Some("2026-05-31".to_string()),
455            parties: vec![sample_party()],
456            keywords: vec!["example".to_string()],
457            homepage_url: Some("https://example.invalid/home".to_string()),
458            download_url: Some("https://example.invalid/download".to_string()),
459            size: Some(42),
460            sha1: Some("a".repeat(40)),
461            md5: Some("b".repeat(32)),
462            sha256: Some("c".repeat(64)),
463            sha512: Some("d".repeat(128)),
464            bug_tracking_url: Some("https://example.invalid/issues".to_string()),
465            code_view_url: Some("https://example.invalid/code".to_string()),
466            vcs_url: Some("git+https://example.invalid/repo.git".to_string()),
467            copyright: Some("Copyright 2026 Example".to_string()),
468            holder: Some("Example Holder".to_string()),
469            declared_license_expression: Some("mit".to_string()),
470            declared_license_expression_spdx: Some("MIT".to_string()),
471            license_detections: vec![sample_license_detection()],
472            other_license_expression: Some("apache-2.0".to_string()),
473            other_license_expression_spdx: Some("Apache-2.0".to_string()),
474            other_license_detections: vec![sample_license_detection()],
475            extracted_license_statement: Some("MIT".to_string()),
476            notice_text: Some("notice".to_string()),
477            source_packages: vec!["pkg:cargo/source@1.0.0".to_string()],
478            file_references: vec![sample_file_reference()],
479            is_private: true,
480            is_virtual: true,
481            extra_data: Some(std::collections::HashMap::from_iter([(
482                "custom".to_string(),
483                Value::String("value".to_string()),
484            )])),
485            dependencies: vec![sample_dependency()],
486            repository_homepage_url: Some("https://example.invalid/repo-home".to_string()),
487            repository_download_url: Some("https://example.invalid/repo-download".to_string()),
488            api_data_url: Some("https://example.invalid/api".to_string()),
489            datasource_id: Some(OutputDatasourceId::from(
490                crate::models::DatasourceId::CargoToml,
491            )),
492            purl: Some("pkg:cargo/example/crate-name@1.2.3".to_string()),
493        }
494    }
495
496    fn sample_dependency() -> OutputDependency {
497        OutputDependency {
498            purl: Some("pkg:cargo/example/dep@1.0.0".to_string()),
499            extracted_requirement: Some("^1.0".to_string()),
500            scope: Some("runtime".to_string()),
501            is_runtime: Some(true),
502            is_optional: Some(false),
503            is_pinned: Some(true),
504            is_direct: Some(true),
505            resolved_package: None,
506            extra_data: Some(std::collections::HashMap::from_iter([(
507                "kind".to_string(),
508                Value::String("normal".to_string()),
509            )])),
510        }
511    }
512
513    fn sample_top_level_dependency() -> OutputTopLevelDependency {
514        OutputTopLevelDependency {
515            purl: Some("pkg:cargo/example/dep@1.0.0".to_string()),
516            extracted_requirement: Some("^1.0".to_string()),
517            scope: Some("runtime".to_string()),
518            is_runtime: Some(true),
519            is_optional: Some(false),
520            is_pinned: Some(true),
521            is_direct: Some(true),
522            resolved_package: None,
523            extra_data: Some(std::collections::HashMap::from_iter([(
524                "kind".to_string(),
525                Value::String("normal".to_string()),
526            )])),
527            dependency_uid: "dep-uid".to_string(),
528            for_package_uid: Some("pkg-uid".to_string()),
529            datafile_path: "Cargo.toml".to_string(),
530            datasource_id: OutputDatasourceId::from(crate::models::DatasourceId::CargoToml),
531            namespace: Some("example".to_string()),
532        }
533    }
534
535    fn sample_top_level_license_detection() -> OutputTopLevelLicenseDetection {
536        OutputTopLevelLicenseDetection {
537            identifier: "top-1".to_string(),
538            license_expression: "mit".to_string(),
539            license_expression_spdx: "MIT".to_string(),
540            detection_count: 1,
541            detection_log: vec!["grouped".to_string()],
542            reference_matches: vec![sample_match()],
543        }
544    }
545
546    fn sample_header() -> OutputHeader {
547        OutputHeader {
548            tool_name: "provenant".to_string(),
549            tool_version: "0.1.7".to_string(),
550            options: Map::from_iter([("--license".to_string(), Value::Bool(true))]),
551            notice: "Generated with Provenant".to_string(),
552            start_timestamp: "2026-05-31T00:00:00Z".to_string(),
553            end_timestamp: "2026-05-31T00:00:10Z".to_string(),
554            output_format_version: "3.0.0".to_string(),
555            duration: 10.0,
556            errors: vec!["none".to_string()],
557            warnings: vec!["warning".to_string()],
558            extra_data: OutputExtraData {
559                system_environment: OutputSystemEnvironment {
560                    operating_system: "Linux".to_string(),
561                    cpu_architecture: "x86_64".to_string(),
562                    platform: "linux".to_string(),
563                    platform_version: "6.0".to_string(),
564                    rust_version: "1.88.0".to_string(),
565                },
566                spdx_license_list_version: "3.26".to_string(),
567                files_count: 1,
568                directories_count: 1,
569                excluded_count: 0,
570                license_index_provenance: Some(OutputLicenseIndexProvenance {
571                    source: "embedded".to_string(),
572                    dataset_fingerprint: "fingerprint".to_string(),
573                    ignored_rules: vec!["rule-a".to_string()],
574                    ignored_licenses: vec!["lic-a".to_string()],
575                    ignored_rules_due_to_licenses: vec!["rule-b".to_string()],
576                    added_rules: vec!["rule-c".to_string()],
577                    replaced_rules: vec!["rule-d".to_string()],
578                    added_licenses: vec!["lic-b".to_string()],
579                    replaced_licenses: vec!["lic-c".to_string()],
580                }),
581            },
582        }
583    }
584
585    fn sample_file_info() -> OutputFileInfo {
586        OutputFileInfo {
587            name: "mod.rs".to_string(),
588            base_name: "mod".to_string(),
589            extension: ".rs".to_string(),
590            path: "src/mod.rs".to_string(),
591            file_type: OutputFileType::File,
592            mime_type: Some("text/rust".to_string()),
593            file_type_label: Some("source".to_string()),
594            size: 123,
595            date: Some("2026-05-31".to_string()),
596            sha1: Some("a".repeat(40)),
597            md5: Some("b".repeat(32)),
598            sha256: Some("c".repeat(64)),
599            sha1_git: Some("d".repeat(40)),
600            programming_language: Some("Rust".to_string()),
601            package_data: vec![sample_package_data()],
602            license_expression: Some("MIT".to_string()),
603            license_detections: vec![sample_license_detection()],
604            license_clues: vec![sample_match()],
605            percentage_of_license_text: Some(50.0),
606            copyrights: vec![sample_copyright()],
607            holders: vec![sample_holder()],
608            authors: vec![sample_author()],
609            emails: vec![sample_email()],
610            urls: vec![sample_url()],
611            for_packages: vec!["pkg-uid".to_string()],
612            scan_errors: vec!["parse warning".to_string()],
613            license_policy: Some(vec![sample_license_policy_entry()]),
614            is_generated: Some(true),
615            is_binary: Some(false),
616            is_text: Some(true),
617            is_archive: Some(false),
618            is_media: Some(false),
619            is_source: Some(true),
620            is_script: Some(false),
621            files_count: Some(1),
622            dirs_count: Some(0),
623            size_count: Some(123),
624            source_count: Some(1),
625            is_legal: true,
626            is_manifest: true,
627            is_readme: true,
628            is_top_level: true,
629            is_key_file: true,
630            is_referenced: true,
631            is_community: true,
632            facets: vec!["core".to_string()],
633            tallies: Some(sample_tallies()),
634        }
635    }
636
637    fn sample_package() -> OutputPackage {
638        OutputPackage {
639            package_type: Some(OutputPackageType::from(crate::models::PackageType::Cargo)),
640            namespace: Some("example".to_string()),
641            name: Some("crate-name".to_string()),
642            version: Some("1.2.3".to_string()),
643            qualifiers: Some(std::collections::HashMap::from_iter([(
644                "arch".to_string(),
645                "x86_64".to_string(),
646            )])),
647            subpath: Some("sub".to_string()),
648            primary_language: Some("Rust".to_string()),
649            description: Some("Example package".to_string()),
650            release_date: Some("2026-05-31".to_string()),
651            parties: vec![sample_party()],
652            keywords: vec!["example".to_string()],
653            homepage_url: Some("https://example.invalid/home".to_string()),
654            download_url: Some("https://example.invalid/download".to_string()),
655            size: Some(42),
656            sha1: Some("a".repeat(40)),
657            md5: Some("b".repeat(32)),
658            sha256: Some("c".repeat(64)),
659            sha512: Some("d".repeat(128)),
660            bug_tracking_url: Some("https://example.invalid/issues".to_string()),
661            code_view_url: Some("https://example.invalid/code".to_string()),
662            vcs_url: Some("git+https://example.invalid/repo.git".to_string()),
663            copyright: Some("Copyright 2026 Example".to_string()),
664            holder: Some("Example Holder".to_string()),
665            declared_license_expression: Some("mit".to_string()),
666            declared_license_expression_spdx: Some("MIT".to_string()),
667            license_detections: vec![sample_license_detection()],
668            other_license_expression: Some("apache-2.0".to_string()),
669            other_license_expression_spdx: Some("Apache-2.0".to_string()),
670            other_license_detections: vec![sample_license_detection()],
671            extracted_license_statement: Some("MIT".to_string()),
672            notice_text: Some("notice".to_string()),
673            source_packages: vec!["pkg:cargo/source@1.0.0".to_string()],
674            is_private: true,
675            is_virtual: true,
676            extra_data: Some(std::collections::HashMap::from_iter([(
677                "custom".to_string(),
678                Value::String("value".to_string()),
679            )])),
680            repository_homepage_url: Some("https://example.invalid/repo-home".to_string()),
681            repository_download_url: Some("https://example.invalid/repo-download".to_string()),
682            api_data_url: Some("https://example.invalid/api".to_string()),
683            purl: Some("pkg:cargo/example/crate-name@1.2.3".to_string()),
684            package_uid: "pkg-uid".to_string(),
685            datafile_paths: vec!["Cargo.toml".to_string()],
686            datasource_ids: vec![OutputDatasourceId::from(
687                crate::models::DatasourceId::CargoToml,
688            )],
689        }
690    }
691
692    fn assert_metadata_matches_serialized_keys<T: Serialize>(type_name: &str, value: &T) {
693        let documented = metadata_field_names(type_name);
694        let serialized = serialized_object_keys(value)
695            .into_iter()
696            .collect::<BTreeSet<_>>();
697        let documented_owned = documented
698            .iter()
699            .map(|s| s.to_string())
700            .collect::<BTreeSet<_>>();
701        assert_eq!(
702            documented_owned, serialized,
703            "metadata mismatch for {}",
704            type_name
705        );
706    }
707
708    #[test]
709    fn documented_type_names_are_unique() {
710        let mut seen = BTreeSet::new();
711        for ty in documented_output_types() {
712            assert!(
713                seen.insert(ty.type_name),
714                "duplicate type doc: {}",
715                ty.type_name
716            );
717        }
718    }
719
720    #[test]
721    fn documented_json_paths_are_unique() {
722        let mut seen = BTreeSet::new();
723        for ty in documented_output_types() {
724            for path in ty.json_paths {
725                assert!(seen.insert(*path), "duplicate json path doc: {}", path);
726            }
727        }
728    }
729
730    #[test]
731    fn documented_field_names_are_unique_per_type() {
732        for ty in documented_output_types() {
733            let mut seen = BTreeSet::new();
734            for field in ty.fields {
735                assert!(
736                    seen.insert(field.json_name),
737                    "duplicate field doc in {}: {}",
738                    ty.type_name,
739                    field.json_name
740                );
741            }
742        }
743    }
744
745    #[test]
746    fn output_file_info_doc_starts_with_public_serialization_order() {
747        let file_info = documented_output_types()
748            .iter()
749            .find(|ty| ty.type_name == "OutputFileInfo")
750            .expect("OutputFileInfo should be documented");
751        let fields = file_info
752            .fields
753            .iter()
754            .map(|field| field.json_name)
755            .collect::<Vec<_>>();
756
757        assert_eq!(
758            &fields[..5],
759            &["path", "type", "name", "base_name", "extension",]
760        );
761    }
762
763    #[test]
764    fn metadata_matches_serialized_keys_for_core_documented_types() {
765        assert_metadata_matches_serialized_keys("OutputHeader", &sample_header());
766        assert_metadata_matches_serialized_keys("OutputFileInfo", &sample_file_info());
767        assert_metadata_matches_serialized_keys("OutputPackage", &sample_package());
768        assert_metadata_matches_serialized_keys("OutputPackageData", &sample_package_data());
769        assert_metadata_matches_serialized_keys("OutputDependency", &sample_dependency());
770        assert_metadata_matches_serialized_keys(
771            "OutputTopLevelDependency",
772            &sample_top_level_dependency(),
773        );
774        assert_metadata_matches_serialized_keys(
775            "OutputTopLevelLicenseDetection",
776            &sample_top_level_license_detection(),
777        );
778    }
779}