Skip to main content

provenant/output/
mod.rs

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