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