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_cyclonedx_json_writer_outputs_bom() {
243        let output = sample_output();
244        let mut bytes = Vec::new();
245        writer_for_format(OutputFormat::CycloneDxJson)
246            .write(&output, &mut bytes, &OutputWriteConfig::default())
247            .expect("cyclonedx json write should succeed");
248
249        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
250        let value: Value = serde_json::from_str(&rendered).expect("valid json");
251        assert_eq!(value["bomFormat"], "CycloneDX");
252        assert_eq!(value["specVersion"], "1.3");
253    }
254
255    #[test]
256    fn test_json_writer_includes_summary_and_key_file_flags() {
257        let mut output = sample_output();
258        output.summary = Some(crate::models::Summary {
259            declared_license_expression: Some("apache-2.0".to_string()),
260            license_clarity_score: Some(crate::models::LicenseClarityScore {
261                score: 100,
262                declared_license: true,
263                identification_precision: true,
264                has_license_text: true,
265                declared_copyrights: true,
266                conflicting_license_categories: false,
267                ambiguous_compound_licensing: false,
268            }),
269            declared_holder: Some("Example Corp.".to_string()),
270            primary_language: Some("Ruby".to_string()),
271            other_license_expressions: vec![crate::models::TallyEntry {
272                value: Some("mit".to_string()),
273                count: 1,
274            }],
275            other_holders: vec![
276                crate::models::TallyEntry {
277                    value: None,
278                    count: 2,
279                },
280                crate::models::TallyEntry {
281                    value: Some("Other Corp.".to_string()),
282                    count: 1,
283                },
284            ],
285            other_languages: vec![crate::models::TallyEntry {
286                value: Some("Python".to_string()),
287                count: 2,
288            }],
289        });
290        output.files[0].is_legal = true;
291        output.files[0].is_top_level = true;
292        output.files[0].is_key_file = true;
293
294        let mut bytes = Vec::new();
295        writer_for_format(OutputFormat::Json)
296            .write(&output, &mut bytes, &OutputWriteConfig::default())
297            .expect("json write should succeed");
298
299        let rendered = String::from_utf8(bytes).expect("json should be utf-8");
300        let value: Value = serde_json::from_str(&rendered).expect("valid json");
301
302        assert_eq!(
303            value["summary"]["declared_license_expression"],
304            "apache-2.0"
305        );
306        assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
307        assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
308        assert_eq!(value["summary"]["primary_language"], "Ruby");
309        assert_eq!(
310            value["summary"]["other_license_expressions"][0]["value"],
311            "mit"
312        );
313        assert!(value["summary"]["other_holders"][0]["value"].is_null());
314        assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
315        assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
316        assert_eq!(value["files"][0]["is_key_file"], true);
317    }
318
319    #[test]
320    fn test_json_and_json_lines_writers_include_top_level_tallies() {
321        let mut output = sample_output();
322        output.tallies = Some(crate::models::Tallies {
323            detected_license_expression: vec![crate::models::TallyEntry {
324                value: Some("mit".to_string()),
325                count: 2,
326            }],
327            copyrights: vec![crate::models::TallyEntry {
328                value: Some("Copyright (c) Example Org".to_string()),
329                count: 1,
330            }],
331            holders: vec![crate::models::TallyEntry {
332                value: Some("Example Org".to_string()),
333                count: 1,
334            }],
335            authors: vec![crate::models::TallyEntry {
336                value: Some("Jane Doe".to_string()),
337                count: 1,
338            }],
339            programming_language: vec![crate::models::TallyEntry {
340                value: Some("Rust".to_string()),
341                count: 1,
342            }],
343        });
344
345        let mut json_bytes = Vec::new();
346        writer_for_format(OutputFormat::Json)
347            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
348            .expect("json write should succeed");
349        let json_value: Value =
350            serde_json::from_slice(&json_bytes).expect("json output should parse");
351        assert_eq!(
352            json_value["tallies"]["detected_license_expression"][0]["value"],
353            "mit"
354        );
355        assert_eq!(
356            json_value["tallies"]["programming_language"][0]["value"],
357            "Rust"
358        );
359
360        let mut jsonl_bytes = Vec::new();
361        writer_for_format(OutputFormat::JsonLines)
362            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
363            .expect("json-lines write should succeed");
364        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
365        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
366    }
367
368    #[test]
369    fn test_json_and_json_lines_writers_include_key_file_tallies() {
370        let mut output = sample_output();
371        output.tallies_of_key_files = Some(crate::models::Tallies {
372            detected_license_expression: vec![crate::models::TallyEntry {
373                value: Some("apache-2.0".to_string()),
374                count: 1,
375            }],
376            copyrights: vec![],
377            holders: vec![],
378            authors: vec![],
379            programming_language: vec![crate::models::TallyEntry {
380                value: Some("Markdown".to_string()),
381                count: 1,
382            }],
383        });
384
385        let mut json_bytes = Vec::new();
386        writer_for_format(OutputFormat::Json)
387            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
388            .expect("json write should succeed");
389        let json_value: Value =
390            serde_json::from_slice(&json_bytes).expect("json output should parse");
391        assert_eq!(
392            json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
393            "apache-2.0"
394        );
395
396        let mut jsonl_bytes = Vec::new();
397        writer_for_format(OutputFormat::JsonLines)
398            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
399            .expect("json-lines write should succeed");
400        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
401        assert!(
402            rendered
403                .lines()
404                .any(|line| line.contains("\"tallies_of_key_files\""))
405        );
406    }
407
408    #[test]
409    fn test_json_and_json_lines_writers_include_file_tallies() {
410        let mut output = sample_output();
411        output.files[0].tallies = Some(crate::models::Tallies {
412            detected_license_expression: vec![crate::models::TallyEntry {
413                value: Some("mit".to_string()),
414                count: 1,
415            }],
416            copyrights: vec![crate::models::TallyEntry {
417                value: None,
418                count: 1,
419            }],
420            holders: vec![],
421            authors: vec![],
422            programming_language: vec![crate::models::TallyEntry {
423                value: Some("Rust".to_string()),
424                count: 1,
425            }],
426        });
427
428        let mut json_bytes = Vec::new();
429        writer_for_format(OutputFormat::Json)
430            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
431            .expect("json write should succeed");
432        let json_value: Value =
433            serde_json::from_slice(&json_bytes).expect("json output should parse");
434        assert_eq!(
435            json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
436            "mit"
437        );
438
439        let mut jsonl_bytes = Vec::new();
440        writer_for_format(OutputFormat::JsonLines)
441            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
442            .expect("json-lines write should succeed");
443        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
444        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
445    }
446
447    #[test]
448    fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
449        let mut output = sample_output();
450        output.files[0].facets = vec!["core".to_string(), "docs".to_string()];
451        output.tallies_by_facet = Some(vec![crate::models::FacetTallies {
452            facet: "core".to_string(),
453            tallies: crate::models::Tallies {
454                detected_license_expression: vec![crate::models::TallyEntry {
455                    value: Some("mit".to_string()),
456                    count: 1,
457                }],
458                copyrights: vec![],
459                holders: vec![],
460                authors: vec![],
461                programming_language: vec![],
462            },
463        }]);
464
465        let mut json_bytes = Vec::new();
466        writer_for_format(OutputFormat::Json)
467            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
468            .expect("json write should succeed");
469        let json_value: Value =
470            serde_json::from_slice(&json_bytes).expect("json output should parse");
471        assert_eq!(json_value["files"][0]["facets"][0], "core");
472        assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
473
474        let mut jsonl_bytes = Vec::new();
475        writer_for_format(OutputFormat::JsonLines)
476            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
477            .expect("json-lines write should succeed");
478        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
479        assert!(
480            rendered
481                .lines()
482                .any(|line| line.contains("\"tallies_by_facet\""))
483        );
484    }
485
486    #[test]
487    fn test_cyclonedx_xml_writer_outputs_xml() {
488        let output = sample_output();
489        let mut bytes = Vec::new();
490        writer_for_format(OutputFormat::CycloneDxXml)
491            .write(&output, &mut bytes, &OutputWriteConfig::default())
492            .expect("cyclonedx xml write should succeed");
493
494        let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
495        assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
496        assert!(rendered.contains("<components>"));
497    }
498
499    #[test]
500    fn test_cyclonedx_json_includes_component_license_expression() {
501        let mut output = sample_output();
502        output.packages = vec![crate::models::Package {
503            package_type: Some(crate::models::PackageType::Maven),
504            namespace: Some("example".to_string()),
505            name: Some("gradle-project".to_string()),
506            version: Some("1.0.0".to_string()),
507            qualifiers: None,
508            subpath: None,
509            primary_language: Some("Java".to_string()),
510            description: None,
511            release_date: None,
512            parties: vec![],
513            keywords: vec![],
514            homepage_url: None,
515            download_url: None,
516            size: None,
517            sha1: None,
518            md5: None,
519            sha256: None,
520            sha512: None,
521            bug_tracking_url: None,
522            code_view_url: None,
523            vcs_url: None,
524            copyright: None,
525            holder: None,
526            declared_license_expression: Some("Apache-2.0".to_string()),
527            declared_license_expression_spdx: Some("Apache-2.0".to_string()),
528            license_detections: vec![],
529            other_license_expression: None,
530            other_license_expression_spdx: None,
531            other_license_detections: vec![],
532            extracted_license_statement: Some("Apache-2.0".to_string()),
533            notice_text: None,
534            source_packages: vec![],
535            is_private: false,
536            is_virtual: false,
537            extra_data: None,
538            repository_homepage_url: None,
539            repository_download_url: None,
540            api_data_url: None,
541            datasource_ids: vec![],
542            purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
543            package_uid: "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
544            datafile_paths: vec![],
545        }];
546
547        let mut bytes = Vec::new();
548        writer_for_format(OutputFormat::CycloneDxJson)
549            .write(&output, &mut bytes, &OutputWriteConfig::default())
550            .expect("cyclonedx json write should succeed");
551
552        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
553        let value: Value = serde_json::from_str(&rendered).expect("valid json");
554
555        assert_eq!(
556            value["components"][0]["licenses"][0]["expression"],
557            "Apache-2.0"
558        );
559    }
560
561    #[test]
562    fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
563        let output = Output {
564            summary: None,
565            tallies: None,
566            tallies_of_key_files: None,
567            tallies_by_facet: None,
568            headers: vec![],
569            packages: vec![],
570            dependencies: vec![],
571            files: vec![],
572            license_references: vec![],
573            license_rule_references: vec![],
574        };
575        let mut bytes = Vec::new();
576        writer_for_format(OutputFormat::SpdxTv)
577            .write(
578                &output,
579                &mut bytes,
580                &OutputWriteConfig {
581                    format: OutputFormat::SpdxTv,
582                    custom_template: None,
583                    scanned_path: Some("scan".to_string()),
584                },
585            )
586            .expect("spdx tv write should succeed");
587
588        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
589        assert_eq!(rendered, "# No results for package 'scan'.\n");
590    }
591
592    #[test]
593    fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
594        let output = Output {
595            summary: None,
596            tallies: None,
597            tallies_of_key_files: None,
598            tallies_by_facet: None,
599            headers: vec![],
600            packages: vec![],
601            dependencies: vec![],
602            files: vec![],
603            license_references: vec![],
604            license_rule_references: vec![],
605        };
606        let mut bytes = Vec::new();
607        writer_for_format(OutputFormat::SpdxRdf)
608            .write(
609                &output,
610                &mut bytes,
611                &OutputWriteConfig {
612                    format: OutputFormat::SpdxRdf,
613                    custom_template: None,
614                    scanned_path: Some("scan".to_string()),
615                },
616            )
617            .expect("spdx rdf write should succeed");
618
619        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
620        assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
621    }
622
623    #[test]
624    fn test_html_writer_outputs_html_document() {
625        let output = sample_output();
626        let mut bytes = Vec::new();
627        writer_for_format(OutputFormat::Html)
628            .write(&output, &mut bytes, &OutputWriteConfig::default())
629            .expect("html write should succeed");
630        let rendered = String::from_utf8(bytes).expect("html should be utf-8");
631        assert!(rendered.contains("<!doctype html>"));
632        assert!(rendered.contains("Custom Template"));
633    }
634
635    #[test]
636    fn test_custom_template_writer_renders_output_context() {
637        let output = sample_output();
638        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
639        let template_path = temp_dir.path().join("template.tera");
640        fs::write(
641            &template_path,
642            "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
643        )
644        .expect("template should be written");
645
646        let mut bytes = Vec::new();
647        writer_for_format(OutputFormat::CustomTemplate)
648            .write(
649                &output,
650                &mut bytes,
651                &OutputWriteConfig {
652                    format: OutputFormat::CustomTemplate,
653                    custom_template: Some(template_path.to_string_lossy().to_string()),
654                    scanned_path: None,
655                },
656            )
657            .expect("custom template write should succeed");
658
659        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
660        assert!(rendered.contains("version=4.0.0"));
661        assert!(rendered.contains("files=1"));
662    }
663
664    #[test]
665    fn test_html_app_writer_creates_assets() {
666        let output = sample_output();
667        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
668        let output_path = temp_dir.path().join("report.html");
669
670        write_output_file(
671            output_path
672                .to_str()
673                .expect("output path should be valid utf-8"),
674            &output,
675            &OutputWriteConfig {
676                format: OutputFormat::HtmlApp,
677                custom_template: None,
678                scanned_path: Some("/tmp/project".to_string()),
679            },
680        )
681        .expect("html app write should succeed");
682
683        let assets_dir = temp_dir.path().join("report_files");
684        assert!(output_path.exists());
685        assert!(assets_dir.join("data.js").exists());
686        assert!(assets_dir.join("app.css").exists());
687        assert!(assets_dir.join("app.js").exists());
688    }
689
690    fn sample_output() -> Output {
691        Output {
692            summary: None,
693            tallies: None,
694            tallies_of_key_files: None,
695            tallies_by_facet: None,
696            headers: vec![Header {
697                start_timestamp: "2026-01-01T00:00:00Z".to_string(),
698                end_timestamp: "2026-01-01T00:00:01Z".to_string(),
699                duration: 1.0,
700                extra_data: ExtraData {
701                    files_count: 1,
702                    directories_count: 1,
703                    excluded_count: 0,
704                    system_environment: SystemEnvironment {
705                        operating_system: Some("darwin".to_string()),
706                        cpu_architecture: "aarch64".to_string(),
707                        platform: "darwin".to_string(),
708                        rust_version: "1.93.0".to_string(),
709                    },
710                },
711                errors: vec![],
712                output_format_version: "4.0.0".to_string(),
713            }],
714            packages: vec![],
715            dependencies: vec![],
716            files: vec![FileInfo::new(
717                "main.rs".to_string(),
718                "main".to_string(),
719                "rs".to_string(),
720                "src/main.rs".to_string(),
721                FileType::File,
722                Some("text/plain".to_string()),
723                42,
724                None,
725                Some(EMPTY_SHA1.to_string()),
726                Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
727                Some("e3b0c44298fc1c149afbf4c8996fb924".to_string()),
728                Some("Rust".to_string()),
729                vec![PackageData::default()],
730                None,
731                vec![LicenseDetection {
732                    license_expression: "mit".to_string(),
733                    license_expression_spdx: "MIT".to_string(),
734                    matches: vec![Match {
735                        license_expression: "mit".to_string(),
736                        license_expression_spdx: "MIT".to_string(),
737                        from_file: None,
738                        start_line: 1,
739                        end_line: 1,
740                        matcher: None,
741                        score: 100.0,
742                        matched_length: None,
743                        match_coverage: None,
744                        rule_relevance: None,
745                        rule_identifier: Some("mit_rule".to_string()),
746                        rule_url: None,
747                        matched_text: None,
748                    }],
749                    identifier: None,
750                }],
751                vec![Copyright {
752                    copyright: "Copyright (c) Example".to_string(),
753                    start_line: 1,
754                    end_line: 1,
755                }],
756                vec![Holder {
757                    holder: "Example Org".to_string(),
758                    start_line: 1,
759                    end_line: 1,
760                }],
761                vec![Author {
762                    author: "Jane Doe".to_string(),
763                    start_line: 1,
764                    end_line: 1,
765                }],
766                vec![OutputEmail {
767                    email: "jane@example.com".to_string(),
768                    start_line: 1,
769                    end_line: 1,
770                }],
771                vec![OutputURL {
772                    url: "https://example.com".to_string(),
773                    start_line: 1,
774                    end_line: 1,
775                }],
776                vec![],
777                vec![],
778            )],
779            license_references: vec![],
780            license_rule_references: vec![],
781        }
782    }
783}