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_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}