use serde::{Deserialize, Serialize};
use crate::check_id::check_id_from_name;
use crate::checks::{CheckResult, CheckStatus, SkippedCheck};
use crate::config::Config;
use crate::policy::{GateClass, PolicySeverity};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolOutcome {
Passed,
FindingsFailed,
FindingsWarning,
SystemError,
Skipped,
Unavailable,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyConclusion {
Satisfied,
Advisory,
Blocked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnalysisStatus {
Complete,
Degraded,
Incomplete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MergeRecommendation {
Approve,
ReviewRequired,
Block,
}
impl MergeRecommendation {
pub fn legacy_verdict(
self,
analysis_status: AnalysisStatus,
quality_pass: bool,
) -> &'static str {
match self {
MergeRecommendation::Block => "BLOCK",
MergeRecommendation::ReviewRequired => "CONDITIONAL",
MergeRecommendation::Approve => {
if analysis_status == AnalysisStatus::Complete && quality_pass {
"PASS"
} else {
"CONDITIONAL"
}
}
}
}
pub fn legacy_recommended_merge(self) -> bool {
self == MergeRecommendation::Approve
}
pub fn machine_status(
self,
analysis_status: AnalysisStatus,
quality_pass: bool,
) -> &'static str {
if self == MergeRecommendation::Approve
&& analysis_status == AnalysisStatus::Complete
&& quality_pass
{
"ok"
} else {
"fail"
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckExecutionState {
Executed,
Skipped,
Unavailable,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckEvaluation {
pub check_id: String,
pub name: String,
pub raw_status: String,
pub execution_state: CheckExecutionState,
pub gate_class: GateClass,
pub severity: PolicySeverity,
pub outcome: ToolOutcome,
pub conclusion: PolicyConclusion,
pub confidence_impact: AnalysisStatus,
pub merge_impact: MergeRecommendation,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRunSummary {
pub analysis_status: AnalysisStatus,
pub merge_recommendation: MergeRecommendation,
pub evaluations: Vec<CheckEvaluation>,
pub blocking_issues: Vec<String>,
pub review_caveats: Vec<String>,
}
pub struct PolicyEngine<'a> {
config: &'a Config,
}
impl<'a> PolicyEngine<'a> {
pub fn new(config: &'a Config) -> Self {
Self { config }
}
pub fn evaluate_run(&self, result: &CheckResult) -> CheckEvaluation {
let id = check_id_from_name(&result.name);
let severity = self.config.policy.severity_for(&id);
if result.status == CheckStatus::Skipped {
let reason = result.output.to_lowercase();
let execution_state = classify_skip_execution_state(&reason);
let (conclusion, confidence_impact, merge_impact) =
self.skip_policy_outcome(severity, &reason);
return CheckEvaluation {
check_id: id,
name: result.name.clone(),
raw_status: result.status.as_str().to_string(),
execution_state,
gate_class: GateClass::Skip,
severity,
outcome: skip_outcome_for(execution_state),
conclusion,
confidence_impact,
merge_impact,
reason: (!result.output.is_empty()).then(|| result.output.clone()),
};
}
let outcome = match result.status {
CheckStatus::Passed => ToolOutcome::Passed,
CheckStatus::Failed => ToolOutcome::FindingsFailed,
CheckStatus::Warnings => ToolOutcome::FindingsWarning,
CheckStatus::Error => ToolOutcome::SystemError,
CheckStatus::Skipped => ToolOutcome::Skipped,
};
let class = match result.status {
CheckStatus::Passed => GateClass::Pass,
CheckStatus::Failed | CheckStatus::Error => GateClass::Fail,
CheckStatus::Warnings => GateClass::Info,
CheckStatus::Skipped => GateClass::Skip,
};
let is_blocking = self.config.policy.is_blocking(severity, class);
let conclusion = if is_blocking {
PolicyConclusion::Blocked
} else if class == GateClass::Fail || class == GateClass::Info {
PolicyConclusion::Advisory
} else {
PolicyConclusion::Satisfied
};
let confidence_impact = if result.status == CheckStatus::Error {
AnalysisStatus::Incomplete
} else {
AnalysisStatus::Complete
};
let merge_impact = if conclusion == PolicyConclusion::Blocked {
MergeRecommendation::Block
} else if conclusion == PolicyConclusion::Advisory {
MergeRecommendation::ReviewRequired
} else {
MergeRecommendation::Approve
};
let has_hard_fails = result
.provenance
.as_ref()
.is_some_and(|p| !p.hard_fail_signatures.is_empty());
let final_merge_impact = if has_hard_fails && merge_impact != MergeRecommendation::Block {
MergeRecommendation::ReviewRequired
} else {
merge_impact
};
CheckEvaluation {
check_id: id,
name: result.name.clone(),
raw_status: result.status.as_str().to_string(),
execution_state: CheckExecutionState::Executed,
gate_class: class,
severity,
outcome,
conclusion,
confidence_impact,
merge_impact: final_merge_impact,
reason: None,
}
}
pub fn evaluate_skip(&self, skipped: &SkippedCheck) -> CheckEvaluation {
let id = check_id_from_name(&skipped.name);
let reason = skipped.reason.to_lowercase();
let severity = self.config.policy.severity_for(&id);
let execution_state = classify_skip_execution_state(&reason);
let (conclusion, confidence_impact, merge_impact) =
self.skip_policy_outcome(severity, &reason);
CheckEvaluation {
check_id: id,
name: skipped.name.clone(),
raw_status: CheckStatus::Skipped.as_str().to_string(),
execution_state,
gate_class: GateClass::Skip,
severity,
outcome: skip_outcome_for(execution_state),
conclusion,
confidence_impact,
merge_impact,
reason: Some(skipped.reason.clone()),
}
}
fn skip_policy_outcome(
&self,
severity: PolicySeverity,
reason: &str,
) -> (PolicyConclusion, AnalysisStatus, MergeRecommendation) {
if reason.starts_with("profile") {
(
PolicyConclusion::Satisfied,
AnalysisStatus::Complete,
MergeRecommendation::Approve,
)
} else if severity == PolicySeverity::Block {
if reason.contains("fast remote-only preset") && self.config.remote_only {
(
PolicyConclusion::Advisory,
AnalysisStatus::Degraded,
MergeRecommendation::ReviewRequired,
)
} else {
(
PolicyConclusion::Blocked,
AnalysisStatus::Incomplete,
MergeRecommendation::Block,
)
}
} else if severity == PolicySeverity::Warn {
(
PolicyConclusion::Advisory,
AnalysisStatus::Degraded,
MergeRecommendation::ReviewRequired,
)
} else {
(
PolicyConclusion::Satisfied,
AnalysisStatus::Complete,
MergeRecommendation::Approve,
)
}
}
pub fn evaluate_all(
&self,
checks: &[CheckResult],
skipped_checks: &[SkippedCheck],
) -> PolicyRunSummary {
let mut evaluations = Vec::with_capacity(checks.len() + skipped_checks.len());
let mut analysis_status = AnalysisStatus::Complete;
let mut merge_recommendation = MergeRecommendation::Approve;
let mut blocking_issues = Vec::new();
let mut review_caveats = Vec::new();
for check in checks {
let eval = self.evaluate_run(check);
bump_summary_status(&mut analysis_status, &mut merge_recommendation, &eval);
if eval.conclusion == PolicyConclusion::Blocked {
blocking_issues.push(format!(
"{} ({})",
check.name,
display_status_label(check.status.as_str())
));
} else if eval.conclusion == PolicyConclusion::Advisory {
review_caveats.push(describe_advisory_check(&eval));
}
evaluations.push(eval);
}
for skipped in skipped_checks {
let eval = self.evaluate_skip(skipped);
bump_summary_status(&mut analysis_status, &mut merge_recommendation, &eval);
if eval.conclusion == PolicyConclusion::Blocked {
blocking_issues.push(format!("{} ({})", skipped.name, skipped.reason));
} else if eval.conclusion == PolicyConclusion::Advisory {
review_caveats.push(format!("{} skipped: {}", skipped.name, skipped.reason));
}
evaluations.push(eval);
}
PolicyRunSummary {
analysis_status,
merge_recommendation,
evaluations,
blocking_issues,
review_caveats,
}
}
}
fn skip_outcome_for(execution_state: CheckExecutionState) -> ToolOutcome {
match execution_state {
CheckExecutionState::Skipped => ToolOutcome::Skipped,
CheckExecutionState::Unavailable => ToolOutcome::Unavailable,
CheckExecutionState::Unknown => ToolOutcome::Unknown,
CheckExecutionState::Executed => ToolOutcome::Skipped,
}
}
fn classify_skip_execution_state(reason: &str) -> CheckExecutionState {
if reason.is_empty() {
return CheckExecutionState::Unknown;
}
if reason.starts_with("profile")
|| reason.contains("disabled")
|| reason.contains("fast remote-only preset")
{
return CheckExecutionState::Skipped;
}
if reason.contains("not installed")
|| reason.contains("not found")
|| reason.contains("missing")
|| reason.contains("unavailable")
{
return CheckExecutionState::Unavailable;
}
CheckExecutionState::Unknown
}
fn bump_summary_status(
analysis_status: &mut AnalysisStatus,
merge_recommendation: &mut MergeRecommendation,
eval: &CheckEvaluation,
) {
if eval.confidence_impact == AnalysisStatus::Incomplete {
*analysis_status = AnalysisStatus::Incomplete;
} else if eval.confidence_impact == AnalysisStatus::Degraded
&& *analysis_status == AnalysisStatus::Complete
{
*analysis_status = AnalysisStatus::Degraded;
}
if eval.merge_impact == MergeRecommendation::Block {
*merge_recommendation = MergeRecommendation::Block;
} else if eval.merge_impact == MergeRecommendation::ReviewRequired
&& *merge_recommendation == MergeRecommendation::Approve
{
*merge_recommendation = MergeRecommendation::ReviewRequired;
}
}
fn describe_advisory_check(eval: &CheckEvaluation) -> String {
match eval.execution_state {
CheckExecutionState::Executed => format!("{} returned {}", eval.name, eval.raw_status),
CheckExecutionState::Skipped => format!("{} was skipped", eval.name),
CheckExecutionState::Unavailable => format!("{} was unavailable for this run", eval.name),
CheckExecutionState::Unknown => format!("{} needs manual review", eval.name),
}
}
fn display_status_label(status: &str) -> String {
let mut chars = status.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => status.to_string(),
}
}