Skip to main content

provenant/output/
mod.rs

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