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}