Skip to main content

provenant/output/
mod.rs

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