use ainl_contracts::{
Assertion, AssertionId, AssertionState, DiscoveredIssue, Feature, FeatureId, FeatureStatus,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationPlan {
pub milestone: String,
pub scrutiny_feature_ids: Vec<FeatureId>,
pub assertion_ids: Vec<AssertionId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssertionFailureAction {
Retry,
BlockMilestone,
ProposeFixFeature {
suggested_feature_id: String,
assertion_id: AssertionId,
},
}
pub fn should_inject_validation_phase(
milestone: &str,
features: &[Feature],
assertions: &[Assertion],
) -> bool {
let milestone_features: Vec<_> = features
.iter()
.filter(|f| f.milestone.as_deref() == Some(milestone))
.collect();
if milestone_features.is_empty() {
return false;
}
let all_done = milestone_features.iter().all(|f| {
matches!(
f.status,
FeatureStatus::Completed | FeatureStatus::Cancelled | FeatureStatus::RolledBack
)
});
if !all_done {
return false;
}
assertions
.iter()
.any(|a| a.milestone.as_deref() == Some(milestone))
}
pub fn build_scrutiny_features(
milestone: &str,
features: &[Feature],
assertions: &[Assertion],
) -> Vec<FeatureId> {
let assertion_ids: std::collections::HashSet<&str> = assertions
.iter()
.filter(|a| a.milestone.as_deref() == Some(milestone))
.map(|a| a.assertion_id.as_str())
.collect();
let mut out = Vec::new();
for f in features {
if f.milestone.as_deref() != Some(milestone) {
continue;
}
let fulfills_milestone_assertion = f
.fulfills
.iter()
.any(|aid| assertion_ids.contains(aid.as_str()));
let needs_scrutiny = !matches!(f.status, FeatureStatus::Completed)
|| fulfills_milestone_assertion;
if needs_scrutiny {
out.push(f.feature_id.clone());
}
}
out.sort_by(|a, b| a.as_str().cmp(b.as_str()));
out.dedup();
out
}
pub fn build_validation_plan(
milestone: &str,
features: &[Feature],
assertions: &[Assertion],
) -> Option<ValidationPlan> {
if !should_inject_validation_phase(milestone, features, assertions) {
return None;
}
Some(ValidationPlan {
milestone: milestone.to_string(),
scrutiny_feature_ids: build_scrutiny_features(milestone, features, assertions),
assertion_ids: assertions
.iter()
.filter(|a| a.milestone.as_deref() == Some(milestone))
.map(|a| a.assertion_id.clone())
.collect(),
})
}
pub fn build_user_testing_features(milestone: &str) -> Vec<FeatureId> {
vec![FeatureId(format!("user-test-{milestone}"))]
}
pub fn propose_fix_for_blocking_issue(
issue: &DiscoveredIssue,
source_feature_id: &FeatureId,
) -> FeatureId {
let slug: String = issue
.description
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(20)
.collect();
let suffix = if slug.is_empty() { "blocking" } else { slug.as_str() };
FeatureId(format!("fix-{}-{}", source_feature_id.as_str(), suffix))
}
pub fn scrutiny_feature_id(base: &FeatureId) -> FeatureId {
FeatureId(format!("scrutiny-{}", base.as_str()))
}
pub fn on_assertion_failed(
assertion: &Assertion,
auto_remediation_enabled: bool,
) -> (AssertionState, AssertionFailureAction) {
let failed_count = assertion.failed_count.saturating_add(1);
let action = if auto_remediation_enabled && failed_count >= 2 {
AssertionFailureAction::ProposeFixFeature {
suggested_feature_id: format!("fix-{}", assertion.assertion_id.as_str()),
assertion_id: assertion.assertion_id.clone(),
}
} else if failed_count >= 3 {
AssertionFailureAction::BlockMilestone
} else {
AssertionFailureAction::Retry
};
(AssertionState::Failed, action)
}
#[cfg(test)]
mod tests {
use super::*;
use ainl_contracts::{DiscoveredIssue, IssueSeverity};
use ainl_contracts::VerificationStep;
fn feat(id: &str, milestone: &str, status: FeatureStatus) -> Feature {
Feature {
feature_id: FeatureId(id.into()),
description: id.into(),
status,
milestone: Some(milestone.into()),
skill_name: None,
touches_files: vec![],
preconditions: vec![],
expected_behavior: vec![],
verification_steps: vec![],
fulfills: vec![],
snapshot: None,
}
}
fn assertn(id: &str, milestone: &str) -> Assertion {
Assertion {
assertion_id: AssertionId(id.into()),
description: id.into(),
verification_steps: vec![VerificationStep::ShellCommand {
cmd: "true".into(),
expected_exit_code: 0,
}],
state: AssertionState::Pending,
milestone: Some(milestone.into()),
failed_count: 0,
}
}
#[test]
fn inject_when_milestone_complete() {
let features = vec![
feat("f1", "m1", FeatureStatus::Completed),
feat("f2", "m1", FeatureStatus::Completed),
];
let assertions = vec![assertn("a1", "m1")];
assert!(should_inject_validation_phase("m1", &features, &assertions));
}
#[test]
fn no_inject_while_feature_in_progress() {
let features = vec![
feat("f1", "m1", FeatureStatus::Completed),
feat("f2", "m1", FeatureStatus::InProgress),
];
let assertions = vec![assertn("a1", "m1")];
assert!(!should_inject_validation_phase("m1", &features, &assertions));
}
#[test]
fn assertion_failed_proposes_fix_after_two() {
let a = Assertion {
failed_count: 1,
..assertn("auth", "m1")
};
let (_, action) = on_assertion_failed(&a, true);
assert!(matches!(
action,
AssertionFailureAction::ProposeFixFeature { .. }
));
}
#[test]
fn user_testing_and_blocking_fix_ids() {
let ids = build_user_testing_features("m1");
assert_eq!(ids[0].as_str(), "user-test-m1");
let issue = DiscoveredIssue {
severity: IssueSeverity::Blocking,
description: "Auth flow broken".into(),
suggested_fix: None,
};
let fid = propose_fix_for_blocking_issue(&issue, &FeatureId("login".into()));
assert!(fid.as_str().starts_with("fix-login-"));
}
}