Skip to main content

provenant/output/
mod.rs

1use std::fs::File;
2use std::io::{self, BufWriter, Write};
3
4use crate::models::Output;
5
6mod cyclonedx;
7mod debian;
8mod html;
9mod jsonl;
10mod shared;
11mod spdx;
12mod template;
13
14pub(crate) const EMPTY_SHA1: &str = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
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, output).map_err(shared::io_other)?;
68                writer.write_all(b"\n")
69            }
70            OutputFormat::JsonPretty => {
71                serde_json::to_writer_pretty(&mut *writer, output).map_err(shared::io_other)?;
72                writer.write_all(b"\n")
73            }
74            OutputFormat::Yaml => write_yaml(output, writer),
75            OutputFormat::JsonLines => jsonl::write_json_lines(output, writer),
76            OutputFormat::Debian => debian::write_debian_copyright(output, writer),
77            OutputFormat::Html => html::write_html_report(output, writer),
78            OutputFormat::CustomTemplate => template::write_custom_template(output, writer, config),
79            OutputFormat::SpdxTv => spdx::write_spdx_tag_value(output, writer, config),
80            OutputFormat::SpdxRdf => spdx::write_spdx_rdf_xml(output, writer, config),
81            OutputFormat::CycloneDxJson => cyclonedx::write_cyclonedx_json(output, writer),
82            OutputFormat::CycloneDxXml => cyclonedx::write_cyclonedx_xml(output, writer),
83        }
84    }
85}
86
87pub fn write_output_file(
88    output_file: &str,
89    output: &Output,
90    config: &OutputWriteConfig,
91) -> io::Result<()> {
92    if output_file == "-" {
93        let stdout = io::stdout();
94        let handle = stdout.lock();
95        let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, handle);
96        writer_for_format(config.format).write(output, &mut writer, config)?;
97        return writer.flush();
98    }
99
100    let file = File::create(output_file)?;
101    let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, file);
102    writer_for_format(config.format).write(output, &mut writer, config)?;
103    writer.flush()
104}
105
106fn write_yaml(output: &Output, writer: &mut dyn Write) -> io::Result<()> {
107    yaml_serde::to_writer(&mut *writer, output).map_err(shared::io_other)?;
108    writer.write_all(b"\n")
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use serde_json::Value;
115    use std::fs;
116
117    use crate::models::{
118        Author, Copyright, ExtraData, FileInfo, FileType, Header, Holder, LicenseDetection, Match,
119        OutputEmail, OutputURL, PackageData, SystemEnvironment,
120    };
121
122    #[test]
123    fn test_yaml_writer_outputs_yaml() {
124        let output = sample_output();
125        let mut bytes = Vec::new();
126        writer_for_format(OutputFormat::Yaml)
127            .write(&output, &mut bytes, &OutputWriteConfig::default())
128            .expect("yaml write should succeed");
129        let rendered = String::from_utf8(bytes).expect("yaml should be utf-8");
130        assert!(rendered.contains("headers:"));
131        assert!(rendered.contains("files:"));
132    }
133
134    #[test]
135    fn test_json_lines_writer_outputs_parseable_lines() {
136        let output = sample_output();
137        let mut bytes = Vec::new();
138        writer_for_format(OutputFormat::JsonLines)
139            .write(&output, &mut bytes, &OutputWriteConfig::default())
140            .expect("json-lines write should succeed");
141
142        let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
143        let lines = rendered.lines().collect::<Vec<_>>();
144        assert!(lines.len() >= 2);
145        for line in lines {
146            serde_json::from_str::<Value>(line).expect("each line should be valid json");
147        }
148    }
149
150    #[test]
151    fn test_debian_writer_outputs_dep5_style_document() {
152        let mut output = sample_output();
153        output.files[0].license_expression = Some("mit".to_string());
154        output.files[0].license_detections[0].matches[0].matched_text = Some(
155            "Permission is hereby granted, free of charge, to any person obtaining a copy"
156                .to_string(),
157        );
158
159        let mut bytes = Vec::new();
160        writer_for_format(OutputFormat::Debian)
161            .write(&output, &mut bytes, &OutputWriteConfig::default())
162            .expect("debian write should succeed");
163
164        let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
165        assert!(rendered.contains(
166            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"
167        ));
168        assert!(rendered.contains("Comment: Generated with Provenant"));
169        assert!(rendered.contains("Files: src/main.rs"));
170        assert!(rendered.contains("Copyright: Example Org"));
171        assert!(rendered.contains("License: mit"));
172        assert!(rendered.contains(" Permission is hereby granted, free of charge"));
173    }
174
175    #[test]
176    fn test_debian_writer_skips_directories_and_deduplicates_license_texts() {
177        let mut output = sample_output();
178        output.files.insert(
179            0,
180            FileInfo::new(
181                "src".to_string(),
182                "src".to_string(),
183                String::new(),
184                "src".to_string(),
185                FileType::Directory,
186                None,
187                None,
188                0,
189                None,
190                None,
191                None,
192                None,
193                None,
194                vec![],
195                None,
196                vec![],
197                vec![],
198                vec![],
199                vec![],
200                vec![],
201                vec![],
202                vec![],
203                vec![],
204                vec![],
205            ),
206        );
207        output.files[1].license_expression = Some("mit".to_string());
208        output.files[1].license_detections[0].matches[0].matched_text =
209            Some("Same text".to_string());
210        output.files[1].license_detections[0].matches.push(Match {
211            license_expression: "mit".to_string(),
212            license_expression_spdx: "MIT".to_string(),
213            from_file: Some("src/main.rs".to_string()),
214            start_line: 1,
215            end_line: 1,
216            matcher: Some("2-aho".to_string()),
217            score: 100.0,
218            matched_length: Some(1),
219            match_coverage: Some(100.0),
220            rule_relevance: Some(100),
221            rule_identifier: Some("mit_rule".to_string()),
222            rule_url: None,
223            matched_text: Some("Same text again".to_string()),
224            referenced_filenames: None,
225            matched_text_diagnostics: None,
226        });
227
228        let mut bytes = Vec::new();
229        writer_for_format(OutputFormat::Debian)
230            .write(&output, &mut bytes, &OutputWriteConfig::default())
231            .expect("debian write should succeed");
232
233        let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
234        assert!(!rendered.contains("Files: src\n"));
235        assert_eq!(rendered.matches(" Same text").count(), 1);
236    }
237
238    #[test]
239    fn test_file_info_serialization_omits_info_fields_when_unset() {
240        let file = FileInfo::new(
241            "main.rs".to_string(),
242            "main".to_string(),
243            "rs".to_string(),
244            "src/main.rs".to_string(),
245            FileType::File,
246            None,
247            None,
248            42,
249            None,
250            None,
251            None,
252            None,
253            None,
254            vec![],
255            None,
256            vec![],
257            vec![],
258            vec![],
259            vec![],
260            vec![],
261            vec![],
262            vec![],
263            vec![],
264            vec![],
265        );
266
267        let value = serde_json::to_value(&file).expect("file info serializes");
268        let object = value.as_object().expect("file info object");
269
270        assert!(!object.contains_key("date"));
271        assert!(!object.contains_key("sha1"));
272        assert!(!object.contains_key("md5"));
273        assert!(!object.contains_key("sha256"));
274        assert!(!object.contains_key("sha1_git"));
275        assert!(!object.contains_key("mime_type"));
276        assert!(!object.contains_key("file_type"));
277        assert!(!object.contains_key("programming_language"));
278        assert!(!object.contains_key("is_binary"));
279        assert!(!object.contains_key("is_text"));
280        assert!(!object.contains_key("is_archive"));
281        assert!(!object.contains_key("is_media"));
282        assert!(!object.contains_key("is_source"));
283        assert!(!object.contains_key("is_script"));
284        assert!(!object.contains_key("files_count"));
285        assert!(!object.contains_key("dirs_count"));
286        assert!(!object.contains_key("size_count"));
287        assert!(!object.contains_key("license_policy"));
288    }
289
290    #[test]
291    fn test_file_info_serialization_keeps_license_policy_when_enabled() {
292        let mut file = FileInfo::new(
293            "main.rs".to_string(),
294            "main".to_string(),
295            "rs".to_string(),
296            "src/main.rs".to_string(),
297            FileType::File,
298            Some("text/plain".to_string()),
299            Some("text".to_string()),
300            42,
301            Some("2026-01-01T00:00:00Z".to_string()),
302            Some(EMPTY_SHA1.to_string()),
303            Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
304            Some("e3b0c44298fc1c149afbf4c8996fb924".to_string()),
305            Some("Rust".to_string()),
306            vec![],
307            None,
308            vec![],
309            vec![],
310            vec![],
311            vec![],
312            vec![],
313            vec![],
314            vec![],
315            vec![],
316            vec![],
317        );
318        file.license_policy = Some(vec![]);
319        file.sha1_git = Some(EMPTY_SHA1.to_string());
320        file.is_binary = Some(false);
321        file.is_text = Some(true);
322        file.is_archive = Some(false);
323        file.is_media = Some(false);
324        file.is_source = Some(true);
325        file.is_script = Some(false);
326        file.files_count = Some(0);
327        file.dirs_count = Some(0);
328        file.size_count = Some(0);
329
330        let value = serde_json::to_value(&file).expect("file info serializes");
331        let object = value.as_object().expect("file info object");
332
333        assert_eq!(object.get("license_policy"), Some(&serde_json::json!([])));
334        assert_eq!(object.get("file_type"), Some(&serde_json::json!("text")));
335        assert_eq!(object.get("is_binary"), Some(&serde_json::json!(false)));
336        assert_eq!(object.get("is_text"), Some(&serde_json::json!(true)));
337        assert_eq!(object.get("files_count"), Some(&serde_json::json!(0)));
338        assert_eq!(object.get("dirs_count"), Some(&serde_json::json!(0)));
339        assert_eq!(object.get("size_count"), Some(&serde_json::json!(0)));
340    }
341
342    #[test]
343    fn test_json_lines_writer_sorts_files_by_path_for_reproducibility() {
344        let mut output = sample_output();
345        output.files.reverse();
346        let mut bytes = Vec::new();
347        writer_for_format(OutputFormat::JsonLines)
348            .write(&output, &mut bytes, &OutputWriteConfig::default())
349            .expect("json-lines write should succeed");
350
351        let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
352        let file_lines = rendered
353            .lines()
354            .filter_map(|line| {
355                let value: Value = serde_json::from_str(line).ok()?;
356                let files = value.get("files")?.as_array()?;
357                files.first()?.get("path")?.as_str().map(str::to_string)
358            })
359            .collect::<Vec<_>>();
360
361        let mut sorted = file_lines.clone();
362        sorted.sort();
363        assert_eq!(file_lines, sorted);
364    }
365
366    #[test]
367    fn test_spdx_tag_value_writer_contains_required_fields() {
368        let output = sample_output();
369        let mut bytes = Vec::new();
370        writer_for_format(OutputFormat::SpdxTv)
371            .write(
372                &output,
373                &mut bytes,
374                &OutputWriteConfig {
375                    format: OutputFormat::SpdxTv,
376                    custom_template: None,
377                    scanned_path: Some("scan".to_string()),
378                },
379            )
380            .expect("spdx tv write should succeed");
381
382        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
383        assert!(rendered.contains("SPDXVersion: SPDX-2.2"));
384        assert!(rendered.contains("FileName: ./src/main.rs"));
385    }
386
387    #[test]
388    fn test_spdx_rdf_writer_outputs_xml() {
389        let output = sample_output();
390        let mut bytes = Vec::new();
391        writer_for_format(OutputFormat::SpdxRdf)
392            .write(
393                &output,
394                &mut bytes,
395                &OutputWriteConfig {
396                    format: OutputFormat::SpdxRdf,
397                    custom_template: None,
398                    scanned_path: Some("scan".to_string()),
399                },
400            )
401            .expect("spdx rdf write should succeed");
402
403        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
404        assert!(rendered.contains("<rdf:RDF"));
405        assert!(rendered.contains("<spdx:SpdxDocument"));
406    }
407
408    #[test]
409    fn test_spdx_writers_emit_real_file_and_package_license_info() {
410        let output = sample_output();
411
412        let mut tv_bytes = Vec::new();
413        writer_for_format(OutputFormat::SpdxTv)
414            .write(
415                &output,
416                &mut tv_bytes,
417                &OutputWriteConfig {
418                    format: OutputFormat::SpdxTv,
419                    custom_template: None,
420                    scanned_path: Some("scan".to_string()),
421                },
422            )
423            .expect("spdx tv write should succeed");
424        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
425        assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
426        assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
427        assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
428        assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
429        assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
430
431        let mut rdf_bytes = Vec::new();
432        writer_for_format(OutputFormat::SpdxRdf)
433            .write(
434                &output,
435                &mut rdf_bytes,
436                &OutputWriteConfig {
437                    format: OutputFormat::SpdxRdf,
438                    custom_template: None,
439                    scanned_path: Some("scan".to_string()),
440                },
441            )
442            .expect("spdx rdf write should succeed");
443        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
444        assert!(rdf_rendered.contains(
445            "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
446        ));
447        assert!(
448            rdf_rendered.contains(
449                "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
450            )
451        );
452        assert!(rdf_rendered.contains(
453            "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
454        ));
455    }
456
457    #[test]
458    fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
459        let mut output = sample_output();
460        output.files[0].license_detections = vec![LicenseDetection {
461            license_expression: "unknown-license-reference".to_string(),
462            license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
463            matches: vec![Match {
464                license_expression: "unknown-license-reference".to_string(),
465                license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
466                    .to_string(),
467                from_file: Some("src/main.rs".to_string()),
468                start_line: 1,
469                end_line: 2,
470                matcher: Some("2-aho".to_string()),
471                score: 100.0,
472                matched_length: Some(4),
473                match_coverage: Some(100.0),
474                rule_relevance: Some(100),
475                rule_identifier: Some("unknown-license-reference.RULE".to_string()),
476                rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
477                matched_text: Some("Custom license text".to_string()),
478                referenced_filenames: Some(vec!["LICENSE".to_string()]),
479                matched_text_diagnostics: None,
480            }],
481            detection_log: vec![],
482            identifier: Some("unknown-ref-id".to_string()),
483        }];
484        output.license_references = vec![crate::models::LicenseReference {
485            key: Some("unknown-license-reference".to_string()),
486            language: Some("en".to_string()),
487            name: "Unknown License Reference".to_string(),
488            short_name: "Unknown License Reference".to_string(),
489            owner: None,
490            homepage_url: None,
491            spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
492            other_spdx_license_keys: vec![],
493            osi_license_key: None,
494            text_urls: vec![],
495            osi_url: None,
496            faq_url: None,
497            other_urls: vec![],
498            category: None,
499            is_exception: false,
500            is_unknown: true,
501            is_generic: false,
502            notes: None,
503            minimum_coverage: None,
504            standard_notice: None,
505            ignorable_copyrights: vec![],
506            ignorable_holders: vec![],
507            ignorable_authors: vec![],
508            ignorable_urls: vec![],
509            ignorable_emails: vec![],
510            scancode_url: None,
511            licensedb_url: None,
512            spdx_url: None,
513            text: "Unused fallback text".to_string(),
514        }];
515
516        let mut tv_bytes = Vec::new();
517        writer_for_format(OutputFormat::SpdxTv)
518            .write(
519                &output,
520                &mut tv_bytes,
521                &OutputWriteConfig {
522                    format: OutputFormat::SpdxTv,
523                    custom_template: None,
524                    scanned_path: Some("scan".to_string()),
525                },
526            )
527            .expect("spdx tv write should succeed");
528        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
529        assert!(
530            tv_rendered
531                .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
532        );
533        assert!(tv_rendered.contains(
534            "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
535        ));
536        assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
537        assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
538        assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
539        assert!(tv_rendered.contains(
540            "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
541        ));
542
543        let mut rdf_bytes = Vec::new();
544        writer_for_format(OutputFormat::SpdxRdf)
545            .write(
546                &output,
547                &mut rdf_bytes,
548                &OutputWriteConfig {
549                    format: OutputFormat::SpdxRdf,
550                    custom_template: None,
551                    scanned_path: Some("scan".to_string()),
552                },
553            )
554            .expect("spdx rdf write should succeed");
555        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
556        assert!(rdf_rendered.contains(
557            "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/LicenseRef-scancode-unknown-license-reference\"/>"
558        ));
559        assert!(rdf_rendered.contains(
560            "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"#LicenseRef-scancode-unknown-license-reference\">"
561        ));
562        assert!(
563            rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
564        );
565    }
566
567    #[test]
568    fn test_cyclonedx_json_writer_outputs_bom() {
569        let output = sample_output();
570        let mut bytes = Vec::new();
571        writer_for_format(OutputFormat::CycloneDxJson)
572            .write(&output, &mut bytes, &OutputWriteConfig::default())
573            .expect("cyclonedx json write should succeed");
574
575        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
576        let value: Value = serde_json::from_str(&rendered).expect("valid json");
577        assert_eq!(value["bomFormat"], "CycloneDX");
578        assert_eq!(value["specVersion"], "1.3");
579    }
580
581    #[test]
582    fn test_json_writer_includes_summary_and_key_file_flags() {
583        let mut output = sample_output();
584        output.summary = Some(crate::models::Summary {
585            declared_license_expression: Some("apache-2.0".to_string()),
586            license_clarity_score: Some(crate::models::LicenseClarityScore {
587                score: 100,
588                declared_license: true,
589                identification_precision: true,
590                has_license_text: true,
591                declared_copyrights: true,
592                conflicting_license_categories: false,
593                ambiguous_compound_licensing: false,
594            }),
595            declared_holder: Some("Example Corp.".to_string()),
596            primary_language: Some("Ruby".to_string()),
597            other_license_expressions: vec![crate::models::TallyEntry {
598                value: Some("mit".to_string()),
599                count: 1,
600            }],
601            other_holders: vec![
602                crate::models::TallyEntry {
603                    value: None,
604                    count: 2,
605                },
606                crate::models::TallyEntry {
607                    value: Some("Other Corp.".to_string()),
608                    count: 1,
609                },
610            ],
611            other_languages: vec![crate::models::TallyEntry {
612                value: Some("Python".to_string()),
613                count: 2,
614            }],
615        });
616        output.files[0].is_legal = true;
617        output.files[0].is_top_level = true;
618        output.files[0].is_key_file = true;
619
620        let mut bytes = Vec::new();
621        writer_for_format(OutputFormat::Json)
622            .write(&output, &mut bytes, &OutputWriteConfig::default())
623            .expect("json write should succeed");
624
625        let rendered = String::from_utf8(bytes).expect("json should be utf-8");
626        let value: Value = serde_json::from_str(&rendered).expect("valid json");
627
628        assert_eq!(
629            value["summary"]["declared_license_expression"],
630            "apache-2.0"
631        );
632        assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
633        assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
634        assert_eq!(value["summary"]["primary_language"], "Ruby");
635        assert_eq!(
636            value["summary"]["other_license_expressions"][0]["value"],
637            "mit"
638        );
639        assert!(value["summary"]["other_holders"][0]["value"].is_null());
640        assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
641        assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
642        assert_eq!(value["files"][0]["is_key_file"], true);
643    }
644
645    #[test]
646    fn test_json_and_json_lines_writers_include_top_level_tallies() {
647        let mut output = sample_output();
648        output.tallies = Some(crate::models::Tallies {
649            detected_license_expression: vec![crate::models::TallyEntry {
650                value: Some("mit".to_string()),
651                count: 2,
652            }],
653            copyrights: vec![crate::models::TallyEntry {
654                value: Some("Copyright (c) Example Org".to_string()),
655                count: 1,
656            }],
657            holders: vec![crate::models::TallyEntry {
658                value: Some("Example Org".to_string()),
659                count: 1,
660            }],
661            authors: vec![crate::models::TallyEntry {
662                value: Some("Jane Doe".to_string()),
663                count: 1,
664            }],
665            programming_language: vec![crate::models::TallyEntry {
666                value: Some("Rust".to_string()),
667                count: 1,
668            }],
669        });
670
671        let mut json_bytes = Vec::new();
672        writer_for_format(OutputFormat::Json)
673            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
674            .expect("json write should succeed");
675        let json_value: Value =
676            serde_json::from_slice(&json_bytes).expect("json output should parse");
677        assert_eq!(
678            json_value["tallies"]["detected_license_expression"][0]["value"],
679            "mit"
680        );
681        assert_eq!(
682            json_value["tallies"]["programming_language"][0]["value"],
683            "Rust"
684        );
685
686        let mut jsonl_bytes = Vec::new();
687        writer_for_format(OutputFormat::JsonLines)
688            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
689            .expect("json-lines write should succeed");
690        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
691        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
692    }
693
694    #[test]
695    fn test_json_and_json_lines_writers_include_key_file_tallies() {
696        let mut output = sample_output();
697        output.tallies_of_key_files = Some(crate::models::Tallies {
698            detected_license_expression: vec![crate::models::TallyEntry {
699                value: Some("apache-2.0".to_string()),
700                count: 1,
701            }],
702            copyrights: vec![],
703            holders: vec![],
704            authors: vec![],
705            programming_language: vec![crate::models::TallyEntry {
706                value: Some("Markdown".to_string()),
707                count: 1,
708            }],
709        });
710
711        let mut json_bytes = Vec::new();
712        writer_for_format(OutputFormat::Json)
713            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
714            .expect("json write should succeed");
715        let json_value: Value =
716            serde_json::from_slice(&json_bytes).expect("json output should parse");
717        assert_eq!(
718            json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
719            "apache-2.0"
720        );
721
722        let mut jsonl_bytes = Vec::new();
723        writer_for_format(OutputFormat::JsonLines)
724            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
725            .expect("json-lines write should succeed");
726        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
727        assert!(
728            rendered
729                .lines()
730                .any(|line| line.contains("\"tallies_of_key_files\""))
731        );
732    }
733
734    #[test]
735    fn test_json_and_json_lines_writers_include_file_tallies() {
736        let mut output = sample_output();
737        output.files[0].tallies = Some(crate::models::Tallies {
738            detected_license_expression: vec![crate::models::TallyEntry {
739                value: Some("mit".to_string()),
740                count: 1,
741            }],
742            copyrights: vec![crate::models::TallyEntry {
743                value: None,
744                count: 1,
745            }],
746            holders: vec![],
747            authors: vec![],
748            programming_language: vec![crate::models::TallyEntry {
749                value: Some("Rust".to_string()),
750                count: 1,
751            }],
752        });
753
754        let mut json_bytes = Vec::new();
755        writer_for_format(OutputFormat::Json)
756            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
757            .expect("json write should succeed");
758        let json_value: Value =
759            serde_json::from_slice(&json_bytes).expect("json output should parse");
760        assert_eq!(
761            json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
762            "mit"
763        );
764
765        let mut jsonl_bytes = Vec::new();
766        writer_for_format(OutputFormat::JsonLines)
767            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
768            .expect("json-lines write should succeed");
769        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
770        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
771    }
772
773    #[test]
774    fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
775        let mut output = sample_output();
776        output.files[0].facets = vec!["core".to_string(), "docs".to_string()];
777        output.tallies_by_facet = Some(vec![crate::models::FacetTallies {
778            facet: "core".to_string(),
779            tallies: crate::models::Tallies {
780                detected_license_expression: vec![crate::models::TallyEntry {
781                    value: Some("mit".to_string()),
782                    count: 1,
783                }],
784                copyrights: vec![],
785                holders: vec![],
786                authors: vec![],
787                programming_language: vec![],
788            },
789        }]);
790
791        let mut json_bytes = Vec::new();
792        writer_for_format(OutputFormat::Json)
793            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
794            .expect("json write should succeed");
795        let json_value: Value =
796            serde_json::from_slice(&json_bytes).expect("json output should parse");
797        assert_eq!(json_value["files"][0]["facets"][0], "core");
798        assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
799
800        let mut jsonl_bytes = Vec::new();
801        writer_for_format(OutputFormat::JsonLines)
802            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
803            .expect("json-lines write should succeed");
804        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
805        assert!(
806            rendered
807                .lines()
808                .any(|line| line.contains("\"tallies_by_facet\""))
809        );
810    }
811
812    #[test]
813    fn test_json_and_json_lines_writers_include_top_level_license_references() {
814        let mut output = sample_output();
815        output.license_references = vec![crate::models::LicenseReference {
816            key: Some("mit".to_string()),
817            language: Some("en".to_string()),
818            name: "MIT License".to_string(),
819            short_name: "MIT".to_string(),
820            owner: Some("Example Owner".to_string()),
821            homepage_url: Some("https://example.com/license".to_string()),
822            spdx_license_key: "MIT".to_string(),
823            other_spdx_license_keys: vec![],
824            osi_license_key: Some("MIT".to_string()),
825            text_urls: vec!["https://example.com/license.txt".to_string()],
826            osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
827            faq_url: None,
828            other_urls: vec![],
829            category: None,
830            is_exception: false,
831            is_unknown: false,
832            is_generic: false,
833            notes: None,
834            minimum_coverage: None,
835            standard_notice: None,
836            ignorable_copyrights: vec![],
837            ignorable_holders: vec![],
838            ignorable_authors: vec![],
839            ignorable_urls: vec![],
840            ignorable_emails: vec![],
841            scancode_url: None,
842            licensedb_url: None,
843            spdx_url: None,
844            text: "MIT text".to_string(),
845        }];
846        output.license_rule_references = vec![crate::models::LicenseRuleReference {
847            identifier: "license-clue_1.RULE".to_string(),
848            license_expression: "unknown-license-reference".to_string(),
849            is_license_text: false,
850            is_license_notice: false,
851            is_license_reference: false,
852            is_license_tag: false,
853            is_license_clue: true,
854            is_license_intro: false,
855            language: None,
856            rule_url: None,
857            is_required_phrase: false,
858            skip_for_required_phrase_generation: false,
859            replaced_by: vec![],
860            is_continuous: false,
861            is_synthetic: false,
862            is_from_license: false,
863            length: 0,
864            relevance: None,
865            minimum_coverage: None,
866            referenced_filenames: vec![],
867            notes: None,
868            ignorable_copyrights: vec![],
869            ignorable_holders: vec![],
870            ignorable_authors: vec![],
871            ignorable_urls: vec![],
872            ignorable_emails: vec![],
873            text: None,
874        }];
875
876        let mut json_bytes = Vec::new();
877        writer_for_format(OutputFormat::Json)
878            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
879            .expect("json write should succeed");
880        let json_value: Value =
881            serde_json::from_slice(&json_bytes).expect("json output should parse");
882        assert_eq!(
883            json_value["license_references"][0]["spdx_license_key"],
884            "MIT"
885        );
886        assert_eq!(json_value["license_references"][0]["key"], "mit");
887        assert_eq!(json_value["license_references"][0]["language"], "en");
888        assert_eq!(
889            json_value["license_references"][0]["owner"],
890            "Example Owner"
891        );
892        assert_eq!(
893            json_value["license_references"][0]["homepage_url"],
894            "https://example.com/license"
895        );
896        assert_eq!(
897            json_value["license_references"][0]["osi_license_key"],
898            "MIT"
899        );
900        assert_eq!(
901            json_value["license_references"][0]["text_urls"][0],
902            "https://example.com/license.txt"
903        );
904        assert_eq!(
905            json_value["license_rule_references"][0]["identifier"],
906            "license-clue_1.RULE"
907        );
908        assert_eq!(
909            json_value["license_rule_references"][0]["relevance"],
910            Value::Null
911        );
912        assert_eq!(
913            json_value["license_rule_references"][0]["length"],
914            Value::from(0)
915        );
916
917        let mut jsonl_bytes = Vec::new();
918        writer_for_format(OutputFormat::JsonLines)
919            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
920            .expect("json-lines write should succeed");
921        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
922        assert!(
923            rendered
924                .lines()
925                .any(|line| line.contains("\"license_references\""))
926        );
927        assert!(
928            rendered
929                .lines()
930                .any(|line| line.contains("\"license_rule_references\""))
931        );
932    }
933
934    #[test]
935    fn test_json_and_json_lines_writers_include_top_level_license_detections() {
936        let mut output = sample_output();
937        output.license_detections = vec![crate::models::TopLevelLicenseDetection {
938            identifier: "mit-id".to_string(),
939            license_expression: "mit".to_string(),
940            license_expression_spdx: "MIT".to_string(),
941            detection_count: 2,
942            detection_log: vec![],
943            reference_matches: vec![crate::models::Match {
944                license_expression: "mit".to_string(),
945                license_expression_spdx: "MIT".to_string(),
946                from_file: Some("src/main.rs".to_string()),
947                start_line: 1,
948                end_line: 3,
949                matcher: Some("1-hash".to_string()),
950                score: 100.0,
951                matched_length: Some(10),
952                match_coverage: Some(100.0),
953                rule_relevance: Some(100),
954                rule_identifier: Some("mit.LICENSE".to_string()),
955                rule_url: None,
956                matched_text: None,
957                referenced_filenames: None,
958                matched_text_diagnostics: None,
959            }],
960        }];
961
962        let mut json_bytes = Vec::new();
963        writer_for_format(OutputFormat::Json)
964            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
965            .expect("json write should succeed");
966        let json_value: Value =
967            serde_json::from_slice(&json_bytes).expect("json output should parse");
968        assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
969        assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
970
971        let mut jsonl_bytes = Vec::new();
972        writer_for_format(OutputFormat::JsonLines)
973            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
974            .expect("json-lines write should succeed");
975        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
976        assert!(
977            rendered
978                .lines()
979                .any(|line| line.contains("\"license_detections\""))
980        );
981    }
982
983    #[test]
984    fn test_cyclonedx_xml_writer_outputs_xml() {
985        let output = sample_output();
986        let mut bytes = Vec::new();
987        writer_for_format(OutputFormat::CycloneDxXml)
988            .write(&output, &mut bytes, &OutputWriteConfig::default())
989            .expect("cyclonedx xml write should succeed");
990
991        let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
992        assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
993        assert!(rendered.contains("<components>"));
994    }
995
996    #[test]
997    fn test_cyclonedx_json_includes_component_license_expression() {
998        let mut output = sample_output();
999        output.packages = vec![crate::models::Package {
1000            package_type: Some(crate::models::PackageType::Maven),
1001            namespace: Some("example".to_string()),
1002            name: Some("gradle-project".to_string()),
1003            version: Some("1.0.0".to_string()),
1004            qualifiers: None,
1005            subpath: None,
1006            primary_language: Some("Java".to_string()),
1007            description: None,
1008            release_date: None,
1009            parties: vec![],
1010            keywords: vec![],
1011            homepage_url: None,
1012            download_url: None,
1013            size: None,
1014            sha1: None,
1015            md5: None,
1016            sha256: None,
1017            sha512: None,
1018            bug_tracking_url: None,
1019            code_view_url: None,
1020            vcs_url: None,
1021            copyright: None,
1022            holder: None,
1023            declared_license_expression: Some("Apache-2.0".to_string()),
1024            declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1025            license_detections: vec![],
1026            other_license_expression: None,
1027            other_license_expression_spdx: None,
1028            other_license_detections: vec![],
1029            extracted_license_statement: Some("Apache-2.0".to_string()),
1030            notice_text: None,
1031            source_packages: vec![],
1032            is_private: false,
1033            is_virtual: false,
1034            extra_data: None,
1035            repository_homepage_url: None,
1036            repository_download_url: None,
1037            api_data_url: None,
1038            datasource_ids: vec![],
1039            purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1040            package_uid: "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1041            datafile_paths: vec![],
1042        }];
1043
1044        let mut bytes = Vec::new();
1045        writer_for_format(OutputFormat::CycloneDxJson)
1046            .write(&output, &mut bytes, &OutputWriteConfig::default())
1047            .expect("cyclonedx json write should succeed");
1048
1049        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1050        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1051
1052        assert_eq!(
1053            value["components"][0]["licenses"][0]["expression"],
1054            "Apache-2.0"
1055        );
1056    }
1057
1058    #[test]
1059    fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1060        let output = Output {
1061            summary: None,
1062            tallies: None,
1063            tallies_of_key_files: None,
1064            tallies_by_facet: None,
1065            headers: vec![],
1066            packages: vec![],
1067            dependencies: vec![],
1068            license_detections: vec![],
1069            files: vec![],
1070            license_references: vec![],
1071            license_rule_references: vec![],
1072        };
1073        let mut bytes = Vec::new();
1074        writer_for_format(OutputFormat::SpdxTv)
1075            .write(
1076                &output,
1077                &mut bytes,
1078                &OutputWriteConfig {
1079                    format: OutputFormat::SpdxTv,
1080                    custom_template: None,
1081                    scanned_path: Some("scan".to_string()),
1082                },
1083            )
1084            .expect("spdx tv write should succeed");
1085
1086        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1087        assert_eq!(rendered, "# No results for package 'scan'.\n");
1088    }
1089
1090    #[test]
1091    fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1092        let output = Output {
1093            summary: None,
1094            tallies: None,
1095            tallies_of_key_files: None,
1096            tallies_by_facet: None,
1097            headers: vec![],
1098            packages: vec![],
1099            dependencies: vec![],
1100            license_detections: vec![],
1101            files: vec![],
1102            license_references: vec![],
1103            license_rule_references: vec![],
1104        };
1105        let mut bytes = Vec::new();
1106        writer_for_format(OutputFormat::SpdxRdf)
1107            .write(
1108                &output,
1109                &mut bytes,
1110                &OutputWriteConfig {
1111                    format: OutputFormat::SpdxRdf,
1112                    custom_template: None,
1113                    scanned_path: Some("scan".to_string()),
1114                },
1115            )
1116            .expect("spdx rdf write should succeed");
1117
1118        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1119        assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1120    }
1121
1122    #[test]
1123    fn test_html_writer_outputs_html_document() {
1124        let output = sample_output();
1125        let mut bytes = Vec::new();
1126        writer_for_format(OutputFormat::Html)
1127            .write(&output, &mut bytes, &OutputWriteConfig::default())
1128            .expect("html write should succeed");
1129        let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1130        assert!(rendered.contains("<!doctype html>"));
1131        assert!(rendered.contains("Custom Template"));
1132    }
1133
1134    #[test]
1135    fn test_custom_template_writer_renders_output_context() {
1136        let output = sample_output();
1137        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1138        let template_path = temp_dir.path().join("template.tera");
1139        fs::write(
1140            &template_path,
1141            "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1142        )
1143        .expect("template should be written");
1144
1145        let mut bytes = Vec::new();
1146        writer_for_format(OutputFormat::CustomTemplate)
1147            .write(
1148                &output,
1149                &mut bytes,
1150                &OutputWriteConfig {
1151                    format: OutputFormat::CustomTemplate,
1152                    custom_template: Some(template_path.to_string_lossy().to_string()),
1153                    scanned_path: None,
1154                },
1155            )
1156            .expect("custom template write should succeed");
1157
1158        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1159        assert!(rendered.contains("version=4.0.0"));
1160        assert!(rendered.contains("files=1"));
1161    }
1162
1163    fn sample_output() -> Output {
1164        Output {
1165            summary: None,
1166            tallies: None,
1167            tallies_of_key_files: None,
1168            tallies_by_facet: None,
1169            headers: vec![Header {
1170                start_timestamp: "2026-01-01T00:00:00Z".to_string(),
1171                end_timestamp: "2026-01-01T00:00:01Z".to_string(),
1172                duration: 1.0,
1173                extra_data: ExtraData {
1174                    files_count: 1,
1175                    directories_count: 1,
1176                    excluded_count: 0,
1177                    system_environment: SystemEnvironment {
1178                        operating_system: Some("darwin".to_string()),
1179                        cpu_architecture: "aarch64".to_string(),
1180                        platform: "darwin".to_string(),
1181                        rust_version: "1.93.0".to_string(),
1182                    },
1183                },
1184                errors: vec![],
1185                output_format_version: "4.0.0".to_string(),
1186            }],
1187            packages: vec![],
1188            dependencies: vec![],
1189            license_detections: vec![],
1190            files: vec![FileInfo::new(
1191                "main.rs".to_string(),
1192                "main".to_string(),
1193                "rs".to_string(),
1194                "src/main.rs".to_string(),
1195                FileType::File,
1196                Some("text/plain".to_string()),
1197                None,
1198                42,
1199                None,
1200                Some(EMPTY_SHA1.to_string()),
1201                Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
1202                Some("e3b0c44298fc1c149afbf4c8996fb924".to_string()),
1203                Some("Rust".to_string()),
1204                vec![PackageData::default()],
1205                None,
1206                vec![LicenseDetection {
1207                    license_expression: "mit".to_string(),
1208                    license_expression_spdx: "MIT".to_string(),
1209                    matches: vec![Match {
1210                        license_expression: "mit".to_string(),
1211                        license_expression_spdx: "MIT".to_string(),
1212                        from_file: None,
1213                        start_line: 1,
1214                        end_line: 1,
1215                        matcher: None,
1216                        score: 100.0,
1217                        matched_length: None,
1218                        match_coverage: None,
1219                        rule_relevance: None,
1220                        rule_identifier: Some("mit_rule".to_string()),
1221                        rule_url: None,
1222                        matched_text: None,
1223                        referenced_filenames: None,
1224                        matched_text_diagnostics: None,
1225                    }],
1226                    detection_log: vec![],
1227                    identifier: None,
1228                }],
1229                vec![],
1230                vec![Copyright {
1231                    copyright: "Copyright (c) Example".to_string(),
1232                    start_line: 1,
1233                    end_line: 1,
1234                }],
1235                vec![Holder {
1236                    holder: "Example Org".to_string(),
1237                    start_line: 1,
1238                    end_line: 1,
1239                }],
1240                vec![Author {
1241                    author: "Jane Doe".to_string(),
1242                    start_line: 1,
1243                    end_line: 1,
1244                }],
1245                vec![OutputEmail {
1246                    email: "jane@example.com".to_string(),
1247                    start_line: 1,
1248                    end_line: 1,
1249                }],
1250                vec![OutputURL {
1251                    url: "https://example.com".to_string(),
1252                    start_line: 1,
1253                    end_line: 1,
1254                }],
1255                vec![],
1256                vec![],
1257            )],
1258            license_references: vec![],
1259            license_rule_references: vec![],
1260        }
1261    }
1262}