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