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_languages: vec![crate::models::TallyEntry {
272 value: Some("Python".to_string()),
273 count: 2,
274 }],
275 });
276 output.files[0].is_legal = true;
277 output.files[0].is_top_level = true;
278 output.files[0].is_key_file = true;
279
280 let mut bytes = Vec::new();
281 writer_for_format(OutputFormat::Json)
282 .write(&output, &mut bytes, &OutputWriteConfig::default())
283 .expect("json write should succeed");
284
285 let rendered = String::from_utf8(bytes).expect("json should be utf-8");
286 let value: Value = serde_json::from_str(&rendered).expect("valid json");
287
288 assert_eq!(
289 value["summary"]["declared_license_expression"],
290 "apache-2.0"
291 );
292 assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
293 assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
294 assert_eq!(value["summary"]["primary_language"], "Ruby");
295 assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
296 assert_eq!(value["files"][0]["is_key_file"], true);
297 }
298
299 #[test]
300 fn test_cyclonedx_xml_writer_outputs_xml() {
301 let output = sample_output();
302 let mut bytes = Vec::new();
303 writer_for_format(OutputFormat::CycloneDxXml)
304 .write(&output, &mut bytes, &OutputWriteConfig::default())
305 .expect("cyclonedx xml write should succeed");
306
307 let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
308 assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
309 assert!(rendered.contains("<components>"));
310 }
311
312 #[test]
313 fn test_cyclonedx_json_includes_component_license_expression() {
314 let mut output = sample_output();
315 output.packages = vec![crate::models::Package {
316 package_type: Some(crate::models::PackageType::Maven),
317 namespace: Some("example".to_string()),
318 name: Some("gradle-project".to_string()),
319 version: Some("1.0.0".to_string()),
320 qualifiers: None,
321 subpath: None,
322 primary_language: Some("Java".to_string()),
323 description: None,
324 release_date: None,
325 parties: vec![],
326 keywords: vec![],
327 homepage_url: None,
328 download_url: None,
329 size: None,
330 sha1: None,
331 md5: None,
332 sha256: None,
333 sha512: None,
334 bug_tracking_url: None,
335 code_view_url: None,
336 vcs_url: None,
337 copyright: None,
338 holder: None,
339 declared_license_expression: Some("Apache-2.0".to_string()),
340 declared_license_expression_spdx: Some("Apache-2.0".to_string()),
341 license_detections: vec![],
342 other_license_expression: None,
343 other_license_expression_spdx: None,
344 other_license_detections: vec![],
345 extracted_license_statement: Some("Apache-2.0".to_string()),
346 notice_text: None,
347 source_packages: vec![],
348 is_private: false,
349 is_virtual: false,
350 extra_data: None,
351 repository_homepage_url: None,
352 repository_download_url: None,
353 api_data_url: None,
354 datasource_ids: vec![],
355 purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
356 package_uid: "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
357 datafile_paths: vec![],
358 }];
359
360 let mut bytes = Vec::new();
361 writer_for_format(OutputFormat::CycloneDxJson)
362 .write(&output, &mut bytes, &OutputWriteConfig::default())
363 .expect("cyclonedx json write should succeed");
364
365 let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
366 let value: Value = serde_json::from_str(&rendered).expect("valid json");
367
368 assert_eq!(
369 value["components"][0]["licenses"][0]["expression"],
370 "Apache-2.0"
371 );
372 }
373
374 #[test]
375 fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
376 let output = Output {
377 summary: None,
378 headers: vec![],
379 packages: vec![],
380 dependencies: vec![],
381 files: vec![],
382 license_references: vec![],
383 license_rule_references: vec![],
384 };
385 let mut bytes = Vec::new();
386 writer_for_format(OutputFormat::SpdxTv)
387 .write(
388 &output,
389 &mut bytes,
390 &OutputWriteConfig {
391 format: OutputFormat::SpdxTv,
392 custom_template: None,
393 scanned_path: Some("scan".to_string()),
394 },
395 )
396 .expect("spdx tv write should succeed");
397
398 let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
399 assert_eq!(rendered, "# No results for package 'scan'.\n");
400 }
401
402 #[test]
403 fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
404 let output = Output {
405 summary: None,
406 headers: vec![],
407 packages: vec![],
408 dependencies: vec![],
409 files: vec![],
410 license_references: vec![],
411 license_rule_references: vec![],
412 };
413 let mut bytes = Vec::new();
414 writer_for_format(OutputFormat::SpdxRdf)
415 .write(
416 &output,
417 &mut bytes,
418 &OutputWriteConfig {
419 format: OutputFormat::SpdxRdf,
420 custom_template: None,
421 scanned_path: Some("scan".to_string()),
422 },
423 )
424 .expect("spdx rdf write should succeed");
425
426 let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
427 assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
428 }
429
430 #[test]
431 fn test_html_writer_outputs_html_document() {
432 let output = sample_output();
433 let mut bytes = Vec::new();
434 writer_for_format(OutputFormat::Html)
435 .write(&output, &mut bytes, &OutputWriteConfig::default())
436 .expect("html write should succeed");
437 let rendered = String::from_utf8(bytes).expect("html should be utf-8");
438 assert!(rendered.contains("<!doctype html>"));
439 assert!(rendered.contains("Custom Template"));
440 }
441
442 #[test]
443 fn test_custom_template_writer_renders_output_context() {
444 let output = sample_output();
445 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
446 let template_path = temp_dir.path().join("template.tera");
447 fs::write(
448 &template_path,
449 "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
450 )
451 .expect("template should be written");
452
453 let mut bytes = Vec::new();
454 writer_for_format(OutputFormat::CustomTemplate)
455 .write(
456 &output,
457 &mut bytes,
458 &OutputWriteConfig {
459 format: OutputFormat::CustomTemplate,
460 custom_template: Some(template_path.to_string_lossy().to_string()),
461 scanned_path: None,
462 },
463 )
464 .expect("custom template write should succeed");
465
466 let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
467 assert!(rendered.contains("version=4.0.0"));
468 assert!(rendered.contains("files=1"));
469 }
470
471 #[test]
472 fn test_html_app_writer_creates_assets() {
473 let output = sample_output();
474 let temp_dir = tempfile::tempdir().expect("tempdir should be created");
475 let output_path = temp_dir.path().join("report.html");
476
477 write_output_file(
478 output_path
479 .to_str()
480 .expect("output path should be valid utf-8"),
481 &output,
482 &OutputWriteConfig {
483 format: OutputFormat::HtmlApp,
484 custom_template: None,
485 scanned_path: Some("/tmp/project".to_string()),
486 },
487 )
488 .expect("html app write should succeed");
489
490 let assets_dir = temp_dir.path().join("report_files");
491 assert!(output_path.exists());
492 assert!(assets_dir.join("data.js").exists());
493 assert!(assets_dir.join("app.css").exists());
494 assert!(assets_dir.join("app.js").exists());
495 }
496
497 fn sample_output() -> Output {
498 Output {
499 summary: None,
500 headers: vec![Header {
501 start_timestamp: "2026-01-01T00:00:00Z".to_string(),
502 end_timestamp: "2026-01-01T00:00:01Z".to_string(),
503 duration: 1.0,
504 extra_data: ExtraData {
505 files_count: 1,
506 directories_count: 1,
507 excluded_count: 0,
508 system_environment: SystemEnvironment {
509 operating_system: Some("darwin".to_string()),
510 cpu_architecture: "aarch64".to_string(),
511 platform: "darwin".to_string(),
512 rust_version: "1.93.0".to_string(),
513 },
514 },
515 errors: vec![],
516 output_format_version: "4.0.0".to_string(),
517 }],
518 packages: vec![],
519 dependencies: vec![],
520 files: vec![FileInfo::new(
521 "main.rs".to_string(),
522 "main".to_string(),
523 "rs".to_string(),
524 "src/main.rs".to_string(),
525 FileType::File,
526 Some("text/plain".to_string()),
527 42,
528 None,
529 Some(EMPTY_SHA1.to_string()),
530 Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
531 Some("e3b0c44298fc1c149afbf4c8996fb924".to_string()),
532 Some("Rust".to_string()),
533 vec![PackageData::default()],
534 None,
535 vec![LicenseDetection {
536 license_expression: "mit".to_string(),
537 license_expression_spdx: "MIT".to_string(),
538 matches: vec![Match {
539 license_expression: "mit".to_string(),
540 license_expression_spdx: "MIT".to_string(),
541 from_file: None,
542 start_line: 1,
543 end_line: 1,
544 matcher: None,
545 score: 100.0,
546 matched_length: None,
547 match_coverage: None,
548 rule_relevance: None,
549 rule_identifier: Some("mit_rule".to_string()),
550 rule_url: None,
551 matched_text: None,
552 }],
553 identifier: None,
554 }],
555 vec![Copyright {
556 copyright: "Copyright (c) Example".to_string(),
557 start_line: 1,
558 end_line: 1,
559 }],
560 vec![Holder {
561 holder: "Example Org".to_string(),
562 start_line: 1,
563 end_line: 1,
564 }],
565 vec![Author {
566 author: "Jane Doe".to_string(),
567 start_line: 1,
568 end_line: 1,
569 }],
570 vec![OutputEmail {
571 email: "jane@example.com".to_string(),
572 start_line: 1,
573 end_line: 1,
574 }],
575 vec![OutputURL {
576 url: "https://example.com".to_string(),
577 start_line: 1,
578 end_line: 1,
579 }],
580 vec![],
581 vec![],
582 )],
583 license_references: vec![],
584 license_rule_references: vec![],
585 }
586 }
587}