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_detected_license_expression_prefers_detection_values() {
450        // The scancode-key field reports the detection's `license_expression`
451        // (e.g. `bsd-new`), not the SPDX form, parallel to the SPDX accessor.
452        let mut internal = sample_internal_output();
453        internal.files[0].license_detections = vec![crate::models::LicenseDetection {
454            license_expression: "bsd-new".to_string(),
455            license_expression_spdx: "BSD-3-Clause".to_string(),
456            matches: vec![],
457            detection_log: vec![],
458            identifier: String::new(),
459        }];
460        internal.files[0].detected_license_expression = None;
461
462        let schema_file = OutputFileInfo::from(&internal.files[0]);
463        let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
464        assert_eq!(schema_value["detected_license_expression"], "bsd-new");
465        assert_eq!(
466            schema_value["detected_license_expression_spdx"],
467            "BSD-3-Clause"
468        );
469    }
470
471    #[test]
472    fn test_detected_license_expression_falls_back_to_package_data_detections() {
473        // With no file-level detections, the scancode-key field falls back to
474        // package-data detections, mirroring the SPDX accessor's second tier.
475        let mut internal = sample_internal_output();
476        internal.files[0].license_detections = vec![];
477        internal.files[0].detected_license_expression = None;
478        internal.files[0].package_data = vec![crate::models::PackageData {
479            license_detections: vec![crate::models::LicenseDetection {
480                license_expression: "bsd-simplified".to_string(),
481                license_expression_spdx: "BSD-2-Clause".to_string(),
482                matches: vec![],
483                detection_log: vec![],
484                identifier: String::new(),
485            }],
486            ..Default::default()
487        }];
488
489        let schema_file = OutputFileInfo::from(&internal.files[0]);
490        let schema_value = serde_json::to_value(&schema_file).expect("file info serializes");
491        assert_eq!(
492            schema_value["detected_license_expression"],
493            "bsd-simplified"
494        );
495        assert_eq!(
496            schema_value["detected_license_expression_spdx"],
497            "BSD-2-Clause"
498        );
499    }
500
501    #[test]
502    fn test_json_lines_writer_sorts_files_by_path_for_reproducibility() {
503        let mut internal = sample_internal_output();
504        internal.files.reverse();
505        let output = Output::from(&internal);
506        let mut bytes = Vec::new();
507        writer_for_format(OutputFormat::JsonLines)
508            .write(&output, &mut bytes, &OutputWriteConfig::default())
509            .expect("json-lines write should succeed");
510
511        let rendered = String::from_utf8(bytes).expect("json-lines should be utf-8");
512        let file_lines = rendered
513            .lines()
514            .filter_map(|line| {
515                let value: Value = serde_json::from_str(line).ok()?;
516                let files = value.get("files")?.as_array()?;
517                files.first()?.get("path")?.as_str().map(str::to_string)
518            })
519            .collect::<Vec<_>>();
520
521        let mut sorted = file_lines.clone();
522        sorted.sort();
523        assert_eq!(file_lines, sorted);
524    }
525
526    #[test]
527    fn test_spdx_tag_value_writer_contains_required_fields() {
528        let output = Output::from(&sample_internal_output());
529        let mut bytes = Vec::new();
530        writer_for_format(OutputFormat::SpdxTv)
531            .write(
532                &output,
533                &mut bytes,
534                &OutputWriteConfig {
535                    format: OutputFormat::SpdxTv,
536                    custom_template: None,
537                    scanned_path: Some("scan".to_string()),
538                },
539            )
540            .expect("spdx tv write should succeed");
541
542        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
543        assert!(rendered.contains("SPDXVersion: SPDX-2.2"));
544        assert!(rendered.contains("FileName: ./src/main.rs"));
545    }
546
547    #[test]
548    fn test_spdx_rdf_writer_outputs_xml() {
549        let output = Output::from(&sample_internal_output());
550        let mut bytes = Vec::new();
551        writer_for_format(OutputFormat::SpdxRdf)
552            .write(
553                &output,
554                &mut bytes,
555                &OutputWriteConfig {
556                    format: OutputFormat::SpdxRdf,
557                    custom_template: None,
558                    scanned_path: Some("scan".to_string()),
559                },
560            )
561            .expect("spdx rdf write should succeed");
562
563        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
564        assert!(rendered.contains("<rdf:RDF"));
565        assert!(rendered.contains("<spdx:SpdxDocument"));
566        assert!(rendered.contains("<spdx:created>2026-01-01T00:00:00Z</spdx:created>"));
567    }
568
569    #[test]
570    fn test_cyclonedx_writers_keep_iso_timestamps_when_headers_use_scancode_format() {
571        let mut internal = sample_internal_output();
572        internal.packages.push(Package::from_package_data(
573            &PackageData {
574                name: Some("demo".to_string()),
575                version: Some("1.0.0".to_string()),
576                ..PackageData::default()
577            },
578            "scan/package.json".to_string(),
579        ));
580        let output = Output::from(&internal);
581
582        let mut json_bytes = Vec::new();
583        writer_for_format(OutputFormat::CycloneDxJson)
584            .write(
585                &output,
586                &mut json_bytes,
587                &OutputWriteConfig {
588                    format: OutputFormat::CycloneDxJson,
589                    custom_template: None,
590                    scanned_path: Some("scan".to_string()),
591                },
592            )
593            .expect("cyclonedx json write should succeed");
594        let json_value: Value =
595            serde_json::from_slice(&json_bytes).expect("cyclonedx json should parse");
596        assert_eq!(
597            json_value["metadata"]["timestamp"].as_str(),
598            Some("2026-01-01T00:00:01Z")
599        );
600
601        let mut xml_bytes = Vec::new();
602        writer_for_format(OutputFormat::CycloneDxXml)
603            .write(
604                &output,
605                &mut xml_bytes,
606                &OutputWriteConfig {
607                    format: OutputFormat::CycloneDxXml,
608                    custom_template: None,
609                    scanned_path: Some("scan".to_string()),
610                },
611            )
612            .expect("cyclonedx xml write should succeed");
613        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
614        assert!(xml.contains("<timestamp>2026-01-01T00:00:01Z</timestamp>"));
615    }
616
617    #[test]
618    fn test_spdx_writers_emit_real_file_and_package_license_info() {
619        let output = Output::from(&sample_internal_output());
620
621        let mut tv_bytes = Vec::new();
622        writer_for_format(OutputFormat::SpdxTv)
623            .write(
624                &output,
625                &mut tv_bytes,
626                &OutputWriteConfig {
627                    format: OutputFormat::SpdxTv,
628                    custom_template: None,
629                    scanned_path: Some("scan".to_string()),
630                },
631            )
632            .expect("spdx tv write should succeed");
633        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
634        assert!(tv_rendered.contains("PackageLicenseConcluded: NOASSERTION"));
635        assert!(tv_rendered.contains("PackageLicenseInfoFromFiles: MIT"));
636        assert!(tv_rendered.contains("LicenseConcluded: NOASSERTION"));
637        assert!(tv_rendered.contains("LicenseInfoInFile: MIT"));
638        assert!(tv_rendered.contains("PackageCopyrightText: Copyright (c) Example"));
639
640        let mut rdf_bytes = Vec::new();
641        writer_for_format(OutputFormat::SpdxRdf)
642            .write(
643                &output,
644                &mut rdf_bytes,
645                &OutputWriteConfig {
646                    format: OutputFormat::SpdxRdf,
647                    custom_template: None,
648                    scanned_path: Some("scan".to_string()),
649                },
650            )
651            .expect("spdx rdf write should succeed");
652        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
653        assert!(rdf_rendered.contains(
654            "<spdx:licenseInfoFromFiles rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
655        ));
656        assert!(
657            rdf_rendered.contains(
658                "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/MIT\"/>"
659            )
660        );
661        assert!(rdf_rendered.contains(
662            "<spdx:licenseConcluded rdf:resource=\"http://spdx.org/rdf/terms#noassertion\"/>"
663        ));
664    }
665
666    #[test]
667    fn test_spdx_writers_emit_license_ref_metadata_and_matched_text() {
668        let mut internal = sample_internal_output();
669        internal.files[0].license_detections = vec![LicenseDetection {
670            license_expression: "unknown-license-reference".to_string(),
671            license_expression_spdx: "LicenseRef-scancode-unknown-license-reference".to_string(),
672            matches: vec![Match {
673                license_expression: "unknown-license-reference".to_string(),
674                license_expression_spdx: "LicenseRef-scancode-unknown-license-reference"
675                    .to_string(),
676                from_file: Some("src/main.rs".to_string()),
677                start_line: LineNumber::ONE,
678                end_line: LineNumber::new(2).unwrap(),
679                matcher: MatcherKind::Aho,
680                score: MatchScore::MAX,
681                matched_length: Some(4),
682                match_coverage: Some(100.0),
683                rule_relevance: Some(100),
684                rule_identifier: "unknown-license-reference.RULE".to_string(),
685                rule_url: Some("https://example.com/unknown-license-reference.LICENSE".to_string()),
686                matched_text: Some("Custom license text".to_string()),
687                referenced_filenames: Some(vec!["LICENSE".to_string()]),
688                matched_text_diagnostics: None,
689            }],
690            detection_log: vec![],
691            identifier: "unknown-ref-id".to_string(),
692        }];
693        internal.license_references = vec![crate::models::LicenseReference {
694            key: Some("unknown-license-reference".to_string()),
695            language: Some("en".to_string()),
696            name: "Unknown License Reference".to_string(),
697            short_name: "Unknown License Reference".to_string(),
698            owner: None,
699            homepage_url: None,
700            spdx_license_key: "LicenseRef-scancode-unknown-license-reference".to_string(),
701            other_spdx_license_keys: vec![],
702            osi_license_key: None,
703            text_urls: vec![],
704            osi_url: None,
705            faq_url: None,
706            other_urls: vec![],
707            category: None,
708            is_exception: false,
709            is_unknown: true,
710            is_generic: false,
711            notes: None,
712            minimum_coverage: None,
713            standard_notice: None,
714            ignorable_copyrights: vec![],
715            ignorable_holders: vec![],
716            ignorable_authors: vec![],
717            ignorable_urls: vec![],
718            ignorable_emails: vec![],
719            scancode_url: None,
720            licensedb_url: None,
721            spdx_url: None,
722            text: "Unused fallback text".to_string(),
723        }];
724        let output = Output::from(&internal);
725
726        let mut tv_bytes = Vec::new();
727        writer_for_format(OutputFormat::SpdxTv)
728            .write(
729                &output,
730                &mut tv_bytes,
731                &OutputWriteConfig {
732                    format: OutputFormat::SpdxTv,
733                    custom_template: None,
734                    scanned_path: Some("scan".to_string()),
735                },
736            )
737            .expect("spdx tv write should succeed");
738        let tv_rendered = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
739        assert!(
740            tv_rendered
741                .contains("LicenseInfoInFile: LicenseRef-scancode-unknown-license-reference")
742        );
743        assert!(tv_rendered.contains(
744            "PackageLicenseInfoFromFiles: LicenseRef-scancode-unknown-license-reference"
745        ));
746        assert!(tv_rendered.contains("LicenseID: LicenseRef-scancode-unknown-license-reference"));
747        assert!(tv_rendered.contains("ExtractedText: <text>Custom license text"));
748        assert!(tv_rendered.contains("LicenseName: Unknown License Reference"));
749        assert!(tv_rendered.contains(
750            "LicenseComment: <text>See details at https://example.com/unknown-license-reference.LICENSE"
751        ));
752
753        let mut rdf_bytes = Vec::new();
754        writer_for_format(OutputFormat::SpdxRdf)
755            .write(
756                &output,
757                &mut rdf_bytes,
758                &OutputWriteConfig {
759                    format: OutputFormat::SpdxRdf,
760                    custom_template: None,
761                    scanned_path: Some("scan".to_string()),
762                },
763            )
764            .expect("spdx rdf write should succeed");
765        let rdf_rendered = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
766        assert!(rdf_rendered.contains(
767            "<spdx:licenseInfoInFile rdf:resource=\"http://spdx.org/licenses/LicenseRef-scancode-unknown-license-reference\"/>"
768        ));
769        assert!(rdf_rendered.contains(
770            "<spdx:hasExtractedLicensingInfo><spdx:ExtractedLicensingInfo rdf:about=\"#LicenseRef-scancode-unknown-license-reference\">"
771        ));
772        assert!(
773            rdf_rendered.contains("<spdx:extractedText>Custom license text</spdx:extractedText>")
774        );
775    }
776
777    #[test]
778    fn test_cyclonedx_json_writer_outputs_bom() {
779        let output = Output::from(&sample_internal_output());
780        let mut bytes = Vec::new();
781        writer_for_format(OutputFormat::CycloneDxJson)
782            .write(&output, &mut bytes, &OutputWriteConfig::default())
783            .expect("cyclonedx json write should succeed");
784
785        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
786        let value: Value = serde_json::from_str(&rendered).expect("valid json");
787        assert_eq!(value["bomFormat"], "CycloneDX");
788        assert_eq!(value["specVersion"], "1.3");
789    }
790
791    #[test]
792    fn test_json_writer_includes_summary_and_key_file_flags() {
793        let mut internal = sample_internal_output();
794        internal.summary = Some(crate::models::Summary {
795            declared_license_expression: Some("apache-2.0".to_string()),
796            license_clarity_score: Some(crate::models::LicenseClarityScore {
797                score: 100,
798                declared_license: true,
799                identification_precision: true,
800                has_license_text: true,
801                declared_copyrights: true,
802                conflicting_license_categories: false,
803                ambiguous_compound_licensing: false,
804            }),
805            declared_holder: Some("Example Corp.".to_string()),
806            primary_language: Some("Ruby".to_string()),
807            other_license_expressions: vec![crate::models::TallyEntry {
808                value: Some("mit".to_string()),
809                count: 1,
810            }],
811            other_holders: vec![
812                crate::models::TallyEntry {
813                    value: None,
814                    count: 2,
815                },
816                crate::models::TallyEntry {
817                    value: Some("Other Corp.".to_string()),
818                    count: 1,
819                },
820            ],
821            other_languages: vec![crate::models::TallyEntry {
822                value: Some("Python".to_string()),
823                count: 2,
824            }],
825        });
826        internal.files[0].is_legal = true;
827        internal.files[0].is_top_level = true;
828        internal.files[0].is_key_file = true;
829        let output = Output::from(&internal);
830
831        let mut bytes = Vec::new();
832        writer_for_format(OutputFormat::Json)
833            .write(&output, &mut bytes, &OutputWriteConfig::default())
834            .expect("json write should succeed");
835
836        let rendered = String::from_utf8(bytes).expect("json should be utf-8");
837        let value: Value = serde_json::from_str(&rendered).expect("valid json");
838
839        assert_eq!(
840            value["summary"]["declared_license_expression"],
841            "apache-2.0"
842        );
843        assert_eq!(value["summary"]["license_clarity_score"]["score"], 100);
844        assert_eq!(value["summary"]["declared_holder"], "Example Corp.");
845        assert_eq!(value["summary"]["primary_language"], "Ruby");
846        assert_eq!(
847            value["summary"]["other_license_expressions"][0]["value"],
848            "mit"
849        );
850        assert!(value["summary"]["other_holders"][0]["value"].is_null());
851        assert_eq!(value["summary"]["other_holders"][1]["value"], "Other Corp.");
852        assert_eq!(value["summary"]["other_languages"][0]["value"], "Python");
853        assert_eq!(value["files"][0]["is_key_file"], true);
854    }
855
856    #[test]
857    fn test_json_and_json_lines_writers_include_top_level_tallies() {
858        let mut internal = sample_internal_output();
859        internal.tallies = Some(crate::models::Tallies {
860            detected_license_expression: vec![crate::models::TallyEntry {
861                value: Some("mit".to_string()),
862                count: 2,
863            }],
864            copyrights: vec![crate::models::TallyEntry {
865                value: Some("Copyright (c) Example Org".to_string()),
866                count: 1,
867            }],
868            holders: vec![crate::models::TallyEntry {
869                value: Some("Example Org".to_string()),
870                count: 1,
871            }],
872            authors: vec![crate::models::TallyEntry {
873                value: Some("Jane Doe".to_string()),
874                count: 1,
875            }],
876            programming_language: vec![crate::models::TallyEntry {
877                value: Some("Rust".to_string()),
878                count: 1,
879            }],
880        });
881        let output = Output::from(&internal);
882
883        let mut json_bytes = Vec::new();
884        writer_for_format(OutputFormat::Json)
885            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
886            .expect("json write should succeed");
887        let json_value: Value =
888            serde_json::from_slice(&json_bytes).expect("json output should parse");
889        assert_eq!(
890            json_value["tallies"]["detected_license_expression"][0]["value"],
891            "mit"
892        );
893        assert_eq!(
894            json_value["tallies"]["programming_language"][0]["value"],
895            "Rust"
896        );
897
898        let mut jsonl_bytes = Vec::new();
899        writer_for_format(OutputFormat::JsonLines)
900            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
901            .expect("json-lines write should succeed");
902        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
903        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
904    }
905
906    #[test]
907    fn test_json_and_json_lines_writers_include_key_file_tallies() {
908        let mut internal = sample_internal_output();
909        internal.tallies_of_key_files = Some(crate::models::Tallies {
910            detected_license_expression: vec![crate::models::TallyEntry {
911                value: Some("apache-2.0".to_string()),
912                count: 1,
913            }],
914            copyrights: vec![],
915            holders: vec![],
916            authors: vec![],
917            programming_language: vec![crate::models::TallyEntry {
918                value: Some("Markdown".to_string()),
919                count: 1,
920            }],
921        });
922        let output = Output::from(&internal);
923
924        let mut json_bytes = Vec::new();
925        writer_for_format(OutputFormat::Json)
926            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
927            .expect("json write should succeed");
928        let json_value: Value =
929            serde_json::from_slice(&json_bytes).expect("json output should parse");
930        assert_eq!(
931            json_value["tallies_of_key_files"]["detected_license_expression"][0]["value"],
932            "apache-2.0"
933        );
934
935        let mut jsonl_bytes = Vec::new();
936        writer_for_format(OutputFormat::JsonLines)
937            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
938            .expect("json-lines write should succeed");
939        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
940        assert!(
941            rendered
942                .lines()
943                .any(|line| line.contains("\"tallies_of_key_files\""))
944        );
945    }
946
947    #[test]
948    fn test_json_and_json_lines_writers_include_file_tallies() {
949        let mut internal = sample_internal_output();
950        internal.files[0].tallies = Some(crate::models::Tallies {
951            detected_license_expression: vec![crate::models::TallyEntry {
952                value: Some("mit".to_string()),
953                count: 1,
954            }],
955            copyrights: vec![crate::models::TallyEntry {
956                value: None,
957                count: 1,
958            }],
959            holders: vec![],
960            authors: vec![],
961            programming_language: vec![crate::models::TallyEntry {
962                value: Some("Rust".to_string()),
963                count: 1,
964            }],
965        });
966        let output = Output::from(&internal);
967
968        let mut json_bytes = Vec::new();
969        writer_for_format(OutputFormat::Json)
970            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
971            .expect("json write should succeed");
972        let json_value: Value =
973            serde_json::from_slice(&json_bytes).expect("json output should parse");
974        assert_eq!(
975            json_value["files"][0]["tallies"]["detected_license_expression"][0]["value"],
976            "mit"
977        );
978
979        let mut jsonl_bytes = Vec::new();
980        writer_for_format(OutputFormat::JsonLines)
981            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
982            .expect("json-lines write should succeed");
983        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
984        assert!(rendered.lines().any(|line| line.contains("\"tallies\"")));
985    }
986
987    #[test]
988    fn test_json_and_json_lines_writers_include_facets_and_tallies_by_facet() {
989        let mut internal = sample_internal_output();
990        internal.files[0].facets = vec!["core".to_string(), "docs".to_string()];
991        internal.tallies_by_facet = Some(vec![crate::models::FacetTallies {
992            facet: "core".to_string(),
993            tallies: crate::models::Tallies {
994                detected_license_expression: vec![crate::models::TallyEntry {
995                    value: Some("mit".to_string()),
996                    count: 1,
997                }],
998                copyrights: vec![],
999                holders: vec![],
1000                authors: vec![],
1001                programming_language: vec![],
1002            },
1003        }]);
1004        let output = Output::from(&internal);
1005
1006        let mut json_bytes = Vec::new();
1007        writer_for_format(OutputFormat::Json)
1008            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1009            .expect("json write should succeed");
1010        let json_value: Value =
1011            serde_json::from_slice(&json_bytes).expect("json output should parse");
1012        assert_eq!(json_value["files"][0]["facets"][0], "core");
1013        assert_eq!(json_value["tallies_by_facet"][0]["facet"], "core");
1014
1015        let mut jsonl_bytes = Vec::new();
1016        writer_for_format(OutputFormat::JsonLines)
1017            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1018            .expect("json-lines write should succeed");
1019        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1020        assert!(
1021            rendered
1022                .lines()
1023                .any(|line| line.contains("\"tallies_by_facet\""))
1024        );
1025    }
1026
1027    #[test]
1028    fn test_json_and_json_lines_writers_include_top_level_license_references() {
1029        let mut internal = sample_internal_output();
1030        internal.license_references = vec![crate::models::LicenseReference {
1031            key: Some("mit".to_string()),
1032            language: Some("en".to_string()),
1033            name: "MIT License".to_string(),
1034            short_name: "MIT".to_string(),
1035            owner: Some("Example Owner".to_string()),
1036            homepage_url: Some("https://example.com/license".to_string()),
1037            spdx_license_key: "MIT".to_string(),
1038            other_spdx_license_keys: vec![],
1039            osi_license_key: Some("MIT".to_string()),
1040            text_urls: vec!["https://example.com/license.txt".to_string()],
1041            osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
1042            faq_url: None,
1043            other_urls: vec![],
1044            category: None,
1045            is_exception: false,
1046            is_unknown: false,
1047            is_generic: false,
1048            notes: None,
1049            minimum_coverage: None,
1050            standard_notice: None,
1051            ignorable_copyrights: vec![],
1052            ignorable_holders: vec![],
1053            ignorable_authors: vec![],
1054            ignorable_urls: vec![],
1055            ignorable_emails: vec![],
1056            scancode_url: None,
1057            licensedb_url: None,
1058            spdx_url: None,
1059            text: "MIT text".to_string(),
1060        }];
1061        internal.license_rule_references = vec![crate::models::LicenseRuleReference {
1062            identifier: "license-clue_1.RULE".to_string(),
1063            license_expression: "unknown-license-reference".to_string(),
1064            is_license_text: false,
1065            is_license_notice: false,
1066            is_license_reference: false,
1067            is_license_tag: false,
1068            is_license_clue: true,
1069            is_license_intro: false,
1070            language: None,
1071            rule_url: None,
1072            is_required_phrase: false,
1073            skip_for_required_phrase_generation: false,
1074            replaced_by: vec![],
1075            is_continuous: false,
1076            is_synthetic: false,
1077            is_from_license: false,
1078            length: 0,
1079            relevance: None,
1080            minimum_coverage: None,
1081            referenced_filenames: vec![],
1082            notes: None,
1083            ignorable_copyrights: vec![],
1084            ignorable_holders: vec![],
1085            ignorable_authors: vec![],
1086            ignorable_urls: vec![],
1087            ignorable_emails: vec![],
1088            text: None,
1089        }];
1090        let output = Output::from(&internal);
1091
1092        let mut json_bytes = Vec::new();
1093        writer_for_format(OutputFormat::Json)
1094            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1095            .expect("json write should succeed");
1096        let json_value: Value =
1097            serde_json::from_slice(&json_bytes).expect("json output should parse");
1098        assert_eq!(
1099            json_value["license_references"][0]["spdx_license_key"],
1100            "MIT"
1101        );
1102        assert_eq!(json_value["license_references"][0]["key"], "mit");
1103        assert_eq!(json_value["license_references"][0]["language"], "en");
1104        assert_eq!(
1105            json_value["license_references"][0]["owner"],
1106            "Example Owner"
1107        );
1108        assert_eq!(
1109            json_value["license_references"][0]["homepage_url"],
1110            "https://example.com/license"
1111        );
1112        assert_eq!(
1113            json_value["license_references"][0]["osi_license_key"],
1114            "MIT"
1115        );
1116        assert_eq!(
1117            json_value["license_references"][0]["text_urls"][0],
1118            "https://example.com/license.txt"
1119        );
1120        assert_eq!(
1121            json_value["license_rule_references"][0]["identifier"],
1122            "license-clue_1.RULE"
1123        );
1124        assert_eq!(
1125            json_value["license_rule_references"][0]["relevance"],
1126            Value::Null
1127        );
1128        assert_eq!(
1129            json_value["license_rule_references"][0]["length"],
1130            Value::from(0)
1131        );
1132
1133        let mut jsonl_bytes = Vec::new();
1134        writer_for_format(OutputFormat::JsonLines)
1135            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1136            .expect("json-lines write should succeed");
1137        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1138        assert!(
1139            rendered
1140                .lines()
1141                .any(|line| line.contains("\"license_references\""))
1142        );
1143        assert!(
1144            rendered
1145                .lines()
1146                .any(|line| line.contains("\"license_rule_references\""))
1147        );
1148    }
1149
1150    #[test]
1151    fn test_json_and_json_lines_writers_include_top_level_license_detections() {
1152        let mut internal = sample_internal_output();
1153        internal.license_detections = vec![crate::models::TopLevelLicenseDetection {
1154            identifier: "mit-id".to_string(),
1155            license_expression: "mit".to_string(),
1156            license_expression_spdx: "MIT".to_string(),
1157            detection_count: 2,
1158            detection_log: vec![],
1159            reference_matches: vec![crate::models::Match {
1160                license_expression: "mit".to_string(),
1161                license_expression_spdx: "MIT".to_string(),
1162                from_file: Some("src/main.rs".to_string()),
1163                start_line: LineNumber::ONE,
1164                end_line: LineNumber::new(3).unwrap(),
1165                matcher: MatcherKind::Hash,
1166                score: MatchScore::MAX,
1167                matched_length: Some(10),
1168                match_coverage: Some(100.0),
1169                rule_relevance: Some(100),
1170                rule_identifier: "mit.LICENSE".to_string(),
1171                rule_url: None,
1172                matched_text: None,
1173                referenced_filenames: None,
1174                matched_text_diagnostics: None,
1175            }],
1176        }];
1177        let output = Output::from(&internal);
1178
1179        let mut json_bytes = Vec::new();
1180        writer_for_format(OutputFormat::Json)
1181            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1182            .expect("json write should succeed");
1183        let json_value: Value =
1184            serde_json::from_slice(&json_bytes).expect("json output should parse");
1185        assert_eq!(json_value["license_detections"][0]["identifier"], "mit-id");
1186        assert_eq!(json_value["license_detections"][0]["detection_count"], 2);
1187
1188        let mut jsonl_bytes = Vec::new();
1189        writer_for_format(OutputFormat::JsonLines)
1190            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1191            .expect("json-lines write should succeed");
1192        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1193        assert!(
1194            rendered
1195                .lines()
1196                .any(|line| line.contains("\"license_detections\""))
1197        );
1198    }
1199
1200    #[test]
1201    fn test_json_and_json_lines_writers_keep_empty_top_level_license_detections() {
1202        let output = Output::from(&sample_internal_output());
1203
1204        let mut json_bytes = Vec::new();
1205        writer_for_format(OutputFormat::Json)
1206            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1207            .expect("json write should succeed");
1208        let json_value: Value =
1209            serde_json::from_slice(&json_bytes).expect("json output should parse");
1210        assert_eq!(json_value["license_detections"], Value::Array(vec![]));
1211
1212        let mut jsonl_bytes = Vec::new();
1213        writer_for_format(OutputFormat::JsonLines)
1214            .write(&output, &mut jsonl_bytes, &OutputWriteConfig::default())
1215            .expect("json-lines write should succeed");
1216        let rendered = String::from_utf8(jsonl_bytes).expect("json-lines should be utf-8");
1217        assert!(
1218            rendered
1219                .lines()
1220                .any(|line| line == r#"{"license_detections":[]}"#)
1221        );
1222    }
1223
1224    #[test]
1225    fn test_public_writer_normalizes_empty_package_maps_without_changing_schema_output() {
1226        let mut internal = sample_internal_output();
1227        internal.packages.push(Package::from_package_data(
1228            &PackageData {
1229                package_type: Some(crate::models::PackageType::Npm),
1230                name: Some("demo".to_string()),
1231                version: Some("1.0.0".to_string()),
1232                ..PackageData::default()
1233            },
1234            "scan/package.json".to_string(),
1235        ));
1236
1237        let output = Output::from(&internal);
1238        let raw_schema = serde_json::to_value(&output).expect("schema output should serialize");
1239        assert_eq!(
1240            raw_schema["packages"][0]["qualifiers"],
1241            serde_json::json!({})
1242        );
1243        assert_eq!(
1244            raw_schema["packages"][0]["extra_data"],
1245            serde_json::json!({})
1246        );
1247
1248        let mut bytes = Vec::new();
1249        writer_for_format(OutputFormat::Json)
1250            .write(&output, &mut bytes, &OutputWriteConfig::default())
1251            .expect("json write should succeed");
1252        let public_value: Value = serde_json::from_slice(&bytes).expect("public json should parse");
1253
1254        assert!(public_value["packages"][0]["qualifiers"].is_null());
1255        assert!(public_value["packages"][0]["extra_data"].is_null());
1256    }
1257
1258    #[test]
1259    fn test_cyclonedx_xml_writer_outputs_xml() {
1260        let output = Output::from(&sample_internal_output());
1261        let mut bytes = Vec::new();
1262        writer_for_format(OutputFormat::CycloneDxXml)
1263            .write(&output, &mut bytes, &OutputWriteConfig::default())
1264            .expect("cyclonedx xml write should succeed");
1265
1266        let rendered = String::from_utf8(bytes).expect("cyclonedx xml should be utf-8");
1267        assert!(rendered.contains("<bom xmlns=\"http://cyclonedx.org/schema/bom/1.3\""));
1268        assert!(rendered.contains("<components>"));
1269    }
1270
1271    #[test]
1272    fn test_cyclonedx_json_includes_component_license_expression() {
1273        let mut internal = sample_internal_output();
1274        internal.packages = vec![crate::models::Package {
1275            package_type: Some(crate::models::PackageType::Maven),
1276            namespace: Some("example".to_string()),
1277            name: Some("gradle-project".to_string()),
1278            version: Some("1.0.0".to_string()),
1279            qualifiers: None,
1280            subpath: None,
1281            primary_language: Some("Java".to_string()),
1282            description: None,
1283            release_date: None,
1284            parties: vec![],
1285            keywords: vec![],
1286            homepage_url: None,
1287            download_url: None,
1288            size: None,
1289            sha1: None,
1290            md5: None,
1291            sha256: None,
1292            sha512: None,
1293            bug_tracking_url: None,
1294            code_view_url: None,
1295            vcs_url: None,
1296            copyright: None,
1297            holder: None,
1298            declared_license_expression: Some("Apache-2.0".to_string()),
1299            declared_license_expression_spdx: Some("Apache-2.0".to_string()),
1300            license_detections: vec![],
1301            other_license_expression: None,
1302            other_license_expression_spdx: None,
1303            other_license_detections: vec![],
1304            extracted_license_statement: Some("Apache-2.0".to_string()),
1305            notice_text: None,
1306            source_packages: vec![],
1307            is_private: false,
1308            is_virtual: false,
1309            extra_data: None,
1310            repository_homepage_url: None,
1311            repository_download_url: None,
1312            api_data_url: None,
1313            datasource_ids: vec![],
1314            purl: Some("pkg:maven/example/gradle-project@1.0.0".to_string()),
1315            package_uid: PackageUid::from_raw(
1316                "pkg:maven/example/gradle-project@1.0.0?uuid=test".to_string(),
1317            ),
1318            datafile_paths: vec![],
1319        }];
1320        let output = Output::from(&internal);
1321
1322        let mut bytes = Vec::new();
1323        writer_for_format(OutputFormat::CycloneDxJson)
1324            .write(&output, &mut bytes, &OutputWriteConfig::default())
1325            .expect("cyclonedx json write should succeed");
1326
1327        let rendered = String::from_utf8(bytes).expect("cyclonedx json should be utf-8");
1328        let value: Value = serde_json::from_str(&rendered).expect("valid json");
1329
1330        assert_eq!(
1331            value["components"][0]["licenses"][0]["expression"],
1332            "Apache-2.0"
1333        );
1334    }
1335
1336    #[test]
1337    fn test_cyclonedx_external_references_are_deduplicated() {
1338        let mut internal = sample_internal_output();
1339        internal.packages = vec![Package::from_package_data(
1340            &PackageData {
1341                package_type: Some(crate::models::PackageType::Npm),
1342                name: Some("demo".to_string()),
1343                version: Some("1.0.0".to_string()),
1344                download_url: Some("https://example.com/download.tgz".to_string()),
1345                repository_download_url: Some("https://example.com/download.tgz".to_string()),
1346                homepage_url: Some("https://example.com".to_string()),
1347                repository_homepage_url: Some("https://example.com".to_string()),
1348                ..PackageData::default()
1349            },
1350            "scan/package.json".to_string(),
1351        )];
1352        let output = Output::from(&internal);
1353
1354        let mut json_bytes = Vec::new();
1355        writer_for_format(OutputFormat::CycloneDxJson)
1356            .write(&output, &mut json_bytes, &OutputWriteConfig::default())
1357            .expect("cyclonedx json write should succeed");
1358        let value: Value = serde_json::from_slice(&json_bytes).expect("valid cyclonedx json");
1359        let refs = value["components"][0]["externalReferences"]
1360            .as_array()
1361            .expect("external references should be an array");
1362        assert_eq!(refs.len(), 2);
1363
1364        let mut xml_bytes = Vec::new();
1365        writer_for_format(OutputFormat::CycloneDxXml)
1366            .write(&output, &mut xml_bytes, &OutputWriteConfig::default())
1367            .expect("cyclonedx xml write should succeed");
1368        let xml = String::from_utf8(xml_bytes).expect("cyclonedx xml should be utf-8");
1369        assert_eq!(xml.matches("https://example.com/download.tgz").count(), 1);
1370        assert_eq!(xml.matches("https://example.com</url>").count(), 1);
1371    }
1372
1373    #[test]
1374    fn test_spdx_prefers_single_detected_package_name_over_scan_root() {
1375        let mut internal = sample_internal_output();
1376        internal.packages = vec![Package::from_package_data(
1377            &PackageData {
1378                package_type: Some(crate::models::PackageType::Npm),
1379                name: Some("detected-package".to_string()),
1380                version: Some("1.0.0".to_string()),
1381                ..PackageData::default()
1382            },
1383            "scan/package.json".to_string(),
1384        )];
1385        let output = Output::from(&internal);
1386
1387        let mut tv_bytes = Vec::new();
1388        writer_for_format(OutputFormat::SpdxTv)
1389            .write(
1390                &output,
1391                &mut tv_bytes,
1392                &OutputWriteConfig {
1393                    format: OutputFormat::SpdxTv,
1394                    custom_template: None,
1395                    scanned_path: Some("scan-root".to_string()),
1396                },
1397            )
1398            .expect("spdx tv write should succeed");
1399        let tv = String::from_utf8(tv_bytes).expect("spdx tv should be utf-8");
1400        assert!(tv.contains("PackageName: detected-package"));
1401        assert!(tv.contains("DocumentNamespace: http://spdx.org/spdxdocs/detected-package"));
1402
1403        let mut rdf_bytes = Vec::new();
1404        writer_for_format(OutputFormat::SpdxRdf)
1405            .write(
1406                &output,
1407                &mut rdf_bytes,
1408                &OutputWriteConfig {
1409                    format: OutputFormat::SpdxRdf,
1410                    custom_template: None,
1411                    scanned_path: Some("scan-root".to_string()),
1412                },
1413            )
1414            .expect("spdx rdf write should succeed");
1415        let rdf = String::from_utf8(rdf_bytes).expect("spdx rdf should be utf-8");
1416        assert!(rdf.contains("<spdx:name>detected-package</spdx:name>"));
1417    }
1418
1419    #[test]
1420    fn test_spdx_empty_scan_tag_value_matches_python_sentinel() {
1421        let output = Output {
1422            summary: None,
1423            tallies: None,
1424            tallies_of_key_files: None,
1425            tallies_by_facet: None,
1426            headers: vec![],
1427            packages: vec![],
1428            dependencies: vec![],
1429            license_detections: vec![],
1430            files: vec![],
1431            license_references: vec![],
1432            license_rule_references: vec![],
1433        };
1434        let mut bytes = Vec::new();
1435        writer_for_format(OutputFormat::SpdxTv)
1436            .write(
1437                &output,
1438                &mut bytes,
1439                &OutputWriteConfig {
1440                    format: OutputFormat::SpdxTv,
1441                    custom_template: None,
1442                    scanned_path: Some("scan".to_string()),
1443                },
1444            )
1445            .expect("spdx tv write should succeed");
1446
1447        let rendered = String::from_utf8(bytes).expect("spdx should be utf-8");
1448        assert_eq!(rendered, "# No results for package 'scan'.\n");
1449    }
1450
1451    #[test]
1452    fn test_spdx_empty_scan_rdf_matches_python_sentinel() {
1453        let output = Output {
1454            summary: None,
1455            tallies: None,
1456            tallies_of_key_files: None,
1457            tallies_by_facet: None,
1458            headers: vec![],
1459            packages: vec![],
1460            dependencies: vec![],
1461            license_detections: vec![],
1462            files: vec![],
1463            license_references: vec![],
1464            license_rule_references: vec![],
1465        };
1466        let mut bytes = Vec::new();
1467        writer_for_format(OutputFormat::SpdxRdf)
1468            .write(
1469                &output,
1470                &mut bytes,
1471                &OutputWriteConfig {
1472                    format: OutputFormat::SpdxRdf,
1473                    custom_template: None,
1474                    scanned_path: Some("scan".to_string()),
1475                },
1476            )
1477            .expect("spdx rdf write should succeed");
1478
1479        let rendered = String::from_utf8(bytes).expect("rdf should be utf-8");
1480        assert_eq!(rendered, "<!-- No results for package 'scan'. -->\n");
1481    }
1482
1483    #[test]
1484    fn test_html_writer_outputs_html_document() {
1485        let output = Output::from(&sample_internal_output());
1486        let mut bytes = Vec::new();
1487        writer_for_format(OutputFormat::Html)
1488            .write(&output, &mut bytes, &OutputWriteConfig::default())
1489            .expect("html write should succeed");
1490        let rendered = String::from_utf8(bytes).expect("html should be utf-8");
1491        assert!(rendered.contains("<!doctype html>"));
1492        assert!(rendered.contains("Provenant HTML Report"));
1493    }
1494
1495    #[test]
1496    fn test_custom_template_writer_renders_output_context() {
1497        let output = Output::from(&sample_internal_output());
1498        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
1499        let template_path = temp_dir.path().join("template.tera");
1500        fs::write(
1501            &template_path,
1502            "version={{ output.headers[0].output_format_version }} files={{ files | length }}",
1503        )
1504        .expect("template should be written");
1505
1506        let mut bytes = Vec::new();
1507        writer_for_format(OutputFormat::CustomTemplate)
1508            .write(
1509                &output,
1510                &mut bytes,
1511                &OutputWriteConfig {
1512                    format: OutputFormat::CustomTemplate,
1513                    custom_template: Some(template_path.to_string_lossy().to_string()),
1514                    scanned_path: None,
1515                },
1516            )
1517            .expect("custom template write should succeed");
1518
1519        let rendered = String::from_utf8(bytes).expect("template output should be utf-8");
1520        assert!(rendered.contains("version=4.1.0"));
1521        assert!(rendered.contains("files=1"));
1522    }
1523
1524    fn sample_internal_output() -> crate::models::Output {
1525        crate::models::Output {
1526            summary: None,
1527            tallies: None,
1528            tallies_of_key_files: None,
1529            tallies_by_facet: None,
1530            headers: vec![Header {
1531                tool_name: "provenant".to_string(),
1532                tool_version: crate::version::BUILD_VERSION.to_string(),
1533                options: serde_json::Map::new(),
1534                notice: crate::models::HEADER_NOTICE.to_string(),
1535                start_timestamp: "2026-01-01T000000.000000".to_string(),
1536                end_timestamp: "2026-01-01T000001.000000".to_string(),
1537                output_format_version: "4.1.0".to_string(),
1538                duration: 1.0,
1539                errors: vec![],
1540                warnings: vec![],
1541                extra_data: ExtraData {
1542                    system_environment: SystemEnvironment {
1543                        operating_system: "darwin".to_string(),
1544                        cpu_architecture: "aarch64".to_string(),
1545                        platform: "darwin".to_string(),
1546                        platform_version: "26.3.1".to_string(),
1547                        rust_version: "1.93.0".to_string(),
1548                    },
1549                    spdx_license_list_version: "3.27".to_string(),
1550                    files_count: 1,
1551                    directories_count: 1,
1552                    excluded_count: 0,
1553                    license_index_provenance: Some(crate::models::LicenseIndexProvenance {
1554                        source: "embedded-artifact".to_string(),
1555                        dataset_fingerprint: "test-fingerprint".to_string(),
1556                        ignored_rules: vec![
1557                            "gpl-2.0_and-unknown-license-reference_1.RULE".to_string(),
1558                        ],
1559                        ignored_licenses: vec![],
1560                        ignored_rules_due_to_licenses: vec![],
1561                        added_rules: vec![],
1562                        replaced_rules: vec![],
1563                        added_licenses: vec![],
1564                        replaced_licenses: vec![],
1565                    }),
1566                },
1567            }],
1568            packages: vec![],
1569            dependencies: vec![],
1570            license_detections: vec![],
1571            files: vec![FileInfo::new(
1572                "main.rs".to_string(),
1573                "main".to_string(),
1574                "rs".to_string(),
1575                "src/main.rs".to_string(),
1576                FileType::File,
1577                Some("text/plain".to_string()),
1578                None,
1579                42,
1580                None,
1581                Some(Sha1Digest::from_hex("da39a3ee5e6b4b0d3255bfef95601890afd80709").unwrap()),
1582                Some(Md5Digest::from_hex("d41d8cd98f00b204e9800998ecf8427e").unwrap()),
1583                Some(
1584                    Sha256Digest::from_hex(
1585                        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1586                    )
1587                    .unwrap(),
1588                ),
1589                Some("Rust".to_string()),
1590                vec![PackageData::default()],
1591                None,
1592                vec![LicenseDetection {
1593                    license_expression: "mit".to_string(),
1594                    license_expression_spdx: "MIT".to_string(),
1595                    matches: vec![Match {
1596                        license_expression: "mit".to_string(),
1597                        license_expression_spdx: "MIT".to_string(),
1598                        from_file: None,
1599                        start_line: LineNumber::ONE,
1600                        end_line: LineNumber::ONE,
1601                        matcher: MatcherKind::Hash,
1602                        score: MatchScore::MAX,
1603                        matched_length: None,
1604                        match_coverage: None,
1605                        rule_relevance: None,
1606                        rule_identifier: "mit_rule".to_string(),
1607                        rule_url: None,
1608                        matched_text: None,
1609                        referenced_filenames: None,
1610                        matched_text_diagnostics: None,
1611                    }],
1612                    detection_log: vec![],
1613                    identifier: String::new(),
1614                }],
1615                vec![],
1616                vec![Copyright {
1617                    copyright: "Copyright (c) Example".to_string(),
1618                    normalized_copyright: None,
1619                    start_line: LineNumber::ONE,
1620                    end_line: LineNumber::ONE,
1621                }],
1622                vec![Holder {
1623                    holder: "Example Org".to_string(),
1624                    start_line: LineNumber::ONE,
1625                    end_line: LineNumber::ONE,
1626                }],
1627                vec![Author {
1628                    author: "Jane Doe".to_string(),
1629                    start_line: LineNumber::ONE,
1630                    end_line: LineNumber::ONE,
1631                }],
1632                vec![OutputEmail {
1633                    email: "jane@example.com".to_string(),
1634                    start_line: LineNumber::ONE,
1635                    end_line: LineNumber::ONE,
1636                }],
1637                vec![OutputURL {
1638                    url: "https://example.com".to_string(),
1639                    start_line: LineNumber::ONE,
1640                    end_line: LineNumber::ONE,
1641                }],
1642                vec![],
1643                vec![],
1644            )],
1645            license_references: vec![],
1646            license_rule_references: vec![],
1647        }
1648    }
1649}