Skip to main content

provenant/output/
mod.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
7use std::fs::File;
8use std::io::{self, BufWriter, Write};
9
10use crate::output_schema::Output;
11
12mod cyclonedx;
13mod debian;
14mod html;
15mod jsonl;
16mod public_serialize;
17mod sarif;
18mod sbom;
19mod shared;
20mod spdx;
21mod spdx_plan;
22mod template;
23
24pub(crate) const SPDX_DOCUMENT_NOTICE: &str = "Generated with Provenant and provided on an \"AS IS\" BASIS, WITHOUT WARRANTIES\nOR CONDITIONS OF ANY KIND, either express or implied. No content created from\nProvenant should be considered or used as legal advice. Consult an attorney\nfor legal advice.\nProvenant is a free software code scanning tool.\nVisit https://github.com/getprovenant/provenant/ for support and download.\nSPDX License List: 3.27";
25const OUTPUT_BUFFER_SIZE: usize = 1024 * 1024;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum OutputFormat {
29    #[default]
30    Json,
31    JsonPretty,
32    Yaml,
33    JsonLines,
34    Debian,
35    Html,
36    CustomTemplate,
37    SpdxTv,
38    SpdxRdf,
39    CycloneDxJson,
40    CycloneDxXml,
41    Sarif,
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct OutputWriteConfig {
46    pub format: OutputFormat,
47    pub custom_template: Option<String>,
48    pub scanned_path: Option<String>,
49}
50
51pub trait OutputWriter {
52    fn write(
53        &self,
54        output: &Output,
55        writer: &mut dyn Write,
56        config: &OutputWriteConfig,
57    ) -> io::Result<()>;
58}
59
60type FormatWriterFn = fn(&Output, &mut dyn Write, &OutputWriteConfig) -> io::Result<()>;
61
62/// Canonical, single-source-of-truth entry for one output format: how to
63/// write it, the CLI flag that requests it (for diagnostics such as the
64/// hollow-SBOM guard in `src/cli/run/mod.rs`), and whether it belongs to the
65/// SBOM-oriented family gated by that guard. Adding a new [`OutputFormat`]
66/// variant means adding one row to [`OUTPUT_WRITERS`]; no other dispatch
67/// table or match arm needs to change.
68struct OutputFormatDescriptor {
69    format: OutputFormat,
70    cli_flag: &'static str,
71    is_sbom: bool,
72    write: FormatWriterFn,
73}
74
75/// SBOM-oriented output formats whose entire value proposition is the
76/// package inventory: an SPDX or CycloneDX document with zero packages looks
77/// like a normal, successful export while actually reporting no components
78/// at all. See the hollow-SBOM guard in `src/cli/run/mod.rs`.
79const OUTPUT_WRITERS: &[OutputFormatDescriptor] = &[
80    OutputFormatDescriptor {
81        format: OutputFormat::Json,
82        cli_flag: "--json",
83        is_sbom: false,
84        write: write_json,
85    },
86    OutputFormatDescriptor {
87        format: OutputFormat::JsonPretty,
88        cli_flag: "--json-pp",
89        is_sbom: false,
90        write: write_json_pretty,
91    },
92    OutputFormatDescriptor {
93        format: OutputFormat::JsonLines,
94        cli_flag: "--json-lines",
95        is_sbom: false,
96        write: |output, writer, _config| jsonl::write_json_lines(output, writer),
97    },
98    OutputFormatDescriptor {
99        format: OutputFormat::Yaml,
100        cli_flag: "--yaml",
101        is_sbom: false,
102        write: |output, writer, _config| write_yaml(output, writer),
103    },
104    OutputFormatDescriptor {
105        format: OutputFormat::Debian,
106        cli_flag: "--debian",
107        is_sbom: false,
108        write: |output, writer, _config| debian::write_debian_copyright(output, writer),
109    },
110    OutputFormatDescriptor {
111        format: OutputFormat::Html,
112        cli_flag: "--html",
113        is_sbom: false,
114        write: |output, writer, _config| html::write_html_report(output, writer),
115    },
116    OutputFormatDescriptor {
117        format: OutputFormat::CustomTemplate,
118        cli_flag: "--custom-output",
119        is_sbom: false,
120        write: |output, writer, config| template::write_custom_template(output, writer, config),
121    },
122    OutputFormatDescriptor {
123        format: OutputFormat::SpdxTv,
124        cli_flag: "--spdx-tv",
125        is_sbom: true,
126        write: |output, writer, config| spdx::write_spdx_tag_value(output, writer, config),
127    },
128    OutputFormatDescriptor {
129        format: OutputFormat::SpdxRdf,
130        cli_flag: "--spdx-rdf",
131        is_sbom: true,
132        write: |output, writer, config| spdx::write_spdx_rdf_xml(output, writer, config),
133    },
134    OutputFormatDescriptor {
135        format: OutputFormat::CycloneDxJson,
136        cli_flag: "--cyclonedx",
137        is_sbom: true,
138        write: |output, writer, _config| cyclonedx::write_cyclonedx_json(output, writer),
139    },
140    OutputFormatDescriptor {
141        format: OutputFormat::CycloneDxXml,
142        cli_flag: "--cyclonedx-xml",
143        is_sbom: true,
144        write: |output, writer, _config| cyclonedx::write_cyclonedx_xml(output, writer),
145    },
146    OutputFormatDescriptor {
147        format: OutputFormat::Sarif,
148        cli_flag: "--sarif",
149        is_sbom: false,
150        write: |output, writer, _config| sarif::write_sarif(output, writer),
151    },
152];
153
154fn descriptor_for(format: OutputFormat) -> &'static OutputFormatDescriptor {
155    OUTPUT_WRITERS
156        .iter()
157        .find(|descriptor| descriptor.format == format)
158        .expect("OUTPUT_WRITERS must have an entry for every OutputFormat variant")
159}
160
161/// The CLI flag (e.g. `--cyclonedx`) that requests `format`, for use in
162/// diagnostics. Kept in sync with clap's `long = "..."` attributes on
163/// `ScanArgs` by the registry test below.
164pub(crate) fn cli_flag_for(format: OutputFormat) -> &'static str {
165    descriptor_for(format).cli_flag
166}
167
168/// Whether `format` belongs to the SBOM-oriented family (SPDX/CycloneDX)
169/// gated by the hollow-SBOM guard in `src/cli/run/mod.rs`.
170pub(crate) fn is_sbom_format(format: OutputFormat) -> bool {
171    descriptor_for(format).is_sbom
172}
173
174pub struct FormatWriter {
175    format: OutputFormat,
176}
177
178pub fn writer_for_format(format: OutputFormat) -> FormatWriter {
179    FormatWriter { format }
180}
181
182impl OutputWriter for FormatWriter {
183    fn write(
184        &self,
185        output: &Output,
186        writer: &mut dyn Write,
187        config: &OutputWriteConfig,
188    ) -> io::Result<()> {
189        (descriptor_for(self.format).write)(output, writer, config)
190    }
191}
192
193fn write_json(
194    output: &Output,
195    writer: &mut dyn Write,
196    _config: &OutputWriteConfig,
197) -> io::Result<()> {
198    serde_json::to_writer(&mut *writer, &public_serialize::PublicOutput(output))
199        .map_err(shared::io_other)?;
200    writer.write_all(b"\n")
201}
202
203fn write_json_pretty(
204    output: &Output,
205    writer: &mut dyn Write,
206    _config: &OutputWriteConfig,
207) -> io::Result<()> {
208    serde_json::to_writer_pretty(&mut *writer, &public_serialize::PublicOutput(output))
209        .map_err(shared::io_other)?;
210    writer.write_all(b"\n")
211}
212
213pub fn write_output_file(
214    output_file: &str,
215    output: &Output,
216    config: &OutputWriteConfig,
217) -> io::Result<()> {
218    if output_file == "-" {
219        let stdout = io::stdout();
220        let handle = stdout.lock();
221        let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, handle);
222        writer_for_format(config.format).write(output, &mut writer, config)?;
223        return writer.flush();
224    }
225
226    let file = File::create(output_file)?;
227    let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, file);
228    writer_for_format(config.format).write(output, &mut writer, config)?;
229    writer.flush()
230}
231
232fn write_yaml(output: &Output, writer: &mut dyn Write) -> io::Result<()> {
233    yaml_serde::to_writer(&mut *writer, &public_serialize::PublicOutput(output))
234        .map_err(shared::io_other)?;
235    writer.write_all(b"\n")
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use serde_json::Value;
242    use std::fs;
243
244    use crate::license_detection::MatcherKind;
245    use crate::models::{
246        Author, Copyright, ExtraData, FileInfo, FileType, GitSha1, Header, Holder,
247        LicenseDetection, LineNumber, Match, MatchScore, Md5Digest, OutputEmail, OutputURL,
248        Package, PackageData, PackageUid, Sha1Digest, Sha256Digest, SystemEnvironment,
249    };
250    use crate::output_schema::OutputFileInfo;
251
252    #[test]
253    fn test_output_writers_registry_covers_every_format_exactly_once() {
254        // Keep this list in sync with the `OutputFormat` enum: it is the
255        // only place that asserts every variant has exactly one
256        // `OUTPUT_WRITERS` row.
257        let all_formats = [
258            OutputFormat::Json,
259            OutputFormat::JsonPretty,
260            OutputFormat::Yaml,
261            OutputFormat::JsonLines,
262            OutputFormat::Debian,
263            OutputFormat::Html,
264            OutputFormat::CustomTemplate,
265            OutputFormat::SpdxTv,
266            OutputFormat::SpdxRdf,
267            OutputFormat::CycloneDxJson,
268            OutputFormat::CycloneDxXml,
269            OutputFormat::Sarif,
270        ];
271        assert_eq!(OUTPUT_WRITERS.len(), all_formats.len());
272        for format in all_formats {
273            assert_eq!(
274                OUTPUT_WRITERS
275                    .iter()
276                    .filter(|descriptor| descriptor.format == format)
277                    .count(),
278                1,
279                "missing or duplicate OUTPUT_WRITERS entry for {format:?}"
280            );
281        }
282    }
283
284    #[test]
285    fn test_sbom_formats_are_exactly_spdx_and_cyclonedx() {
286        let sbom_formats: Vec<OutputFormat> = OUTPUT_WRITERS
287            .iter()
288            .filter(|descriptor| descriptor.is_sbom)
289            .map(|descriptor| descriptor.format)
290            .collect();
291        assert_eq!(
292            sbom_formats,
293            vec![
294                OutputFormat::SpdxTv,
295                OutputFormat::SpdxRdf,
296                OutputFormat::CycloneDxJson,
297                OutputFormat::CycloneDxXml,
298            ]
299        );
300        assert_eq!(cli_flag_for(OutputFormat::CycloneDxJson), "--cyclonedx");
301        assert!(is_sbom_format(OutputFormat::SpdxTv));
302        assert!(!is_sbom_format(OutputFormat::Json));
303    }
304
305    #[test]
306    fn test_yaml_writer_outputs_yaml() {
307        let output = Output::from(&sample_internal_output());
308        let mut bytes = Vec::new();
309        writer_for_format(OutputFormat::Yaml)
310            .write(&output, &mut bytes, &OutputWriteConfig::default())
311            .expect("yaml write should succeed");
312        let rendered = String::from_utf8(bytes).expect("yaml should be utf-8");
313        assert!(rendered.contains("headers:"));
314        assert!(rendered.contains("files:"));
315    }
316
317    #[test]
318    fn test_json_lines_writer_outputs_parseable_lines() {
319        let output = Output::from(&sample_internal_output());
320        let mut bytes = Vec::new();
321        writer_for_format(OutputFormat::JsonLines)
322            .write(&output, &mut bytes, &OutputWriteConfig::default())
323            .expect("json-lines write should succeed");
324
325        let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
326        let lines = rendered.lines().collect::<Vec<_>>();
327        assert!(lines.len() >= 2);
328        for line in lines {
329            serde_json::from_str::<Value>(line).expect("each line should be valid json");
330        }
331    }
332
333    #[test]
334    fn test_yaml_writer_emits_license_index_provenance_in_headers() {
335        let output = Output::from(&sample_internal_output());
336        let mut bytes = Vec::new();
337        writer_for_format(OutputFormat::Yaml)
338            .write(&output, &mut bytes, &OutputWriteConfig::default())
339            .expect("yaml write should succeed");
340
341        let rendered = String::from_utf8(bytes).expect("yaml should be utf-8");
342        assert!(rendered.contains("license_index_provenance:"));
343        assert!(rendered.contains("dataset_fingerprint: test-fingerprint"));
344        assert!(rendered.contains("source: embedded-artifact"));
345    }
346
347    #[test]
348    fn test_debian_writer_outputs_dep5_style_document() {
349        let mut internal = sample_internal_output();
350        internal.files[0].detected_license_expression = Some("mit".to_string());
351        internal.files[0].license_detections[0].matches[0].matched_text = Some(
352            "Permission is hereby granted, free of charge, to any person obtaining a copy"
353                .to_string(),
354        );
355        let output = Output::from(&internal);
356
357        let mut bytes = Vec::new();
358        writer_for_format(OutputFormat::Debian)
359            .write(&output, &mut bytes, &OutputWriteConfig::default())
360            .expect("debian write should succeed");
361
362        let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
363        assert!(rendered.contains(
364            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"
365        ));
366        assert!(rendered.contains("Comment: Generated with Provenant"));
367        assert!(rendered.contains("Files: src/main.rs"));
368        assert!(rendered.contains("Copyright: Example Org"));
369        assert!(rendered.contains("License: MIT"));
370        assert!(rendered.contains(" Permission is hereby granted, free of charge"));
371    }
372
373    #[test]
374    fn test_debian_writer_skips_directories_and_deduplicates_license_texts() {
375        let mut internal = sample_internal_output();
376        internal.files.insert(
377            0,
378            FileInfo::new(
379                "src".to_string(),
380                "src".to_string(),
381                String::new(),
382                "src".to_string(),
383                FileType::Directory,
384                None,
385                None,
386                0,
387                None,
388                None,
389                None,
390                None,
391                None,
392                vec![],
393                None,
394                vec![],
395                vec![],
396                vec![],
397                vec![],
398                vec![],
399                vec![],
400                vec![],
401                vec![],
402                vec![],
403            ),
404        );
405        internal.files[1].detected_license_expression = Some("mit".to_string());
406        internal.files[1].license_detections[0].matches[0].matched_text =
407            Some("Same text".to_string());
408        internal.files[1].license_detections[0].matches.push(Match {
409            license_expression: "mit".to_string(),
410            license_expression_spdx: "MIT".to_string(),
411            from_file: Some("src/main.rs".to_string()),
412            start_line: LineNumber::ONE,
413            end_line: LineNumber::ONE,
414            matcher: MatcherKind::Aho,
415            score: MatchScore::MAX,
416            matched_length: Some(1),
417            match_coverage: Some(100.0),
418            rule_relevance: Some(100),
419            rule_identifier: "mit_rule".to_string(),
420            rule_url: None,
421            matched_text: Some("Same text again".to_string()),
422            referenced_filenames: None,
423            matched_text_diagnostics: None,
424        });
425        let output = Output::from(&internal);
426
427        let mut bytes = Vec::new();
428        writer_for_format(OutputFormat::Debian)
429            .write(&output, &mut bytes, &OutputWriteConfig::default())
430            .expect("debian write should succeed");
431
432        let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
433        assert!(!rendered.contains("Files: src\n"));
434        assert_eq!(rendered.matches(" Same text").count(), 1);
435    }
436
437    #[test]
438    fn test_file_info_serialization_omits_info_fields_when_unset() {
439        let file = FileInfo::new(
440            "main.rs".to_string(),
441            "main".to_string(),
442            "rs".to_string(),
443            "src/main.rs".to_string(),
444            FileType::File,
445            None,
446            None,
447            42,
448            None,
449            None,
450            None,
451            None,
452            None,
453            vec![],
454            None,
455            vec![],
456            vec![],
457            vec![],
458            vec![],
459            vec![],
460            vec![],
461            vec![],
462            vec![],
463            vec![],
464        );
465
466        let schema_file = OutputFileInfo::from(&file);
467        let value = serde_json::to_value(&schema_file).expect("file info serializes");
468        let object = value.as_object().expect("file info object");
469
470        assert!(!object.contains_key("date"));
471        assert!(!object.contains_key("sha1"));
472        assert!(!object.contains_key("md5"));
473        assert!(!object.contains_key("sha256"));
474        assert!(!object.contains_key("sha1_git"));
475        assert!(!object.contains_key("mime_type"));
476        assert!(!object.contains_key("file_type"));
477        assert!(!object.contains_key("programming_language"));
478        assert!(!object.contains_key("is_binary"));
479        assert!(!object.contains_key("is_text"));
480        assert!(!object.contains_key("is_archive"));
481        assert!(!object.contains_key("is_media"));
482        assert!(!object.contains_key("is_source"));
483        assert!(!object.contains_key("is_script"));
484        assert!(!object.contains_key("files_count"));
485        assert!(!object.contains_key("dirs_count"));
486        assert!(!object.contains_key("size_count"));
487        assert!(!object.contains_key("license_policy"));
488    }
489
490    #[test]
491    fn test_file_info_serialization_keeps_license_policy_when_enabled() {
492        let mut file = FileInfo::new(
493            "main.rs".to_string(),
494            "main".to_string(),
495            "rs".to_string(),
496            "src/main.rs".to_string(),
497            FileType::File,
498            Some("text/plain".to_string()),
499            Some("text".to_string()),
500            42,
501            Some("2026-01-01T00:00:00Z".to_string()),
502            Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
503            Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
504            Some(
505                Sha256Digest::from_hex(
506                    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
507                )
508                .unwrap(),
509            ),
510            Some("Rust".to_string()),
511            vec![],
512            None,
513            vec![],
514            vec![],
515            vec![],
516            vec![],
517            vec![],
518            vec![],
519            vec![],
520            vec![],
521            vec![],
522        );
523        file.license_policy = Some(vec![]);
524        file.sha1_git =
525            Some(GitSha1::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap());
526        file.is_binary = Some(false);
527        file.is_text = Some(true);
528        file.is_archive = Some(false);
529        file.is_media = Some(false);
530        file.is_source = Some(true);
531        file.is_script = Some(false);
532        file.files_count = Some(0);
533        file.dirs_count = Some(0);
534        file.size_count = Some(0);
535
536        let schema_file = OutputFileInfo::from(&file);
537        let value = serde_json::to_value(&schema_file).expect("file info serializes");
538        let object = value.as_object().expect("file info object");
539
540        assert_eq!(object.get("license_policy"), Some(&serde_json::json!([])));
541        assert_eq!(object.get("file_type"), Some(&serde_json::json!("text")));
542        assert_eq!(object.get("is_binary"), Some(&serde_json::json!(false)));
543        assert_eq!(object.get("is_text"), Some(&serde_json::json!(true)));
544        assert_eq!(object.get("files_count"), Some(&serde_json::json!(0)));
545        assert_eq!(object.get("dirs_count"), Some(&serde_json::json!(0)));
546        assert_eq!(object.get("size_count"), Some(&serde_json::json!(0)));
547    }
548
549    #[test]
550    fn test_detected_license_expression_spdx_prefers_detection_spdx_values() {
551        let mut internal = sample_internal_output();
552        internal.files[0].detected_license_expression = Some("mit".to_string());
553
554        let schema_file = OutputFileInfo::from(&internal.files[0]);
555        let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
556        assert_eq!(schema_value["detected_license_expression_spdx"], "MIT");
557
558        let output = Output::from(&internal);
559        let mut bytes = Vec::new();
560        writer_for_format(OutputFormat::Json)
561            .write(&output, &mut bytes, &OutputWriteConfig::default())
562            .expect("json write should succeed");
563
564        let rendered: Value = serde_json::from_slice(&bytes).expect("json output should parse");
565        assert_eq!(
566            rendered["files"][0]["detected_license_expression_spdx"],
567            "MIT"
568        );
569    }
570
571    #[test]
572    fn test_detected_license_expression_spdx_preserves_distinct_nested_operands() {
573        let mut internal = sample_internal_output();
574        internal.files[0].license_detections = vec![crate::models::LicenseDetection {
575            license_expression: "mit AND (apache-2.0 OR mit)".to_string(),
576            license_expression_spdx: "MIT AND (Apache-2.0 OR MIT)".to_string(),
577            matches: vec![],
578            detection_log: vec![],
579            identifier: String::new(),
580        }];
581        internal.files[0].detected_license_expression = None;
582
583        let schema_file = OutputFileInfo::from(&internal.files[0]);
584        let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
585        assert_eq!(
586            schema_value["detected_license_expression_spdx"],
587            "MIT AND (Apache-2.0 OR MIT)"
588        );
589    }
590
591    #[test]
592    fn test_detected_license_expression_spdx_prefers_covering_joined_expression() {
593        let mut internal = sample_internal_output();
594        internal.files[0].license_detections = vec![
595            crate::models::LicenseDetection {
596                license_expression: "apache-2.0 OR mit".to_string(),
597                license_expression_spdx: "Apache-2.0 OR MIT".to_string(),
598                matches: vec![],
599                detection_log: vec![],
600                identifier: String::new(),
601            },
602            crate::models::LicenseDetection {
603                license_expression: "apache-2.0".to_string(),
604                license_expression_spdx: "Apache-2.0".to_string(),
605                matches: vec![],
606                detection_log: vec![],
607                identifier: String::new(),
608            },
609        ];
610        internal.files[0].detected_license_expression = None;
611
612        let schema_file = OutputFileInfo::from(&internal.files[0]);
613        let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
614        assert_eq!(
615            schema_value["detected_license_expression_spdx"],
616            "Apache-2.0 OR MIT"
617        );
618    }
619
620    #[test]
621    fn test_detected_license_expression_prefers_detection_values() {
622        // The scancode-key field reports the detection's `license_expression`
623        // (e.g. `bsd-new`), not the SPDX form, parallel to the SPDX accessor.
624        let mut internal = sample_internal_output();
625        internal.files[0].license_detections = vec![crate::models::LicenseDetection {
626            license_expression: "bsd-new".to_string(),
627            license_expression_spdx: "BSD-3-Clause".to_string(),
628            matches: vec![],
629            detection_log: vec![],
630            identifier: String::new(),
631        }];
632        internal.files[0].detected_license_expression = None;
633
634        let schema_file = OutputFileInfo::from(&internal.files[0]);
635        let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
636        assert_eq!(schema_value["detected_license_expression"], "bsd-new");
637        assert_eq!(
638            schema_value["detected_license_expression_spdx"],
639            "BSD-3-Clause"
640        );
641    }
642
643    #[test]
644    fn test_detected_license_expression_falls_back_to_package_data_detections() {
645        // With no file-level detections, the scancode-key field falls back to
646        // package-data detections, mirroring the SPDX accessor's second tier.
647        let mut internal = sample_internal_output();
648        internal.files[0].license_detections = vec![];
649        internal.files[0].detected_license_expression = None;
650        internal.files[0].package_data = vec![crate::models::PackageData {
651            license_detections: vec![crate::models::LicenseDetection {
652                license_expression: "bsd-simplified".to_string(),
653                license_expression_spdx: "BSD-2-Clause".to_string(),
654                matches: vec![],
655                detection_log: vec![],
656                identifier: String::new(),
657            }],
658            ..Default::default()
659        }];
660
661        let schema_file = OutputFileInfo::from(&internal.files[0]);
662        let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
663        assert_eq!(
664            schema_value["detected_license_expression"],
665            "bsd-simplified"
666        );
667        assert_eq!(
668            schema_value["detected_license_expression_spdx"],
669            "BSD-2-Clause"
670        );
671    }
672
673    #[test]
674    fn test_json_lines_writer_sorts_files_by_path_for_reproducibility() {
675        let mut internal = sample_internal_output();
676        internal.files.reverse();
677        let output = Output::from(&internal);
678        let mut bytes = Vec::new();
679        writer_for_format(OutputFormat::JsonLines)
680            .write(&output, &mut bytes, &OutputWriteConfig::default())
681            .expect("json-lines write should succeed");
682
683        let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
684        let file_lines = rendered
685            .lines()
686            .filter_map(|line| {
687                let value: Value = serde_json::from_str(line).ok()?;
688                let files = value.get("files")?.as_array()?;
689                files.first()?.get("path")?.as_str().map(str::to_string)
690            })
691            .collect::<Vec<_>>();
692
693        let mut sorted = file_lines.clone();
694        sorted.sort();
695        assert_eq!(file_lines, sorted);
696    }
697
698    #[test]
699    fn test_spdx_tag_value_writer_contains_required_fields() {
700        let output = Output::from(&sample_internal_output());
701        let mut bytes = Vec::new();
702        writer_for_format(OutputFormat::SpdxTv)
703            .write(
704                &output,
705                &mut bytes,
706                &OutputWriteConfig {
707                    format: OutputFormat::SpdxTv,
708                    custom_template: None,
709                    scanned_path: Some("scan".to_string()),
710                },
711            )
712            .expect("spdx tv write should succeed");
713
714        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
715        assert!(rendered.contains("SPDXVersion: SPDX-2.2"));
716        assert!(rendered.contains("FileName: ./src/main.rs"));
717        assert!(
718            rendered.contains("Creator: Tool: Provenant-"),
719            "SPDX TV must emit CreationInfo Creator for spdx-tools"
720        );
721        assert!(
722            rendered.contains("Created:"),
723            "SPDX TV must emit CreationInfo Created for spdx-tools"
724        );
725        assert!(
726            rendered.contains("Relationship: SPDXRef-DOCUMENT DESCRIBES SPDXRef-001"),
727            "SPDX TV must describe the package from the document"
728        );
729    }
730
731    #[test]
732    fn test_spdx_rdf_writer_outputs_xml() {
733        let output = Output::from(&sample_internal_output());
734        let mut bytes = Vec::new();
735        writer_for_format(OutputFormat::SpdxRdf)
736            .write(
737                &output,
738                &mut bytes,
739                &OutputWriteConfig {
740                    format: OutputFormat::SpdxRdf,
741                    custom_template: None,
742                    scanned_path: Some("scan".to_string()),
743                },
744            )
745            .expect("spdx rdf write should succeed");
746
747        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
748        assert!(rendered.contains("<rdf:RDF"));
749        assert!(rendered.contains("<spdx:SpdxDocument"));
750        assert!(rendered.contains("<spdx:created>2026-01-01T00:00:00Z</spdx:created>"));
751        assert!(
752            rendered.contains("<spdx:creator>Tool: Provenant-"),
753            "SPDX RDF must emit a creator Actor for spdx-tools"
754        );
755        assert!(
756            rendered.contains("relationshipType_describes"),
757            "SPDX RDF must include Document DESCRIBES package"
758        );
759    }
760
761    #[test]
762    fn test_cyclonedx_writers_keep_iso_timestamps_when_headers_use_scancode_format() {
763        let mut internal = sample_internal_output();
764        internal.packages.push(Package::from_package_data(
765            &PackageData {
766                name: Some("demo".to_string()),
767                version: Some("1.0.0".to_string()),
768                ..PackageData::default()
769            },
770            "scan/package.json".to_string(),
771        ));
772        let output = Output::from(&internal);
773
774        let mut json_bytes = Vec::new();
775        writer_for_format(OutputFormat::CycloneDxJson)
776            .write(
777                &output,
778                &mut json_bytes,
779                &OutputWriteConfig {
780                    format: OutputFormat::CycloneDxJson,
781                    custom_template: None,
782                    scanned_path: Some("scan".to_string()),
783                },
784            )
785            .expect("cyclonedx json write should succeed");
786        let json_value: Value =
787            serde_json::from_slice(&json_bytes).expect("cyclonedx json should parse");
788        assert_eq!(
789            json_value["metadata"]["timestamp"].as_str(),
790            Some("2026-01-01T00:00:01Z")
791        );
792
793        let mut xml_bytes = Vec::new();
794        writer_for_format(OutputFormat::CycloneDxXml)
795            .write(
796                &output,
797                &mut xml_bytes,
798                &OutputWriteConfig {
799                    format: OutputFormat::CycloneDxXml,
800                    custom_template: None,
801                    scanned_path: Some("scan".to_string()),
802                },
803            )
804            .expect("cyclonedx xml write should succeed");
805        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
806        assert!(xml.contains("<timestamp>2026-01-01T00:00:01Z</timestamp>"));
807    }
808
809    #[test]
810    fn test_spdx_writers_emit_real_file_and_package_license_info() {
811        let output = Output::from(&sample_internal_output());
812
813        let mut tv_bytes = Vec::new();
814        writer_for_format(OutputFormat::SpdxTv)
815            .write(
816                &output,
817                &mut tv_bytes,
818                &OutputWriteConfig {
819                    format: OutputFormat::SpdxTv,
820                    custom_template: None,
821                    scanned_path: Some("scan".to_string()),
822                },
823            )
824            .expect("spdx tv write should succeed");
825        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
826        assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
827        assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
828        assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
829        assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
830        assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
831
832        let mut rdf_bytes = Vec::new();
833        writer_for_format(OutputFormat::SpdxRdf)
834            .write(
835                &output,
836                &mut rdf_bytes,
837                &OutputWriteConfig {
838                    format: OutputFormat::SpdxRdf,
839                    custom_template: None,
840                    scanned_path: Some("scan".to_string()),
841                },
842            )
843            .expect("spdx rdf write should succeed");
844        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
845        assert!(rdf_rendered.contains(
846            "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
847        ));
848        assert!(
849            rdf_rendered.contains(
850                "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
851            )
852        );
853        assert!(rdf_rendered.contains(
854            "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
855        ));
856    }
857
858    #[test]
859    fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
860        let mut internal = sample_internal_output();
861        internal.files[0].license_detections = vec![LicenseDetection {
862            license_expression: "unknown-license-reference".to_string(),
863            license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
864            matches: vec![Match {
865                license_expression: "unknown-license-reference".to_string(),
866                license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
867                    .to_string(),
868                from_file: Some("src/main.rs".to_string()),
869                start_line: LineNumber::ONE,
870                end_line: LineNumber::new(2).unwrap(),
871                matcher: MatcherKind::Aho,
872                score: MatchScore::MAX,
873                matched_length: Some(4),
874                match_coverage: Some(100.0),
875                rule_relevance: Some(100),
876                rule_identifier: "unknown-license-reference.RULE".to_string(),
877                rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
878                matched_text: Some("Custom license text".to_string()),
879                referenced_filenames: Some(vec!["LICENSE".to_string()]),
880                matched_text_diagnostics: None,
881            }],
882            detection_log: vec![],
883            identifier: "unknown-ref-id".to_string(),
884        }];
885        internal.license_references = vec![crate::models::LicenseReference {
886            key: Some("unknown-license-reference".to_string()),
887            language: Some("en".to_string()),
888            name: "Unknown License Reference".to_string(),
889            short_name: "Unknown License Reference".to_string(),
890            owner: None,
891            homepage_url: None,
892            spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
893            other_spdx_license_keys: vec![],
894            osi_license_key: None,
895            text_urls: vec![],
896            osi_url: None,
897            faq_url: None,
898            other_urls: vec![],
899            category: None,
900            is_exception: false,
901            is_unknown: true,
902            is_generic: false,
903            notes: None,
904            minimum_coverage: None,
905            standard_notice: None,
906            ignorable_copyrights: vec![],
907            ignorable_holders: vec![],
908            ignorable_authors: vec![],
909            ignorable_urls: vec![],
910            ignorable_emails: vec![],
911            scancode_url: None,
912            licensedb_url: None,
913            spdx_url: None,
914            text: "Unused fallback text".to_string(),
915        }];
916        let output = Output::from(&internal);
917
918        let mut tv_bytes = Vec::new();
919        writer_for_format(OutputFormat::SpdxTv)
920            .write(
921                &output,
922                &mut tv_bytes,
923                &OutputWriteConfig {
924                    format: OutputFormat::SpdxTv,
925                    custom_template: None,
926                    scanned_path: Some("scan".to_string()),
927                },
928            )
929            .expect("spdx tv write should succeed");
930        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
931        assert!(
932            tv_rendered
933                .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
934        );
935        assert!(tv_rendered.contains(
936            "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
937        ));
938        assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
939        assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
940        assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
941        assert!(tv_rendered.contains(
942            "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
943        ));
944
945        let mut rdf_bytes = Vec::new();
946        writer_for_format(OutputFormat::SpdxRdf)
947            .write(
948                &output,
949                &mut rdf_bytes,
950                &OutputWriteConfig {
951                    format: OutputFormat::SpdxRdf,
952                    custom_template: None,
953                    scanned_path: Some("scan".to_string()),
954                },
955            )
956            .expect("spdx rdf write should succeed");
957        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
958        assert!(rdf_rendered.contains(
959            "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/spdxdocs/scan#LicenseRef-scancode-unknown-license-reference\"/>"
960        ));
961        assert!(rdf_rendered.contains(
962            "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"http://spdx.org/spdxdocs/scan#LicenseRef-scancode-unknown-license-reference\">"
963        ));
964        assert!(
965            rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
966        );
967    }
968
969    #[test]
970    fn test_cyclonedx_json_writer_outputs_bom() {
971        let output = Output::from(&sample_internal_output());
972        let mut bytes = Vec::new();
973        writer_for_format(OutputFormat::CycloneDxJson)
974            .write(&output, &mut bytes, &OutputWriteConfig::default())
975            .expect("cyclonedx json write should succeed");
976
977        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
978        let value: Value = serde_json::from_str(&rendered).expect("valid json");
979        assert_eq!(value["bomFormat"], "CycloneDX");
980        assert_eq!(value["specVersion"], "1.7");
981        assert_eq!(
982            value["$schema"],
983            "http://cyclonedx.org/schema/bom-1.7.schema.json"
984        );
985    }
986
987    #[test]
988    fn test_json_writer_includes_summary_and_key_file_flags() {
989        let mut internal = sample_internal_output();
990        internal.summary = Some(crate::models::Summary {
991            declared_license_expression: Some("apache-2.0".to_string()),
992            license_clarity_score: Some(crate::models::LicenseClarityScore {
993                score: 100,
994                declared_license: true,
995                identification_precision: true,
996                has_license_text: true,
997                declared_copyrights: true,
998                conflicting_license_categories: false,
999                ambiguous_compound_licensing: false,
1000            }),
1001            declared_holder: Some("Example Corp.".to_string()),
1002            primary_language: Some("Ruby".to_string()),
1003            other_license_expressions: vec![crate::models::TallyEntry {
1004                value: Some("mit".to_string()),
1005                count: 1,
1006            }],
1007            other_holders: vec![
1008                crate::models::TallyEntry {
1009                    value: None,
1010                    count: 2,
1011                },
1012                crate::models::TallyEntry {
1013                    value: Some("Other Corp.".to_string()),
1014                    count: 1,
1015                },
1016            ],
1017            other_languages: vec![crate::models::TallyEntry {
1018                value: Some("Python".to_string()),
1019                count: 2,
1020            }],
1021        });
1022        internal.files[0].is_legal = true;
1023        internal.files[0].is_top_level = true;
1024        internal.files[0].is_key_file = true;
1025        let output = Output::from(&internal);
1026
1027        let mut bytes = Vec::new();
1028        writer_for_format(OutputFormat::Json)
1029            .write(&output, &mut bytes, &OutputWriteConfig::default())
1030            .expect("json write should succeed");
1031
1032        let rendered = String::from_utf8(bytes).expect("json should be utf-8");
1033        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1034
1035        assert_eq!(
1036            value["summary"]["declared_license_expression"],
1037            "apache-2.0"
1038        );
1039        assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
1040        assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
1041        assert_eq!(value["summary"]["primary_language"], "Ruby");
1042        assert_eq!(
1043            value["summary"]["other_license_expressions"][0]["value"],
1044            "mit"
1045        );
1046        assert!(value["summary"]["other_holders"][0]["value"].is_null());
1047        assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
1048        assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
1049        assert_eq!(value["files"][0]["is_key_file"], true);
1050    }
1051
1052    #[test]
1053    fn test_json_and_json_lines_writers_include_top_level_tallies() {
1054        let mut internal = sample_internal_output();
1055        internal.tallies = Some(crate::models::Tallies {
1056            detected_license_expression: vec![crate::models::TallyEntry {
1057                value: Some("mit".to_string()),
1058                count: 2,
1059            }],
1060            copyrights: vec![crate::models::TallyEntry {
1061                value: Some("Copyright (c) Example Org".to_string()),
1062                count: 1,
1063            }],
1064            holders: vec![crate::models::TallyEntry {
1065                value: Some("Example Org".to_string()),
1066                count: 1,
1067            }],
1068            authors: vec![crate::models::TallyEntry {
1069                value: Some("Jane Doe".to_string()),
1070                count: 1,
1071            }],
1072            programming_language: vec![crate::models::TallyEntry {
1073                value: Some("Rust".to_string()),
1074                count: 1,
1075            }],
1076        });
1077        let output = Output::from(&internal);
1078
1079        let mut json_bytes = Vec::new();
1080        writer_for_format(OutputFormat::Json)
1081            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1082            .expect("json write should succeed");
1083        let json_value: Value =
1084            serde_json::from_slice(&json_bytes).expect("json output should parse");
1085        assert_eq!(
1086            json_value["tallies"]["detected_license_expression"][0]["value"],
1087            "mit"
1088        );
1089        assert_eq!(
1090            json_value["tallies"]["programming_language"][0]["value"],
1091            "Rust"
1092        );
1093
1094        let mut jsonl_bytes = Vec::new();
1095        writer_for_format(OutputFormat::JsonLines)
1096            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1097            .expect("json-lines write should succeed");
1098        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1099        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1100    }
1101
1102    #[test]
1103    fn test_json_and_json_lines_writers_include_key_file_tallies() {
1104        let mut internal = sample_internal_output();
1105        internal.tallies_of_key_files = Some(crate::models::Tallies {
1106            detected_license_expression: vec![crate::models::TallyEntry {
1107                value: Some("apache-2.0".to_string()),
1108                count: 1,
1109            }],
1110            copyrights: vec![],
1111            holders: vec![],
1112            authors: vec![],
1113            programming_language: vec![crate::models::TallyEntry {
1114                value: Some("Markdown".to_string()),
1115                count: 1,
1116            }],
1117        });
1118        let output = Output::from(&internal);
1119
1120        let mut json_bytes = Vec::new();
1121        writer_for_format(OutputFormat::Json)
1122            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1123            .expect("json write should succeed");
1124        let json_value: Value =
1125            serde_json::from_slice(&json_bytes).expect("json output should parse");
1126        assert_eq!(
1127            json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
1128            "apache-2.0"
1129        );
1130
1131        let mut jsonl_bytes = Vec::new();
1132        writer_for_format(OutputFormat::JsonLines)
1133            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1134            .expect("json-lines write should succeed");
1135        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1136        assert!(
1137            rendered
1138                .lines()
1139                .any(|line| line.contains("\"tallies_of_key_files\""))
1140        );
1141    }
1142
1143    #[test]
1144    fn test_json_and_json_lines_writers_include_file_tallies() {
1145        let mut internal = sample_internal_output();
1146        internal.files[0].tallies = Some(crate::models::Tallies {
1147            detected_license_expression: vec![crate::models::TallyEntry {
1148                value: Some("mit".to_string()),
1149                count: 1,
1150            }],
1151            copyrights: vec![crate::models::TallyEntry {
1152                value: None,
1153                count: 1,
1154            }],
1155            holders: vec![],
1156            authors: vec![],
1157            programming_language: vec![crate::models::TallyEntry {
1158                value: Some("Rust".to_string()),
1159                count: 1,
1160            }],
1161        });
1162        let output = Output::from(&internal);
1163
1164        let mut json_bytes = Vec::new();
1165        writer_for_format(OutputFormat::Json)
1166            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1167            .expect("json write should succeed");
1168        let json_value: Value =
1169            serde_json::from_slice(&json_bytes).expect("json output should parse");
1170        assert_eq!(
1171            json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
1172            "mit"
1173        );
1174
1175        let mut jsonl_bytes = Vec::new();
1176        writer_for_format(OutputFormat::JsonLines)
1177            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1178            .expect("json-lines write should succeed");
1179        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1180        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1181    }
1182
1183    #[test]
1184    fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
1185        let mut internal = sample_internal_output();
1186        internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
1187        internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
1188            facet: "core".to_string(),
1189            tallies: crate::models::Tallies {
1190                detected_license_expression: vec![crate::models::TallyEntry {
1191                    value: Some("mit".to_string()),
1192                    count: 1,
1193                }],
1194                copyrights: vec![],
1195                holders: vec![],
1196                authors: vec![],
1197                programming_language: vec![],
1198            },
1199        }]);
1200        let output = Output::from(&internal);
1201
1202        let mut json_bytes = Vec::new();
1203        writer_for_format(OutputFormat::Json)
1204            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1205            .expect("json write should succeed");
1206        let json_value: Value =
1207            serde_json::from_slice(&json_bytes).expect("json output should parse");
1208        assert_eq!(json_value["files"][0]["facets"][0], "core");
1209        assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
1210
1211        let mut jsonl_bytes = Vec::new();
1212        writer_for_format(OutputFormat::JsonLines)
1213            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1214            .expect("json-lines write should succeed");
1215        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1216        assert!(
1217            rendered
1218                .lines()
1219                .any(|line| line.contains("\"tallies_by_facet\""))
1220        );
1221    }
1222
1223    #[test]
1224    fn test_json_and_json_lines_writers_include_top_level_license_references() {
1225        let mut internal = sample_internal_output();
1226        internal.license_references = vec![crate::models::LicenseReference {
1227            key: Some("mit".to_string()),
1228            language: Some("en".to_string()),
1229            name: "MIT License".to_string(),
1230            short_name: "MIT".to_string(),
1231            owner: Some("Example Owner".to_string()),
1232            homepage_url: Some("https://example.com/license".to_string()),
1233            spdx_license_key: "MIT".to_string(),
1234            other_spdx_license_keys: vec![],
1235            osi_license_key: Some("MIT".to_string()),
1236            text_urls: vec!["https://example.com/license.txt".to_string()],
1237            osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
1238            faq_url: None,
1239            other_urls: vec![],
1240            category: None,
1241            is_exception: false,
1242            is_unknown: false,
1243            is_generic: false,
1244            notes: None,
1245            minimum_coverage: None,
1246            standard_notice: None,
1247            ignorable_copyrights: vec![],
1248            ignorable_holders: vec![],
1249            ignorable_authors: vec![],
1250            ignorable_urls: vec![],
1251            ignorable_emails: vec![],
1252            scancode_url: None,
1253            licensedb_url: None,
1254            spdx_url: None,
1255            text: "MIT text".to_string(),
1256        }];
1257        internal.license_rule_references = vec![crate::models::LicenseRuleReference {
1258            identifier: "license-clue_1.RULE".to_string(),
1259            license_expression: "unknown-license-reference".to_string(),
1260            is_license_text: false,
1261            is_license_notice: false,
1262            is_license_reference: false,
1263            is_license_tag: false,
1264            is_license_clue: true,
1265            is_license_intro: false,
1266            language: None,
1267            rule_url: None,
1268            is_required_phrase: false,
1269            skip_for_required_phrase_generation: false,
1270            replaced_by: vec![],
1271            is_continuous: false,
1272            is_synthetic: false,
1273            is_from_license: false,
1274            length: 0,
1275            relevance: None,
1276            minimum_coverage: None,
1277            referenced_filenames: vec![],
1278            notes: None,
1279            ignorable_copyrights: vec![],
1280            ignorable_holders: vec![],
1281            ignorable_authors: vec![],
1282            ignorable_urls: vec![],
1283            ignorable_emails: vec![],
1284            text: None,
1285        }];
1286        let output = Output::from(&internal);
1287
1288        let mut json_bytes = Vec::new();
1289        writer_for_format(OutputFormat::Json)
1290            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1291            .expect("json write should succeed");
1292        let json_value: Value =
1293            serde_json::from_slice(&json_bytes).expect("json output should parse");
1294        assert_eq!(
1295            json_value["license_references"][0]["spdx_license_key"],
1296            "MIT"
1297        );
1298        assert_eq!(json_value["license_references"][0]["key"], "mit");
1299        assert_eq!(json_value["license_references"][0]["language"], "en");
1300        assert_eq!(
1301            json_value["license_references"][0]["owner"],
1302            "Example Owner"
1303        );
1304        assert_eq!(
1305            json_value["license_references"][0]["homepage_url"],
1306            "https://example.com/license"
1307        );
1308        assert_eq!(
1309            json_value["license_references"][0]["osi_license_key"],
1310            "MIT"
1311        );
1312        assert_eq!(
1313            json_value["license_references"][0]["text_urls"][0],
1314            "https://example.com/license.txt"
1315        );
1316        assert_eq!(
1317            json_value["license_rule_references"][0]["identifier"],
1318            "license-clue_1.RULE"
1319        );
1320        assert_eq!(
1321            json_value["license_rule_references"][0]["relevance"],
1322            Value::Null
1323        );
1324        assert_eq!(
1325            json_value["license_rule_references"][0]["length"],
1326            Value::from(0)
1327        );
1328
1329        let mut jsonl_bytes = Vec::new();
1330        writer_for_format(OutputFormat::JsonLines)
1331            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1332            .expect("json-lines write should succeed");
1333        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1334        assert!(
1335            rendered
1336                .lines()
1337                .any(|line| line.contains("\"license_references\""))
1338        );
1339        assert!(
1340            rendered
1341                .lines()
1342                .any(|line| line.contains("\"license_rule_references\""))
1343        );
1344    }
1345
1346    #[test]
1347    fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1348        let mut internal = sample_internal_output();
1349        internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1350            identifier: "mit-id".to_string(),
1351            license_expression: "mit".to_string(),
1352            license_expression_spdx: "MIT".to_string(),
1353            detection_count: 2,
1354            detection_log: vec![],
1355            reference_matches: vec![crate::models::Match {
1356                license_expression: "mit".to_string(),
1357                license_expression_spdx: "MIT".to_string(),
1358                from_file: Some("src/main.rs".to_string()),
1359                start_line: LineNumber::ONE,
1360                end_line: LineNumber::new(3).unwrap(),
1361                matcher: MatcherKind::Hash,
1362                score: MatchScore::MAX,
1363                matched_length: Some(10),
1364                match_coverage: Some(100.0),
1365                rule_relevance: Some(100),
1366                rule_identifier: "mit.LICENSE".to_string(),
1367                rule_url: None,
1368                matched_text: None,
1369                referenced_filenames: None,
1370                matched_text_diagnostics: None,
1371            }],
1372        }];
1373        let output = Output::from(&internal);
1374
1375        let mut json_bytes = Vec::new();
1376        writer_for_format(OutputFormat::Json)
1377            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1378            .expect("json write should succeed");
1379        let json_value: Value =
1380            serde_json::from_slice(&json_bytes).expect("json output should parse");
1381        assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1382        assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1383
1384        let mut jsonl_bytes = Vec::new();
1385        writer_for_format(OutputFormat::JsonLines)
1386            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1387            .expect("json-lines write should succeed");
1388        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1389        assert!(
1390            rendered
1391                .lines()
1392                .any(|line| line.contains("\"license_detections\""))
1393        );
1394    }
1395
1396    #[test]
1397    fn test_json_and_json_lines_writers_keep_empty_top_level_license_detections() {
1398        let output = Output::from(&sample_internal_output());
1399
1400        let mut json_bytes = Vec::new();
1401        writer_for_format(OutputFormat::Json)
1402            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1403            .expect("json write should succeed");
1404        let json_value: Value =
1405            serde_json::from_slice(&json_bytes).expect("json output should parse");
1406        assert_eq!(json_value["license_detections"], Value::Array(vec![]));
1407
1408        let mut jsonl_bytes = Vec::new();
1409        writer_for_format(OutputFormat::JsonLines)
1410            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1411            .expect("json-lines write should succeed");
1412        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1413        assert!(
1414            rendered
1415                .lines()
1416                .any(|line| line == r#"{"license_detections":[]}"#)
1417        );
1418    }
1419
1420    #[test]
1421    fn test_public_writer_normalizes_empty_package_maps_without_changing_schema_output() {
1422        let mut internal = sample_internal_output();
1423        internal.packages.push(Package::from_package_data(
1424            &PackageData {
1425                package_type: Some(crate::models::PackageType::Npm),
1426                name: Some("demo".to_string()),
1427                version: Some("1.0.0".to_string()),
1428                ..PackageData::default()
1429            },
1430            "scan/package.json".to_string(),
1431        ));
1432
1433        let output = Output::from(&internal);
1434        let raw_schema = serde_json::to_value(&output).expect("schema output should serialize");
1435        assert_eq!(
1436            raw_schema["packages"][0]["qualifiers"],
1437            serde_json::json!({})
1438        );
1439        assert_eq!(
1440            raw_schema["packages"][0]["extra_data"],
1441            serde_json::json!({})
1442        );
1443
1444        let mut bytes = Vec::new();
1445        writer_for_format(OutputFormat::Json)
1446            .write(&output, &mut bytes, &OutputWriteConfig::default())
1447            .expect("json write should succeed");
1448        let public_value: Value = serde_json::from_slice(&bytes).expect("public json should parse");
1449
1450        assert!(public_value["packages"][0]["qualifiers"].is_null());
1451        assert!(public_value["packages"][0]["extra_data"].is_null());
1452    }
1453
1454    #[test]
1455    fn test_cyclonedx_xml_writer_outputs_xml() {
1456        let output = Output::from(&sample_internal_output());
1457        let mut bytes = Vec::new();
1458        writer_for_format(OutputFormat::CycloneDxXml)
1459            .write(&output, &mut bytes, &OutputWriteConfig::default())
1460            .expect("cyclonedx xml write should succeed");
1461
1462        let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1463        assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.7\""));
1464        assert!(rendered.contains("<components>"));
1465    }
1466
1467    #[test]
1468    fn test_cyclonedx_json_includes_component_license_expression() {
1469        let mut internal = sample_internal_output();
1470        internal.packages = vec![crate::models::Package {
1471            package_type: Some(crate::models::PackageType::Maven),
1472            namespace: Some("example".to_string()),
1473            name: Some("gradle-project".to_string()),
1474            version: Some("1.0.0".to_string()),
1475            qualifiers: None,
1476            subpath: None,
1477            primary_language: Some("Java".to_string()),
1478            description: None,
1479            release_date: None,
1480            parties: vec![],
1481            keywords: vec![],
1482            homepage_url: None,
1483            download_url: None,
1484            size: None,
1485            sha1: None,
1486            md5: None,
1487            sha256: None,
1488            sha512: None,
1489            bug_tracking_url: None,
1490            code_view_url: None,
1491            vcs_url: None,
1492            copyright: None,
1493            holder: None,
1494            declared_license_expression: Some("Apache-2.0".to_string()),
1495            declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1496            license_detections: vec![],
1497            other_license_expression: None,
1498            other_license_expression_spdx: None,
1499            other_license_detections: vec![],
1500            extracted_license_statement: Some("Apache-2.0".to_string()),
1501            notice_text: None,
1502            source_packages: vec![],
1503            is_private: false,
1504            is_virtual: false,
1505            extra_data: None,
1506            repository_homepage_url: None,
1507            repository_download_url: None,
1508            api_data_url: None,
1509            datasource_ids: vec![],
1510            purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1511            package_uid: PackageUid::from_raw(
1512                "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1513            ),
1514            datafile_paths: vec![],
1515        }];
1516        let output = Output::from(&internal);
1517
1518        let mut bytes = Vec::new();
1519        writer_for_format(OutputFormat::CycloneDxJson)
1520            .write(&output, &mut bytes, &OutputWriteConfig::default())
1521            .expect("cyclonedx json write should succeed");
1522
1523        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1524        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1525
1526        assert_eq!(
1527            value["components"][0]["licenses"][0]["expression"],
1528            "Apache-2.0"
1529        );
1530    }
1531
1532    #[test]
1533    fn test_cyclonedx_external_references_are_deduplicated() {
1534        let mut internal = sample_internal_output();
1535        internal.packages = vec![Package::from_package_data(
1536            &PackageData {
1537                package_type: Some(crate::models::PackageType::Npm),
1538                name: Some("demo".to_string()),
1539                version: Some("1.0.0".to_string()),
1540                download_url: Some("https://example.com/download.tgz".to_string()),
1541                repository_download_url: Some("https://example.com/download.tgz".to_string()),
1542                homepage_url: Some("https://example.com".to_string()),
1543                repository_homepage_url: Some("https://example.com".to_string()),
1544                ..PackageData::default()
1545            },
1546            "scan/package.json".to_string(),
1547        )];
1548        let output = Output::from(&internal);
1549
1550        let mut json_bytes = Vec::new();
1551        writer_for_format(OutputFormat::CycloneDxJson)
1552            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1553            .expect("cyclonedx json write should succeed");
1554        let value: Value = serde_json::from_slice(&json_bytes).expect("valid cyclonedx json");
1555        let refs = value["components"][0]["externalReferences"]
1556            .as_array()
1557            .expect("external references should be an array");
1558        assert_eq!(refs.len(), 2);
1559
1560        let mut xml_bytes = Vec::new();
1561        writer_for_format(OutputFormat::CycloneDxXml)
1562            .write(&output, &mut xml_bytes, &OutputWriteConfig::default())
1563            .expect("cyclonedx xml write should succeed");
1564        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
1565        // Scope the dedup assertion to `<components>`: the sole package here
1566        // also becomes `metadata.component` (see the metadata-component
1567        // tests in `output/cyclonedx.rs`), which legitimately repeats these
1568        // URLs once more outside `<components>`.
1569        let components_section = xml
1570            .split("<components>")
1571            .nth(1)
1572            .and_then(|s| s.split("</components>").next())
1573            .expect("xml must contain a components section");
1574        assert_eq!(
1575            components_section
1576                .matches("https://example.com/download.tgz")
1577                .count(),
1578            1
1579        );
1580        assert_eq!(
1581            components_section
1582                .matches("https://example.com</url>")
1583                .count(),
1584            1
1585        );
1586    }
1587
1588    #[test]
1589    fn test_spdx_prefers_single_detected_package_name_over_scan_root() {
1590        let mut internal = sample_internal_output();
1591        internal.packages = vec![Package::from_package_data(
1592            &PackageData {
1593                package_type: Some(crate::models::PackageType::Npm),
1594                name: Some("detected-package".to_string()),
1595                version: Some("1.0.0".to_string()),
1596                ..PackageData::default()
1597            },
1598            "scan/package.json".to_string(),
1599        )];
1600        let output = Output::from(&internal);
1601
1602        let mut tv_bytes = Vec::new();
1603        writer_for_format(OutputFormat::SpdxTv)
1604            .write(
1605                &output,
1606                &mut tv_bytes,
1607                &OutputWriteConfig {
1608                    format: OutputFormat::SpdxTv,
1609                    custom_template: None,
1610                    scanned_path: Some("scan-root".to_string()),
1611                },
1612            )
1613            .expect("spdx tv write should succeed");
1614        let tv = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
1615        assert!(tv.contains("PackageName: detected-package"));
1616        assert!(tv.contains("DocumentNamespace: http://spdx.org/spdxdocs/detected-package"));
1617
1618        let mut rdf_bytes = Vec::new();
1619        writer_for_format(OutputFormat::SpdxRdf)
1620            .write(
1621                &output,
1622                &mut rdf_bytes,
1623                &OutputWriteConfig {
1624                    format: OutputFormat::SpdxRdf,
1625                    custom_template: None,
1626                    scanned_path: Some("scan-root".to_string()),
1627                },
1628            )
1629            .expect("spdx rdf write should succeed");
1630        let rdf = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
1631        assert!(rdf.contains("<spdx:name>detected-package</spdx:name>"));
1632    }
1633
1634    #[test]
1635    fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1636        let output = Output {
1637            summary: None,
1638            tallies: None,
1639            tallies_of_key_files: None,
1640            tallies_by_facet: None,
1641            headers: vec![],
1642            packages: vec![],
1643            dependencies: vec![],
1644            license_detections: vec![],
1645            files: vec![],
1646            license_references: vec![],
1647            license_rule_references: vec![],
1648        };
1649        let mut bytes = Vec::new();
1650        writer_for_format(OutputFormat::SpdxTv)
1651            .write(
1652                &output,
1653                &mut bytes,
1654                &OutputWriteConfig {
1655                    format: OutputFormat::SpdxTv,
1656                    custom_template: None,
1657                    scanned_path: Some("scan".to_string()),
1658                },
1659            )
1660            .expect("spdx tv write should succeed");
1661
1662        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1663        assert_eq!(rendered, "# No results for package 'scan'.\n");
1664    }
1665
1666    #[test]
1667    fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1668        let output = Output {
1669            summary: None,
1670            tallies: None,
1671            tallies_of_key_files: None,
1672            tallies_by_facet: None,
1673            headers: vec![],
1674            packages: vec![],
1675            dependencies: vec![],
1676            license_detections: vec![],
1677            files: vec![],
1678            license_references: vec![],
1679            license_rule_references: vec![],
1680        };
1681        let mut bytes = Vec::new();
1682        writer_for_format(OutputFormat::SpdxRdf)
1683            .write(
1684                &output,
1685                &mut bytes,
1686                &OutputWriteConfig {
1687                    format: OutputFormat::SpdxRdf,
1688                    custom_template: None,
1689                    scanned_path: Some("scan".to_string()),
1690                },
1691            )
1692            .expect("spdx rdf write should succeed");
1693
1694        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1695        assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1696    }
1697
1698    #[test]
1699    fn test_html_writer_outputs_html_document() {
1700        let output = Output::from(&sample_internal_output());
1701        let mut bytes = Vec::new();
1702        writer_for_format(OutputFormat::Html)
1703            .write(&output, &mut bytes, &OutputWriteConfig::default())
1704            .expect("html write should succeed");
1705        let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1706        assert!(rendered.contains("<!doctype html>"));
1707        assert!(rendered.contains("Provenant HTML Report"));
1708    }
1709
1710    #[test]
1711    fn test_custom_template_writer_renders_output_context() {
1712        let output = Output::from(&sample_internal_output());
1713        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1714        let template_path = temp_dir.path().join("template.tera");
1715        fs::write(
1716            &template_path,
1717            "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1718        )
1719        .expect("template should be written");
1720
1721        let mut bytes = Vec::new();
1722        writer_for_format(OutputFormat::CustomTemplate)
1723            .write(
1724                &output,
1725                &mut bytes,
1726                &OutputWriteConfig {
1727                    format: OutputFormat::CustomTemplate,
1728                    custom_template: Some(template_path.to_string_lossy().to_string()),
1729                    scanned_path: None,
1730                },
1731            )
1732            .expect("custom template write should succeed");
1733
1734        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1735        assert!(rendered.contains("version=4.1.0"));
1736        assert!(rendered.contains("files=1"));
1737    }
1738
1739    #[test]
1740    fn test_custom_template_writer_exposes_scancode_namespace() {
1741        let output = Output::from(&sample_internal_output());
1742        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1743        let template_path = temp_dir.path().join("template.j2");
1744        // ScanCode's custom-template contract: path-keyed `files` reshape plus
1745        // top-level `version`, all reachable under the `scancode` namespace.
1746        fs::write(
1747            &template_path,
1748            concat!(
1749                r#"lc={{ scancode.files.license_copyright["src/main.rs"] | length }} "#,
1750                r#"first={{ scancode.files.license_copyright["src/main.rs"][0].what }} "#,
1751                r#"name={{ scancode.files.infos["src/main.rs"].name }} "#,
1752                r#"pkg={{ scancode.files.package_data["src/main.rs"] | length }} "#,
1753                r#"ver={{ scancode.version }}"#,
1754            ),
1755        )
1756        .expect("template should be written");
1757
1758        let mut bytes = Vec::new();
1759        writer_for_format(OutputFormat::CustomTemplate)
1760            .write(
1761                &output,
1762                &mut bytes,
1763                &OutputWriteConfig {
1764                    format: OutputFormat::CustomTemplate,
1765                    custom_template: Some(template_path.to_string_lossy().to_string()),
1766                    scanned_path: None,
1767                },
1768            )
1769            .expect("custom template write should succeed");
1770
1771        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1772        // One copyright + one license match, copyright sorts first (same line).
1773        assert!(rendered.contains("lc=2"), "rendered: {rendered}");
1774        assert!(rendered.contains("first=copyright"), "rendered: {rendered}");
1775        assert!(rendered.contains("name=main"), "rendered: {rendered}");
1776        assert!(rendered.contains("pkg=1"), "rendered: {rendered}");
1777        assert!(
1778            rendered.contains(&format!("ver={}", crate::version::BUILD_VERSION)),
1779            "rendered: {rendered}"
1780        );
1781    }
1782
1783    fn sample_internal_output() -> crate::models::Output {
1784        crate::models::Output {
1785            summary: None,
1786            tallies: None,
1787            tallies_of_key_files: None,
1788            tallies_by_facet: None,
1789            headers: vec![Header {
1790                tool_name: "provenant".to_string(),
1791                tool_version: crate::version::BUILD_VERSION.to_string(),
1792                options: serde_json::Map::new(),
1793                notice: crate::models::HEADER_NOTICE.to_string(),
1794                start_timestamp: "2026-01-01T000000.000000".to_string(),
1795                end_timestamp: "2026-01-01T000001.000000".to_string(),
1796                output_format_version: "4.1.0".to_string(),
1797                duration: 1.0,
1798                errors: vec![],
1799                warnings: vec![],
1800                extra_data: ExtraData {
1801                    system_environment: SystemEnvironment {
1802                        operating_system: "darwin".to_string(),
1803                        cpu_architecture: "aarch64".to_string(),
1804                        platform: "darwin".to_string(),
1805                        platform_version: "26.3.1".to_string(),
1806                        rust_version: "1.93.0".to_string(),
1807                    },
1808                    spdx_license_list_version: "3.27".to_string(),
1809                    files_count: 1,
1810                    directories_count: 1,
1811                    excluded_count: 0,
1812                    license_index_provenance: Some(crate::models::LicenseIndexProvenance {
1813                        source: "embedded-artifact".to_string(),
1814                        dataset_fingerprint: "test-fingerprint".to_string(),
1815                        ignored_rules: vec![
1816                            "gpl-2.0_and-unknown-license-reference_1.RULE".to_string(),
1817                        ],
1818                        ignored_licenses: vec![],
1819                        ignored_rules_due_to_licenses: vec![],
1820                        added_rules: vec![],
1821                        replaced_rules: vec![],
1822                        added_licenses: vec![],
1823                        replaced_licenses: vec![],
1824                    }),
1825                },
1826            }],
1827            packages: vec![],
1828            dependencies: vec![],
1829            license_detections: vec![],
1830            files: vec![FileInfo::new(
1831                "main.rs".to_string(),
1832                "main".to_string(),
1833                "rs".to_string(),
1834                "src/main.rs".to_string(),
1835                FileType::File,
1836                Some("text/plain".to_string()),
1837                None,
1838                42,
1839                None,
1840                Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1841                Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1842                Some(
1843                    Sha256Digest::from_hex(
1844                        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1845                    )
1846                    .unwrap(),
1847                ),
1848                Some("Rust".to_string()),
1849                vec![PackageData::default()],
1850                None,
1851                vec![LicenseDetection {
1852                    license_expression: "mit".to_string(),
1853                    license_expression_spdx: "MIT".to_string(),
1854                    matches: vec![Match {
1855                        license_expression: "mit".to_string(),
1856                        license_expression_spdx: "MIT".to_string(),
1857                        from_file: None,
1858                        start_line: LineNumber::ONE,
1859                        end_line: LineNumber::ONE,
1860                        matcher: MatcherKind::Hash,
1861                        score: MatchScore::MAX,
1862                        matched_length: None,
1863                        match_coverage: None,
1864                        rule_relevance: None,
1865                        rule_identifier: "mit_rule".to_string(),
1866                        rule_url: None,
1867                        matched_text: None,
1868                        referenced_filenames: None,
1869                        matched_text_diagnostics: None,
1870                    }],
1871                    detection_log: vec![],
1872                    identifier: String::new(),
1873                }],
1874                vec![],
1875                vec![Copyright {
1876                    copyright: "Copyright (c) Example".to_string(),
1877                    normalized_copyright: None,
1878                    start_line: LineNumber::ONE,
1879                    end_line: LineNumber::ONE,
1880                }],
1881                vec![Holder {
1882                    holder: "Example Org".to_string(),
1883                    start_line: LineNumber::ONE,
1884                    end_line: LineNumber::ONE,
1885                }],
1886                vec![Author {
1887                    author: "Jane Doe".to_string(),
1888                    start_line: LineNumber::ONE,
1889                    end_line: LineNumber::ONE,
1890                }],
1891                vec![OutputEmail {
1892                    email: "jane@example.com".to_string(),
1893                    start_line: LineNumber::ONE,
1894                    end_line: LineNumber::ONE,
1895                }],
1896                vec![OutputURL {
1897                    url: "https://example.com".to_string(),
1898                    start_line: LineNumber::ONE,
1899                    end_line: LineNumber::ONE,
1900                }],
1901                vec![],
1902                vec![],
1903            )],
1904            license_references: vec![],
1905            license_rule_references: vec![],
1906        }
1907    }
1908}