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            compliance_alert: None,
399        }
400    }
401
402    fn sample_author() -> OutputAuthor {
403        OutputAuthor {
404            author: "Example Author".to_string(),
405            start_line: 1,
406            end_line: 1,
407        }
408    }
409
410    fn sample_copyright() -> OutputCopyright {
411        OutputCopyright {
412            copyright: "Copyright 2026 Example".to_string(),
413            start_line: 1,
414            end_line: 1,
415        }
416    }
417
418    fn sample_email() -> OutputEmail {
419        OutputEmail {
420            email: "example@example.invalid".to_string(),
421            start_line: 1,
422            end_line: 1,
423        }
424    }
425
426    fn sample_holder() -> OutputHolder {
427        OutputHolder {
428            holder: "Example Holder".to_string(),
429            start_line: 1,
430            end_line: 1,
431        }
432    }
433
434    fn sample_url() -> OutputURL {
435        OutputURL {
436            url: "https://example.invalid".to_string(),
437            start_line: 1,
438            end_line: 1,
439        }
440    }
441
442    fn sample_package_data() -> OutputPackageData {
443        OutputPackageData {
444            package_type: Some(OutputPackageType::from(crate::models::PackageType::Cargo)),
445            namespace: Some("example".to_string()),
446            name: Some("crate-name".to_string()),
447            version: Some("1.2.3".to_string()),
448            qualifiers: Some(std::collections::HashMap::from_iter([(
449                "arch".to_string(),
450                "x86_64".to_string(),
451            )])),
452            subpath: Some("sub".to_string()),
453            primary_language: Some("Rust".to_string()),
454            description: Some("Example package data".to_string()),
455            release_date: Some("2026-05-31".to_string()),
456            parties: vec![sample_party()],
457            keywords: vec!["example".to_string()],
458            homepage_url: Some("https://example.invalid/home".to_string()),
459            download_url: Some("https://example.invalid/download".to_string()),
460            size: Some(42),
461            sha1: Some("a".repeat(40)),
462            md5: Some("b".repeat(32)),
463            sha256: Some("c".repeat(64)),
464            sha512: Some("d".repeat(128)),
465            bug_tracking_url: Some("https://example.invalid/issues".to_string()),
466            code_view_url: Some("https://example.invalid/code".to_string()),
467            vcs_url: Some("git+https://example.invalid/repo.git".to_string()),
468            copyright: Some("Copyright 2026 Example".to_string()),
469            holder: Some("Example Holder".to_string()),
470            declared_license_expression: Some("mit".to_string()),
471            declared_license_expression_spdx: Some("MIT".to_string()),
472            license_detections: vec![sample_license_detection()],
473            other_license_expression: Some("apache-2.0".to_string()),
474            other_license_expression_spdx: Some("Apache-2.0".to_string()),
475            other_license_detections: vec![sample_license_detection()],
476            extracted_license_statement: Some("MIT".to_string()),
477            notice_text: Some("notice".to_string()),
478            source_packages: vec!["pkg:cargo/source@1.0.0".to_string()],
479            file_references: vec![sample_file_reference()],
480            is_private: true,
481            is_virtual: true,
482            extra_data: Some(std::collections::HashMap::from_iter([(
483                "custom".to_string(),
484                Value::String("value".to_string()),
485            )])),
486            dependencies: vec![sample_dependency()],
487            repository_homepage_url: Some("https://example.invalid/repo-home".to_string()),
488            repository_download_url: Some("https://example.invalid/repo-download".to_string()),
489            api_data_url: Some("https://example.invalid/api".to_string()),
490            datasource_id: Some(OutputDatasourceId::from(
491                crate::models::DatasourceId::CargoToml,
492            )),
493            purl: Some("pkg:cargo/example/crate-name@1.2.3".to_string()),
494        }
495    }
496
497    fn sample_dependency() -> OutputDependency {
498        OutputDependency {
499            purl: Some("pkg:cargo/example/dep@1.0.0".to_string()),
500            extracted_requirement: Some("^1.0".to_string()),
501            scope: Some("runtime".to_string()),
502            is_runtime: Some(true),
503            is_optional: Some(false),
504            is_pinned: Some(true),
505            is_direct: Some(true),
506            resolved_package: None,
507            extra_data: Some(std::collections::HashMap::from_iter([(
508                "kind".to_string(),
509                Value::String("normal".to_string()),
510            )])),
511        }
512    }
513
514    fn sample_top_level_dependency() -> OutputTopLevelDependency {
515        OutputTopLevelDependency {
516            purl: Some("pkg:cargo/example/dep@1.0.0".to_string()),
517            extracted_requirement: Some("^1.0".to_string()),
518            scope: Some("runtime".to_string()),
519            is_runtime: Some(true),
520            is_optional: Some(false),
521            is_pinned: Some(true),
522            is_direct: Some(true),
523            resolved_package: None,
524            extra_data: Some(std::collections::HashMap::from_iter([(
525                "kind".to_string(),
526                Value::String("normal".to_string()),
527            )])),
528            dependency_uid: "dep-uid".to_string(),
529            for_package_uid: Some("pkg-uid".to_string()),
530            datafile_path: "Cargo.toml".to_string(),
531            datasource_id: OutputDatasourceId::from(crate::models::DatasourceId::CargoToml),
532            namespace: Some("example".to_string()),
533        }
534    }
535
536    fn sample_top_level_license_detection() -> OutputTopLevelLicenseDetection {
537        OutputTopLevelLicenseDetection {
538            identifier: "top-1".to_string(),
539            license_expression: "mit".to_string(),
540            license_expression_spdx: "MIT".to_string(),
541            detection_count: 1,
542            detection_log: vec!["grouped".to_string()],
543            reference_matches: vec![sample_match()],
544        }
545    }
546
547    fn sample_header() -> OutputHeader {
548        OutputHeader {
549            tool_name: "provenant".to_string(),
550            tool_version: "0.1.7".to_string(),
551            options: Map::from_iter([("--license".to_string(), Value::Bool(true))]),
552            notice: "Generated with Provenant".to_string(),
553            start_timestamp: "2026-05-31T00:00:00Z".to_string(),
554            end_timestamp: "2026-05-31T00:00:10Z".to_string(),
555            output_format_version: "3.0.0".to_string(),
556            duration: 10.0,
557            errors: vec!["none".to_string()],
558            warnings: vec!["warning".to_string()],
559            extra_data: OutputExtraData {
560                system_environment: OutputSystemEnvironment {
561                    operating_system: "Linux".to_string(),
562                    cpu_architecture: "x86_64".to_string(),
563                    platform: "linux".to_string(),
564                    platform_version: "6.0".to_string(),
565                    rust_version: "1.88.0".to_string(),
566                },
567                spdx_license_list_version: "3.26".to_string(),
568                files_count: 1,
569                directories_count: 1,
570                excluded_count: 0,
571                license_index_provenance: Some(OutputLicenseIndexProvenance {
572                    source: "embedded".to_string(),
573                    dataset_fingerprint: "fingerprint".to_string(),
574                    ignored_rules: vec!["rule-a".to_string()],
575                    ignored_licenses: vec!["lic-a".to_string()],
576                    ignored_rules_due_to_licenses: vec!["rule-b".to_string()],
577                    added_rules: vec!["rule-c".to_string()],
578                    replaced_rules: vec!["rule-d".to_string()],
579                    added_licenses: vec!["lic-b".to_string()],
580                    replaced_licenses: vec!["lic-c".to_string()],
581                }),
582            },
583        }
584    }
585
586    fn sample_file_info() -> OutputFileInfo {
587        OutputFileInfo {
588            name: "mod.rs".to_string(),
589            base_name: "mod".to_string(),
590            extension: ".rs".to_string(),
591            path: "src/mod.rs".to_string(),
592            file_type: OutputFileType::File,
593            mime_type: Some("text/rust".to_string()),
594            file_type_label: Some("source".to_string()),
595            size: 123,
596            date: Some("2026-05-31".to_string()),
597            sha1: Some("a".repeat(40)),
598            md5: Some("b".repeat(32)),
599            sha256: Some("c".repeat(64)),
600            sha1_git: Some("d".repeat(40)),
601            programming_language: Some("Rust".to_string()),
602            package_data: vec![sample_package_data()],
603            license_expression: Some("MIT".to_string()),
604            license_expression_spdx: Some("MIT".to_string()),
605            license_detections: vec![sample_license_detection()],
606            license_clues: vec![sample_match()],
607            percentage_of_license_text: Some(50.0),
608            copyrights: vec![sample_copyright()],
609            holders: vec![sample_holder()],
610            authors: vec![sample_author()],
611            emails: vec![sample_email()],
612            urls: vec![sample_url()],
613            for_packages: vec!["pkg-uid".to_string()],
614            scan_errors: vec!["parse warning".to_string()],
615            license_policy: Some(vec![sample_license_policy_entry()]),
616            is_generated: Some(true),
617            is_binary: Some(false),
618            is_text: Some(true),
619            is_archive: Some(false),
620            is_media: Some(false),
621            is_source: Some(true),
622            is_script: Some(false),
623            files_count: Some(1),
624            dirs_count: Some(0),
625            size_count: Some(123),
626            source_count: Some(1),
627            is_legal: true,
628            is_manifest: true,
629            is_readme: true,
630            is_top_level: true,
631            is_key_file: true,
632            is_referenced: true,
633            is_community: true,
634            facets: vec!["core".to_string()],
635            tallies: Some(sample_tallies()),
636        }
637    }
638
639    fn sample_package() -> OutputPackage {
640        OutputPackage {
641            package_type: Some(OutputPackageType::from(crate::models::PackageType::Cargo)),
642            namespace: Some("example".to_string()),
643            name: Some("crate-name".to_string()),
644            version: Some("1.2.3".to_string()),
645            qualifiers: Some(std::collections::HashMap::from_iter([(
646                "arch".to_string(),
647                "x86_64".to_string(),
648            )])),
649            subpath: Some("sub".to_string()),
650            primary_language: Some("Rust".to_string()),
651            description: Some("Example package".to_string()),
652            release_date: Some("2026-05-31".to_string()),
653            parties: vec![sample_party()],
654            keywords: vec!["example".to_string()],
655            homepage_url: Some("https://example.invalid/home".to_string()),
656            download_url: Some("https://example.invalid/download".to_string()),
657            size: Some(42),
658            sha1: Some("a".repeat(40)),
659            md5: Some("b".repeat(32)),
660            sha256: Some("c".repeat(64)),
661            sha512: Some("d".repeat(128)),
662            bug_tracking_url: Some("https://example.invalid/issues".to_string()),
663            code_view_url: Some("https://example.invalid/code".to_string()),
664            vcs_url: Some("git+https://example.invalid/repo.git".to_string()),
665            copyright: Some("Copyright 2026 Example".to_string()),
666            holder: Some("Example Holder".to_string()),
667            declared_license_expression: Some("mit".to_string()),
668            declared_license_expression_spdx: Some("MIT".to_string()),
669            license_detections: vec![sample_license_detection()],
670            other_license_expression: Some("apache-2.0".to_string()),
671            other_license_expression_spdx: Some("Apache-2.0".to_string()),
672            other_license_detections: vec![sample_license_detection()],
673            extracted_license_statement: Some("MIT".to_string()),
674            notice_text: Some("notice".to_string()),
675            source_packages: vec!["pkg:cargo/source@1.0.0".to_string()],
676            is_private: true,
677            is_virtual: true,
678            extra_data: Some(std::collections::HashMap::from_iter([(
679                "custom".to_string(),
680                Value::String("value".to_string()),
681            )])),
682            repository_homepage_url: Some("https://example.invalid/repo-home".to_string()),
683            repository_download_url: Some("https://example.invalid/repo-download".to_string()),
684            api_data_url: Some("https://example.invalid/api".to_string()),
685            purl: Some("pkg:cargo/example/crate-name@1.2.3".to_string()),
686            package_uid: "pkg-uid".to_string(),
687            datafile_paths: vec!["Cargo.toml".to_string()],
688            datasource_ids: vec![OutputDatasourceId::from(
689                crate::models::DatasourceId::CargoToml,
690            )],
691        }
692    }
693
694    fn assert_metadata_matches_serialized_keys<T: Serialize>(type_name: &str, value: &T) {
695        let documented = metadata_field_names(type_name);
696        let serialized = serialized_object_keys(value)
697            .into_iter()
698            .collect::<BTreeSet<_>>();
699        let documented_owned = documented
700            .iter()
701            .map(|s| s.to_string())
702            .collect::<BTreeSet<_>>();
703        assert_eq!(
704            documented_owned, serialized,
705            "metadata mismatch for {}",
706            type_name
707        );
708    }
709
710    #[test]
711    fn documented_type_names_are_unique() {
712        let mut seen = BTreeSet::new();
713        for ty in documented_output_types() {
714            assert!(
715                seen.insert(ty.type_name),
716                "duplicate type doc: {}",
717                ty.type_name
718            );
719        }
720    }
721
722    #[test]
723    fn documented_json_paths_are_unique() {
724        let mut seen = BTreeSet::new();
725        for ty in documented_output_types() {
726            for path in ty.json_paths {
727                assert!(seen.insert(*path), "duplicate json path doc: {}", path);
728            }
729        }
730    }
731
732    #[test]
733    fn documented_field_names_are_unique_per_type() {
734        for ty in documented_output_types() {
735            let mut seen = BTreeSet::new();
736            for field in ty.fields {
737                assert!(
738                    seen.insert(field.json_name),
739                    "duplicate field doc in {}: {}",
740                    ty.type_name,
741                    field.json_name
742                );
743            }
744        }
745    }
746
747    #[test]
748    fn output_file_info_doc_starts_with_public_serialization_order() {
749        let file_info = documented_output_types()
750            .iter()
751            .find(|ty| ty.type_name == "OutputFileInfo")
752            .expect("OutputFileInfo should be documented");
753        let fields = file_info
754            .fields
755            .iter()
756            .map(|field| field.json_name)
757            .collect::<Vec<_>>();
758
759        assert_eq!(
760            &fields[..5],
761            &["path", "type", "name", "base_name", "extension",]
762        );
763    }
764
765    #[test]
766    fn metadata_matches_serialized_keys_for_core_documented_types() {
767        assert_metadata_matches_serialized_keys("OutputHeader", &sample_header());
768        assert_metadata_matches_serialized_keys("OutputFileInfo", &sample_file_info());
769        assert_metadata_matches_serialized_keys("OutputPackage", &sample_package());
770        assert_metadata_matches_serialized_keys("OutputPackageData", &sample_package_data());
771        assert_metadata_matches_serialized_keys("OutputDependency", &sample_dependency());
772        assert_metadata_matches_serialized_keys(
773            "OutputTopLevelDependency",
774            &sample_top_level_dependency(),
775        );
776        assert_metadata_matches_serialized_keys(
777            "OutputTopLevelLicenseDetection",
778            &sample_top_level_license_detection(),
779        );
780    }
781}