Skip to main content

provenant/output/
mod.rs

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