1use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use crate::converter::prune_empty_fields;
20use crate::converter::validator::Finding;
21
22use super::provenance::{EvidenceLevel, FieldProvenance};
23use super::result::{
24 compute_evidence_summary, compute_release_status, evaluate_release_gate, EvidenceSummary,
25 GenerationResult, ReleaseGateResult, ReleaseStatus,
26};
27use super::unresolved::{UnresolvedField, UnresolvedReason};
28
29pub const REPORT_SCHEMA_VERSION: &str = "1.0";
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct GenerationReport {
40 pub report_schema_version: String,
41 pub release_status: ReleaseStatus,
42 pub findings: Vec<Finding>,
43 pub unresolved: Vec<UnresolvedField>,
44 pub provenance: Vec<FieldProvenance>,
45 pub evidence_summary: EvidenceSummary,
46 pub release_gate: ReleaseGateResult,
47}
48
49pub fn build_generation_report(result: &GenerationResult) -> GenerationReport {
58 let evidence_summary = compute_evidence_summary(&result.provenance, &result.unresolved);
59 let release_status = compute_release_status(&result.unresolved, &result.findings);
60
61 let mut gated = result.clone();
62 gated.release_status = release_status;
63 let release_gate = evaluate_release_gate(&gated);
64
65 GenerationReport {
66 report_schema_version: REPORT_SCHEMA_VERSION.to_string(),
67 release_status,
68 findings: result.findings.clone(),
69 unresolved: result.unresolved.clone(),
70 provenance: result.provenance.clone(),
71 evidence_summary,
72 release_gate,
73 }
74}
75
76#[derive(Debug, Error)]
77pub enum GenerationArtifactError {
78 #[error("failed to serialize official SDS JSON: {0}")]
79 OfficialSdsSerialization(#[source] serde_json::Error),
80 #[error("failed to serialize generation report JSON: {0}")]
81 ReportSerialization(#[source] serde_json::Error),
82 #[error("invalid artifact state: {0}")]
83 InvalidArtifactState(String),
84}
85
86pub fn serialize_official_sds(
92 result: &GenerationResult,
93) -> Result<String, GenerationArtifactError> {
94 let value = serde_json::to_value(&result.sds)
95 .map_err(GenerationArtifactError::OfficialSdsSerialization)?;
96 let pruned = prune_empty_fields(value);
97 serde_json::to_string_pretty(&pruned).map_err(GenerationArtifactError::OfficialSdsSerialization)
98}
99
100pub fn serialize_generation_report(
105 report: &GenerationReport,
106) -> Result<String, GenerationArtifactError> {
107 serde_json::to_string_pretty(report).map_err(GenerationArtifactError::ReportSerialization)
108}
109
110pub struct GenerationArtifacts {
114 pub official_sds_json: String,
115 pub generation_report_json: String,
116 pub review_report_markdown: String,
117}
118
119pub fn build_generation_artifacts(
120 result: &GenerationResult,
121) -> Result<GenerationArtifacts, GenerationArtifactError> {
122 let official_sds_json = serialize_official_sds(result)?;
123 let report = build_generation_report(result);
124 let generation_report_json = serialize_generation_report(&report)?;
125 let review_report_markdown = render_review_report(&report);
126 Ok(GenerationArtifacts {
127 official_sds_json,
128 generation_report_json,
129 review_report_markdown,
130 })
131}
132
133pub fn render_review_report(report: &GenerationReport) -> String {
143 let mut out = String::new();
144
145 out.push_str("# SDS Generation Review Report\n\n");
146
147 out.push_str("## Release status\n\n");
148 out.push_str(describe_release_status(report.release_status));
149 out.push_str("\n\n");
150 out.push_str(
151 "This output is an automatically generated SDS draft. It has not been \
152 approved by a qualified reviewer.\n\n",
153 );
154
155 let blocking_unresolved = report
156 .unresolved
157 .iter()
158 .filter(|f| f.blocks_release)
159 .count();
160 out.push_str("## Summary\n\n");
161 out.push_str(&format!("- Findings: {}\n", report.findings.len()));
162 out.push_str(&format!(
163 "- Blocking findings: {}\n",
164 report.release_gate.blocking_findings.len()
165 ));
166 out.push_str(&format!(
167 "- Unresolved fields: {}\n",
168 report.unresolved.len()
169 ));
170 out.push_str(&format!(
171 "- Unresolved fields blocking release: {}\n",
172 blocking_unresolved
173 ));
174 out.push_str(&format!(
175 "- Confirmed fields: {}\n",
176 report.evidence_summary.confirmed
177 ));
178 out.push_str(&format!(
179 "- Unverified user-input fields: {}\n",
180 report.evidence_summary.unverified_user_input
181 ));
182 out.push('\n');
183
184 out.push_str("## Blocking issues\n\n");
185 render_findings_list(
186 &mut out,
187 &sorted_findings(&report.release_gate.blocking_findings),
188 );
189
190 out.push_str("## Required actions\n\n");
191 if report.release_gate.required_actions.is_empty() {
192 out.push_str("None.\n\n");
193 } else {
194 for action in &report.release_gate.required_actions {
195 out.push_str(&format!("- {}\n", esc(action)));
196 }
197 out.push('\n');
198 }
199
200 out.push_str("## Unresolved fields\n\n");
201 if report.unresolved.is_empty() {
202 out.push_str("None.\n\n");
203 } else {
204 let mut unresolved = report.unresolved.clone();
205 unresolved.sort_by(|a, b| a.path.cmp(&b.path));
206 for field in &unresolved {
207 render_unresolved_field(&mut out, field);
208 }
209 }
210
211 out.push_str("## Findings\n\n");
212 render_findings_list(&mut out, &sorted_findings(&report.findings));
213
214 out.push_str("## Evidence summary\n\n");
215 let s = &report.evidence_summary;
216 out.push_str(&format!("- Confirmed: {}\n", s.confirmed));
217 out.push_str(&format!("- Supplied: {}\n", s.supplied));
218 out.push_str(&format!("- Literature: {}\n", s.literature));
219 out.push_str(&format!("- Calculated: {}\n", s.calculated));
220 out.push_str(&format!("- Estimated: {}\n", s.estimated));
221 out.push_str(&format!("- Unresolved: {}\n", s.unresolved));
222 out.push_str(&format!("- Not applicable: {}\n", s.not_applicable));
223 out.push_str(&format!(
224 "- Product test evidence: {}\n",
225 s.product_test_evidence
226 ));
227 out.push_str(&format!(
228 "- Unverified user input: {}\n",
229 s.unverified_user_input
230 ));
231 out.push('\n');
232
233 out.push_str("## Provenance\n\n");
234 if report.provenance.is_empty() {
235 out.push_str("None.\n\n");
236 } else {
237 for p in &report.provenance {
238 render_provenance_entry(&mut out, p);
239 }
240 out.push('\n');
241 }
242
243 out
244}
245
246fn render_findings_list(out: &mut String, findings: &[Finding]) {
247 if findings.is_empty() {
248 out.push_str("None.\n\n");
249 return;
250 }
251 for f in findings {
252 out.push_str(&format!(
253 "- **[{}] {}**: {}\n",
254 esc(&f.level),
255 esc(&f.rule),
256 esc(&f.message)
257 ));
258 }
259 out.push('\n');
260}
261
262fn sorted_findings(findings: &[Finding]) -> Vec<Finding> {
263 let mut findings = findings.to_vec();
264 findings.sort_by_key(finding_sort_key);
265 findings
266}
267
268fn finding_sort_key(f: &Finding) -> (u8, String, String) {
269 let rank = match f.level.as_str() {
270 "CRIT" => 0,
271 "HIGH" => 1,
272 "MED" => 2,
273 "LOW" => 3,
274 "WARN" => 4,
275 _ => 5,
276 };
277 (rank, f.rule.clone(), f.message.clone())
278}
279
280fn render_unresolved_field(out: &mut String, field: &UnresolvedField) {
281 out.push_str(&format!("### {}\n\n", esc(&field.title)));
282 out.push_str(&format!("- Path: `{}`\n", field.path));
283 out.push_str(&format!("- Reason: {}\n", describe_reason(field.reason)));
284 out.push_str(&format!("- Blocks release: {}\n", field.blocks_release));
285 out.push_str(&format!("- Safety impact: {:?}\n", field.safety_impact));
286 out.push_str(&format!(
287 "- Regulatory impact: {:?}\n",
288 field.regulatory_impact
289 ));
290 if !field.required_inputs.is_empty() {
291 out.push_str("- Required inputs:\n");
292 for input in &field.required_inputs {
293 match &input.unit {
294 Some(unit) => out.push_str(&format!(
295 " - {} ({}): {}\n",
296 esc(&input.name),
297 esc(unit),
298 esc(&input.description)
299 )),
300 None => out.push_str(&format!(
301 " - {}: {}\n",
302 esc(&input.name),
303 esc(&input.description)
304 )),
305 }
306 }
307 }
308 if !field.acceptable_evidence.is_empty() {
309 out.push_str("- Acceptable evidence:\n");
310 for level in &field.acceptable_evidence {
311 out.push_str(&format!(" - {}\n", describe_evidence_level(*level)));
312 }
313 }
314 out.push_str(&format!(
315 "- Recommended action: {}\n",
316 esc(&field.recommended_action)
317 ));
318 out.push('\n');
319}
320
321fn render_provenance_entry(out: &mut String, p: &FieldProvenance) {
322 out.push_str(&format!(
323 "- `{}` — {:?}, confidence {:?}",
324 p.path, p.source_type, p.confidence
325 ));
326 if let Some(reference) = &p.source_reference {
327 out.push_str(&format!(", reference: {}", esc(reference)));
328 }
329 if let Some(value) = &p.source_value {
330 out.push_str(&format!(", value: {}", esc(value)));
331 }
332 if !p.method.is_empty() {
333 out.push_str(&format!(", method: {}", esc(&p.method)));
334 }
335 if let Some(sample_id) = &p.sample_id {
336 out.push_str(&format!(", sample: {}", esc(sample_id)));
337 }
338 if let Some(batch_id) = &p.batch_id {
339 out.push_str(&format!(", batch: {}", esc(batch_id)));
340 }
341 if let Some(test_method) = &p.test_method {
342 out.push_str(&format!(", test method: {}", esc(test_method)));
343 }
344 if let Some(retrieved_at) = &p.retrieved_at {
345 out.push_str(&format!(", retrieved: {}", esc(retrieved_at)));
346 }
347 if !p.warnings.is_empty() {
348 let warnings: Vec<String> = p.warnings.iter().map(|w| esc(w)).collect();
349 out.push_str(&format!(", warnings: {}", warnings.join("; ")));
350 }
351 out.push('\n');
352}
353
354fn describe_release_status(status: ReleaseStatus) -> &'static str {
355 match status {
356 ReleaseStatus::Draft => "DRAFT",
357 ReleaseStatus::ReviewRequired => "REVIEW REQUIRED",
358 ReleaseStatus::Blocked => "BLOCKED",
359 ReleaseStatus::Approved => "APPROVED (AUTOMATED DRAFT — STILL REQUIRES HUMAN SIGN-OFF)",
360 }
361}
362
363fn describe_reason(reason: UnresolvedReason) -> &'static str {
364 match reason {
365 UnresolvedReason::MissingInput => "Missing input",
366 UnresolvedReason::ProductTestRequired => "Product test required",
367 UnresolvedReason::AmbiguousChemicalIdentity => "Ambiguous chemical identity",
368 UnresolvedReason::ConflictingSources => "Conflicting sources",
369 UnresolvedReason::UnsupportedCalculation => "Unsupported calculation",
370 UnresolvedReason::InsufficientMeasurementConditions => {
371 "Insufficient measurement conditions"
372 }
373 UnresolvedReason::MixtureCannotBeDerivedFromComponents => {
374 "Mixture property cannot be derived from component values"
375 }
376 UnresolvedReason::RegulatoryJudgementRequired => "Regulatory judgement required",
377 UnresolvedReason::HumanReviewRequired => "Human review required",
378 }
379}
380
381fn describe_evidence_level(level: EvidenceLevel) -> &'static str {
382 match level {
383 EvidenceLevel::ProductTestReport => "product test report",
384 EvidenceLevel::EquivalentBatchTestReport => "equivalent-batch test report",
385 EvidenceLevel::SupplierSpecification => "supplier specification",
386 EvidenceLevel::SupplierSds => "supplier SDS",
387 EvidenceLevel::RegulatoryDatabase => "regulatory database",
388 EvidenceLevel::PeerReviewedLiterature => "peer-reviewed literature",
389 EvidenceLevel::ReferenceDatabase => "reference database",
390 EvidenceLevel::DeterministicCalculation => "deterministic calculation",
391 EvidenceLevel::ModelEstimate => "model estimate",
392 EvidenceLevel::UnverifiedUserInput => "unverified user input",
393 EvidenceLevel::None => "none",
394 }
395}
396
397fn esc(input: &str) -> String {
405 let mut out = String::with_capacity(input.len());
406 for ch in input.chars() {
407 match ch {
408 '\n' | '\r' => out.push(' '),
409 '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '|' => {
410 out.push('\\');
411 out.push(ch);
412 }
413 _ => out.push(ch),
414 }
415 }
416 out
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::generation::input::ProductInput;
423 use crate::generation::provenance::{path, ConfidenceLevel};
424 use crate::generation::result::generate_from_resolved_input;
425 use crate::generation::unresolved::{RegulatoryImpact, RequiredInput, SafetyImpact};
426 use std::collections::HashMap;
427
428 fn empty_result() -> GenerationResult {
429 generate_from_resolved_input(&ProductInput::default(), &HashMap::new())
430 }
431
432 fn with_high_finding(rule: &str, message: &str) -> GenerationResult {
433 let mut result = empty_result();
434 result.findings.push(Finding {
435 level: "HIGH".into(),
436 rule: rule.into(),
437 message: message.into(),
438 });
439 result.release_status = compute_release_status(&result.unresolved, &result.findings);
440 result
441 }
442
443 fn sample_unresolved(path: &str, recommended_action: &str) -> UnresolvedField {
444 UnresolvedField {
445 path: path.into(),
446 title: "Flash point".into(),
447 reason: UnresolvedReason::ProductTestRequired,
448 required_inputs: vec![
449 RequiredInput::new("measured value", "The measured flash point").with_unit("°C"),
450 RequiredInput::new("test method", "The test method used"),
451 ],
452 acceptable_evidence: vec![
453 EvidenceLevel::ProductTestReport,
454 EvidenceLevel::EquivalentBatchTestReport,
455 ],
456 safety_impact: SafetyImpact::High,
457 regulatory_impact: RegulatoryImpact::High,
458 recommended_action: recommended_action.into(),
459 blocks_release: true,
460 }
461 }
462
463 #[test]
466 fn official_sds_json_contains_only_sds_root_fields() {
467 let mut result = with_high_finding("GEN-TEST", "a HIGH finding");
468 result.unresolved.push(sample_unresolved(
469 &path::composition_row(0, path::CAS_NO),
470 "Obtain a product test report.",
471 ));
472 result.provenance.push(FieldProvenance::supplied(
473 path::TRADE_NAME_JP,
474 "user-supplied trade name",
475 ));
476
477 let json = serialize_official_sds(&result).unwrap();
478 for leak in [
479 "release_status",
480 "findings",
481 "unresolved",
482 "provenance",
483 "evidence_summary",
484 "release_gate",
485 "report_schema_version",
486 "NormalizationStatus",
487 "screening_alerts",
488 "evidence_id",
489 "source_reference",
490 "confidence",
491 ] {
492 assert!(
493 !json.contains(leak),
494 "official SDS JSON must not contain '{leak}'"
495 );
496 }
497 }
498
499 #[test]
500 fn empty_official_fields_are_pruned() {
501 let result = empty_result();
502 let json = serialize_official_sds(&result).unwrap();
503 assert!(!json.contains("null"));
504 assert!(!json.contains(r#""""#));
505 }
506
507 #[test]
508 fn section_3_order_in_official_json_is_unchanged() {
509 let mut input = ProductInput::default();
510 input.trade_name = "Two Component Mix".into();
511 input.components = vec![
512 crate::generation::input::ComponentInput {
513 cas_number: Some("7732-18-5".into()),
514 name: Some("Water".into()),
515 concentration: crate::generation::input::ConcentrationRange {
516 exact: Some(90.0),
517 lower: None,
518 upper: None,
519 unit: "%".into(),
520 },
521 },
522 crate::generation::input::ComponentInput {
523 cas_number: Some("64-17-5".into()),
524 name: Some("Ethanol".into()),
525 concentration: crate::generation::input::ConcentrationRange {
526 exact: Some(10.0),
527 lower: None,
528 upper: None,
529 unit: "%".into(),
530 },
531 },
532 ];
533 let result = generate_from_resolved_input(&input, &HashMap::new());
534 let json = serialize_official_sds(&result).unwrap();
535 let water_pos = json.find("Water").unwrap();
536 let ethanol_pos = json.find("Ethanol").unwrap();
537 assert!(water_pos < ethanol_pos, "component order must be preserved");
538 }
539
540 #[test]
543 fn generation_report_json_does_not_contain_full_sds() {
544 let result = with_high_finding("GEN-TEST", "a HIGH finding");
551 let report = build_generation_report(&result);
552 let json = serialize_generation_report(&report).unwrap();
553 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
554 let keys: std::collections::BTreeSet<&str> = value
555 .as_object()
556 .unwrap()
557 .keys()
558 .map(String::as_str)
559 .collect();
560 let expected: std::collections::BTreeSet<&str> = [
561 "report_schema_version",
562 "release_status",
563 "findings",
564 "unresolved",
565 "provenance",
566 "evidence_summary",
567 "release_gate",
568 ]
569 .into_iter()
570 .collect();
571 assert_eq!(keys, expected);
572 }
573
574 #[test]
575 fn generation_report_contains_blocking_findings_and_required_actions() {
576 let mut result = with_high_finding("GEN-TEST", "a HIGH finding");
577 result.unresolved.push(sample_unresolved(
578 &path::composition_row(0, path::CAS_NO),
579 "Obtain a product test report.",
580 ));
581 result.release_status = compute_release_status(&result.unresolved, &result.findings);
582 let report = build_generation_report(&result);
583
584 assert!(!report.release_gate.blocking_findings.is_empty());
585 assert!(!report.release_gate.required_actions.is_empty());
586 assert_eq!(report.release_status, ReleaseStatus::Blocked);
587 }
588
589 #[test]
590 fn required_actions_are_deduplicated() {
591 let mut result = empty_result();
592 result
593 .unresolved
594 .push(sample_unresolved("path.a", "Obtain a product test report."));
595 result
596 .unresolved
597 .push(sample_unresolved("path.b", "Obtain a product test report."));
598 let report = build_generation_report(&result);
599 assert_eq!(report.release_gate.required_actions.len(), 1);
600 }
601
602 #[test]
603 fn all_provenance_details_remain_available_in_the_report() {
604 use crate::generation::evidence::{EvidenceApplicability, EvidenceSource};
605 use crate::generation::provenance::MeasurementConditions;
606
607 let source = EvidenceSource {
608 id: "ev1".into(),
609 level: EvidenceLevel::ProductTestReport,
610 reference: "Lab Report 2026-014".into(),
611 issuer: None,
612 document_date: None,
613 applies_to: EvidenceApplicability::FinishedProduct,
614 };
615 let conditions = MeasurementConditions {
616 temperature_c: None,
617 pressure_kpa: None,
618 atmosphere: None,
619 };
620 let mut result = empty_result();
621 result
622 .provenance
623 .push(FieldProvenance::from_measured_evidence(
624 path::composition_row(0, "FlashPoint"),
625 &source,
626 Some("Closed Cup (ASTM D93)"),
627 Some("sample-42"),
628 None,
629 &conditions,
630 ));
631 let report = build_generation_report(&result);
632 let json = serialize_generation_report(&report).unwrap();
633 assert!(json.contains("sample-42"));
634 assert!(json.contains("ASTM D93"));
635 }
636
637 #[test]
640 fn markdown_report_begins_with_draft_warning() {
641 let report = build_generation_report(&empty_result());
642 let md = render_review_report(&report);
643 assert!(md.starts_with("# SDS Generation Review Report\n"));
644 let summary_pos = md.find("## Summary").unwrap();
645 let warning_pos = md.find("has not been approved").unwrap();
646 assert!(
647 warning_pos < summary_pos,
648 "draft warning must appear before the summary"
649 );
650 }
651
652 #[test]
653 fn blocked_status_is_visibly_rendered_as_blocked() {
654 let result = with_high_finding("GEN-TEST", "a HIGH finding");
655 let report = build_generation_report(&result);
656 let md = render_review_report(&report);
657 assert!(md.contains("BLOCKED"));
658 }
659
660 #[test]
661 fn review_required_status_is_visibly_rendered() {
662 let mut result = empty_result();
663 result.findings.push(Finding {
664 level: "LOW".into(),
665 rule: "GEN-TEST".into(),
666 message: "a LOW finding".into(),
667 });
668 result.release_status = compute_release_status(&result.unresolved, &result.findings);
669 let report = build_generation_report(&result);
670 let md = render_review_report(&report);
671 assert!(md.contains("REVIEW REQUIRED"));
672 }
673
674 #[test]
675 fn approved_status_still_carries_draft_warning() {
676 let mut report = build_generation_report(&empty_result());
677 report.release_status = ReleaseStatus::Approved;
678 let md = render_review_report(&report);
679 assert!(md.contains("has not been approved"));
680 assert!(!md.to_uppercase().contains("APPROVED\n"));
681 }
682
683 #[test]
684 fn formula_mismatch_remains_blocking_in_all_report_forms() {
685 let result = with_high_finding(
686 "GEN-STRUCTURE-FORMULA-MISMATCH",
687 "resolver formula C2H6O disagrees with calculated formula C6H12O6",
688 );
689 assert_eq!(result.release_status, ReleaseStatus::Blocked);
690 let report = build_generation_report(&result);
691 assert_eq!(report.release_status, ReleaseStatus::Blocked);
692 assert!(report
693 .release_gate
694 .blocking_findings
695 .iter()
696 .any(|f| f.rule == "GEN-STRUCTURE-FORMULA-MISMATCH"));
697 let md = render_review_report(&report);
698 assert!(md.contains("BLOCKED"));
699 assert!(md.contains("GEN-STRUCTURE-FORMULA-MISMATCH"));
700 }
701
702 #[test]
703 fn multi_fragment_review_warning_is_nonblocking_by_itself() {
704 let mut result = empty_result();
705 result.findings.push(Finding {
706 level: "MED".into(),
707 rule: "GEN-STRUCTURE-MULTIFRAGMENT".into(),
708 message: "structure has multiple disconnected fragments".into(),
709 });
710 result.release_status = compute_release_status(&result.unresolved, &result.findings);
711 assert_eq!(result.release_status, ReleaseStatus::ReviewRequired);
712 let report = build_generation_report(&result);
713 assert!(report.release_gate.blocking_findings.is_empty());
714 let md = render_review_report(&report);
715 assert!(!md.contains("BLOCKED"));
716 assert!(md.contains("REVIEW REQUIRED"));
717 }
718
719 #[test]
720 fn user_supplied_content_cannot_inject_headings_or_raw_html() {
721 let malicious = "\n## Fake Injected Heading\n<script>alert(1)</script>\n| break | table |";
722 let mut result = empty_result();
723 result.findings.push(Finding {
724 level: "LOW".into(),
725 rule: "GEN-TEST".into(),
726 message: malicious.into(),
727 });
728 result.release_status = compute_release_status(&result.unresolved, &result.findings);
729 let report = build_generation_report(&result);
730 let md = render_review_report(&report);
731
732 let heading_lines: Vec<&str> = md.lines().filter(|l| l.starts_with("## ")).collect();
733 assert_eq!(
734 heading_lines.len(),
735 8,
736 "no new top-level heading may be injected"
737 );
738 assert!(!md.contains("<script>"));
739 assert!(md.contains("\\<script\\>"));
740 }
741
742 #[test]
743 fn repeated_artifact_generation_is_byte_equivalent() {
744 let mut result = with_high_finding("GEN-TEST", "a HIGH finding");
745 result.provenance.push(FieldProvenance::supplied(
746 path::TRADE_NAME_JP,
747 "user-supplied trade name",
748 ));
749 let a = build_generation_artifacts(&result).unwrap();
750 let b = build_generation_artifacts(&result).unwrap();
751 assert_eq!(a.official_sds_json, b.official_sds_json);
752 assert_eq!(a.generation_report_json, b.generation_report_json);
753 assert_eq!(a.review_report_markdown, b.review_report_markdown);
754 }
755
756 #[test]
757 fn confidence_level_serializes_snake_case_in_report() {
758 let mut result = empty_result();
759 result.provenance.push(FieldProvenance::supplied(
760 path::TRADE_NAME_JP,
761 "user-supplied trade name",
762 ));
763 let report = build_generation_report(&result);
764 let json = serialize_generation_report(&report).unwrap();
765 assert!(json.contains("\"unverified\""));
766 let _ = ConfidenceLevel::Unverified;
767 }
768}