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