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.3");
981    }
982
983    #[test]
984    fn test_json_writer_includes_summary_and_key_file_flags() {
985        let mut internal = sample_internal_output();
986        internal.summary = Some(crate::models::Summary {
987            declared_license_expression: Some("apache-2.0".to_string()),
988            license_clarity_score: Some(crate::models::LicenseClarityScore {
989                score: 100,
990                declared_license: true,
991                identification_precision: true,
992                has_license_text: true,
993                declared_copyrights: true,
994                conflicting_license_categories: false,
995                ambiguous_compound_licensing: false,
996            }),
997            declared_holder: Some("Example Corp.".to_string()),
998            primary_language: Some("Ruby".to_string()),
999            other_license_expressions: vec![crate::models::TallyEntry {
1000                value: Some("mit".to_string()),
1001                count: 1,
1002            }],
1003            other_holders: vec![
1004                crate::models::TallyEntry {
1005                    value: None,
1006                    count: 2,
1007                },
1008                crate::models::TallyEntry {
1009                    value: Some("Other Corp.".to_string()),
1010                    count: 1,
1011                },
1012            ],
1013            other_languages: vec![crate::models::TallyEntry {
1014                value: Some("Python".to_string()),
1015                count: 2,
1016            }],
1017        });
1018        internal.files[0].is_legal = true;
1019        internal.files[0].is_top_level = true;
1020        internal.files[0].is_key_file = true;
1021        let output = Output::from(&internal);
1022
1023        let mut bytes = Vec::new();
1024        writer_for_format(OutputFormat::Json)
1025            .write(&output, &mut bytes, &OutputWriteConfig::default())
1026            .expect("json write should succeed");
1027
1028        let rendered = String::from_utf8(bytes).expect("json should be utf-8");
1029        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1030
1031        assert_eq!(
1032            value["summary"]["declared_license_expression"],
1033            "apache-2.0"
1034        );
1035        assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
1036        assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
1037        assert_eq!(value["summary"]["primary_language"], "Ruby");
1038        assert_eq!(
1039            value["summary"]["other_license_expressions"][0]["value"],
1040            "mit"
1041        );
1042        assert!(value["summary"]["other_holders"][0]["value"].is_null());
1043        assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
1044        assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
1045        assert_eq!(value["files"][0]["is_key_file"], true);
1046    }
1047
1048    #[test]
1049    fn test_json_and_json_lines_writers_include_top_level_tallies() {
1050        let mut internal = sample_internal_output();
1051        internal.tallies = Some(crate::models::Tallies {
1052            detected_license_expression: vec![crate::models::TallyEntry {
1053                value: Some("mit".to_string()),
1054                count: 2,
1055            }],
1056            copyrights: vec![crate::models::TallyEntry {
1057                value: Some("Copyright (c) Example Org".to_string()),
1058                count: 1,
1059            }],
1060            holders: vec![crate::models::TallyEntry {
1061                value: Some("Example Org".to_string()),
1062                count: 1,
1063            }],
1064            authors: vec![crate::models::TallyEntry {
1065                value: Some("Jane Doe".to_string()),
1066                count: 1,
1067            }],
1068            programming_language: vec![crate::models::TallyEntry {
1069                value: Some("Rust".to_string()),
1070                count: 1,
1071            }],
1072        });
1073        let output = Output::from(&internal);
1074
1075        let mut json_bytes = Vec::new();
1076        writer_for_format(OutputFormat::Json)
1077            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1078            .expect("json write should succeed");
1079        let json_value: Value =
1080            serde_json::from_slice(&json_bytes).expect("json output should parse");
1081        assert_eq!(
1082            json_value["tallies"]["detected_license_expression"][0]["value"],
1083            "mit"
1084        );
1085        assert_eq!(
1086            json_value["tallies"]["programming_language"][0]["value"],
1087            "Rust"
1088        );
1089
1090        let mut jsonl_bytes = Vec::new();
1091        writer_for_format(OutputFormat::JsonLines)
1092            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1093            .expect("json-lines write should succeed");
1094        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1095        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1096    }
1097
1098    #[test]
1099    fn test_json_and_json_lines_writers_include_key_file_tallies() {
1100        let mut internal = sample_internal_output();
1101        internal.tallies_of_key_files = Some(crate::models::Tallies {
1102            detected_license_expression: vec![crate::models::TallyEntry {
1103                value: Some("apache-2.0".to_string()),
1104                count: 1,
1105            }],
1106            copyrights: vec![],
1107            holders: vec![],
1108            authors: vec![],
1109            programming_language: vec![crate::models::TallyEntry {
1110                value: Some("Markdown".to_string()),
1111                count: 1,
1112            }],
1113        });
1114        let output = Output::from(&internal);
1115
1116        let mut json_bytes = Vec::new();
1117        writer_for_format(OutputFormat::Json)
1118            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1119            .expect("json write should succeed");
1120        let json_value: Value =
1121            serde_json::from_slice(&json_bytes).expect("json output should parse");
1122        assert_eq!(
1123            json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
1124            "apache-2.0"
1125        );
1126
1127        let mut jsonl_bytes = Vec::new();
1128        writer_for_format(OutputFormat::JsonLines)
1129            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1130            .expect("json-lines write should succeed");
1131        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1132        assert!(
1133            rendered
1134                .lines()
1135                .any(|line| line.contains("\"tallies_of_key_files\""))
1136        );
1137    }
1138
1139    #[test]
1140    fn test_json_and_json_lines_writers_include_file_tallies() {
1141        let mut internal = sample_internal_output();
1142        internal.files[0].tallies = Some(crate::models::Tallies {
1143            detected_license_expression: vec![crate::models::TallyEntry {
1144                value: Some("mit".to_string()),
1145                count: 1,
1146            }],
1147            copyrights: vec![crate::models::TallyEntry {
1148                value: None,
1149                count: 1,
1150            }],
1151            holders: vec![],
1152            authors: vec![],
1153            programming_language: vec![crate::models::TallyEntry {
1154                value: Some("Rust".to_string()),
1155                count: 1,
1156            }],
1157        });
1158        let output = Output::from(&internal);
1159
1160        let mut json_bytes = Vec::new();
1161        writer_for_format(OutputFormat::Json)
1162            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1163            .expect("json write should succeed");
1164        let json_value: Value =
1165            serde_json::from_slice(&json_bytes).expect("json output should parse");
1166        assert_eq!(
1167            json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
1168            "mit"
1169        );
1170
1171        let mut jsonl_bytes = Vec::new();
1172        writer_for_format(OutputFormat::JsonLines)
1173            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1174            .expect("json-lines write should succeed");
1175        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1176        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1177    }
1178
1179    #[test]
1180    fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
1181        let mut internal = sample_internal_output();
1182        internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
1183        internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
1184            facet: "core".to_string(),
1185            tallies: crate::models::Tallies {
1186                detected_license_expression: vec![crate::models::TallyEntry {
1187                    value: Some("mit".to_string()),
1188                    count: 1,
1189                }],
1190                copyrights: vec![],
1191                holders: vec![],
1192                authors: vec![],
1193                programming_language: vec![],
1194            },
1195        }]);
1196        let output = Output::from(&internal);
1197
1198        let mut json_bytes = Vec::new();
1199        writer_for_format(OutputFormat::Json)
1200            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1201            .expect("json write should succeed");
1202        let json_value: Value =
1203            serde_json::from_slice(&json_bytes).expect("json output should parse");
1204        assert_eq!(json_value["files"][0]["facets"][0], "core");
1205        assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
1206
1207        let mut jsonl_bytes = Vec::new();
1208        writer_for_format(OutputFormat::JsonLines)
1209            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1210            .expect("json-lines write should succeed");
1211        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1212        assert!(
1213            rendered
1214                .lines()
1215                .any(|line| line.contains("\"tallies_by_facet\""))
1216        );
1217    }
1218
1219    #[test]
1220    fn test_json_and_json_lines_writers_include_top_level_license_references() {
1221        let mut internal = sample_internal_output();
1222        internal.license_references = vec![crate::models::LicenseReference {
1223            key: Some("mit".to_string()),
1224            language: Some("en".to_string()),
1225            name: "MIT License".to_string(),
1226            short_name: "MIT".to_string(),
1227            owner: Some("Example Owner".to_string()),
1228            homepage_url: Some("https://example.com/license".to_string()),
1229            spdx_license_key: "MIT".to_string(),
1230            other_spdx_license_keys: vec![],
1231            osi_license_key: Some("MIT".to_string()),
1232            text_urls: vec!["https://example.com/license.txt".to_string()],
1233            osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
1234            faq_url: None,
1235            other_urls: vec![],
1236            category: None,
1237            is_exception: false,
1238            is_unknown: false,
1239            is_generic: false,
1240            notes: None,
1241            minimum_coverage: None,
1242            standard_notice: None,
1243            ignorable_copyrights: vec![],
1244            ignorable_holders: vec![],
1245            ignorable_authors: vec![],
1246            ignorable_urls: vec![],
1247            ignorable_emails: vec![],
1248            scancode_url: None,
1249            licensedb_url: None,
1250            spdx_url: None,
1251            text: "MIT text".to_string(),
1252        }];
1253        internal.license_rule_references = vec![crate::models::LicenseRuleReference {
1254            identifier: "license-clue_1.RULE".to_string(),
1255            license_expression: "unknown-license-reference".to_string(),
1256            is_license_text: false,
1257            is_license_notice: false,
1258            is_license_reference: false,
1259            is_license_tag: false,
1260            is_license_clue: true,
1261            is_license_intro: false,
1262            language: None,
1263            rule_url: None,
1264            is_required_phrase: false,
1265            skip_for_required_phrase_generation: false,
1266            replaced_by: vec![],
1267            is_continuous: false,
1268            is_synthetic: false,
1269            is_from_license: false,
1270            length: 0,
1271            relevance: None,
1272            minimum_coverage: None,
1273            referenced_filenames: vec![],
1274            notes: None,
1275            ignorable_copyrights: vec![],
1276            ignorable_holders: vec![],
1277            ignorable_authors: vec![],
1278            ignorable_urls: vec![],
1279            ignorable_emails: vec![],
1280            text: None,
1281        }];
1282        let output = Output::from(&internal);
1283
1284        let mut json_bytes = Vec::new();
1285        writer_for_format(OutputFormat::Json)
1286            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1287            .expect("json write should succeed");
1288        let json_value: Value =
1289            serde_json::from_slice(&json_bytes).expect("json output should parse");
1290        assert_eq!(
1291            json_value["license_references"][0]["spdx_license_key"],
1292            "MIT"
1293        );
1294        assert_eq!(json_value["license_references"][0]["key"], "mit");
1295        assert_eq!(json_value["license_references"][0]["language"], "en");
1296        assert_eq!(
1297            json_value["license_references"][0]["owner"],
1298            "Example Owner"
1299        );
1300        assert_eq!(
1301            json_value["license_references"][0]["homepage_url"],
1302            "https://example.com/license"
1303        );
1304        assert_eq!(
1305            json_value["license_references"][0]["osi_license_key"],
1306            "MIT"
1307        );
1308        assert_eq!(
1309            json_value["license_references"][0]["text_urls"][0],
1310            "https://example.com/license.txt"
1311        );
1312        assert_eq!(
1313            json_value["license_rule_references"][0]["identifier"],
1314            "license-clue_1.RULE"
1315        );
1316        assert_eq!(
1317            json_value["license_rule_references"][0]["relevance"],
1318            Value::Null
1319        );
1320        assert_eq!(
1321            json_value["license_rule_references"][0]["length"],
1322            Value::from(0)
1323        );
1324
1325        let mut jsonl_bytes = Vec::new();
1326        writer_for_format(OutputFormat::JsonLines)
1327            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1328            .expect("json-lines write should succeed");
1329        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1330        assert!(
1331            rendered
1332                .lines()
1333                .any(|line| line.contains("\"license_references\""))
1334        );
1335        assert!(
1336            rendered
1337                .lines()
1338                .any(|line| line.contains("\"license_rule_references\""))
1339        );
1340    }
1341
1342    #[test]
1343    fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1344        let mut internal = sample_internal_output();
1345        internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1346            identifier: "mit-id".to_string(),
1347            license_expression: "mit".to_string(),
1348            license_expression_spdx: "MIT".to_string(),
1349            detection_count: 2,
1350            detection_log: vec![],
1351            reference_matches: vec![crate::models::Match {
1352                license_expression: "mit".to_string(),
1353                license_expression_spdx: "MIT".to_string(),
1354                from_file: Some("src/main.rs".to_string()),
1355                start_line: LineNumber::ONE,
1356                end_line: LineNumber::new(3).unwrap(),
1357                matcher: MatcherKind::Hash,
1358                score: MatchScore::MAX,
1359                matched_length: Some(10),
1360                match_coverage: Some(100.0),
1361                rule_relevance: Some(100),
1362                rule_identifier: "mit.LICENSE".to_string(),
1363                rule_url: None,
1364                matched_text: None,
1365                referenced_filenames: None,
1366                matched_text_diagnostics: None,
1367            }],
1368        }];
1369        let output = Output::from(&internal);
1370
1371        let mut json_bytes = Vec::new();
1372        writer_for_format(OutputFormat::Json)
1373            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1374            .expect("json write should succeed");
1375        let json_value: Value =
1376            serde_json::from_slice(&json_bytes).expect("json output should parse");
1377        assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1378        assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1379
1380        let mut jsonl_bytes = Vec::new();
1381        writer_for_format(OutputFormat::JsonLines)
1382            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1383            .expect("json-lines write should succeed");
1384        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1385        assert!(
1386            rendered
1387                .lines()
1388                .any(|line| line.contains("\"license_detections\""))
1389        );
1390    }
1391
1392    #[test]
1393    fn test_json_and_json_lines_writers_keep_empty_top_level_license_detections() {
1394        let output = Output::from(&sample_internal_output());
1395
1396        let mut json_bytes = Vec::new();
1397        writer_for_format(OutputFormat::Json)
1398            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1399            .expect("json write should succeed");
1400        let json_value: Value =
1401            serde_json::from_slice(&json_bytes).expect("json output should parse");
1402        assert_eq!(json_value["license_detections"], Value::Array(vec![]));
1403
1404        let mut jsonl_bytes = Vec::new();
1405        writer_for_format(OutputFormat::JsonLines)
1406            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1407            .expect("json-lines write should succeed");
1408        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1409        assert!(
1410            rendered
1411                .lines()
1412                .any(|line| line == r#"{"license_detections":[]}"#)
1413        );
1414    }
1415
1416    #[test]
1417    fn test_public_writer_normalizes_empty_package_maps_without_changing_schema_output() {
1418        let mut internal = sample_internal_output();
1419        internal.packages.push(Package::from_package_data(
1420            &PackageData {
1421                package_type: Some(crate::models::PackageType::Npm),
1422                name: Some("demo".to_string()),
1423                version: Some("1.0.0".to_string()),
1424                ..PackageData::default()
1425            },
1426            "scan/package.json".to_string(),
1427        ));
1428
1429        let output = Output::from(&internal);
1430        let raw_schema = serde_json::to_value(&output).expect("schema output should serialize");
1431        assert_eq!(
1432            raw_schema["packages"][0]["qualifiers"],
1433            serde_json::json!({})
1434        );
1435        assert_eq!(
1436            raw_schema["packages"][0]["extra_data"],
1437            serde_json::json!({})
1438        );
1439
1440        let mut bytes = Vec::new();
1441        writer_for_format(OutputFormat::Json)
1442            .write(&output, &mut bytes, &OutputWriteConfig::default())
1443            .expect("json write should succeed");
1444        let public_value: Value = serde_json::from_slice(&bytes).expect("public json should parse");
1445
1446        assert!(public_value["packages"][0]["qualifiers"].is_null());
1447        assert!(public_value["packages"][0]["extra_data"].is_null());
1448    }
1449
1450    #[test]
1451    fn test_cyclonedx_xml_writer_outputs_xml() {
1452        let output = Output::from(&sample_internal_output());
1453        let mut bytes = Vec::new();
1454        writer_for_format(OutputFormat::CycloneDxXml)
1455            .write(&output, &mut bytes, &OutputWriteConfig::default())
1456            .expect("cyclonedx xml write should succeed");
1457
1458        let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1459        assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
1460        assert!(rendered.contains("<components>"));
1461    }
1462
1463    #[test]
1464    fn test_cyclonedx_json_includes_component_license_expression() {
1465        let mut internal = sample_internal_output();
1466        internal.packages = vec![crate::models::Package {
1467            package_type: Some(crate::models::PackageType::Maven),
1468            namespace: Some("example".to_string()),
1469            name: Some("gradle-project".to_string()),
1470            version: Some("1.0.0".to_string()),
1471            qualifiers: None,
1472            subpath: None,
1473            primary_language: Some("Java".to_string()),
1474            description: None,
1475            release_date: None,
1476            parties: vec![],
1477            keywords: vec![],
1478            homepage_url: None,
1479            download_url: None,
1480            size: None,
1481            sha1: None,
1482            md5: None,
1483            sha256: None,
1484            sha512: None,
1485            bug_tracking_url: None,
1486            code_view_url: None,
1487            vcs_url: None,
1488            copyright: None,
1489            holder: None,
1490            declared_license_expression: Some("Apache-2.0".to_string()),
1491            declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1492            license_detections: vec![],
1493            other_license_expression: None,
1494            other_license_expression_spdx: None,
1495            other_license_detections: vec![],
1496            extracted_license_statement: Some("Apache-2.0".to_string()),
1497            notice_text: None,
1498            source_packages: vec![],
1499            is_private: false,
1500            is_virtual: false,
1501            extra_data: None,
1502            repository_homepage_url: None,
1503            repository_download_url: None,
1504            api_data_url: None,
1505            datasource_ids: vec![],
1506            purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1507            package_uid: PackageUid::from_raw(
1508                "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1509            ),
1510            datafile_paths: vec![],
1511        }];
1512        let output = Output::from(&internal);
1513
1514        let mut bytes = Vec::new();
1515        writer_for_format(OutputFormat::CycloneDxJson)
1516            .write(&output, &mut bytes, &OutputWriteConfig::default())
1517            .expect("cyclonedx json write should succeed");
1518
1519        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1520        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1521
1522        assert_eq!(
1523            value["components"][0]["licenses"][0]["expression"],
1524            "Apache-2.0"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_cyclonedx_external_references_are_deduplicated() {
1530        let mut internal = sample_internal_output();
1531        internal.packages = vec![Package::from_package_data(
1532            &PackageData {
1533                package_type: Some(crate::models::PackageType::Npm),
1534                name: Some("demo".to_string()),
1535                version: Some("1.0.0".to_string()),
1536                download_url: Some("https://example.com/download.tgz".to_string()),
1537                repository_download_url: Some("https://example.com/download.tgz".to_string()),
1538                homepage_url: Some("https://example.com".to_string()),
1539                repository_homepage_url: Some("https://example.com".to_string()),
1540                ..PackageData::default()
1541            },
1542            "scan/package.json".to_string(),
1543        )];
1544        let output = Output::from(&internal);
1545
1546        let mut json_bytes = Vec::new();
1547        writer_for_format(OutputFormat::CycloneDxJson)
1548            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1549            .expect("cyclonedx json write should succeed");
1550        let value: Value = serde_json::from_slice(&json_bytes).expect("valid cyclonedx json");
1551        let refs = value["components"][0]["externalReferences"]
1552            .as_array()
1553            .expect("external references should be an array");
1554        assert_eq!(refs.len(), 2);
1555
1556        let mut xml_bytes = Vec::new();
1557        writer_for_format(OutputFormat::CycloneDxXml)
1558            .write(&output, &mut xml_bytes, &OutputWriteConfig::default())
1559            .expect("cyclonedx xml write should succeed");
1560        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
1561        // Scope the dedup assertion to `<components>`: the sole package here
1562        // also becomes `metadata.component` (see the metadata-component
1563        // tests in `output/cyclonedx.rs`), which legitimately repeats these
1564        // URLs once more outside `<components>`.
1565        let components_section = xml
1566            .split("<components>")
1567            .nth(1)
1568            .and_then(|s| s.split("</components>").next())
1569            .expect("xml must contain a components section");
1570        assert_eq!(
1571            components_section
1572                .matches("https://example.com/download.tgz")
1573                .count(),
1574            1
1575        );
1576        assert_eq!(
1577            components_section
1578                .matches("https://example.com</url>")
1579                .count(),
1580            1
1581        );
1582    }
1583
1584    #[test]
1585    fn test_spdx_prefers_single_detected_package_name_over_scan_root() {
1586        let mut internal = sample_internal_output();
1587        internal.packages = vec![Package::from_package_data(
1588            &PackageData {
1589                package_type: Some(crate::models::PackageType::Npm),
1590                name: Some("detected-package".to_string()),
1591                version: Some("1.0.0".to_string()),
1592                ..PackageData::default()
1593            },
1594            "scan/package.json".to_string(),
1595        )];
1596        let output = Output::from(&internal);
1597
1598        let mut tv_bytes = Vec::new();
1599        writer_for_format(OutputFormat::SpdxTv)
1600            .write(
1601                &output,
1602                &mut tv_bytes,
1603                &OutputWriteConfig {
1604                    format: OutputFormat::SpdxTv,
1605                    custom_template: None,
1606                    scanned_path: Some("scan-root".to_string()),
1607                },
1608            )
1609            .expect("spdx tv write should succeed");
1610        let tv = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
1611        assert!(tv.contains("PackageName: detected-package"));
1612        assert!(tv.contains("DocumentNamespace: http://spdx.org/spdxdocs/detected-package"));
1613
1614        let mut rdf_bytes = Vec::new();
1615        writer_for_format(OutputFormat::SpdxRdf)
1616            .write(
1617                &output,
1618                &mut rdf_bytes,
1619                &OutputWriteConfig {
1620                    format: OutputFormat::SpdxRdf,
1621                    custom_template: None,
1622                    scanned_path: Some("scan-root".to_string()),
1623                },
1624            )
1625            .expect("spdx rdf write should succeed");
1626        let rdf = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
1627        assert!(rdf.contains("<spdx:name>detected-package</spdx:name>"));
1628    }
1629
1630    #[test]
1631    fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1632        let output = Output {
1633            summary: None,
1634            tallies: None,
1635            tallies_of_key_files: None,
1636            tallies_by_facet: None,
1637            headers: vec![],
1638            packages: vec![],
1639            dependencies: vec![],
1640            license_detections: vec![],
1641            files: vec![],
1642            license_references: vec![],
1643            license_rule_references: vec![],
1644        };
1645        let mut bytes = Vec::new();
1646        writer_for_format(OutputFormat::SpdxTv)
1647            .write(
1648                &output,
1649                &mut bytes,
1650                &OutputWriteConfig {
1651                    format: OutputFormat::SpdxTv,
1652                    custom_template: None,
1653                    scanned_path: Some("scan".to_string()),
1654                },
1655            )
1656            .expect("spdx tv write should succeed");
1657
1658        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1659        assert_eq!(rendered, "# No results for package 'scan'.\n");
1660    }
1661
1662    #[test]
1663    fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1664        let output = Output {
1665            summary: None,
1666            tallies: None,
1667            tallies_of_key_files: None,
1668            tallies_by_facet: None,
1669            headers: vec![],
1670            packages: vec![],
1671            dependencies: vec![],
1672            license_detections: vec![],
1673            files: vec![],
1674            license_references: vec![],
1675            license_rule_references: vec![],
1676        };
1677        let mut bytes = Vec::new();
1678        writer_for_format(OutputFormat::SpdxRdf)
1679            .write(
1680                &output,
1681                &mut bytes,
1682                &OutputWriteConfig {
1683                    format: OutputFormat::SpdxRdf,
1684                    custom_template: None,
1685                    scanned_path: Some("scan".to_string()),
1686                },
1687            )
1688            .expect("spdx rdf write should succeed");
1689
1690        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1691        assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1692    }
1693
1694    #[test]
1695    fn test_html_writer_outputs_html_document() {
1696        let output = Output::from(&sample_internal_output());
1697        let mut bytes = Vec::new();
1698        writer_for_format(OutputFormat::Html)
1699            .write(&output, &mut bytes, &OutputWriteConfig::default())
1700            .expect("html write should succeed");
1701        let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1702        assert!(rendered.contains("<!doctype html>"));
1703        assert!(rendered.contains("Provenant HTML Report"));
1704    }
1705
1706    #[test]
1707    fn test_custom_template_writer_renders_output_context() {
1708        let output = Output::from(&sample_internal_output());
1709        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1710        let template_path = temp_dir.path().join("template.tera");
1711        fs::write(
1712            &template_path,
1713            "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1714        )
1715        .expect("template should be written");
1716
1717        let mut bytes = Vec::new();
1718        writer_for_format(OutputFormat::CustomTemplate)
1719            .write(
1720                &output,
1721                &mut bytes,
1722                &OutputWriteConfig {
1723                    format: OutputFormat::CustomTemplate,
1724                    custom_template: Some(template_path.to_string_lossy().to_string()),
1725                    scanned_path: None,
1726                },
1727            )
1728            .expect("custom template write should succeed");
1729
1730        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1731        assert!(rendered.contains("version=4.1.0"));
1732        assert!(rendered.contains("files=1"));
1733    }
1734
1735    #[test]
1736    fn test_custom_template_writer_exposes_scancode_namespace() {
1737        let output = Output::from(&sample_internal_output());
1738        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1739        let template_path = temp_dir.path().join("template.j2");
1740        // ScanCode's custom-template contract: path-keyed `files` reshape plus
1741        // top-level `version`, all reachable under the `scancode` namespace.
1742        fs::write(
1743            &template_path,
1744            concat!(
1745                r#"lc={{ scancode.files.license_copyright["src/main.rs"] | length }} "#,
1746                r#"first={{ scancode.files.license_copyright["src/main.rs"][0].what }} "#,
1747                r#"name={{ scancode.files.infos["src/main.rs"].name }} "#,
1748                r#"pkg={{ scancode.files.package_data["src/main.rs"] | length }} "#,
1749                r#"ver={{ scancode.version }}"#,
1750            ),
1751        )
1752        .expect("template should be written");
1753
1754        let mut bytes = Vec::new();
1755        writer_for_format(OutputFormat::CustomTemplate)
1756            .write(
1757                &output,
1758                &mut bytes,
1759                &OutputWriteConfig {
1760                    format: OutputFormat::CustomTemplate,
1761                    custom_template: Some(template_path.to_string_lossy().to_string()),
1762                    scanned_path: None,
1763                },
1764            )
1765            .expect("custom template write should succeed");
1766
1767        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1768        // One copyright + one license match, copyright sorts first (same line).
1769        assert!(rendered.contains("lc=2"), "rendered: {rendered}");
1770        assert!(rendered.contains("first=copyright"), "rendered: {rendered}");
1771        assert!(rendered.contains("name=main"), "rendered: {rendered}");
1772        assert!(rendered.contains("pkg=1"), "rendered: {rendered}");
1773        assert!(
1774            rendered.contains(&format!("ver={}", crate::version::BUILD_VERSION)),
1775            "rendered: {rendered}"
1776        );
1777    }
1778
1779    fn sample_internal_output() -> crate::models::Output {
1780        crate::models::Output {
1781            summary: None,
1782            tallies: None,
1783            tallies_of_key_files: None,
1784            tallies_by_facet: None,
1785            headers: vec![Header {
1786                tool_name: "provenant".to_string(),
1787                tool_version: crate::version::BUILD_VERSION.to_string(),
1788                options: serde_json::Map::new(),
1789                notice: crate::models::HEADER_NOTICE.to_string(),
1790                start_timestamp: "2026-01-01T000000.000000".to_string(),
1791                end_timestamp: "2026-01-01T000001.000000".to_string(),
1792                output_format_version: "4.1.0".to_string(),
1793                duration: 1.0,
1794                errors: vec![],
1795                warnings: vec![],
1796                extra_data: ExtraData {
1797                    system_environment: SystemEnvironment {
1798                        operating_system: "darwin".to_string(),
1799                        cpu_architecture: "aarch64".to_string(),
1800                        platform: "darwin".to_string(),
1801                        platform_version: "26.3.1".to_string(),
1802                        rust_version: "1.93.0".to_string(),
1803                    },
1804                    spdx_license_list_version: "3.27".to_string(),
1805                    files_count: 1,
1806                    directories_count: 1,
1807                    excluded_count: 0,
1808                    license_index_provenance: Some(crate::models::LicenseIndexProvenance {
1809                        source: "embedded-artifact".to_string(),
1810                        dataset_fingerprint: "test-fingerprint".to_string(),
1811                        ignored_rules: vec![
1812                            "gpl-2.0_and-unknown-license-reference_1.RULE".to_string(),
1813                        ],
1814                        ignored_licenses: vec![],
1815                        ignored_rules_due_to_licenses: vec![],
1816                        added_rules: vec![],
1817                        replaced_rules: vec![],
1818                        added_licenses: vec![],
1819                        replaced_licenses: vec![],
1820                    }),
1821                },
1822            }],
1823            packages: vec![],
1824            dependencies: vec![],
1825            license_detections: vec![],
1826            files: vec![FileInfo::new(
1827                "main.rs".to_string(),
1828                "main".to_string(),
1829                "rs".to_string(),
1830                "src/main.rs".to_string(),
1831                FileType::File,
1832                Some("text/plain".to_string()),
1833                None,
1834                42,
1835                None,
1836                Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1837                Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1838                Some(
1839                    Sha256Digest::from_hex(
1840                        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1841                    )
1842                    .unwrap(),
1843                ),
1844                Some("Rust".to_string()),
1845                vec![PackageData::default()],
1846                None,
1847                vec![LicenseDetection {
1848                    license_expression: "mit".to_string(),
1849                    license_expression_spdx: "MIT".to_string(),
1850                    matches: vec![Match {
1851                        license_expression: "mit".to_string(),
1852                        license_expression_spdx: "MIT".to_string(),
1853                        from_file: None,
1854                        start_line: LineNumber::ONE,
1855                        end_line: LineNumber::ONE,
1856                        matcher: MatcherKind::Hash,
1857                        score: MatchScore::MAX,
1858                        matched_length: None,
1859                        match_coverage: None,
1860                        rule_relevance: None,
1861                        rule_identifier: "mit_rule".to_string(),
1862                        rule_url: None,
1863                        matched_text: None,
1864                        referenced_filenames: None,
1865                        matched_text_diagnostics: None,
1866                    }],
1867                    detection_log: vec![],
1868                    identifier: String::new(),
1869                }],
1870                vec![],
1871                vec![Copyright {
1872                    copyright: "Copyright (c) Example".to_string(),
1873                    normalized_copyright: None,
1874                    start_line: LineNumber::ONE,
1875                    end_line: LineNumber::ONE,
1876                }],
1877                vec![Holder {
1878                    holder: "Example Org".to_string(),
1879                    start_line: LineNumber::ONE,
1880                    end_line: LineNumber::ONE,
1881                }],
1882                vec![Author {
1883                    author: "Jane Doe".to_string(),
1884                    start_line: LineNumber::ONE,
1885                    end_line: LineNumber::ONE,
1886                }],
1887                vec![OutputEmail {
1888                    email: "jane@example.com".to_string(),
1889                    start_line: LineNumber::ONE,
1890                    end_line: LineNumber::ONE,
1891                }],
1892                vec![OutputURL {
1893                    url: "https://example.com".to_string(),
1894                    start_line: LineNumber::ONE,
1895                    end_line: LineNumber::ONE,
1896                }],
1897                vec![],
1898                vec![],
1899            )],
1900            license_references: vec![],
1901            license_rule_references: vec![],
1902        }
1903    }
1904}