Skip to main content

provenant/output/
mod.rs

1use std::fs::File;
2use std::io::{self, BufWriter, Write};
3
4use crate::output_schema::Output;
5
6mod cyclonedx;
7mod debian;
8mod html;
9mod jsonl;
10mod shared;
11mod spdx;
12mod template;
13
14pub(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";
15const OUTPUT_BUFFER_SIZE: usize = 1024 * 1024;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum OutputFormat {
19    #[default]
20    Json,
21    JsonPretty,
22    Yaml,
23    JsonLines,
24    Debian,
25    Html,
26    CustomTemplate,
27    SpdxTv,
28    SpdxRdf,
29    CycloneDxJson,
30    CycloneDxXml,
31}
32
33#[derive(Debug, Clone, Default)]
34pub struct OutputWriteConfig {
35    pub format: OutputFormat,
36    pub custom_template: Option<String>,
37    pub scanned_path: Option<String>,
38}
39
40pub trait OutputWriter {
41    fn write(
42        &self,
43        output: &Output,
44        writer: &mut dyn Write,
45        config: &OutputWriteConfig,
46    ) -> io::Result<()>;
47}
48
49pub struct FormatWriter {
50    format: OutputFormat,
51}
52
53pub fn writer_for_format(format: OutputFormat) -> FormatWriter {
54    FormatWriter { format }
55}
56
57impl OutputWriter for FormatWriter {
58    fn write(
59        &self,
60        output: &Output,
61        writer: &mut dyn Write,
62        config: &OutputWriteConfig,
63    ) -> io::Result<()> {
64        match self.format {
65            OutputFormat::Json => {
66                serde_json::to_writer(&mut *writer, output).map_err(shared::io_other)?;
67                writer.write_all(b"\n")
68            }
69            OutputFormat::JsonPretty => {
70                serde_json::to_writer_pretty(&mut *writer, output).map_err(shared::io_other)?;
71                writer.write_all(b"\n")
72            }
73            OutputFormat::Yaml => write_yaml(output, writer),
74            OutputFormat::JsonLines => jsonl::write_json_lines(output, writer),
75            OutputFormat::Debian => debian::write_debian_copyright(output, writer),
76            OutputFormat::Html => html::write_html_report(output, writer),
77            OutputFormat::CustomTemplate => template::write_custom_template(output, writer, config),
78            OutputFormat::SpdxTv => spdx::write_spdx_tag_value(output, writer, config),
79            OutputFormat::SpdxRdf => spdx::write_spdx_rdf_xml(output, writer, config),
80            OutputFormat::CycloneDxJson => cyclonedx::write_cyclonedx_json(output, writer),
81            OutputFormat::CycloneDxXml => cyclonedx::write_cyclonedx_xml(output, writer),
82        }
83    }
84}
85
86pub fn write_output_file(
87    output_file: &str,
88    output: &Output,
89    config: &OutputWriteConfig,
90) -> io::Result<()> {
91    if output_file == "-" {
92        let stdout = io::stdout();
93        let handle = stdout.lock();
94        let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, handle);
95        writer_for_format(config.format).write(output, &mut writer, config)?;
96        return writer.flush();
97    }
98
99    let file = File::create(output_file)?;
100    let mut writer = BufWriter::with_capacity(OUTPUT_BUFFER_SIZE, file);
101    writer_for_format(config.format).write(output, &mut writer, config)?;
102    writer.flush()
103}
104
105fn write_yaml(output: &Output, writer: &mut dyn Write) -> io::Result<()> {
106    yaml_serde::to_writer(&mut *writer, output).map_err(shared::io_other)?;
107    writer.write_all(b"\n")
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use serde_json::Value;
114    use std::fs;
115
116    use crate::models::{
117        Author, Copyright, ExtraData, FileInfo, FileType, GitSha1, Header, Holder,
118        LicenseDetection, LineNumber, Match, MatchScore, Md5Digest, OutputEmail, OutputURL,
119        Package, PackageData, PackageUid, Sha1Digest, Sha256Digest, SystemEnvironment,
120    };
121    use crate::output_schema::OutputFileInfo;
122
123    #[test]
124    fn test_yaml_writer_outputs_yaml() {
125        let output = Output::from(&sample_internal_output());
126        let mut bytes = Vec::new();
127        writer_for_format(OutputFormat::Yaml)
128            .write(&output, &mut bytes, &OutputWriteConfig::default())
129            .expect("yaml write should succeed");
130        let rendered = String::from_utf8(bytes).expect("yaml should be utf-8");
131        assert!(rendered.contains("headers:"));
132        assert!(rendered.contains("files:"));
133    }
134
135    #[test]
136    fn test_json_lines_writer_outputs_parseable_lines() {
137        let output = Output::from(&sample_internal_output());
138        let mut bytes = Vec::new();
139        writer_for_format(OutputFormat::JsonLines)
140            .write(&output, &mut bytes, &OutputWriteConfig::default())
141            .expect("json-lines write should succeed");
142
143        let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
144        let lines = rendered.lines().collect::<Vec<_>>();
145        assert!(lines.len() >= 2);
146        for line in lines {
147            serde_json::from_str::<Value>(line).expect("each line should be valid json");
148        }
149    }
150
151    #[test]
152    fn test_debian_writer_outputs_dep5_style_document() {
153        let mut internal = sample_internal_output();
154        internal.files[0].license_expression = Some("mit".to_string());
155        internal.files[0].license_detections[0].matches[0].matched_text = Some(
156            "Permission is hereby granted, free of charge, to any person obtaining a copy"
157                .to_string(),
158        );
159        let output = Output::from(&internal);
160
161        let mut bytes = Vec::new();
162        writer_for_format(OutputFormat::Debian)
163            .write(&output, &mut bytes, &OutputWriteConfig::default())
164            .expect("debian write should succeed");
165
166        let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
167        assert!(rendered.contains(
168            "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"
169        ));
170        assert!(rendered.contains("Comment: Generated with Provenant"));
171        assert!(rendered.contains("Files: src/main.rs"));
172        assert!(rendered.contains("Copyright: Example Org"));
173        assert!(rendered.contains("License: mit"));
174        assert!(rendered.contains(" Permission is hereby granted, free of charge"));
175    }
176
177    #[test]
178    fn test_debian_writer_skips_directories_and_deduplicates_license_texts() {
179        let mut internal = sample_internal_output();
180        internal.files.insert(
181            0,
182            FileInfo::new(
183                "src".to_string(),
184                "src".to_string(),
185                String::new(),
186                "src".to_string(),
187                FileType::Directory,
188                None,
189                None,
190                0,
191                None,
192                None,
193                None,
194                None,
195                None,
196                vec![],
197                None,
198                vec![],
199                vec![],
200                vec![],
201                vec![],
202                vec![],
203                vec![],
204                vec![],
205                vec![],
206                vec![],
207            ),
208        );
209        internal.files[1].license_expression = Some("mit".to_string());
210        internal.files[1].license_detections[0].matches[0].matched_text =
211            Some("Same text".to_string());
212        internal.files[1].license_detections[0].matches.push(Match {
213            license_expression: "mit".to_string(),
214            license_expression_spdx: "MIT".to_string(),
215            from_file: Some("src/main.rs".to_string()),
216            start_line: LineNumber::ONE,
217            end_line: LineNumber::ONE,
218            matcher: Some("2-aho".to_string()),
219            score: MatchScore::MAX,
220            matched_length: Some(1),
221            match_coverage: Some(100.0),
222            rule_relevance: Some(100),
223            rule_identifier: Some("mit_rule".to_string()),
224            rule_url: None,
225            matched_text: Some("Same text again".to_string()),
226            referenced_filenames: None,
227            matched_text_diagnostics: None,
228        });
229        let output = Output::from(&internal);
230
231        let mut bytes = Vec::new();
232        writer_for_format(OutputFormat::Debian)
233            .write(&output, &mut bytes, &OutputWriteConfig::default())
234            .expect("debian write should succeed");
235
236        let rendered = String::from_utf8(bytes).expect("debian output should be utf-8");
237        assert!(!rendered.contains("Files: src\n"));
238        assert_eq!(rendered.matches(" Same text").count(), 1);
239    }
240
241    #[test]
242    fn test_file_info_serialization_omits_info_fields_when_unset() {
243        let file = FileInfo::new(
244            "main.rs".to_string(),
245            "main".to_string(),
246            "rs".to_string(),
247            "src/main.rs".to_string(),
248            FileType::File,
249            None,
250            None,
251            42,
252            None,
253            None,
254            None,
255            None,
256            None,
257            vec![],
258            None,
259            vec![],
260            vec![],
261            vec![],
262            vec![],
263            vec![],
264            vec![],
265            vec![],
266            vec![],
267            vec![],
268        );
269
270        let schema_file = OutputFileInfo::from(&file);
271        let value = serde_json::to_value(&schema_file).expect("file info serializes");
272        let object = value.as_object().expect("file info object");
273
274        assert!(!object.contains_key("date"));
275        assert!(!object.contains_key("sha1"));
276        assert!(!object.contains_key("md5"));
277        assert!(!object.contains_key("sha256"));
278        assert!(!object.contains_key("sha1_git"));
279        assert!(!object.contains_key("mime_type"));
280        assert!(!object.contains_key("file_type"));
281        assert!(!object.contains_key("programming_language"));
282        assert!(!object.contains_key("is_binary"));
283        assert!(!object.contains_key("is_text"));
284        assert!(!object.contains_key("is_archive"));
285        assert!(!object.contains_key("is_media"));
286        assert!(!object.contains_key("is_source"));
287        assert!(!object.contains_key("is_script"));
288        assert!(!object.contains_key("files_count"));
289        assert!(!object.contains_key("dirs_count"));
290        assert!(!object.contains_key("size_count"));
291        assert!(!object.contains_key("license_policy"));
292    }
293
294    #[test]
295    fn test_file_info_serialization_keeps_license_policy_when_enabled() {
296        let mut file = FileInfo::new(
297            "main.rs".to_string(),
298            "main".to_string(),
299            "rs".to_string(),
300            "src/main.rs".to_string(),
301            FileType::File,
302            Some("text/plain".to_string()),
303            Some("text".to_string()),
304            42,
305            Some("2026-01-01T00:00:00Z".to_string()),
306            Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
307            Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
308            Some(
309                Sha256Digest::from_hex(
310                    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
311                )
312                .unwrap(),
313            ),
314            Some("Rust".to_string()),
315            vec![],
316            None,
317            vec![],
318            vec![],
319            vec![],
320            vec![],
321            vec![],
322            vec![],
323            vec![],
324            vec![],
325            vec![],
326        );
327        file.license_policy = Some(vec![]);
328        file.sha1_git =
329            Some(GitSha1::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap());
330        file.is_binary = Some(false);
331        file.is_text = Some(true);
332        file.is_archive = Some(false);
333        file.is_media = Some(false);
334        file.is_source = Some(true);
335        file.is_script = Some(false);
336        file.files_count = Some(0);
337        file.dirs_count = Some(0);
338        file.size_count = Some(0);
339
340        let schema_file = OutputFileInfo::from(&file);
341        let value = serde_json::to_value(&schema_file).expect("file info serializes");
342        let object = value.as_object().expect("file info object");
343
344        assert_eq!(object.get("license_policy"), Some(&serde_json::json!([])));
345        assert_eq!(object.get("file_type"), Some(&serde_json::json!("text")));
346        assert_eq!(object.get("is_binary"), Some(&serde_json::json!(false)));
347        assert_eq!(object.get("is_text"), Some(&serde_json::json!(true)));
348        assert_eq!(object.get("files_count"), Some(&serde_json::json!(0)));
349        assert_eq!(object.get("dirs_count"), Some(&serde_json::json!(0)));
350        assert_eq!(object.get("size_count"), Some(&serde_json::json!(0)));
351    }
352
353    #[test]
354    fn test_json_lines_writer_sorts_files_by_path_for_reproducibility() {
355        let mut internal = sample_internal_output();
356        internal.files.reverse();
357        let output = Output::from(&internal);
358        let mut bytes = Vec::new();
359        writer_for_format(OutputFormat::JsonLines)
360            .write(&output, &mut bytes, &OutputWriteConfig::default())
361            .expect("json-lines write should succeed");
362
363        let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
364        let file_lines = rendered
365            .lines()
366            .filter_map(|line| {
367                let value: Value = serde_json::from_str(line).ok()?;
368                let files = value.get("files")?.as_array()?;
369                files.first()?.get("path")?.as_str().map(str::to_string)
370            })
371            .collect::<Vec<_>>();
372
373        let mut sorted = file_lines.clone();
374        sorted.sort();
375        assert_eq!(file_lines, sorted);
376    }
377
378    #[test]
379    fn test_spdx_tag_value_writer_contains_required_fields() {
380        let output = Output::from(&sample_internal_output());
381        let mut bytes = Vec::new();
382        writer_for_format(OutputFormat::SpdxTv)
383            .write(
384                &output,
385                &mut bytes,
386                &OutputWriteConfig {
387                    format: OutputFormat::SpdxTv,
388                    custom_template: None,
389                    scanned_path: Some("scan".to_string()),
390                },
391            )
392            .expect("spdx tv write should succeed");
393
394        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
395        assert!(rendered.contains("SPDXVersion: SPDX-2.2"));
396        assert!(rendered.contains("FileName: ./src/main.rs"));
397    }
398
399    #[test]
400    fn test_spdx_rdf_writer_outputs_xml() {
401        let output = Output::from(&sample_internal_output());
402        let mut bytes = Vec::new();
403        writer_for_format(OutputFormat::SpdxRdf)
404            .write(
405                &output,
406                &mut bytes,
407                &OutputWriteConfig {
408                    format: OutputFormat::SpdxRdf,
409                    custom_template: None,
410                    scanned_path: Some("scan".to_string()),
411                },
412            )
413            .expect("spdx rdf write should succeed");
414
415        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
416        assert!(rendered.contains("<rdf:RDF"));
417        assert!(rendered.contains("<spdx:SpdxDocument"));
418        assert!(rendered.contains("<spdx:created>2026-01-01T00:00:00Z</spdx:created>"));
419    }
420
421    #[test]
422    fn test_cyclonedx_writers_keep_iso_timestamps_when_headers_use_scancode_format() {
423        let mut internal = sample_internal_output();
424        internal.packages.push(Package::from_package_data(
425            &PackageData {
426                name: Some("demo".to_string()),
427                version: Some("1.0.0".to_string()),
428                ..PackageData::default()
429            },
430            "scan/package.json".to_string(),
431        ));
432        let output = Output::from(&internal);
433
434        let mut json_bytes = Vec::new();
435        writer_for_format(OutputFormat::CycloneDxJson)
436            .write(
437                &output,
438                &mut json_bytes,
439                &OutputWriteConfig {
440                    format: OutputFormat::CycloneDxJson,
441                    custom_template: None,
442                    scanned_path: Some("scan".to_string()),
443                },
444            )
445            .expect("cyclonedx json write should succeed");
446        let json_value: Value =
447            serde_json::from_slice(&json_bytes).expect("cyclonedx json should parse");
448        assert_eq!(
449            json_value["metadata"]["timestamp"].as_str(),
450            Some("2026-01-01T00:00:01Z")
451        );
452
453        let mut xml_bytes = Vec::new();
454        writer_for_format(OutputFormat::CycloneDxXml)
455            .write(
456                &output,
457                &mut xml_bytes,
458                &OutputWriteConfig {
459                    format: OutputFormat::CycloneDxXml,
460                    custom_template: None,
461                    scanned_path: Some("scan".to_string()),
462                },
463            )
464            .expect("cyclonedx xml write should succeed");
465        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
466        assert!(xml.contains("<timestamp>2026-01-01T00:00:01Z</timestamp>"));
467    }
468
469    #[test]
470    fn test_spdx_writers_emit_real_file_and_package_license_info() {
471        let output = Output::from(&sample_internal_output());
472
473        let mut tv_bytes = Vec::new();
474        writer_for_format(OutputFormat::SpdxTv)
475            .write(
476                &output,
477                &mut tv_bytes,
478                &OutputWriteConfig {
479                    format: OutputFormat::SpdxTv,
480                    custom_template: None,
481                    scanned_path: Some("scan".to_string()),
482                },
483            )
484            .expect("spdx tv write should succeed");
485        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
486        assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
487        assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
488        assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
489        assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
490        assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
491
492        let mut rdf_bytes = Vec::new();
493        writer_for_format(OutputFormat::SpdxRdf)
494            .write(
495                &output,
496                &mut rdf_bytes,
497                &OutputWriteConfig {
498                    format: OutputFormat::SpdxRdf,
499                    custom_template: None,
500                    scanned_path: Some("scan".to_string()),
501                },
502            )
503            .expect("spdx rdf write should succeed");
504        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
505        assert!(rdf_rendered.contains(
506            "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
507        ));
508        assert!(
509            rdf_rendered.contains(
510                "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
511            )
512        );
513        assert!(rdf_rendered.contains(
514            "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
515        ));
516    }
517
518    #[test]
519    fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
520        let mut internal = sample_internal_output();
521        internal.files[0].license_detections = vec![LicenseDetection {
522            license_expression: "unknown-license-reference".to_string(),
523            license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
524            matches: vec![Match {
525                license_expression: "unknown-license-reference".to_string(),
526                license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
527                    .to_string(),
528                from_file: Some("src/main.rs".to_string()),
529                start_line: LineNumber::ONE,
530                end_line: LineNumber::new(2).unwrap(),
531                matcher: Some("2-aho".to_string()),
532                score: MatchScore::MAX,
533                matched_length: Some(4),
534                match_coverage: Some(100.0),
535                rule_relevance: Some(100),
536                rule_identifier: Some("unknown-license-reference.RULE".to_string()),
537                rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
538                matched_text: Some("Custom license text".to_string()),
539                referenced_filenames: Some(vec!["LICENSE".to_string()]),
540                matched_text_diagnostics: None,
541            }],
542            detection_log: vec![],
543            identifier: Some("unknown-ref-id".to_string()),
544        }];
545        internal.license_references = vec![crate::models::LicenseReference {
546            key: Some("unknown-license-reference".to_string()),
547            language: Some("en".to_string()),
548            name: "Unknown License Reference".to_string(),
549            short_name: "Unknown License Reference".to_string(),
550            owner: None,
551            homepage_url: None,
552            spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
553            other_spdx_license_keys: vec![],
554            osi_license_key: None,
555            text_urls: vec![],
556            osi_url: None,
557            faq_url: None,
558            other_urls: vec![],
559            category: None,
560            is_exception: false,
561            is_unknown: true,
562            is_generic: false,
563            notes: None,
564            minimum_coverage: None,
565            standard_notice: None,
566            ignorable_copyrights: vec![],
567            ignorable_holders: vec![],
568            ignorable_authors: vec![],
569            ignorable_urls: vec![],
570            ignorable_emails: vec![],
571            scancode_url: None,
572            licensedb_url: None,
573            spdx_url: None,
574            text: "Unused fallback text".to_string(),
575        }];
576        let output = Output::from(&internal);
577
578        let mut tv_bytes = Vec::new();
579        writer_for_format(OutputFormat::SpdxTv)
580            .write(
581                &output,
582                &mut tv_bytes,
583                &OutputWriteConfig {
584                    format: OutputFormat::SpdxTv,
585                    custom_template: None,
586                    scanned_path: Some("scan".to_string()),
587                },
588            )
589            .expect("spdx tv write should succeed");
590        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
591        assert!(
592            tv_rendered
593                .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
594        );
595        assert!(tv_rendered.contains(
596            "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
597        ));
598        assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
599        assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
600        assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
601        assert!(tv_rendered.contains(
602            "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
603        ));
604
605        let mut rdf_bytes = Vec::new();
606        writer_for_format(OutputFormat::SpdxRdf)
607            .write(
608                &output,
609                &mut rdf_bytes,
610                &OutputWriteConfig {
611                    format: OutputFormat::SpdxRdf,
612                    custom_template: None,
613                    scanned_path: Some("scan".to_string()),
614                },
615            )
616            .expect("spdx rdf write should succeed");
617        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
618        assert!(rdf_rendered.contains(
619            "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/LicenseRef-scancode-unknown-license-reference\"/>"
620        ));
621        assert!(rdf_rendered.contains(
622            "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"#LicenseRef-scancode-unknown-license-reference\">"
623        ));
624        assert!(
625            rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
626        );
627    }
628
629    #[test]
630    fn test_cyclonedx_json_writer_outputs_bom() {
631        let output = Output::from(&sample_internal_output());
632        let mut bytes = Vec::new();
633        writer_for_format(OutputFormat::CycloneDxJson)
634            .write(&output, &mut bytes, &OutputWriteConfig::default())
635            .expect("cyclonedx json write should succeed");
636
637        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
638        let value: Value = serde_json::from_str(&rendered).expect("valid json");
639        assert_eq!(value["bomFormat"], "CycloneDX");
640        assert_eq!(value["specVersion"], "1.3");
641    }
642
643    #[test]
644    fn test_json_writer_includes_summary_and_key_file_flags() {
645        let mut internal = sample_internal_output();
646        internal.summary = Some(crate::models::Summary {
647            declared_license_expression: Some("apache-2.0".to_string()),
648            license_clarity_score: Some(crate::models::LicenseClarityScore {
649                score: 100,
650                declared_license: true,
651                identification_precision: true,
652                has_license_text: true,
653                declared_copyrights: true,
654                conflicting_license_categories: false,
655                ambiguous_compound_licensing: false,
656            }),
657            declared_holder: Some("Example Corp.".to_string()),
658            primary_language: Some("Ruby".to_string()),
659            other_license_expressions: vec![crate::models::TallyEntry {
660                value: Some("mit".to_string()),
661                count: 1,
662            }],
663            other_holders: vec![
664                crate::models::TallyEntry {
665                    value: None,
666                    count: 2,
667                },
668                crate::models::TallyEntry {
669                    value: Some("Other Corp.".to_string()),
670                    count: 1,
671                },
672            ],
673            other_languages: vec![crate::models::TallyEntry {
674                value: Some("Python".to_string()),
675                count: 2,
676            }],
677        });
678        internal.files[0].is_legal = true;
679        internal.files[0].is_top_level = true;
680        internal.files[0].is_key_file = true;
681        let output = Output::from(&internal);
682
683        let mut bytes = Vec::new();
684        writer_for_format(OutputFormat::Json)
685            .write(&output, &mut bytes, &OutputWriteConfig::default())
686            .expect("json write should succeed");
687
688        let rendered = String::from_utf8(bytes).expect("json should be utf-8");
689        let value: Value = serde_json::from_str(&rendered).expect("valid json");
690
691        assert_eq!(
692            value["summary"]["declared_license_expression"],
693            "apache-2.0"
694        );
695        assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
696        assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
697        assert_eq!(value["summary"]["primary_language"], "Ruby");
698        assert_eq!(
699            value["summary"]["other_license_expressions"][0]["value"],
700            "mit"
701        );
702        assert!(value["summary"]["other_holders"][0]["value"].is_null());
703        assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
704        assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
705        assert_eq!(value["files"][0]["is_key_file"], true);
706    }
707
708    #[test]
709    fn test_json_and_json_lines_writers_include_top_level_tallies() {
710        let mut internal = sample_internal_output();
711        internal.tallies = Some(crate::models::Tallies {
712            detected_license_expression: vec![crate::models::TallyEntry {
713                value: Some("mit".to_string()),
714                count: 2,
715            }],
716            copyrights: vec![crate::models::TallyEntry {
717                value: Some("Copyright (c) Example Org".to_string()),
718                count: 1,
719            }],
720            holders: vec![crate::models::TallyEntry {
721                value: Some("Example Org".to_string()),
722                count: 1,
723            }],
724            authors: vec![crate::models::TallyEntry {
725                value: Some("Jane Doe".to_string()),
726                count: 1,
727            }],
728            programming_language: vec![crate::models::TallyEntry {
729                value: Some("Rust".to_string()),
730                count: 1,
731            }],
732        });
733        let output = Output::from(&internal);
734
735        let mut json_bytes = Vec::new();
736        writer_for_format(OutputFormat::Json)
737            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
738            .expect("json write should succeed");
739        let json_value: Value =
740            serde_json::from_slice(&json_bytes).expect("json output should parse");
741        assert_eq!(
742            json_value["tallies"]["detected_license_expression"][0]["value"],
743            "mit"
744        );
745        assert_eq!(
746            json_value["tallies"]["programming_language"][0]["value"],
747            "Rust"
748        );
749
750        let mut jsonl_bytes = Vec::new();
751        writer_for_format(OutputFormat::JsonLines)
752            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
753            .expect("json-lines write should succeed");
754        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
755        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
756    }
757
758    #[test]
759    fn test_json_and_json_lines_writers_include_key_file_tallies() {
760        let mut internal = sample_internal_output();
761        internal.tallies_of_key_files = Some(crate::models::Tallies {
762            detected_license_expression: vec![crate::models::TallyEntry {
763                value: Some("apache-2.0".to_string()),
764                count: 1,
765            }],
766            copyrights: vec![],
767            holders: vec![],
768            authors: vec![],
769            programming_language: vec![crate::models::TallyEntry {
770                value: Some("Markdown".to_string()),
771                count: 1,
772            }],
773        });
774        let output = Output::from(&internal);
775
776        let mut json_bytes = Vec::new();
777        writer_for_format(OutputFormat::Json)
778            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
779            .expect("json write should succeed");
780        let json_value: Value =
781            serde_json::from_slice(&json_bytes).expect("json output should parse");
782        assert_eq!(
783            json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
784            "apache-2.0"
785        );
786
787        let mut jsonl_bytes = Vec::new();
788        writer_for_format(OutputFormat::JsonLines)
789            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
790            .expect("json-lines write should succeed");
791        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
792        assert!(
793            rendered
794                .lines()
795                .any(|line| line.contains("\"tallies_of_key_files\""))
796        );
797    }
798
799    #[test]
800    fn test_json_and_json_lines_writers_include_file_tallies() {
801        let mut internal = sample_internal_output();
802        internal.files[0].tallies = Some(crate::models::Tallies {
803            detected_license_expression: vec![crate::models::TallyEntry {
804                value: Some("mit".to_string()),
805                count: 1,
806            }],
807            copyrights: vec![crate::models::TallyEntry {
808                value: None,
809                count: 1,
810            }],
811            holders: vec![],
812            authors: vec![],
813            programming_language: vec![crate::models::TallyEntry {
814                value: Some("Rust".to_string()),
815                count: 1,
816            }],
817        });
818        let output = Output::from(&internal);
819
820        let mut json_bytes = Vec::new();
821        writer_for_format(OutputFormat::Json)
822            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
823            .expect("json write should succeed");
824        let json_value: Value =
825            serde_json::from_slice(&json_bytes).expect("json output should parse");
826        assert_eq!(
827            json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
828            "mit"
829        );
830
831        let mut jsonl_bytes = Vec::new();
832        writer_for_format(OutputFormat::JsonLines)
833            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
834            .expect("json-lines write should succeed");
835        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
836        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
837    }
838
839    #[test]
840    fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
841        let mut internal = sample_internal_output();
842        internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
843        internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
844            facet: "core".to_string(),
845            tallies: crate::models::Tallies {
846                detected_license_expression: vec![crate::models::TallyEntry {
847                    value: Some("mit".to_string()),
848                    count: 1,
849                }],
850                copyrights: vec![],
851                holders: vec![],
852                authors: vec![],
853                programming_language: vec![],
854            },
855        }]);
856        let output = Output::from(&internal);
857
858        let mut json_bytes = Vec::new();
859        writer_for_format(OutputFormat::Json)
860            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
861            .expect("json write should succeed");
862        let json_value: Value =
863            serde_json::from_slice(&json_bytes).expect("json output should parse");
864        assert_eq!(json_value["files"][0]["facets"][0], "core");
865        assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
866
867        let mut jsonl_bytes = Vec::new();
868        writer_for_format(OutputFormat::JsonLines)
869            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
870            .expect("json-lines write should succeed");
871        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
872        assert!(
873            rendered
874                .lines()
875                .any(|line| line.contains("\"tallies_by_facet\""))
876        );
877    }
878
879    #[test]
880    fn test_json_and_json_lines_writers_include_top_level_license_references() {
881        let mut internal = sample_internal_output();
882        internal.license_references = vec![crate::models::LicenseReference {
883            key: Some("mit".to_string()),
884            language: Some("en".to_string()),
885            name: "MIT License".to_string(),
886            short_name: "MIT".to_string(),
887            owner: Some("Example Owner".to_string()),
888            homepage_url: Some("https://example.com/license".to_string()),
889            spdx_license_key: "MIT".to_string(),
890            other_spdx_license_keys: vec![],
891            osi_license_key: Some("MIT".to_string()),
892            text_urls: vec!["https://example.com/license.txt".to_string()],
893            osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
894            faq_url: None,
895            other_urls: vec![],
896            category: None,
897            is_exception: false,
898            is_unknown: false,
899            is_generic: false,
900            notes: None,
901            minimum_coverage: None,
902            standard_notice: None,
903            ignorable_copyrights: vec![],
904            ignorable_holders: vec![],
905            ignorable_authors: vec![],
906            ignorable_urls: vec![],
907            ignorable_emails: vec![],
908            scancode_url: None,
909            licensedb_url: None,
910            spdx_url: None,
911            text: "MIT text".to_string(),
912        }];
913        internal.license_rule_references = vec![crate::models::LicenseRuleReference {
914            identifier: "license-clue_1.RULE".to_string(),
915            license_expression: "unknown-license-reference".to_string(),
916            is_license_text: false,
917            is_license_notice: false,
918            is_license_reference: false,
919            is_license_tag: false,
920            is_license_clue: true,
921            is_license_intro: false,
922            language: None,
923            rule_url: None,
924            is_required_phrase: false,
925            skip_for_required_phrase_generation: false,
926            replaced_by: vec![],
927            is_continuous: false,
928            is_synthetic: false,
929            is_from_license: false,
930            length: 0,
931            relevance: None,
932            minimum_coverage: None,
933            referenced_filenames: vec![],
934            notes: None,
935            ignorable_copyrights: vec![],
936            ignorable_holders: vec![],
937            ignorable_authors: vec![],
938            ignorable_urls: vec![],
939            ignorable_emails: vec![],
940            text: None,
941        }];
942        let output = Output::from(&internal);
943
944        let mut json_bytes = Vec::new();
945        writer_for_format(OutputFormat::Json)
946            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
947            .expect("json write should succeed");
948        let json_value: Value =
949            serde_json::from_slice(&json_bytes).expect("json output should parse");
950        assert_eq!(
951            json_value["license_references"][0]["spdx_license_key"],
952            "MIT"
953        );
954        assert_eq!(json_value["license_references"][0]["key"], "mit");
955        assert_eq!(json_value["license_references"][0]["language"], "en");
956        assert_eq!(
957            json_value["license_references"][0]["owner"],
958            "Example Owner"
959        );
960        assert_eq!(
961            json_value["license_references"][0]["homepage_url"],
962            "https://example.com/license"
963        );
964        assert_eq!(
965            json_value["license_references"][0]["osi_license_key"],
966            "MIT"
967        );
968        assert_eq!(
969            json_value["license_references"][0]["text_urls"][0],
970            "https://example.com/license.txt"
971        );
972        assert_eq!(
973            json_value["license_rule_references"][0]["identifier"],
974            "license-clue_1.RULE"
975        );
976        assert_eq!(
977            json_value["license_rule_references"][0]["relevance"],
978            Value::Null
979        );
980        assert_eq!(
981            json_value["license_rule_references"][0]["length"],
982            Value::from(0)
983        );
984
985        let mut jsonl_bytes = Vec::new();
986        writer_for_format(OutputFormat::JsonLines)
987            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
988            .expect("json-lines write should succeed");
989        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
990        assert!(
991            rendered
992                .lines()
993                .any(|line| line.contains("\"license_references\""))
994        );
995        assert!(
996            rendered
997                .lines()
998                .any(|line| line.contains("\"license_rule_references\""))
999        );
1000    }
1001
1002    #[test]
1003    fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1004        let mut internal = sample_internal_output();
1005        internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1006            identifier: "mit-id".to_string(),
1007            license_expression: "mit".to_string(),
1008            license_expression_spdx: "MIT".to_string(),
1009            detection_count: 2,
1010            detection_log: vec![],
1011            reference_matches: vec![crate::models::Match {
1012                license_expression: "mit".to_string(),
1013                license_expression_spdx: "MIT".to_string(),
1014                from_file: Some("src/main.rs".to_string()),
1015                start_line: LineNumber::ONE,
1016                end_line: LineNumber::new(3).unwrap(),
1017                matcher: Some("1-hash".to_string()),
1018                score: MatchScore::MAX,
1019                matched_length: Some(10),
1020                match_coverage: Some(100.0),
1021                rule_relevance: Some(100),
1022                rule_identifier: Some("mit.LICENSE".to_string()),
1023                rule_url: None,
1024                matched_text: None,
1025                referenced_filenames: None,
1026                matched_text_diagnostics: None,
1027            }],
1028        }];
1029        let output = Output::from(&internal);
1030
1031        let mut json_bytes = Vec::new();
1032        writer_for_format(OutputFormat::Json)
1033            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1034            .expect("json write should succeed");
1035        let json_value: Value =
1036            serde_json::from_slice(&json_bytes).expect("json output should parse");
1037        assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1038        assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1039
1040        let mut jsonl_bytes = Vec::new();
1041        writer_for_format(OutputFormat::JsonLines)
1042            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1043            .expect("json-lines write should succeed");
1044        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1045        assert!(
1046            rendered
1047                .lines()
1048                .any(|line| line.contains("\"license_detections\""))
1049        );
1050    }
1051
1052    #[test]
1053    fn test_cyclonedx_xml_writer_outputs_xml() {
1054        let output = Output::from(&sample_internal_output());
1055        let mut bytes = Vec::new();
1056        writer_for_format(OutputFormat::CycloneDxXml)
1057            .write(&output, &mut bytes, &OutputWriteConfig::default())
1058            .expect("cyclonedx xml write should succeed");
1059
1060        let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1061        assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
1062        assert!(rendered.contains("<components>"));
1063    }
1064
1065    #[test]
1066    fn test_cyclonedx_json_includes_component_license_expression() {
1067        let mut internal = sample_internal_output();
1068        internal.packages = vec![crate::models::Package {
1069            package_type: Some(crate::models::PackageType::Maven),
1070            namespace: Some("example".to_string()),
1071            name: Some("gradle-project".to_string()),
1072            version: Some("1.0.0".to_string()),
1073            qualifiers: None,
1074            subpath: None,
1075            primary_language: Some("Java".to_string()),
1076            description: None,
1077            release_date: None,
1078            parties: vec![],
1079            keywords: vec![],
1080            homepage_url: None,
1081            download_url: None,
1082            size: None,
1083            sha1: None,
1084            md5: None,
1085            sha256: None,
1086            sha512: None,
1087            bug_tracking_url: None,
1088            code_view_url: None,
1089            vcs_url: None,
1090            copyright: None,
1091            holder: None,
1092            declared_license_expression: Some("Apache-2.0".to_string()),
1093            declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1094            license_detections: vec![],
1095            other_license_expression: None,
1096            other_license_expression_spdx: None,
1097            other_license_detections: vec![],
1098            extracted_license_statement: Some("Apache-2.0".to_string()),
1099            notice_text: None,
1100            source_packages: vec![],
1101            is_private: false,
1102            is_virtual: false,
1103            extra_data: None,
1104            repository_homepage_url: None,
1105            repository_download_url: None,
1106            api_data_url: None,
1107            datasource_ids: vec![],
1108            purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1109            package_uid: PackageUid::from_raw(
1110                "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1111            ),
1112            datafile_paths: vec![],
1113        }];
1114        let output = Output::from(&internal);
1115
1116        let mut bytes = Vec::new();
1117        writer_for_format(OutputFormat::CycloneDxJson)
1118            .write(&output, &mut bytes, &OutputWriteConfig::default())
1119            .expect("cyclonedx json write should succeed");
1120
1121        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1122        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1123
1124        assert_eq!(
1125            value["components"][0]["licenses"][0]["expression"],
1126            "Apache-2.0"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1132        let output = Output {
1133            summary: None,
1134            tallies: None,
1135            tallies_of_key_files: None,
1136            tallies_by_facet: None,
1137            headers: vec![],
1138            packages: vec![],
1139            dependencies: vec![],
1140            license_detections: vec![],
1141            files: vec![],
1142            license_references: vec![],
1143            license_rule_references: vec![],
1144        };
1145        let mut bytes = Vec::new();
1146        writer_for_format(OutputFormat::SpdxTv)
1147            .write(
1148                &output,
1149                &mut bytes,
1150                &OutputWriteConfig {
1151                    format: OutputFormat::SpdxTv,
1152                    custom_template: None,
1153                    scanned_path: Some("scan".to_string()),
1154                },
1155            )
1156            .expect("spdx tv write should succeed");
1157
1158        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1159        assert_eq!(rendered, "# No results for package 'scan'.\n");
1160    }
1161
1162    #[test]
1163    fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1164        let output = Output {
1165            summary: None,
1166            tallies: None,
1167            tallies_of_key_files: None,
1168            tallies_by_facet: None,
1169            headers: vec![],
1170            packages: vec![],
1171            dependencies: vec![],
1172            license_detections: vec![],
1173            files: vec![],
1174            license_references: vec![],
1175            license_rule_references: vec![],
1176        };
1177        let mut bytes = Vec::new();
1178        writer_for_format(OutputFormat::SpdxRdf)
1179            .write(
1180                &output,
1181                &mut bytes,
1182                &OutputWriteConfig {
1183                    format: OutputFormat::SpdxRdf,
1184                    custom_template: None,
1185                    scanned_path: Some("scan".to_string()),
1186                },
1187            )
1188            .expect("spdx rdf write should succeed");
1189
1190        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1191        assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1192    }
1193
1194    #[test]
1195    fn test_html_writer_outputs_html_document() {
1196        let output = Output::from(&sample_internal_output());
1197        let mut bytes = Vec::new();
1198        writer_for_format(OutputFormat::Html)
1199            .write(&output, &mut bytes, &OutputWriteConfig::default())
1200            .expect("html write should succeed");
1201        let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1202        assert!(rendered.contains("<!doctype html>"));
1203        assert!(rendered.contains("Custom Template"));
1204    }
1205
1206    #[test]
1207    fn test_custom_template_writer_renders_output_context() {
1208        let output = Output::from(&sample_internal_output());
1209        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1210        let template_path = temp_dir.path().join("template.tera");
1211        fs::write(
1212            &template_path,
1213            "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1214        )
1215        .expect("template should be written");
1216
1217        let mut bytes = Vec::new();
1218        writer_for_format(OutputFormat::CustomTemplate)
1219            .write(
1220                &output,
1221                &mut bytes,
1222                &OutputWriteConfig {
1223                    format: OutputFormat::CustomTemplate,
1224                    custom_template: Some(template_path.to_string_lossy().to_string()),
1225                    scanned_path: None,
1226                },
1227            )
1228            .expect("custom template write should succeed");
1229
1230        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1231        assert!(rendered.contains("version=4.1.0"));
1232        assert!(rendered.contains("files=1"));
1233    }
1234
1235    fn sample_internal_output() -> crate::models::Output {
1236        crate::models::Output {
1237            summary: None,
1238            tallies: None,
1239            tallies_of_key_files: None,
1240            tallies_by_facet: None,
1241            headers: vec![Header {
1242                tool_name: "provenant".to_string(),
1243                tool_version: env!("CARGO_PKG_VERSION").to_string(),
1244                options: serde_json::Map::new(),
1245                notice: crate::models::HEADER_NOTICE.to_string(),
1246                start_timestamp: "2026-01-01T000000.000000".to_string(),
1247                end_timestamp: "2026-01-01T000001.000000".to_string(),
1248                output_format_version: "4.1.0".to_string(),
1249                duration: 1.0,
1250                errors: vec![],
1251                warnings: vec![],
1252                extra_data: ExtraData {
1253                    system_environment: SystemEnvironment {
1254                        operating_system: "darwin".to_string(),
1255                        cpu_architecture: "aarch64".to_string(),
1256                        platform: "darwin".to_string(),
1257                        platform_version: "26.3.1".to_string(),
1258                        rust_version: "1.93.0".to_string(),
1259                    },
1260                    spdx_license_list_version: "3.27".to_string(),
1261                    files_count: 1,
1262                    directories_count: 1,
1263                    excluded_count: 0,
1264                },
1265            }],
1266            packages: vec![],
1267            dependencies: vec![],
1268            license_detections: vec![],
1269            files: vec![FileInfo::new(
1270                "main.rs".to_string(),
1271                "main".to_string(),
1272                "rs".to_string(),
1273                "src/main.rs".to_string(),
1274                FileType::File,
1275                Some("text/plain".to_string()),
1276                None,
1277                42,
1278                None,
1279                Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1280                Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1281                Some(
1282                    Sha256Digest::from_hex(
1283                        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1284                    )
1285                    .unwrap(),
1286                ),
1287                Some("Rust".to_string()),
1288                vec![PackageData::default()],
1289                None,
1290                vec![LicenseDetection {
1291                    license_expression: "mit".to_string(),
1292                    license_expression_spdx: "MIT".to_string(),
1293                    matches: vec![Match {
1294                        license_expression: "mit".to_string(),
1295                        license_expression_spdx: "MIT".to_string(),
1296                        from_file: None,
1297                        start_line: LineNumber::ONE,
1298                        end_line: LineNumber::ONE,
1299                        matcher: None,
1300                        score: MatchScore::MAX,
1301                        matched_length: None,
1302                        match_coverage: None,
1303                        rule_relevance: None,
1304                        rule_identifier: Some("mit_rule".to_string()),
1305                        rule_url: None,
1306                        matched_text: None,
1307                        referenced_filenames: None,
1308                        matched_text_diagnostics: None,
1309                    }],
1310                    detection_log: vec![],
1311                    identifier: None,
1312                }],
1313                vec![],
1314                vec![Copyright {
1315                    copyright: "Copyright (c) Example".to_string(),
1316                    start_line: LineNumber::ONE,
1317                    end_line: LineNumber::ONE,
1318                }],
1319                vec![Holder {
1320                    holder: "Example Org".to_string(),
1321                    start_line: LineNumber::ONE,
1322                    end_line: LineNumber::ONE,
1323                }],
1324                vec![Author {
1325                    author: "Jane Doe".to_string(),
1326                    start_line: LineNumber::ONE,
1327                    end_line: LineNumber::ONE,
1328                }],
1329                vec![OutputEmail {
1330                    email: "jane@example.com".to_string(),
1331                    start_line: LineNumber::ONE,
1332                    end_line: LineNumber::ONE,
1333                }],
1334                vec![OutputURL {
1335                    url: "https://example.com".to_string(),
1336                    start_line: LineNumber::ONE,
1337                    end_line: LineNumber::ONE,
1338                }],
1339                vec![],
1340                vec![],
1341            )],
1342            license_references: vec![],
1343            license_rule_references: vec![],
1344        }
1345    }
1346}