Skip to main content

provenant/output/
mod.rs

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