Skip to main content

sdsforge_core/generation/
artifacts.rs

1//! Projects a [`GenerationResult`] into the three artifacts a `generate`
2//! invocation actually publishes: the official MHLW SDS JSON, a
3//! machine-readable generation report, and a human-readable Markdown review
4//! report.
5//!
6//! Pure and filesystem-free — this module only decides what bytes to write,
7//! never where. Filesystem output belongs to the CLI/task layer (see
8//! `sdsforge::tasks::run_generate`).
9//!
10//! `GenerationResult` already keeps `sds` (the official draft) strictly
11//! separate from `findings`/`unresolved`/`provenance`/`evidence_summary`/
12//! `release_status` (everything that explains the draft). This module does
13//! not blur that boundary: [`serialize_official_sds`] touches only `sds`,
14//! [`GenerationReport`] never embeds `SdsRoot`.
15
16use 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
29/// Version of the `generation_report.json` *shape*, independent of the
30/// crate version — the report format and the crate can each change without
31/// the other. No timestamp is embedded by default: repeated serialization
32/// of the same [`GenerationResult`] must stay byte-equivalent.
33pub const REPORT_SCHEMA_VERSION: &str = "1.0";
34
35/// Everything needed to understand an SDS draft's completeness, without the
36/// draft itself. Never embeds `SdsRoot` — see `tests::
37/// generation_report_json_does_not_contain_full_sds`.
38#[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
49/// Builds a [`GenerationReport`] from a [`GenerationResult`].
50///
51/// `evidence_summary`/`release_status` are recomputed from
52/// `result.provenance`/`result.unresolved`/`result.findings` rather than
53/// copied from `result` as-is — a caller could have hand-built or edited a
54/// `GenerationResult`, and the report must never trust stale counters. The
55/// recomputed values feed [`evaluate_release_gate`] so `release_gate.status`
56/// can never disagree with `release_status` above it.
57pub 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
86/// Serializes only `result.sds` — the same serialize→[`prune_empty_fields`]→
87/// pretty-print path the existing `to-json`/`render` commands already use,
88/// so `official_sds.json` matches the formatting of every other JSON this
89/// crate writes. No report metadata, evidence references, or normalization
90/// diagnostics are ever added.
91pub 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
100/// Serializes a [`GenerationReport`] as pretty JSON. Contains only what
101/// [`GenerationReport`] itself carries — no API keys, HTTP headers, evidence
102/// binaries, temp-file paths, or ad hoc debug formatting ever enter this
103/// type, so there is nothing to redact here.
104pub fn serialize_generation_report(
105    report: &GenerationReport,
106) -> Result<String, GenerationArtifactError> {
107    serde_json::to_string_pretty(report).map_err(GenerationArtifactError::ReportSerialization)
108}
109
110/// The three artifacts a `generate` invocation publishes, as ready-to-write
111/// strings. Keeps filesystem concerns out of `sdsforge-core` while giving
112/// Rust callers the same three outputs the CLI writes to disk.
113pub 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
133// ---------------------------------------------------------------------------
134// Markdown review report
135// ---------------------------------------------------------------------------
136
137/// Renders a deterministic, human-readable Markdown summary of a
138/// [`GenerationReport`]. Never uses an LLM. Always states plainly that the
139/// output is an unapproved automated draft — even a (currently
140/// unreachable-by-generation-code) `ReleaseStatus::Approved` still carries
141/// that disclaimer, so the wording can never be read as a real approval.
142pub 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
397/// Neutralizes the Markdown/HTML control characters that could otherwise
398/// let user-supplied text (sample IDs, evidence references, measurement
399/// methods — anything traced back to `ProductInput`) break the report's
400/// structure or inject raw HTML: embedded newlines are collapsed so text
401/// can never start a new line (defusing headings/list markers/table rows
402/// at once), and the remaining structurally significant characters are
403/// backslash-escaped.
404fn 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    // -- official_sds.json purity ------------------------------------------
464
465    #[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    // -- generation_report.json ----------------------------------------------
541
542    #[test]
543    fn generation_report_json_does_not_contain_full_sds() {
544        // `path` strings legitimately reuse MHLW field names (e.g.
545        // "Identification.TradeProductIdentity.TradeNameJP"), so a substring
546        // check can't distinguish "provenance references this field" from
547        // "SdsRoot was embedded". Check the top-level key set instead: it
548        // must be exactly the report's own fields, never `sds`/`Identification`/
549        // any other schema section key.
550        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    // -- Markdown review report -----------------------------------------------
638
639    #[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}