Skip to main content

provenant/output/
mod.rs

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