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_json_lines_writer_sorts_files_by_path_for_reproducibility() {
398        let mut internal = sample_internal_output();
399        internal.files.reverse();
400        let output = Output::from(&internal);
401        let mut bytes = Vec::new();
402        writer_for_format(OutputFormat::JsonLines)
403            .write(&output, &mut bytes, &OutputWriteConfig::default())
404            .expect("json-lines write should succeed");
405
406        let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
407        let file_lines = rendered
408            .lines()
409            .filter_map(|line| {
410                let value: Value = serde_json::from_str(line).ok()?;
411                let files = value.get("files")?.as_array()?;
412                files.first()?.get("path")?.as_str().map(str::to_string)
413            })
414            .collect::<Vec<_>>();
415
416        let mut sorted = file_lines.clone();
417        sorted.sort();
418        assert_eq!(file_lines, sorted);
419    }
420
421    #[test]
422    fn test_spdx_tag_value_writer_contains_required_fields() {
423        let output = Output::from(&sample_internal_output());
424        let mut bytes = Vec::new();
425        writer_for_format(OutputFormat::SpdxTv)
426            .write(
427                &output,
428                &mut bytes,
429                &OutputWriteConfig {
430                    format: OutputFormat::SpdxTv,
431                    custom_template: None,
432                    scanned_path: Some("scan".to_string()),
433                },
434            )
435            .expect("spdx tv write should succeed");
436
437        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
438        assert!(rendered.contains("SPDXVersion: SPDX-2.2"));
439        assert!(rendered.contains("FileName: ./src/main.rs"));
440    }
441
442    #[test]
443    fn test_spdx_rdf_writer_outputs_xml() {
444        let output = Output::from(&sample_internal_output());
445        let mut bytes = Vec::new();
446        writer_for_format(OutputFormat::SpdxRdf)
447            .write(
448                &output,
449                &mut bytes,
450                &OutputWriteConfig {
451                    format: OutputFormat::SpdxRdf,
452                    custom_template: None,
453                    scanned_path: Some("scan".to_string()),
454                },
455            )
456            .expect("spdx rdf write should succeed");
457
458        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
459        assert!(rendered.contains("<rdf:RDF"));
460        assert!(rendered.contains("<spdx:SpdxDocument"));
461        assert!(rendered.contains("<spdx:created>2026-01-01T00:00:00Z</spdx:created>"));
462    }
463
464    #[test]
465    fn test_cyclonedx_writers_keep_iso_timestamps_when_headers_use_scancode_format() {
466        let mut internal = sample_internal_output();
467        internal.packages.push(Package::from_package_data(
468            &PackageData {
469                name: Some("demo".to_string()),
470                version: Some("1.0.0".to_string()),
471                ..PackageData::default()
472            },
473            "scan/package.json".to_string(),
474        ));
475        let output = Output::from(&internal);
476
477        let mut json_bytes = Vec::new();
478        writer_for_format(OutputFormat::CycloneDxJson)
479            .write(
480                &output,
481                &mut json_bytes,
482                &OutputWriteConfig {
483                    format: OutputFormat::CycloneDxJson,
484                    custom_template: None,
485                    scanned_path: Some("scan".to_string()),
486                },
487            )
488            .expect("cyclonedx json write should succeed");
489        let json_value: Value =
490            serde_json::from_slice(&json_bytes).expect("cyclonedx json should parse");
491        assert_eq!(
492            json_value["metadata"]["timestamp"].as_str(),
493            Some("2026-01-01T00:00:01Z")
494        );
495
496        let mut xml_bytes = Vec::new();
497        writer_for_format(OutputFormat::CycloneDxXml)
498            .write(
499                &output,
500                &mut xml_bytes,
501                &OutputWriteConfig {
502                    format: OutputFormat::CycloneDxXml,
503                    custom_template: None,
504                    scanned_path: Some("scan".to_string()),
505                },
506            )
507            .expect("cyclonedx xml write should succeed");
508        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
509        assert!(xml.contains("<timestamp>2026-01-01T00:00:01Z</timestamp>"));
510    }
511
512    #[test]
513    fn test_spdx_writers_emit_real_file_and_package_license_info() {
514        let output = Output::from(&sample_internal_output());
515
516        let mut tv_bytes = Vec::new();
517        writer_for_format(OutputFormat::SpdxTv)
518            .write(
519                &output,
520                &mut tv_bytes,
521                &OutputWriteConfig {
522                    format: OutputFormat::SpdxTv,
523                    custom_template: None,
524                    scanned_path: Some("scan".to_string()),
525                },
526            )
527            .expect("spdx tv write should succeed");
528        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
529        assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
530        assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
531        assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
532        assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
533        assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
534
535        let mut rdf_bytes = Vec::new();
536        writer_for_format(OutputFormat::SpdxRdf)
537            .write(
538                &output,
539                &mut rdf_bytes,
540                &OutputWriteConfig {
541                    format: OutputFormat::SpdxRdf,
542                    custom_template: None,
543                    scanned_path: Some("scan".to_string()),
544                },
545            )
546            .expect("spdx rdf write should succeed");
547        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
548        assert!(rdf_rendered.contains(
549            "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
550        ));
551        assert!(
552            rdf_rendered.contains(
553                "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
554            )
555        );
556        assert!(rdf_rendered.contains(
557            "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
558        ));
559    }
560
561    #[test]
562    fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
563        let mut internal = sample_internal_output();
564        internal.files[0].license_detections = vec![LicenseDetection {
565            license_expression: "unknown-license-reference".to_string(),
566            license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
567            matches: vec![Match {
568                license_expression: "unknown-license-reference".to_string(),
569                license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
570                    .to_string(),
571                from_file: Some("src/main.rs".to_string()),
572                start_line: LineNumber::ONE,
573                end_line: LineNumber::new(2).unwrap(),
574                matcher: Some("2-aho".to_string()),
575                score: MatchScore::MAX,
576                matched_length: Some(4),
577                match_coverage: Some(100.0),
578                rule_relevance: Some(100),
579                rule_identifier: Some("unknown-license-reference.RULE".to_string()),
580                rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
581                matched_text: Some("Custom license text".to_string()),
582                referenced_filenames: Some(vec!["LICENSE".to_string()]),
583                matched_text_diagnostics: None,
584            }],
585            detection_log: vec![],
586            identifier: Some("unknown-ref-id".to_string()),
587        }];
588        internal.license_references = vec![crate::models::LicenseReference {
589            key: Some("unknown-license-reference".to_string()),
590            language: Some("en".to_string()),
591            name: "Unknown License Reference".to_string(),
592            short_name: "Unknown License Reference".to_string(),
593            owner: None,
594            homepage_url: None,
595            spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
596            other_spdx_license_keys: vec![],
597            osi_license_key: None,
598            text_urls: vec![],
599            osi_url: None,
600            faq_url: None,
601            other_urls: vec![],
602            category: None,
603            is_exception: false,
604            is_unknown: true,
605            is_generic: false,
606            notes: None,
607            minimum_coverage: None,
608            standard_notice: None,
609            ignorable_copyrights: vec![],
610            ignorable_holders: vec![],
611            ignorable_authors: vec![],
612            ignorable_urls: vec![],
613            ignorable_emails: vec![],
614            scancode_url: None,
615            licensedb_url: None,
616            spdx_url: None,
617            text: "Unused fallback text".to_string(),
618        }];
619        let output = Output::from(&internal);
620
621        let mut tv_bytes = Vec::new();
622        writer_for_format(OutputFormat::SpdxTv)
623            .write(
624                &output,
625                &mut tv_bytes,
626                &OutputWriteConfig {
627                    format: OutputFormat::SpdxTv,
628                    custom_template: None,
629                    scanned_path: Some("scan".to_string()),
630                },
631            )
632            .expect("spdx tv write should succeed");
633        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
634        assert!(
635            tv_rendered
636                .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
637        );
638        assert!(tv_rendered.contains(
639            "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
640        ));
641        assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
642        assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
643        assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
644        assert!(tv_rendered.contains(
645            "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
646        ));
647
648        let mut rdf_bytes = Vec::new();
649        writer_for_format(OutputFormat::SpdxRdf)
650            .write(
651                &output,
652                &mut rdf_bytes,
653                &OutputWriteConfig {
654                    format: OutputFormat::SpdxRdf,
655                    custom_template: None,
656                    scanned_path: Some("scan".to_string()),
657                },
658            )
659            .expect("spdx rdf write should succeed");
660        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
661        assert!(rdf_rendered.contains(
662            "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/LicenseRef-scancode-unknown-license-reference\"/>"
663        ));
664        assert!(rdf_rendered.contains(
665            "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"#LicenseRef-scancode-unknown-license-reference\">"
666        ));
667        assert!(
668            rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
669        );
670    }
671
672    #[test]
673    fn test_cyclonedx_json_writer_outputs_bom() {
674        let output = Output::from(&sample_internal_output());
675        let mut bytes = Vec::new();
676        writer_for_format(OutputFormat::CycloneDxJson)
677            .write(&output, &mut bytes, &OutputWriteConfig::default())
678            .expect("cyclonedx json write should succeed");
679
680        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
681        let value: Value = serde_json::from_str(&rendered).expect("valid json");
682        assert_eq!(value["bomFormat"], "CycloneDX");
683        assert_eq!(value["specVersion"], "1.3");
684    }
685
686    #[test]
687    fn test_json_writer_includes_summary_and_key_file_flags() {
688        let mut internal = sample_internal_output();
689        internal.summary = Some(crate::models::Summary {
690            declared_license_expression: Some("apache-2.0".to_string()),
691            license_clarity_score: Some(crate::models::LicenseClarityScore {
692                score: 100,
693                declared_license: true,
694                identification_precision: true,
695                has_license_text: true,
696                declared_copyrights: true,
697                conflicting_license_categories: false,
698                ambiguous_compound_licensing: false,
699            }),
700            declared_holder: Some("Example Corp.".to_string()),
701            primary_language: Some("Ruby".to_string()),
702            other_license_expressions: vec![crate::models::TallyEntry {
703                value: Some("mit".to_string()),
704                count: 1,
705            }],
706            other_holders: vec![
707                crate::models::TallyEntry {
708                    value: None,
709                    count: 2,
710                },
711                crate::models::TallyEntry {
712                    value: Some("Other Corp.".to_string()),
713                    count: 1,
714                },
715            ],
716            other_languages: vec![crate::models::TallyEntry {
717                value: Some("Python".to_string()),
718                count: 2,
719            }],
720        });
721        internal.files[0].is_legal = true;
722        internal.files[0].is_top_level = true;
723        internal.files[0].is_key_file = true;
724        let output = Output::from(&internal);
725
726        let mut bytes = Vec::new();
727        writer_for_format(OutputFormat::Json)
728            .write(&output, &mut bytes, &OutputWriteConfig::default())
729            .expect("json write should succeed");
730
731        let rendered = String::from_utf8(bytes).expect("json should be utf-8");
732        let value: Value = serde_json::from_str(&rendered).expect("valid json");
733
734        assert_eq!(
735            value["summary"]["declared_license_expression"],
736            "apache-2.0"
737        );
738        assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
739        assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
740        assert_eq!(value["summary"]["primary_language"], "Ruby");
741        assert_eq!(
742            value["summary"]["other_license_expressions"][0]["value"],
743            "mit"
744        );
745        assert!(value["summary"]["other_holders"][0]["value"].is_null());
746        assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
747        assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
748        assert_eq!(value["files"][0]["is_key_file"], true);
749    }
750
751    #[test]
752    fn test_json_and_json_lines_writers_include_top_level_tallies() {
753        let mut internal = sample_internal_output();
754        internal.tallies = Some(crate::models::Tallies {
755            detected_license_expression: vec![crate::models::TallyEntry {
756                value: Some("mit".to_string()),
757                count: 2,
758            }],
759            copyrights: vec![crate::models::TallyEntry {
760                value: Some("Copyright (c) Example Org".to_string()),
761                count: 1,
762            }],
763            holders: vec![crate::models::TallyEntry {
764                value: Some("Example Org".to_string()),
765                count: 1,
766            }],
767            authors: vec![crate::models::TallyEntry {
768                value: Some("Jane Doe".to_string()),
769                count: 1,
770            }],
771            programming_language: vec![crate::models::TallyEntry {
772                value: Some("Rust".to_string()),
773                count: 1,
774            }],
775        });
776        let output = Output::from(&internal);
777
778        let mut json_bytes = Vec::new();
779        writer_for_format(OutputFormat::Json)
780            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
781            .expect("json write should succeed");
782        let json_value: Value =
783            serde_json::from_slice(&json_bytes).expect("json output should parse");
784        assert_eq!(
785            json_value["tallies"]["detected_license_expression"][0]["value"],
786            "mit"
787        );
788        assert_eq!(
789            json_value["tallies"]["programming_language"][0]["value"],
790            "Rust"
791        );
792
793        let mut jsonl_bytes = Vec::new();
794        writer_for_format(OutputFormat::JsonLines)
795            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
796            .expect("json-lines write should succeed");
797        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
798        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
799    }
800
801    #[test]
802    fn test_json_and_json_lines_writers_include_key_file_tallies() {
803        let mut internal = sample_internal_output();
804        internal.tallies_of_key_files = Some(crate::models::Tallies {
805            detected_license_expression: vec![crate::models::TallyEntry {
806                value: Some("apache-2.0".to_string()),
807                count: 1,
808            }],
809            copyrights: vec![],
810            holders: vec![],
811            authors: vec![],
812            programming_language: vec![crate::models::TallyEntry {
813                value: Some("Markdown".to_string()),
814                count: 1,
815            }],
816        });
817        let output = Output::from(&internal);
818
819        let mut json_bytes = Vec::new();
820        writer_for_format(OutputFormat::Json)
821            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
822            .expect("json write should succeed");
823        let json_value: Value =
824            serde_json::from_slice(&json_bytes).expect("json output should parse");
825        assert_eq!(
826            json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
827            "apache-2.0"
828        );
829
830        let mut jsonl_bytes = Vec::new();
831        writer_for_format(OutputFormat::JsonLines)
832            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
833            .expect("json-lines write should succeed");
834        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
835        assert!(
836            rendered
837                .lines()
838                .any(|line| line.contains("\"tallies_of_key_files\""))
839        );
840    }
841
842    #[test]
843    fn test_json_and_json_lines_writers_include_file_tallies() {
844        let mut internal = sample_internal_output();
845        internal.files[0].tallies = Some(crate::models::Tallies {
846            detected_license_expression: vec![crate::models::TallyEntry {
847                value: Some("mit".to_string()),
848                count: 1,
849            }],
850            copyrights: vec![crate::models::TallyEntry {
851                value: None,
852                count: 1,
853            }],
854            holders: vec![],
855            authors: vec![],
856            programming_language: vec![crate::models::TallyEntry {
857                value: Some("Rust".to_string()),
858                count: 1,
859            }],
860        });
861        let output = Output::from(&internal);
862
863        let mut json_bytes = Vec::new();
864        writer_for_format(OutputFormat::Json)
865            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
866            .expect("json write should succeed");
867        let json_value: Value =
868            serde_json::from_slice(&json_bytes).expect("json output should parse");
869        assert_eq!(
870            json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
871            "mit"
872        );
873
874        let mut jsonl_bytes = Vec::new();
875        writer_for_format(OutputFormat::JsonLines)
876            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
877            .expect("json-lines write should succeed");
878        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
879        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
880    }
881
882    #[test]
883    fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
884        let mut internal = sample_internal_output();
885        internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
886        internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
887            facet: "core".to_string(),
888            tallies: crate::models::Tallies {
889                detected_license_expression: vec![crate::models::TallyEntry {
890                    value: Some("mit".to_string()),
891                    count: 1,
892                }],
893                copyrights: vec![],
894                holders: vec![],
895                authors: vec![],
896                programming_language: vec![],
897            },
898        }]);
899        let output = Output::from(&internal);
900
901        let mut json_bytes = Vec::new();
902        writer_for_format(OutputFormat::Json)
903            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
904            .expect("json write should succeed");
905        let json_value: Value =
906            serde_json::from_slice(&json_bytes).expect("json output should parse");
907        assert_eq!(json_value["files"][0]["facets"][0], "core");
908        assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
909
910        let mut jsonl_bytes = Vec::new();
911        writer_for_format(OutputFormat::JsonLines)
912            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
913            .expect("json-lines write should succeed");
914        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
915        assert!(
916            rendered
917                .lines()
918                .any(|line| line.contains("\"tallies_by_facet\""))
919        );
920    }
921
922    #[test]
923    fn test_json_and_json_lines_writers_include_top_level_license_references() {
924        let mut internal = sample_internal_output();
925        internal.license_references = vec![crate::models::LicenseReference {
926            key: Some("mit".to_string()),
927            language: Some("en".to_string()),
928            name: "MIT License".to_string(),
929            short_name: "MIT".to_string(),
930            owner: Some("Example Owner".to_string()),
931            homepage_url: Some("https://example.com/license".to_string()),
932            spdx_license_key: "MIT".to_string(),
933            other_spdx_license_keys: vec![],
934            osi_license_key: Some("MIT".to_string()),
935            text_urls: vec!["https://example.com/license.txt".to_string()],
936            osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
937            faq_url: None,
938            other_urls: vec![],
939            category: None,
940            is_exception: false,
941            is_unknown: false,
942            is_generic: false,
943            notes: None,
944            minimum_coverage: None,
945            standard_notice: None,
946            ignorable_copyrights: vec![],
947            ignorable_holders: vec![],
948            ignorable_authors: vec![],
949            ignorable_urls: vec![],
950            ignorable_emails: vec![],
951            scancode_url: None,
952            licensedb_url: None,
953            spdx_url: None,
954            text: "MIT text".to_string(),
955        }];
956        internal.license_rule_references = vec![crate::models::LicenseRuleReference {
957            identifier: "license-clue_1.RULE".to_string(),
958            license_expression: "unknown-license-reference".to_string(),
959            is_license_text: false,
960            is_license_notice: false,
961            is_license_reference: false,
962            is_license_tag: false,
963            is_license_clue: true,
964            is_license_intro: false,
965            language: None,
966            rule_url: None,
967            is_required_phrase: false,
968            skip_for_required_phrase_generation: false,
969            replaced_by: vec![],
970            is_continuous: false,
971            is_synthetic: false,
972            is_from_license: false,
973            length: 0,
974            relevance: None,
975            minimum_coverage: None,
976            referenced_filenames: vec![],
977            notes: None,
978            ignorable_copyrights: vec![],
979            ignorable_holders: vec![],
980            ignorable_authors: vec![],
981            ignorable_urls: vec![],
982            ignorable_emails: vec![],
983            text: None,
984        }];
985        let output = Output::from(&internal);
986
987        let mut json_bytes = Vec::new();
988        writer_for_format(OutputFormat::Json)
989            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
990            .expect("json write should succeed");
991        let json_value: Value =
992            serde_json::from_slice(&json_bytes).expect("json output should parse");
993        assert_eq!(
994            json_value["license_references"][0]["spdx_license_key"],
995            "MIT"
996        );
997        assert_eq!(json_value["license_references"][0]["key"], "mit");
998        assert_eq!(json_value["license_references"][0]["language"], "en");
999        assert_eq!(
1000            json_value["license_references"][0]["owner"],
1001            "Example Owner"
1002        );
1003        assert_eq!(
1004            json_value["license_references"][0]["homepage_url"],
1005            "https://example.com/license"
1006        );
1007        assert_eq!(
1008            json_value["license_references"][0]["osi_license_key"],
1009            "MIT"
1010        );
1011        assert_eq!(
1012            json_value["license_references"][0]["text_urls"][0],
1013            "https://example.com/license.txt"
1014        );
1015        assert_eq!(
1016            json_value["license_rule_references"][0]["identifier"],
1017            "license-clue_1.RULE"
1018        );
1019        assert_eq!(
1020            json_value["license_rule_references"][0]["relevance"],
1021            Value::Null
1022        );
1023        assert_eq!(
1024            json_value["license_rule_references"][0]["length"],
1025            Value::from(0)
1026        );
1027
1028        let mut jsonl_bytes = Vec::new();
1029        writer_for_format(OutputFormat::JsonLines)
1030            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1031            .expect("json-lines write should succeed");
1032        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1033        assert!(
1034            rendered
1035                .lines()
1036                .any(|line| line.contains("\"license_references\""))
1037        );
1038        assert!(
1039            rendered
1040                .lines()
1041                .any(|line| line.contains("\"license_rule_references\""))
1042        );
1043    }
1044
1045    #[test]
1046    fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1047        let mut internal = sample_internal_output();
1048        internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1049            identifier: "mit-id".to_string(),
1050            license_expression: "mit".to_string(),
1051            license_expression_spdx: "MIT".to_string(),
1052            detection_count: 2,
1053            detection_log: vec![],
1054            reference_matches: vec![crate::models::Match {
1055                license_expression: "mit".to_string(),
1056                license_expression_spdx: "MIT".to_string(),
1057                from_file: Some("src/main.rs".to_string()),
1058                start_line: LineNumber::ONE,
1059                end_line: LineNumber::new(3).unwrap(),
1060                matcher: Some("1-hash".to_string()),
1061                score: MatchScore::MAX,
1062                matched_length: Some(10),
1063                match_coverage: Some(100.0),
1064                rule_relevance: Some(100),
1065                rule_identifier: Some("mit.LICENSE".to_string()),
1066                rule_url: None,
1067                matched_text: None,
1068                referenced_filenames: None,
1069                matched_text_diagnostics: None,
1070            }],
1071        }];
1072        let output = Output::from(&internal);
1073
1074        let mut json_bytes = Vec::new();
1075        writer_for_format(OutputFormat::Json)
1076            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1077            .expect("json write should succeed");
1078        let json_value: Value =
1079            serde_json::from_slice(&json_bytes).expect("json output should parse");
1080        assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1081        assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1082
1083        let mut jsonl_bytes = Vec::new();
1084        writer_for_format(OutputFormat::JsonLines)
1085            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1086            .expect("json-lines write should succeed");
1087        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1088        assert!(
1089            rendered
1090                .lines()
1091                .any(|line| line.contains("\"license_detections\""))
1092        );
1093    }
1094
1095    #[test]
1096    fn test_json_and_json_lines_writers_keep_empty_top_level_license_detections() {
1097        let output = Output::from(&sample_internal_output());
1098
1099        let mut json_bytes = Vec::new();
1100        writer_for_format(OutputFormat::Json)
1101            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1102            .expect("json write should succeed");
1103        let json_value: Value =
1104            serde_json::from_slice(&json_bytes).expect("json output should parse");
1105        assert_eq!(json_value["license_detections"], Value::Array(vec![]));
1106
1107        let mut jsonl_bytes = Vec::new();
1108        writer_for_format(OutputFormat::JsonLines)
1109            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1110            .expect("json-lines write should succeed");
1111        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1112        assert!(
1113            rendered
1114                .lines()
1115                .any(|line| line == r#"{"license_detections":[]}"#)
1116        );
1117    }
1118
1119    #[test]
1120    fn test_public_writer_normalizes_empty_package_maps_without_changing_schema_output() {
1121        let mut internal = sample_internal_output();
1122        internal.packages.push(Package::from_package_data(
1123            &PackageData {
1124                package_type: Some(crate::models::PackageType::Npm),
1125                name: Some("demo".to_string()),
1126                version: Some("1.0.0".to_string()),
1127                ..PackageData::default()
1128            },
1129            "scan/package.json".to_string(),
1130        ));
1131
1132        let output = Output::from(&internal);
1133        let raw_schema = serde_json::to_value(&output).expect("schema output should serialize");
1134        assert_eq!(
1135            raw_schema["packages"][0]["qualifiers"],
1136            serde_json::json!({})
1137        );
1138        assert_eq!(
1139            raw_schema["packages"][0]["extra_data"],
1140            serde_json::json!({})
1141        );
1142
1143        let mut bytes = Vec::new();
1144        writer_for_format(OutputFormat::Json)
1145            .write(&output, &mut bytes, &OutputWriteConfig::default())
1146            .expect("json write should succeed");
1147        let public_value: Value = serde_json::from_slice(&bytes).expect("public json should parse");
1148
1149        assert!(public_value["packages"][0]["qualifiers"].is_null());
1150        assert!(public_value["packages"][0]["extra_data"].is_null());
1151    }
1152
1153    #[test]
1154    fn test_cyclonedx_xml_writer_outputs_xml() {
1155        let output = Output::from(&sample_internal_output());
1156        let mut bytes = Vec::new();
1157        writer_for_format(OutputFormat::CycloneDxXml)
1158            .write(&output, &mut bytes, &OutputWriteConfig::default())
1159            .expect("cyclonedx xml write should succeed");
1160
1161        let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1162        assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
1163        assert!(rendered.contains("<components>"));
1164    }
1165
1166    #[test]
1167    fn test_cyclonedx_json_includes_component_license_expression() {
1168        let mut internal = sample_internal_output();
1169        internal.packages = vec![crate::models::Package {
1170            package_type: Some(crate::models::PackageType::Maven),
1171            namespace: Some("example".to_string()),
1172            name: Some("gradle-project".to_string()),
1173            version: Some("1.0.0".to_string()),
1174            qualifiers: None,
1175            subpath: None,
1176            primary_language: Some("Java".to_string()),
1177            description: None,
1178            release_date: None,
1179            parties: vec![],
1180            keywords: vec![],
1181            homepage_url: None,
1182            download_url: None,
1183            size: None,
1184            sha1: None,
1185            md5: None,
1186            sha256: None,
1187            sha512: None,
1188            bug_tracking_url: None,
1189            code_view_url: None,
1190            vcs_url: None,
1191            copyright: None,
1192            holder: None,
1193            declared_license_expression: Some("Apache-2.0".to_string()),
1194            declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1195            license_detections: vec![],
1196            other_license_expression: None,
1197            other_license_expression_spdx: None,
1198            other_license_detections: vec![],
1199            extracted_license_statement: Some("Apache-2.0".to_string()),
1200            notice_text: None,
1201            source_packages: vec![],
1202            is_private: false,
1203            is_virtual: false,
1204            extra_data: None,
1205            repository_homepage_url: None,
1206            repository_download_url: None,
1207            api_data_url: None,
1208            datasource_ids: vec![],
1209            purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1210            package_uid: PackageUid::from_raw(
1211                "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1212            ),
1213            datafile_paths: vec![],
1214        }];
1215        let output = Output::from(&internal);
1216
1217        let mut bytes = Vec::new();
1218        writer_for_format(OutputFormat::CycloneDxJson)
1219            .write(&output, &mut bytes, &OutputWriteConfig::default())
1220            .expect("cyclonedx json write should succeed");
1221
1222        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1223        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1224
1225        assert_eq!(
1226            value["components"][0]["licenses"][0]["expression"],
1227            "Apache-2.0"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_cyclonedx_external_references_are_deduplicated() {
1233        let mut internal = sample_internal_output();
1234        internal.packages = vec![Package::from_package_data(
1235            &PackageData {
1236                package_type: Some(crate::models::PackageType::Npm),
1237                name: Some("demo".to_string()),
1238                version: Some("1.0.0".to_string()),
1239                download_url: Some("https://example.com/download.tgz".to_string()),
1240                repository_download_url: Some("https://example.com/download.tgz".to_string()),
1241                homepage_url: Some("https://example.com".to_string()),
1242                repository_homepage_url: Some("https://example.com".to_string()),
1243                ..PackageData::default()
1244            },
1245            "scan/package.json".to_string(),
1246        )];
1247        let output = Output::from(&internal);
1248
1249        let mut json_bytes = Vec::new();
1250        writer_for_format(OutputFormat::CycloneDxJson)
1251            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1252            .expect("cyclonedx json write should succeed");
1253        let value: Value = serde_json::from_slice(&json_bytes).expect("valid cyclonedx json");
1254        let refs = value["components"][0]["externalReferences"]
1255            .as_array()
1256            .expect("external references should be an array");
1257        assert_eq!(refs.len(), 2);
1258
1259        let mut xml_bytes = Vec::new();
1260        writer_for_format(OutputFormat::CycloneDxXml)
1261            .write(&output, &mut xml_bytes, &OutputWriteConfig::default())
1262            .expect("cyclonedx xml write should succeed");
1263        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
1264        assert_eq!(xml.matches("https://example.com/download.tgz").count(), 1);
1265        assert_eq!(xml.matches("https://example.com</url>").count(), 1);
1266    }
1267
1268    #[test]
1269    fn test_spdx_prefers_single_detected_package_name_over_scan_root() {
1270        let mut internal = sample_internal_output();
1271        internal.packages = vec![Package::from_package_data(
1272            &PackageData {
1273                package_type: Some(crate::models::PackageType::Npm),
1274                name: Some("detected-package".to_string()),
1275                version: Some("1.0.0".to_string()),
1276                ..PackageData::default()
1277            },
1278            "scan/package.json".to_string(),
1279        )];
1280        let output = Output::from(&internal);
1281
1282        let mut tv_bytes = Vec::new();
1283        writer_for_format(OutputFormat::SpdxTv)
1284            .write(
1285                &output,
1286                &mut tv_bytes,
1287                &OutputWriteConfig {
1288                    format: OutputFormat::SpdxTv,
1289                    custom_template: None,
1290                    scanned_path: Some("scan-root".to_string()),
1291                },
1292            )
1293            .expect("spdx tv write should succeed");
1294        let tv = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
1295        assert!(tv.contains("PackageName: detected-package"));
1296        assert!(tv.contains("DocumentNamespace: http://spdx.org/spdxdocs/detected-package"));
1297
1298        let mut rdf_bytes = Vec::new();
1299        writer_for_format(OutputFormat::SpdxRdf)
1300            .write(
1301                &output,
1302                &mut rdf_bytes,
1303                &OutputWriteConfig {
1304                    format: OutputFormat::SpdxRdf,
1305                    custom_template: None,
1306                    scanned_path: Some("scan-root".to_string()),
1307                },
1308            )
1309            .expect("spdx rdf write should succeed");
1310        let rdf = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
1311        assert!(rdf.contains("<spdx:name>detected-package</spdx:name>"));
1312    }
1313
1314    #[test]
1315    fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1316        let output = Output {
1317            summary: None,
1318            tallies: None,
1319            tallies_of_key_files: None,
1320            tallies_by_facet: None,
1321            headers: vec![],
1322            packages: vec![],
1323            dependencies: vec![],
1324            license_detections: vec![],
1325            files: vec![],
1326            license_references: vec![],
1327            license_rule_references: vec![],
1328        };
1329        let mut bytes = Vec::new();
1330        writer_for_format(OutputFormat::SpdxTv)
1331            .write(
1332                &output,
1333                &mut bytes,
1334                &OutputWriteConfig {
1335                    format: OutputFormat::SpdxTv,
1336                    custom_template: None,
1337                    scanned_path: Some("scan".to_string()),
1338                },
1339            )
1340            .expect("spdx tv write should succeed");
1341
1342        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1343        assert_eq!(rendered, "# No results for package 'scan'.\n");
1344    }
1345
1346    #[test]
1347    fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1348        let output = Output {
1349            summary: None,
1350            tallies: None,
1351            tallies_of_key_files: None,
1352            tallies_by_facet: None,
1353            headers: vec![],
1354            packages: vec![],
1355            dependencies: vec![],
1356            license_detections: vec![],
1357            files: vec![],
1358            license_references: vec![],
1359            license_rule_references: vec![],
1360        };
1361        let mut bytes = Vec::new();
1362        writer_for_format(OutputFormat::SpdxRdf)
1363            .write(
1364                &output,
1365                &mut bytes,
1366                &OutputWriteConfig {
1367                    format: OutputFormat::SpdxRdf,
1368                    custom_template: None,
1369                    scanned_path: Some("scan".to_string()),
1370                },
1371            )
1372            .expect("spdx rdf write should succeed");
1373
1374        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1375        assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1376    }
1377
1378    #[test]
1379    fn test_html_writer_outputs_html_document() {
1380        let output = Output::from(&sample_internal_output());
1381        let mut bytes = Vec::new();
1382        writer_for_format(OutputFormat::Html)
1383            .write(&output, &mut bytes, &OutputWriteConfig::default())
1384            .expect("html write should succeed");
1385        let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1386        assert!(rendered.contains("<!doctype html>"));
1387        assert!(rendered.contains("Provenant HTML Report"));
1388    }
1389
1390    #[test]
1391    fn test_custom_template_writer_renders_output_context() {
1392        let output = Output::from(&sample_internal_output());
1393        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1394        let template_path = temp_dir.path().join("template.tera");
1395        fs::write(
1396            &template_path,
1397            "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1398        )
1399        .expect("template should be written");
1400
1401        let mut bytes = Vec::new();
1402        writer_for_format(OutputFormat::CustomTemplate)
1403            .write(
1404                &output,
1405                &mut bytes,
1406                &OutputWriteConfig {
1407                    format: OutputFormat::CustomTemplate,
1408                    custom_template: Some(template_path.to_string_lossy().to_string()),
1409                    scanned_path: None,
1410                },
1411            )
1412            .expect("custom template write should succeed");
1413
1414        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1415        assert!(rendered.contains("version=4.1.0"));
1416        assert!(rendered.contains("files=1"));
1417    }
1418
1419    fn sample_internal_output() -> crate::models::Output {
1420        crate::models::Output {
1421            summary: None,
1422            tallies: None,
1423            tallies_of_key_files: None,
1424            tallies_by_facet: None,
1425            headers: vec![Header {
1426                tool_name: "provenant".to_string(),
1427                tool_version: crate::version::BUILD_VERSION.to_string(),
1428                options: serde_json::Map::new(),
1429                notice: crate::models::HEADER_NOTICE.to_string(),
1430                start_timestamp: "2026-01-01T000000.000000".to_string(),
1431                end_timestamp: "2026-01-01T000001.000000".to_string(),
1432                output_format_version: "4.1.0".to_string(),
1433                duration: 1.0,
1434                errors: vec![],
1435                warnings: vec![],
1436                extra_data: ExtraData {
1437                    system_environment: SystemEnvironment {
1438                        operating_system: "darwin".to_string(),
1439                        cpu_architecture: "aarch64".to_string(),
1440                        platform: "darwin".to_string(),
1441                        platform_version: "26.3.1".to_string(),
1442                        rust_version: "1.93.0".to_string(),
1443                    },
1444                    spdx_license_list_version: "3.27".to_string(),
1445                    files_count: 1,
1446                    directories_count: 1,
1447                    excluded_count: 0,
1448                    license_index_provenance: Some(crate::models::LicenseIndexProvenance {
1449                        source: "embedded-artifact".to_string(),
1450                        dataset_fingerprint: "test-fingerprint".to_string(),
1451                        ignored_rules: vec![
1452                            "gpl-2.0_and-unknown-license-reference_1.RULE".to_string(),
1453                        ],
1454                        ignored_licenses: vec![],
1455                        ignored_rules_due_to_licenses: vec![],
1456                        added_rules: vec![],
1457                        replaced_rules: vec![],
1458                        added_licenses: vec![],
1459                        replaced_licenses: vec![],
1460                    }),
1461                },
1462            }],
1463            packages: vec![],
1464            dependencies: vec![],
1465            license_detections: vec![],
1466            files: vec![FileInfo::new(
1467                "main.rs".to_string(),
1468                "main".to_string(),
1469                "rs".to_string(),
1470                "src/main.rs".to_string(),
1471                FileType::File,
1472                Some("text/plain".to_string()),
1473                None,
1474                42,
1475                None,
1476                Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1477                Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1478                Some(
1479                    Sha256Digest::from_hex(
1480                        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1481                    )
1482                    .unwrap(),
1483                ),
1484                Some("Rust".to_string()),
1485                vec![PackageData::default()],
1486                None,
1487                vec![LicenseDetection {
1488                    license_expression: "mit".to_string(),
1489                    license_expression_spdx: "MIT".to_string(),
1490                    matches: vec![Match {
1491                        license_expression: "mit".to_string(),
1492                        license_expression_spdx: "MIT".to_string(),
1493                        from_file: None,
1494                        start_line: LineNumber::ONE,
1495                        end_line: LineNumber::ONE,
1496                        matcher: None,
1497                        score: MatchScore::MAX,
1498                        matched_length: None,
1499                        match_coverage: None,
1500                        rule_relevance: None,
1501                        rule_identifier: Some("mit_rule".to_string()),
1502                        rule_url: None,
1503                        matched_text: None,
1504                        referenced_filenames: None,
1505                        matched_text_diagnostics: None,
1506                    }],
1507                    detection_log: vec![],
1508                    identifier: None,
1509                }],
1510                vec![],
1511                vec![Copyright {
1512                    copyright: "Copyright (c) Example".to_string(),
1513                    start_line: LineNumber::ONE,
1514                    end_line: LineNumber::ONE,
1515                }],
1516                vec![Holder {
1517                    holder: "Example Org".to_string(),
1518                    start_line: LineNumber::ONE,
1519                    end_line: LineNumber::ONE,
1520                }],
1521                vec![Author {
1522                    author: "Jane Doe".to_string(),
1523                    start_line: LineNumber::ONE,
1524                    end_line: LineNumber::ONE,
1525                }],
1526                vec![OutputEmail {
1527                    email: "jane@example.com".to_string(),
1528                    start_line: LineNumber::ONE,
1529                    end_line: LineNumber::ONE,
1530                }],
1531                vec![OutputURL {
1532                    url: "https://example.com".to_string(),
1533                    start_line: LineNumber::ONE,
1534                    end_line: LineNumber::ONE,
1535                }],
1536                vec![],
1537                vec![],
1538            )],
1539            license_references: vec![],
1540            license_rule_references: vec![],
1541        }
1542    }
1543}