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}