//! Workflow module: story gating and test result tracking. //! //! This module provides the in-memory primitives for: //! - reading story metadata (front matter) for gating decisions //! - tracking test run results //! - evaluating acceptance readiness //! //! NOTE: This is a naive, local-only implementation that will be //! refactored later into orchestration-aware components. use crate::io::story_metadata::{StoryMetadata, TestPlanStatus}; use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Eq)] pub enum TestStatus { Pass, Fail, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TestCaseResult { pub name: String, pub status: TestStatus, pub details: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TestRunSummary { pub total: usize, pub passed: usize, pub failed: usize, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AcceptanceDecision { pub can_accept: bool, pub reasons: Vec, pub warning: Option, } #[derive(Debug, Clone, Default)] pub struct StoryTestResults { pub unit: Vec, pub integration: Vec, } #[derive(Debug, Clone, Default)] #[allow(dead_code)] pub struct WorkflowState { pub stories: HashMap, pub results: HashMap, } #[allow(dead_code)] impl WorkflowState { pub fn upsert_story(&mut self, story_id: String, metadata: StoryMetadata) { self.stories.insert(story_id, metadata); } pub fn load_story_metadata(&mut self, stories: Vec<(String, StoryMetadata)>) { for (story_id, metadata) in stories { self.stories.insert(story_id, metadata); } } pub fn refresh_story_metadata(&mut self, story_id: String, metadata: StoryMetadata) -> bool { match self.stories.get(&story_id) { Some(existing) if existing == &metadata => false, _ => { self.stories.insert(story_id, metadata); true } } } pub fn record_test_results( &mut self, story_id: String, unit: Vec, integration: Vec, ) { let _ = self.record_test_results_validated(story_id, unit, integration); } pub fn record_test_results_validated( &mut self, story_id: String, unit: Vec, integration: Vec, ) -> Result<(), String> { let failures = unit .iter() .chain(integration.iter()) .filter(|test| test.status == TestStatus::Fail) .count(); if failures > 1 { return Err(format!( "Multiple failing tests detected ({failures}); register failures one at a time." )); } self.results .insert(story_id, StoryTestResults { unit, integration }); Ok(()) } } #[allow(dead_code)] pub fn can_start_implementation(metadata: &StoryMetadata) -> Result<(), String> { match metadata.test_plan { Some(TestPlanStatus::Approved) => Ok(()), Some(TestPlanStatus::WaitingForApproval) => { Err("Test plan is waiting for approval; implementation is blocked.".to_string()) } Some(TestPlanStatus::Unknown(ref value)) => Err(format!( "Test plan state is unknown ({value}); implementation is blocked." )), None => Err("Missing test plan status; implementation is blocked.".to_string()), } } pub fn summarize_results(results: &StoryTestResults) -> TestRunSummary { let mut total = 0; let mut passed = 0; let mut failed = 0; for test in results.unit.iter().chain(results.integration.iter()) { total += 1; match test.status { TestStatus::Pass => passed += 1, TestStatus::Fail => failed += 1, } } TestRunSummary { total, passed, failed, } } pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision { let summary = summarize_results(results); if summary.failed == 0 && summary.total > 0 { return AcceptanceDecision { can_accept: true, reasons: Vec::new(), warning: None, }; } let mut reasons = Vec::new(); if summary.total == 0 { reasons.push("No test results recorded for the story.".to_string()); } if summary.failed > 0 { reasons.push(format!( "{} test(s) are failing; acceptance is blocked.", summary.failed )); } let warning = if summary.failed > 1 { Some(format!( "Multiple tests are failing ({} failures).", summary.failed )) } else { None }; AcceptanceDecision { can_accept: false, reasons, warning, } } #[cfg(test)] mod tests { use super::*; #[test] fn warns_when_multiple_tests_fail() { let results = StoryTestResults { unit: vec![ TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Fail, details: None, }, TestCaseResult { name: "unit-2".to_string(), status: TestStatus::Fail, details: None, }, ], integration: vec![TestCaseResult { name: "integration-1".to_string(), status: TestStatus::Pass, details: None, }], }; let decision = evaluate_acceptance(&results); assert!(!decision.can_accept); assert_eq!( decision.warning, Some("Multiple tests are failing (2 failures).".to_string()) ); } #[test] fn rejects_recording_multiple_failures() { let mut state = WorkflowState::default(); let unit = vec![ TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Fail, details: None, }, TestCaseResult { name: "unit-2".to_string(), status: TestStatus::Fail, details: None, }, ]; let integration = vec![TestCaseResult { name: "integration-1".to_string(), status: TestStatus::Pass, details: None, }]; let result = state.record_test_results_validated("story-26".to_string(), unit, integration); assert!(result.is_err()); } }