Skip to main content

provenant/output/
mod.rs

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