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        assert!(
550            rendered.contains("Creator: Tool: Provenant-"),
551            "SPDX TV must emit CreationInfo Creator for spdx-tools"
552        );
553        assert!(
554            rendered.contains("Created:"),
555            "SPDX TV must emit CreationInfo Created for spdx-tools"
556        );
557        assert!(
558            rendered.contains("Relationship: SPDXRef-DOCUMENT DESCRIBES SPDXRef-001"),
559            "SPDX TV must describe the package from the document"
560        );
561    }
562
563    #[test]
564    fn test_spdx_rdf_writer_outputs_xml() {
565        let output = Output::from(&sample_internal_output());
566        let mut bytes = Vec::new();
567        writer_for_format(OutputFormat::SpdxRdf)
568            .write(
569                &output,
570                &mut bytes,
571                &OutputWriteConfig {
572                    format: OutputFormat::SpdxRdf,
573                    custom_template: None,
574                    scanned_path: Some("scan".to_string()),
575                },
576            )
577            .expect("spdx rdf write should succeed");
578
579        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
580        assert!(rendered.contains("<rdf:RDF"));
581        assert!(rendered.contains("<spdx:SpdxDocument"));
582        assert!(rendered.contains("<spdx:created>2026-01-01T00:00:00Z</spdx:created>"));
583        assert!(
584            rendered.contains("<spdx:creator>Tool: Provenant-"),
585            "SPDX RDF must emit a creator Actor for spdx-tools"
586        );
587        assert!(
588            rendered.contains("relationshipType_describes"),
589            "SPDX RDF must include Document DESCRIBES package"
590        );
591    }
592
593    #[test]
594    fn test_cyclonedx_writers_keep_iso_timestamps_when_headers_use_scancode_format() {
595        let mut internal = sample_internal_output();
596        internal.packages.push(Package::from_package_data(
597            &PackageData {
598                name: Some("demo".to_string()),
599                version: Some("1.0.0".to_string()),
600                ..PackageData::default()
601            },
602            "scan/package.json".to_string(),
603        ));
604        let output = Output::from(&internal);
605
606        let mut json_bytes = Vec::new();
607        writer_for_format(OutputFormat::CycloneDxJson)
608            .write(
609                &output,
610                &mut json_bytes,
611                &OutputWriteConfig {
612                    format: OutputFormat::CycloneDxJson,
613                    custom_template: None,
614                    scanned_path: Some("scan".to_string()),
615                },
616            )
617            .expect("cyclonedx json write should succeed");
618        let json_value: Value =
619            serde_json::from_slice(&json_bytes).expect("cyclonedx json should parse");
620        assert_eq!(
621            json_value["metadata"]["timestamp"].as_str(),
622            Some("2026-01-01T00:00:01Z")
623        );
624
625        let mut xml_bytes = Vec::new();
626        writer_for_format(OutputFormat::CycloneDxXml)
627            .write(
628                &output,
629                &mut xml_bytes,
630                &OutputWriteConfig {
631                    format: OutputFormat::CycloneDxXml,
632                    custom_template: None,
633                    scanned_path: Some("scan".to_string()),
634                },
635            )
636            .expect("cyclonedx xml write should succeed");
637        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
638        assert!(xml.contains("<timestamp>2026-01-01T00:00:01Z</timestamp>"));
639    }
640
641    #[test]
642    fn test_spdx_writers_emit_real_file_and_package_license_info() {
643        let output = Output::from(&sample_internal_output());
644
645        let mut tv_bytes = Vec::new();
646        writer_for_format(OutputFormat::SpdxTv)
647            .write(
648                &output,
649                &mut tv_bytes,
650                &OutputWriteConfig {
651                    format: OutputFormat::SpdxTv,
652                    custom_template: None,
653                    scanned_path: Some("scan".to_string()),
654                },
655            )
656            .expect("spdx tv write should succeed");
657        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
658        assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
659        assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
660        assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
661        assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
662        assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
663
664        let mut rdf_bytes = Vec::new();
665        writer_for_format(OutputFormat::SpdxRdf)
666            .write(
667                &output,
668                &mut rdf_bytes,
669                &OutputWriteConfig {
670                    format: OutputFormat::SpdxRdf,
671                    custom_template: None,
672                    scanned_path: Some("scan".to_string()),
673                },
674            )
675            .expect("spdx rdf write should succeed");
676        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
677        assert!(rdf_rendered.contains(
678            "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
679        ));
680        assert!(
681            rdf_rendered.contains(
682                "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
683            )
684        );
685        assert!(rdf_rendered.contains(
686            "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
687        ));
688    }
689
690    #[test]
691    fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
692        let mut internal = sample_internal_output();
693        internal.files[0].license_detections = vec![LicenseDetection {
694            license_expression: "unknown-license-reference".to_string(),
695            license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
696            matches: vec![Match {
697                license_expression: "unknown-license-reference".to_string(),
698                license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
699                    .to_string(),
700                from_file: Some("src/main.rs".to_string()),
701                start_line: LineNumber::ONE,
702                end_line: LineNumber::new(2).unwrap(),
703                matcher: MatcherKind::Aho,
704                score: MatchScore::MAX,
705                matched_length: Some(4),
706                match_coverage: Some(100.0),
707                rule_relevance: Some(100),
708                rule_identifier: "unknown-license-reference.RULE".to_string(),
709                rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
710                matched_text: Some("Custom license text".to_string()),
711                referenced_filenames: Some(vec!["LICENSE".to_string()]),
712                matched_text_diagnostics: None,
713            }],
714            detection_log: vec![],
715            identifier: "unknown-ref-id".to_string(),
716        }];
717        internal.license_references = vec![crate::models::LicenseReference {
718            key: Some("unknown-license-reference".to_string()),
719            language: Some("en".to_string()),
720            name: "Unknown License Reference".to_string(),
721            short_name: "Unknown License Reference".to_string(),
722            owner: None,
723            homepage_url: None,
724            spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
725            other_spdx_license_keys: vec![],
726            osi_license_key: None,
727            text_urls: vec![],
728            osi_url: None,
729            faq_url: None,
730            other_urls: vec![],
731            category: None,
732            is_exception: false,
733            is_unknown: true,
734            is_generic: false,
735            notes: None,
736            minimum_coverage: None,
737            standard_notice: None,
738            ignorable_copyrights: vec![],
739            ignorable_holders: vec![],
740            ignorable_authors: vec![],
741            ignorable_urls: vec![],
742            ignorable_emails: vec![],
743            scancode_url: None,
744            licensedb_url: None,
745            spdx_url: None,
746            text: "Unused fallback text".to_string(),
747        }];
748        let output = Output::from(&internal);
749
750        let mut tv_bytes = Vec::new();
751        writer_for_format(OutputFormat::SpdxTv)
752            .write(
753                &output,
754                &mut tv_bytes,
755                &OutputWriteConfig {
756                    format: OutputFormat::SpdxTv,
757                    custom_template: None,
758                    scanned_path: Some("scan".to_string()),
759                },
760            )
761            .expect("spdx tv write should succeed");
762        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
763        assert!(
764            tv_rendered
765                .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
766        );
767        assert!(tv_rendered.contains(
768            "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
769        ));
770        assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
771        assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
772        assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
773        assert!(tv_rendered.contains(
774            "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
775        ));
776
777        let mut rdf_bytes = Vec::new();
778        writer_for_format(OutputFormat::SpdxRdf)
779            .write(
780                &output,
781                &mut rdf_bytes,
782                &OutputWriteConfig {
783                    format: OutputFormat::SpdxRdf,
784                    custom_template: None,
785                    scanned_path: Some("scan".to_string()),
786                },
787            )
788            .expect("spdx rdf write should succeed");
789        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
790        assert!(rdf_rendered.contains(
791            "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/LicenseRef-scancode-unknown-license-reference\"/>"
792        ));
793        assert!(rdf_rendered.contains(
794            "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"http://spdx.org/spdxdocs/scan#LicenseRef-scancode-unknown-license-reference\">"
795        ));
796        assert!(
797            rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
798        );
799    }
800
801    #[test]
802    fn test_cyclonedx_json_writer_outputs_bom() {
803        let output = Output::from(&sample_internal_output());
804        let mut bytes = Vec::new();
805        writer_for_format(OutputFormat::CycloneDxJson)
806            .write(&output, &mut bytes, &OutputWriteConfig::default())
807            .expect("cyclonedx json write should succeed");
808
809        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
810        let value: Value = serde_json::from_str(&rendered).expect("valid json");
811        assert_eq!(value["bomFormat"], "CycloneDX");
812        assert_eq!(value["specVersion"], "1.3");
813    }
814
815    #[test]
816    fn test_json_writer_includes_summary_and_key_file_flags() {
817        let mut internal = sample_internal_output();
818        internal.summary = Some(crate::models::Summary {
819            declared_license_expression: Some("apache-2.0".to_string()),
820            license_clarity_score: Some(crate::models::LicenseClarityScore {
821                score: 100,
822                declared_license: true,
823                identification_precision: true,
824                has_license_text: true,
825                declared_copyrights: true,
826                conflicting_license_categories: false,
827                ambiguous_compound_licensing: false,
828            }),
829            declared_holder: Some("Example Corp.".to_string()),
830            primary_language: Some("Ruby".to_string()),
831            other_license_expressions: vec![crate::models::TallyEntry {
832                value: Some("mit".to_string()),
833                count: 1,
834            }],
835            other_holders: vec![
836                crate::models::TallyEntry {
837                    value: None,
838                    count: 2,
839                },
840                crate::models::TallyEntry {
841                    value: Some("Other Corp.".to_string()),
842                    count: 1,
843                },
844            ],
845            other_languages: vec![crate::models::TallyEntry {
846                value: Some("Python".to_string()),
847                count: 2,
848            }],
849        });
850        internal.files[0].is_legal = true;
851        internal.files[0].is_top_level = true;
852        internal.files[0].is_key_file = true;
853        let output = Output::from(&internal);
854
855        let mut bytes = Vec::new();
856        writer_for_format(OutputFormat::Json)
857            .write(&output, &mut bytes, &OutputWriteConfig::default())
858            .expect("json write should succeed");
859
860        let rendered = String::from_utf8(bytes).expect("json should be utf-8");
861        let value: Value = serde_json::from_str(&rendered).expect("valid json");
862
863        assert_eq!(
864            value["summary"]["declared_license_expression"],
865            "apache-2.0"
866        );
867        assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
868        assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
869        assert_eq!(value["summary"]["primary_language"], "Ruby");
870        assert_eq!(
871            value["summary"]["other_license_expressions"][0]["value"],
872            "mit"
873        );
874        assert!(value["summary"]["other_holders"][0]["value"].is_null());
875        assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
876        assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
877        assert_eq!(value["files"][0]["is_key_file"], true);
878    }
879
880    #[test]
881    fn test_json_and_json_lines_writers_include_top_level_tallies() {
882        let mut internal = sample_internal_output();
883        internal.tallies = Some(crate::models::Tallies {
884            detected_license_expression: vec![crate::models::TallyEntry {
885                value: Some("mit".to_string()),
886                count: 2,
887            }],
888            copyrights: vec![crate::models::TallyEntry {
889                value: Some("Copyright (c) Example Org".to_string()),
890                count: 1,
891            }],
892            holders: vec![crate::models::TallyEntry {
893                value: Some("Example Org".to_string()),
894                count: 1,
895            }],
896            authors: vec![crate::models::TallyEntry {
897                value: Some("Jane Doe".to_string()),
898                count: 1,
899            }],
900            programming_language: vec![crate::models::TallyEntry {
901                value: Some("Rust".to_string()),
902                count: 1,
903            }],
904        });
905        let output = Output::from(&internal);
906
907        let mut json_bytes = Vec::new();
908        writer_for_format(OutputFormat::Json)
909            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
910            .expect("json write should succeed");
911        let json_value: Value =
912            serde_json::from_slice(&json_bytes).expect("json output should parse");
913        assert_eq!(
914            json_value["tallies"]["detected_license_expression"][0]["value"],
915            "mit"
916        );
917        assert_eq!(
918            json_value["tallies"]["programming_language"][0]["value"],
919            "Rust"
920        );
921
922        let mut jsonl_bytes = Vec::new();
923        writer_for_format(OutputFormat::JsonLines)
924            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
925            .expect("json-lines write should succeed");
926        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
927        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
928    }
929
930    #[test]
931    fn test_json_and_json_lines_writers_include_key_file_tallies() {
932        let mut internal = sample_internal_output();
933        internal.tallies_of_key_files = Some(crate::models::Tallies {
934            detected_license_expression: vec![crate::models::TallyEntry {
935                value: Some("apache-2.0".to_string()),
936                count: 1,
937            }],
938            copyrights: vec![],
939            holders: vec![],
940            authors: vec![],
941            programming_language: vec![crate::models::TallyEntry {
942                value: Some("Markdown".to_string()),
943                count: 1,
944            }],
945        });
946        let output = Output::from(&internal);
947
948        let mut json_bytes = Vec::new();
949        writer_for_format(OutputFormat::Json)
950            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
951            .expect("json write should succeed");
952        let json_value: Value =
953            serde_json::from_slice(&json_bytes).expect("json output should parse");
954        assert_eq!(
955            json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
956            "apache-2.0"
957        );
958
959        let mut jsonl_bytes = Vec::new();
960        writer_for_format(OutputFormat::JsonLines)
961            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
962            .expect("json-lines write should succeed");
963        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
964        assert!(
965            rendered
966                .lines()
967                .any(|line| line.contains("\"tallies_of_key_files\""))
968        );
969    }
970
971    #[test]
972    fn test_json_and_json_lines_writers_include_file_tallies() {
973        let mut internal = sample_internal_output();
974        internal.files[0].tallies = Some(crate::models::Tallies {
975            detected_license_expression: vec![crate::models::TallyEntry {
976                value: Some("mit".to_string()),
977                count: 1,
978            }],
979            copyrights: vec![crate::models::TallyEntry {
980                value: None,
981                count: 1,
982            }],
983            holders: vec![],
984            authors: vec![],
985            programming_language: vec![crate::models::TallyEntry {
986                value: Some("Rust".to_string()),
987                count: 1,
988            }],
989        });
990        let output = Output::from(&internal);
991
992        let mut json_bytes = Vec::new();
993        writer_for_format(OutputFormat::Json)
994            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
995            .expect("json write should succeed");
996        let json_value: Value =
997            serde_json::from_slice(&json_bytes).expect("json output should parse");
998        assert_eq!(
999            json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
1000            "mit"
1001        );
1002
1003        let mut jsonl_bytes = Vec::new();
1004        writer_for_format(OutputFormat::JsonLines)
1005            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1006            .expect("json-lines write should succeed");
1007        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1008        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
1009    }
1010
1011    #[test]
1012    fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
1013        let mut internal = sample_internal_output();
1014        internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
1015        internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
1016            facet: "core".to_string(),
1017            tallies: crate::models::Tallies {
1018                detected_license_expression: vec![crate::models::TallyEntry {
1019                    value: Some("mit".to_string()),
1020                    count: 1,
1021                }],
1022                copyrights: vec![],
1023                holders: vec![],
1024                authors: vec![],
1025                programming_language: vec![],
1026            },
1027        }]);
1028        let output = Output::from(&internal);
1029
1030        let mut json_bytes = Vec::new();
1031        writer_for_format(OutputFormat::Json)
1032            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1033            .expect("json write should succeed");
1034        let json_value: Value =
1035            serde_json::from_slice(&json_bytes).expect("json output should parse");
1036        assert_eq!(json_value["files"][0]["facets"][0], "core");
1037        assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
1038
1039        let mut jsonl_bytes = Vec::new();
1040        writer_for_format(OutputFormat::JsonLines)
1041            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1042            .expect("json-lines write should succeed");
1043        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1044        assert!(
1045            rendered
1046                .lines()
1047                .any(|line| line.contains("\"tallies_by_facet\""))
1048        );
1049    }
1050
1051    #[test]
1052    fn test_json_and_json_lines_writers_include_top_level_license_references() {
1053        let mut internal = sample_internal_output();
1054        internal.license_references = vec![crate::models::LicenseReference {
1055            key: Some("mit".to_string()),
1056            language: Some("en".to_string()),
1057            name: "MIT License".to_string(),
1058            short_name: "MIT".to_string(),
1059            owner: Some("Example Owner".to_string()),
1060            homepage_url: Some("https://example.com/license".to_string()),
1061            spdx_license_key: "MIT".to_string(),
1062            other_spdx_license_keys: vec![],
1063            osi_license_key: Some("MIT".to_string()),
1064            text_urls: vec!["https://example.com/license.txt".to_string()],
1065            osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
1066            faq_url: None,
1067            other_urls: vec![],
1068            category: None,
1069            is_exception: false,
1070            is_unknown: false,
1071            is_generic: false,
1072            notes: None,
1073            minimum_coverage: None,
1074            standard_notice: None,
1075            ignorable_copyrights: vec![],
1076            ignorable_holders: vec![],
1077            ignorable_authors: vec![],
1078            ignorable_urls: vec![],
1079            ignorable_emails: vec![],
1080            scancode_url: None,
1081            licensedb_url: None,
1082            spdx_url: None,
1083            text: "MIT text".to_string(),
1084        }];
1085        internal.license_rule_references = vec![crate::models::LicenseRuleReference {
1086            identifier: "license-clue_1.RULE".to_string(),
1087            license_expression: "unknown-license-reference".to_string(),
1088            is_license_text: false,
1089            is_license_notice: false,
1090            is_license_reference: false,
1091            is_license_tag: false,
1092            is_license_clue: true,
1093            is_license_intro: false,
1094            language: None,
1095            rule_url: None,
1096            is_required_phrase: false,
1097            skip_for_required_phrase_generation: false,
1098            replaced_by: vec![],
1099            is_continuous: false,
1100            is_synthetic: false,
1101            is_from_license: false,
1102            length: 0,
1103            relevance: None,
1104            minimum_coverage: None,
1105            referenced_filenames: vec![],
1106            notes: None,
1107            ignorable_copyrights: vec![],
1108            ignorable_holders: vec![],
1109            ignorable_authors: vec![],
1110            ignorable_urls: vec![],
1111            ignorable_emails: vec![],
1112            text: None,
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["license_references"][0]["spdx_license_key"],
1124            "MIT"
1125        );
1126        assert_eq!(json_value["license_references"][0]["key"], "mit");
1127        assert_eq!(json_value["license_references"][0]["language"], "en");
1128        assert_eq!(
1129            json_value["license_references"][0]["owner"],
1130            "Example Owner"
1131        );
1132        assert_eq!(
1133            json_value["license_references"][0]["homepage_url"],
1134            "https://example.com/license"
1135        );
1136        assert_eq!(
1137            json_value["license_references"][0]["osi_license_key"],
1138            "MIT"
1139        );
1140        assert_eq!(
1141            json_value["license_references"][0]["text_urls"][0],
1142            "https://example.com/license.txt"
1143        );
1144        assert_eq!(
1145            json_value["license_rule_references"][0]["identifier"],
1146            "license-clue_1.RULE"
1147        );
1148        assert_eq!(
1149            json_value["license_rule_references"][0]["relevance"],
1150            Value::Null
1151        );
1152        assert_eq!(
1153            json_value["license_rule_references"][0]["length"],
1154            Value::from(0)
1155        );
1156
1157        let mut jsonl_bytes = Vec::new();
1158        writer_for_format(OutputFormat::JsonLines)
1159            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1160            .expect("json-lines write should succeed");
1161        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1162        assert!(
1163            rendered
1164                .lines()
1165                .any(|line| line.contains("\"license_references\""))
1166        );
1167        assert!(
1168            rendered
1169                .lines()
1170                .any(|line| line.contains("\"license_rule_references\""))
1171        );
1172    }
1173
1174    #[test]
1175    fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1176        let mut internal = sample_internal_output();
1177        internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1178            identifier: "mit-id".to_string(),
1179            license_expression: "mit".to_string(),
1180            license_expression_spdx: "MIT".to_string(),
1181            detection_count: 2,
1182            detection_log: vec![],
1183            reference_matches: vec![crate::models::Match {
1184                license_expression: "mit".to_string(),
1185                license_expression_spdx: "MIT".to_string(),
1186                from_file: Some("src/main.rs".to_string()),
1187                start_line: LineNumber::ONE,
1188                end_line: LineNumber::new(3).unwrap(),
1189                matcher: MatcherKind::Hash,
1190                score: MatchScore::MAX,
1191                matched_length: Some(10),
1192                match_coverage: Some(100.0),
1193                rule_relevance: Some(100),
1194                rule_identifier: "mit.LICENSE".to_string(),
1195                rule_url: None,
1196                matched_text: None,
1197                referenced_filenames: None,
1198                matched_text_diagnostics: None,
1199            }],
1200        }];
1201        let output = Output::from(&internal);
1202
1203        let mut json_bytes = Vec::new();
1204        writer_for_format(OutputFormat::Json)
1205            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1206            .expect("json write should succeed");
1207        let json_value: Value =
1208            serde_json::from_slice(&json_bytes).expect("json output should parse");
1209        assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1210        assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1211
1212        let mut jsonl_bytes = Vec::new();
1213        writer_for_format(OutputFormat::JsonLines)
1214            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1215            .expect("json-lines write should succeed");
1216        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1217        assert!(
1218            rendered
1219                .lines()
1220                .any(|line| line.contains("\"license_detections\""))
1221        );
1222    }
1223
1224    #[test]
1225    fn test_json_and_json_lines_writers_keep_empty_top_level_license_detections() {
1226        let output = Output::from(&sample_internal_output());
1227
1228        let mut json_bytes = Vec::new();
1229        writer_for_format(OutputFormat::Json)
1230            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1231            .expect("json write should succeed");
1232        let json_value: Value =
1233            serde_json::from_slice(&json_bytes).expect("json output should parse");
1234        assert_eq!(json_value["license_detections"], Value::Array(vec![]));
1235
1236        let mut jsonl_bytes = Vec::new();
1237        writer_for_format(OutputFormat::JsonLines)
1238            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1239            .expect("json-lines write should succeed");
1240        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1241        assert!(
1242            rendered
1243                .lines()
1244                .any(|line| line == r#"{"license_detections":[]}"#)
1245        );
1246    }
1247
1248    #[test]
1249    fn test_public_writer_normalizes_empty_package_maps_without_changing_schema_output() {
1250        let mut internal = sample_internal_output();
1251        internal.packages.push(Package::from_package_data(
1252            &PackageData {
1253                package_type: Some(crate::models::PackageType::Npm),
1254                name: Some("demo".to_string()),
1255                version: Some("1.0.0".to_string()),
1256                ..PackageData::default()
1257            },
1258            "scan/package.json".to_string(),
1259        ));
1260
1261        let output = Output::from(&internal);
1262        let raw_schema = serde_json::to_value(&output).expect("schema output should serialize");
1263        assert_eq!(
1264            raw_schema["packages"][0]["qualifiers"],
1265            serde_json::json!({})
1266        );
1267        assert_eq!(
1268            raw_schema["packages"][0]["extra_data"],
1269            serde_json::json!({})
1270        );
1271
1272        let mut bytes = Vec::new();
1273        writer_for_format(OutputFormat::Json)
1274            .write(&output, &mut bytes, &OutputWriteConfig::default())
1275            .expect("json write should succeed");
1276        let public_value: Value = serde_json::from_slice(&bytes).expect("public json should parse");
1277
1278        assert!(public_value["packages"][0]["qualifiers"].is_null());
1279        assert!(public_value["packages"][0]["extra_data"].is_null());
1280    }
1281
1282    #[test]
1283    fn test_cyclonedx_xml_writer_outputs_xml() {
1284        let output = Output::from(&sample_internal_output());
1285        let mut bytes = Vec::new();
1286        writer_for_format(OutputFormat::CycloneDxXml)
1287            .write(&output, &mut bytes, &OutputWriteConfig::default())
1288            .expect("cyclonedx xml write should succeed");
1289
1290        let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1291        assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
1292        assert!(rendered.contains("<components>"));
1293    }
1294
1295    #[test]
1296    fn test_cyclonedx_json_includes_component_license_expression() {
1297        let mut internal = sample_internal_output();
1298        internal.packages = vec![crate::models::Package {
1299            package_type: Some(crate::models::PackageType::Maven),
1300            namespace: Some("example".to_string()),
1301            name: Some("gradle-project".to_string()),
1302            version: Some("1.0.0".to_string()),
1303            qualifiers: None,
1304            subpath: None,
1305            primary_language: Some("Java".to_string()),
1306            description: None,
1307            release_date: None,
1308            parties: vec![],
1309            keywords: vec![],
1310            homepage_url: None,
1311            download_url: None,
1312            size: None,
1313            sha1: None,
1314            md5: None,
1315            sha256: None,
1316            sha512: None,
1317            bug_tracking_url: None,
1318            code_view_url: None,
1319            vcs_url: None,
1320            copyright: None,
1321            holder: None,
1322            declared_license_expression: Some("Apache-2.0".to_string()),
1323            declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1324            license_detections: vec![],
1325            other_license_expression: None,
1326            other_license_expression_spdx: None,
1327            other_license_detections: vec![],
1328            extracted_license_statement: Some("Apache-2.0".to_string()),
1329            notice_text: None,
1330            source_packages: vec![],
1331            is_private: false,
1332            is_virtual: false,
1333            extra_data: None,
1334            repository_homepage_url: None,
1335            repository_download_url: None,
1336            api_data_url: None,
1337            datasource_ids: vec![],
1338            purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1339            package_uid: PackageUid::from_raw(
1340                "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1341            ),
1342            datafile_paths: vec![],
1343        }];
1344        let output = Output::from(&internal);
1345
1346        let mut bytes = Vec::new();
1347        writer_for_format(OutputFormat::CycloneDxJson)
1348            .write(&output, &mut bytes, &OutputWriteConfig::default())
1349            .expect("cyclonedx json write should succeed");
1350
1351        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1352        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1353
1354        assert_eq!(
1355            value["components"][0]["licenses"][0]["expression"],
1356            "Apache-2.0"
1357        );
1358    }
1359
1360    #[test]
1361    fn test_cyclonedx_external_references_are_deduplicated() {
1362        let mut internal = sample_internal_output();
1363        internal.packages = vec![Package::from_package_data(
1364            &PackageData {
1365                package_type: Some(crate::models::PackageType::Npm),
1366                name: Some("demo".to_string()),
1367                version: Some("1.0.0".to_string()),
1368                download_url: Some("https://example.com/download.tgz".to_string()),
1369                repository_download_url: Some("https://example.com/download.tgz".to_string()),
1370                homepage_url: Some("https://example.com".to_string()),
1371                repository_homepage_url: Some("https://example.com".to_string()),
1372                ..PackageData::default()
1373            },
1374            "scan/package.json".to_string(),
1375        )];
1376        let output = Output::from(&internal);
1377
1378        let mut json_bytes = Vec::new();
1379        writer_for_format(OutputFormat::CycloneDxJson)
1380            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1381            .expect("cyclonedx json write should succeed");
1382        let value: Value = serde_json::from_slice(&json_bytes).expect("valid cyclonedx json");
1383        let refs = value["components"][0]["externalReferences"]
1384            .as_array()
1385            .expect("external references should be an array");
1386        assert_eq!(refs.len(), 2);
1387
1388        let mut xml_bytes = Vec::new();
1389        writer_for_format(OutputFormat::CycloneDxXml)
1390            .write(&output, &mut xml_bytes, &OutputWriteConfig::default())
1391            .expect("cyclonedx xml write should succeed");
1392        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
1393        assert_eq!(xml.matches("https://example.com/download.tgz").count(), 1);
1394        assert_eq!(xml.matches("https://example.com</url>").count(), 1);
1395    }
1396
1397    #[test]
1398    fn test_spdx_prefers_single_detected_package_name_over_scan_root() {
1399        let mut internal = sample_internal_output();
1400        internal.packages = vec![Package::from_package_data(
1401            &PackageData {
1402                package_type: Some(crate::models::PackageType::Npm),
1403                name: Some("detected-package".to_string()),
1404                version: Some("1.0.0".to_string()),
1405                ..PackageData::default()
1406            },
1407            "scan/package.json".to_string(),
1408        )];
1409        let output = Output::from(&internal);
1410
1411        let mut tv_bytes = Vec::new();
1412        writer_for_format(OutputFormat::SpdxTv)
1413            .write(
1414                &output,
1415                &mut tv_bytes,
1416                &OutputWriteConfig {
1417                    format: OutputFormat::SpdxTv,
1418                    custom_template: None,
1419                    scanned_path: Some("scan-root".to_string()),
1420                },
1421            )
1422            .expect("spdx tv write should succeed");
1423        let tv = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
1424        assert!(tv.contains("PackageName: detected-package"));
1425        assert!(tv.contains("DocumentNamespace: http://spdx.org/spdxdocs/detected-package"));
1426
1427        let mut rdf_bytes = Vec::new();
1428        writer_for_format(OutputFormat::SpdxRdf)
1429            .write(
1430                &output,
1431                &mut rdf_bytes,
1432                &OutputWriteConfig {
1433                    format: OutputFormat::SpdxRdf,
1434                    custom_template: None,
1435                    scanned_path: Some("scan-root".to_string()),
1436                },
1437            )
1438            .expect("spdx rdf write should succeed");
1439        let rdf = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
1440        assert!(rdf.contains("<spdx:name>detected-package</spdx:name>"));
1441    }
1442
1443    #[test]
1444    fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1445        let output = Output {
1446            summary: None,
1447            tallies: None,
1448            tallies_of_key_files: None,
1449            tallies_by_facet: None,
1450            headers: vec![],
1451            packages: vec![],
1452            dependencies: vec![],
1453            license_detections: vec![],
1454            files: vec![],
1455            license_references: vec![],
1456            license_rule_references: vec![],
1457        };
1458        let mut bytes = Vec::new();
1459        writer_for_format(OutputFormat::SpdxTv)
1460            .write(
1461                &output,
1462                &mut bytes,
1463                &OutputWriteConfig {
1464                    format: OutputFormat::SpdxTv,
1465                    custom_template: None,
1466                    scanned_path: Some("scan".to_string()),
1467                },
1468            )
1469            .expect("spdx tv write should succeed");
1470
1471        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1472        assert_eq!(rendered, "# No results for package 'scan'.\n");
1473    }
1474
1475    #[test]
1476    fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1477        let output = Output {
1478            summary: None,
1479            tallies: None,
1480            tallies_of_key_files: None,
1481            tallies_by_facet: None,
1482            headers: vec![],
1483            packages: vec![],
1484            dependencies: vec![],
1485            license_detections: vec![],
1486            files: vec![],
1487            license_references: vec![],
1488            license_rule_references: vec![],
1489        };
1490        let mut bytes = Vec::new();
1491        writer_for_format(OutputFormat::SpdxRdf)
1492            .write(
1493                &output,
1494                &mut bytes,
1495                &OutputWriteConfig {
1496                    format: OutputFormat::SpdxRdf,
1497                    custom_template: None,
1498                    scanned_path: Some("scan".to_string()),
1499                },
1500            )
1501            .expect("spdx rdf write should succeed");
1502
1503        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1504        assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1505    }
1506
1507    #[test]
1508    fn test_html_writer_outputs_html_document() {
1509        let output = Output::from(&sample_internal_output());
1510        let mut bytes = Vec::new();
1511        writer_for_format(OutputFormat::Html)
1512            .write(&output, &mut bytes, &OutputWriteConfig::default())
1513            .expect("html write should succeed");
1514        let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1515        assert!(rendered.contains("<!doctype html>"));
1516        assert!(rendered.contains("Provenant HTML Report"));
1517    }
1518
1519    #[test]
1520    fn test_custom_template_writer_renders_output_context() {
1521        let output = Output::from(&sample_internal_output());
1522        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1523        let template_path = temp_dir.path().join("template.tera");
1524        fs::write(
1525            &template_path,
1526            "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1527        )
1528        .expect("template should be written");
1529
1530        let mut bytes = Vec::new();
1531        writer_for_format(OutputFormat::CustomTemplate)
1532            .write(
1533                &output,
1534                &mut bytes,
1535                &OutputWriteConfig {
1536                    format: OutputFormat::CustomTemplate,
1537                    custom_template: Some(template_path.to_string_lossy().to_string()),
1538                    scanned_path: None,
1539                },
1540            )
1541            .expect("custom template write should succeed");
1542
1543        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1544        assert!(rendered.contains("version=4.1.0"));
1545        assert!(rendered.contains("files=1"));
1546    }
1547
1548    #[test]
1549    fn test_custom_template_writer_exposes_scancode_namespace() {
1550        let output = Output::from(&sample_internal_output());
1551        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1552        let template_path = temp_dir.path().join("template.j2");
1553        // ScanCode's custom-template contract: path-keyed `files` reshape plus
1554        // top-level `version`, all reachable under the `scancode` namespace.
1555        fs::write(
1556            &template_path,
1557            concat!(
1558                r#"lc={{ scancode.files.license_copyright["src/main.rs"] | length }} "#,
1559                r#"first={{ scancode.files.license_copyright["src/main.rs"][0].what }} "#,
1560                r#"name={{ scancode.files.infos["src/main.rs"].name }} "#,
1561                r#"pkg={{ scancode.files.package_data["src/main.rs"] | length }} "#,
1562                r#"ver={{ scancode.version }}"#,
1563            ),
1564        )
1565        .expect("template should be written");
1566
1567        let mut bytes = Vec::new();
1568        writer_for_format(OutputFormat::CustomTemplate)
1569            .write(
1570                &output,
1571                &mut bytes,
1572                &OutputWriteConfig {
1573                    format: OutputFormat::CustomTemplate,
1574                    custom_template: Some(template_path.to_string_lossy().to_string()),
1575                    scanned_path: None,
1576                },
1577            )
1578            .expect("custom template write should succeed");
1579
1580        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1581        // One copyright + one license match, copyright sorts first (same line).
1582        assert!(rendered.contains("lc=2"), "rendered: {rendered}");
1583        assert!(rendered.contains("first=copyright"), "rendered: {rendered}");
1584        assert!(rendered.contains("name=main"), "rendered: {rendered}");
1585        assert!(rendered.contains("pkg=1"), "rendered: {rendered}");
1586        assert!(
1587            rendered.contains(&format!("ver={}", crate::version::BUILD_VERSION)),
1588            "rendered: {rendered}"
1589        );
1590    }
1591
1592    fn sample_internal_output() -> crate::models::Output {
1593        crate::models::Output {
1594            summary: None,
1595            tallies: None,
1596            tallies_of_key_files: None,
1597            tallies_by_facet: None,
1598            headers: vec![Header {
1599                tool_name: "provenant".to_string(),
1600                tool_version: crate::version::BUILD_VERSION.to_string(),
1601                options: serde_json::Map::new(),
1602                notice: crate::models::HEADER_NOTICE.to_string(),
1603                start_timestamp: "2026-01-01T000000.000000".to_string(),
1604                end_timestamp: "2026-01-01T000001.000000".to_string(),
1605                output_format_version: "4.1.0".to_string(),
1606                duration: 1.0,
1607                errors: vec![],
1608                warnings: vec![],
1609                extra_data: ExtraData {
1610                    system_environment: SystemEnvironment {
1611                        operating_system: "darwin".to_string(),
1612                        cpu_architecture: "aarch64".to_string(),
1613                        platform: "darwin".to_string(),
1614                        platform_version: "26.3.1".to_string(),
1615                        rust_version: "1.93.0".to_string(),
1616                    },
1617                    spdx_license_list_version: "3.27".to_string(),
1618                    files_count: 1,
1619                    directories_count: 1,
1620                    excluded_count: 0,
1621                    license_index_provenance: Some(crate::models::LicenseIndexProvenance {
1622                        source: "embedded-artifact".to_string(),
1623                        dataset_fingerprint: "test-fingerprint".to_string(),
1624                        ignored_rules: vec![
1625                            "gpl-2.0_and-unknown-license-reference_1.RULE".to_string(),
1626                        ],
1627                        ignored_licenses: vec![],
1628                        ignored_rules_due_to_licenses: vec![],
1629                        added_rules: vec![],
1630                        replaced_rules: vec![],
1631                        added_licenses: vec![],
1632                        replaced_licenses: vec![],
1633                    }),
1634                },
1635            }],
1636            packages: vec![],
1637            dependencies: vec![],
1638            license_detections: vec![],
1639            files: vec![FileInfo::new(
1640                "main.rs".to_string(),
1641                "main".to_string(),
1642                "rs".to_string(),
1643                "src/main.rs".to_string(),
1644                FileType::File,
1645                Some("text/plain".to_string()),
1646                None,
1647                42,
1648                None,
1649                Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1650                Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1651                Some(
1652                    Sha256Digest::from_hex(
1653                        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1654                    )
1655                    .unwrap(),
1656                ),
1657                Some("Rust".to_string()),
1658                vec![PackageData::default()],
1659                None,
1660                vec![LicenseDetection {
1661                    license_expression: "mit".to_string(),
1662                    license_expression_spdx: "MIT".to_string(),
1663                    matches: vec![Match {
1664                        license_expression: "mit".to_string(),
1665                        license_expression_spdx: "MIT".to_string(),
1666                        from_file: None,
1667                        start_line: LineNumber::ONE,
1668                        end_line: LineNumber::ONE,
1669                        matcher: MatcherKind::Hash,
1670                        score: MatchScore::MAX,
1671                        matched_length: None,
1672                        match_coverage: None,
1673                        rule_relevance: None,
1674                        rule_identifier: "mit_rule".to_string(),
1675                        rule_url: None,
1676                        matched_text: None,
1677                        referenced_filenames: None,
1678                        matched_text_diagnostics: None,
1679                    }],
1680                    detection_log: vec![],
1681                    identifier: String::new(),
1682                }],
1683                vec![],
1684                vec![Copyright {
1685                    copyright: "Copyright (c) Example".to_string(),
1686                    normalized_copyright: None,
1687                    start_line: LineNumber::ONE,
1688                    end_line: LineNumber::ONE,
1689                }],
1690                vec![Holder {
1691                    holder: "Example Org".to_string(),
1692                    start_line: LineNumber::ONE,
1693                    end_line: LineNumber::ONE,
1694                }],
1695                vec![Author {
1696                    author: "Jane Doe".to_string(),
1697                    start_line: LineNumber::ONE,
1698                    end_line: LineNumber::ONE,
1699                }],
1700                vec![OutputEmail {
1701                    email: "jane@example.com".to_string(),
1702                    start_line: LineNumber::ONE,
1703                    end_line: LineNumber::ONE,
1704                }],
1705                vec![OutputURL {
1706                    url: "https://example.com".to_string(),
1707                    start_line: LineNumber::ONE,
1708                    end_line: LineNumber::ONE,
1709                }],
1710                vec![],
1711                vec![],
1712            )],
1713            license_references: vec![],
1714            license_rule_references: vec![],
1715        }
1716    }
1717}