axiom-truth 0.4.1

Axiom — the truth layer: validation, simulation, guidance, and policy lens
Documentation
// Copyright 2024-2026 Reflective Labs
// SPDX-License-Identifier: MIT

//! Validation response building for Converge Truth specs.
//!
//! Transforms raw `SpecValidation` results into structured step views
//! and summaries suitable for UI consumption.

use serde::Serialize;

use crate::gherkin::{IssueCategory, Severity, SpecValidation, ValidationConfig, ValidationIssue};
use crate::truths::TruthGovernance;

/// A validation step with status and summary.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ValidationStep {
    pub id: &'static str,
    pub label: &'static str,
    pub status: &'static str,
    pub summary: String,
    pub detail: Option<String>,
}

/// Governance block presence flags.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GovernanceFlags {
    pub intent: bool,
    pub authority: bool,
    pub constraint: bool,
    pub evidence: bool,
    pub exception: bool,
}

/// Build validation steps from a successful parse + validation.
pub fn build_steps(validation: &SpecValidation, config: &ValidationConfig) -> Vec<ValidationStep> {
    let convention_issues: Vec<_> = validation
        .issues
        .iter()
        .filter(|issue| matches!(issue.category, IssueCategory::Convention))
        .collect();
    let business_issues: Vec<_> = validation
        .issues
        .iter()
        .filter(|issue| matches!(issue.category, IssueCategory::BusinessSense))
        .collect();

    vec![
        ValidationStep {
            id: "syntax",
            label: "Syntax",
            status: "ok",
            summary: "Truth declarations and Gherkin structure parsed successfully.".into(),
            detail: None,
        },
        semantics_step(&convention_issues),
        business_analysis_step(config, Some(&business_issues)),
    ]
}

/// Build steps for a parse error (syntax failed).
pub fn build_parse_error_steps(message: &str, config: &ValidationConfig) -> Vec<ValidationStep> {
    vec![
        ValidationStep {
            id: "syntax",
            label: "Syntax",
            status: "issue",
            summary: "The Truth or Feature document could not be parsed.".into(),
            detail: Some(message.to_string()),
        },
        ValidationStep {
            id: "semantics",
            label: "Semantics",
            status: "unavailable",
            summary: "Governance and convention checks did not run because parsing failed.".into(),
            detail: None,
        },
        business_analysis_step(config, None),
    ]
}

/// Generate a human-readable summary of validation results.
pub fn summarize(validation: &SpecValidation) -> String {
    let errors = validation
        .issues
        .iter()
        .filter(|i| i.severity == Severity::Error)
        .count();
    let warnings = validation
        .issues
        .iter()
        .filter(|i| i.severity == Severity::Warning)
        .count();
    let sw = pluralize("scenario", validation.scenario_count);

    if errors == 0 && warnings == 0 {
        format!(
            "Local checks passed across {} {}.",
            validation.scenario_count, sw
        )
    } else if errors == 0 {
        format!(
            "Local checks passed with {} {} across {} {}.",
            warnings,
            pluralize("warning", warnings),
            validation.scenario_count,
            sw
        )
    } else {
        format!(
            "Local checks found {} {} and {} {} across {} {}.",
            errors,
            pluralize("error", errors),
            warnings,
            pluralize("warning", warnings),
            validation.scenario_count,
            sw
        )
    }
}

/// Build governance flags from parsed governance blocks.
pub fn governance_flags(governance: &TruthGovernance) -> GovernanceFlags {
    GovernanceFlags {
        intent: governance.intent.is_some(),
        authority: governance.authority.is_some(),
        constraint: governance.constraint.is_some(),
        evidence: governance.evidence.is_some(),
        exception: governance.exception.is_some(),
    }
}

/// Offline validation note explaining what was and wasn't checked.
pub fn offline_note() -> String {
    "Local validation checks Converge Truth parsing, governance blocks, and Gherkin conventions. \
     Business-sense and compilability checks stay disabled until a live ChatBackend validator is configured."
        .into()
}

// ─── Internal ───

fn semantics_step(issues: &[&ValidationIssue]) -> ValidationStep {
    if issues.is_empty() {
        return ValidationStep {
            id: "semantics",
            label: "Semantics",
            status: "ok",
            summary: "Governance blocks and scenario conventions look consistent.".into(),
            detail: None,
        };
    }

    ValidationStep {
        id: "semantics",
        label: "Semantics",
        status: "issue",
        summary: format!(
            "{} governance or convention {} need attention.",
            issues.len(),
            pluralize("rule", issues.len())
        ),
        detail: issue_detail(issues),
    }
}

fn business_analysis_step(
    config: &ValidationConfig,
    issues: Option<&[&ValidationIssue]>,
) -> ValidationStep {
    if !config.check_business_sense {
        return ValidationStep {
            id: "business-analysis",
            label: "Business Analysis",
            status: "unavailable",
            summary: "Business analysis is disabled in offline mode.".into(),
            detail: Some(offline_note()),
        };
    }

    match issues {
        None | Some([]) => ValidationStep {
            id: "business-analysis",
            label: "Business Analysis",
            status: "ok",
            summary: "Business analysis completed without findings.".into(),
            detail: None,
        },
        Some(issues) => ValidationStep {
            id: "business-analysis",
            label: "Business Analysis",
            status: "issue",
            summary: format!(
                "{} business-analysis {} need attention.",
                issues.len(),
                pluralize("finding", issues.len())
            ),
            detail: issue_detail(issues),
        },
    }
}

fn issue_detail(issues: &[&ValidationIssue]) -> Option<String> {
    let mut lines = Vec::new();
    for issue in issues.iter().take(3) {
        let mut line = format!("{}: {}", issue.location, issue.message);
        if let Some(suggestion) = &issue.suggestion {
            line.push_str(&format!(" Suggestion: {suggestion}"));
        }
        if !lines.contains(&line) {
            lines.push(line);
        }
    }
    if lines.is_empty() {
        None
    } else {
        Some(lines.join("\n"))
    }
}

fn pluralize(word: &'static str, count: usize) -> &'static str {
    if count == 1 {
        word
    } else {
        match word {
            "warning" => "warnings",
            "error" => "errors",
            "scenario" => "scenarios",
            "rule" => "rules",
            "finding" => "findings",
            _ => word,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn summary_no_issues() {
        let v = SpecValidation {
            is_valid: true,
            file_path: "test".into(),
            scenario_count: 2,
            issues: vec![],
            confidence: 1.0,
            scenario_metas: vec![],
            governance: TruthGovernance::default(),
        };
        assert_eq!(summarize(&v), "Local checks passed across 2 scenarios.");
    }

    #[test]
    fn parse_error_steps() {
        let config = ValidationConfig {
            check_business_sense: false,
            check_compilability: false,
            check_conventions: true,
            min_confidence: 0.0,
        };
        let steps = build_parse_error_steps("bad syntax", &config);
        assert_eq!(steps[0].status, "issue");
        assert_eq!(steps[1].status, "unavailable");
        assert_eq!(steps[2].status, "unavailable");
    }
}