use serde::Serialize;
use crate::gherkin::{IssueCategory, Severity, SpecValidation, ValidationConfig, ValidationIssue};
use crate::truths::TruthGovernance;
#[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>,
}
#[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,
}
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)),
]
}
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),
]
}
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
)
}
}
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(),
}
}
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()
}
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");
}
}