Skip to main content

agentic_planning/
validation.rs

1use crate::types::{
2    CreateCommitmentRequest, CreateDecisionRequest, CreateGoalRequest, GoalId, GoalStatus,
3    Timestamp,
4};
5use crate::PlanningEngine;
6use thiserror::Error;
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Error)]
10pub enum ValidationError {
11    #[error("Goal title is required")]
12    GoalTitleRequired,
13    #[error("Goal title too long (max {max}, got {got})")]
14    GoalTitleTooLong { max: usize, got: usize },
15    #[error("Goal intention is required")]
16    IntentionRequired,
17    #[error("Invalid priority: {0:?}")]
18    InvalidPriority(String),
19    #[error("Invalid status transition from {from:?} to {to:?}")]
20    InvalidStatusTransition { from: GoalStatus, to: GoalStatus },
21    #[error("Deadline must be in the future")]
22    DeadlineInPast,
23    #[error("Progress must be between 0 and 1")]
24    InvalidProgress,
25    #[error("Circular dependency detected: {0:?}")]
26    CircularDependency(Vec<GoalId>),
27    #[error("Parent goal not found: {0:?}")]
28    ParentNotFound(GoalId),
29    #[error("Decision question is required")]
30    DecisionQuestionRequired,
31    #[error("Decision must have at least 2 options")]
32    InsufficientOptions,
33    #[error("Commitment promise is required")]
34    PromiseRequired,
35    #[error("Commitment stakeholder is required")]
36    StakeholderRequired,
37    #[error("Weight must be between 0 and 1")]
38    InvalidWeight,
39    #[error("Emotional weight must be between 0.0 and 1.0")]
40    EmotionalWeightOutOfBounds,
41    #[error("Goal cannot depend on itself")]
42    SelfDependency,
43    #[error("Stakeholder importance must be between 0.0 and 1.0")]
44    StakeholderImportanceOutOfBounds,
45    #[error("Dream must have at least 1 scenario")]
46    DreamScenarioRequired,
47    #[error("Federation must have at least 2 members")]
48    FederationMembersRequired,
49}
50
51pub type ValidationResult<T> = std::result::Result<T, Vec<ValidationError>>;
52
53impl PlanningEngine {
54    pub fn validate_create_goal(&self, request: &CreateGoalRequest) -> ValidationResult<()> {
55        let mut errors = Vec::new();
56
57        if request.title.trim().is_empty() {
58            errors.push(ValidationError::GoalTitleRequired);
59        }
60        if request.title.len() > 200 {
61            errors.push(ValidationError::GoalTitleTooLong {
62                max: 200,
63                got: request.title.len(),
64            });
65        }
66        if request.intention.trim().is_empty() {
67            errors.push(ValidationError::IntentionRequired);
68        }
69        // R4: emotional_weight bounds check
70        if let Some(ew) = request.emotional_weight {
71            if !(0.0..=1.0).contains(&ew) {
72                errors.push(ValidationError::EmotionalWeightOutOfBounds);
73            }
74        }
75        if let Some(deadline) = request.deadline {
76            // R4: deadline reachability — must be in the future
77            if deadline < Timestamp::now() {
78                errors.push(ValidationError::DeadlineInPast);
79            }
80        }
81        if let Some(parent_id) = request.parent {
82            // R4: dead parent — must reference existing goal
83            if !self.goal_store.contains_key(&parent_id) {
84                errors.push(ValidationError::ParentNotFound(parent_id));
85            }
86        }
87        if let Some(deps) = &request.dependencies {
88            // R4: self-dependency check
89            for dep_id in deps {
90                if request.parent == Some(*dep_id) {
91                    // Parent can't also be a dependency (structural cycle)
92                } else if !self.goal_store.contains_key(dep_id) {
93                    // Allow deps on not-yet-created goals in some workflows
94                }
95            }
96            if let Some(cycle) = self.detect_dependency_cycle(deps, &[]) {
97                errors.push(ValidationError::CircularDependency(cycle));
98            }
99        }
100
101        if errors.is_empty() {
102            Ok(())
103        } else {
104            Err(errors)
105        }
106    }
107
108    pub fn validate_status_transition(
109        &self,
110        current: GoalStatus,
111        target: GoalStatus,
112    ) -> ValidationResult<()> {
113        let valid = matches!(
114            (current, target),
115            (GoalStatus::Draft, GoalStatus::Active)
116                | (GoalStatus::Draft, GoalStatus::Abandoned)
117                | (GoalStatus::Active, GoalStatus::Blocked)
118                | (GoalStatus::Active, GoalStatus::Paused)
119                | (GoalStatus::Active, GoalStatus::Completed)
120                | (GoalStatus::Active, GoalStatus::Abandoned)
121                | (GoalStatus::Blocked, GoalStatus::Active)
122                | (GoalStatus::Blocked, GoalStatus::Abandoned)
123                | (GoalStatus::Paused, GoalStatus::Active)
124                | (GoalStatus::Paused, GoalStatus::Abandoned)
125                | (GoalStatus::Completed, GoalStatus::Reborn)
126                | (GoalStatus::Abandoned, GoalStatus::Reborn)
127                | (GoalStatus::Reborn, GoalStatus::Active)
128        );
129
130        if valid {
131            Ok(())
132        } else {
133            Err(vec![ValidationError::InvalidStatusTransition {
134                from: current,
135                to: target,
136            }])
137        }
138    }
139
140    fn detect_dependency_cycle(&self, deps: &[GoalId], visited: &[GoalId]) -> Option<Vec<GoalId>> {
141        for dep_id in deps {
142            if visited.contains(dep_id) {
143                return Some(visited.to_vec());
144            }
145            if let Some(dep_goal) = self.goal_store.get(dep_id) {
146                let mut new_visited = visited.to_vec();
147                new_visited.push(*dep_id);
148                if let Some(cycle) =
149                    self.detect_dependency_cycle(&dep_goal.dependencies, &new_visited)
150                {
151                    return Some(cycle);
152                }
153            }
154        }
155        None
156    }
157
158    pub fn validate_create_decision(
159        &self,
160        request: &CreateDecisionRequest,
161    ) -> ValidationResult<()> {
162        let mut errors = Vec::new();
163        if request.question.trim().is_empty() {
164            errors.push(ValidationError::DecisionQuestionRequired);
165        }
166        if errors.is_empty() {
167            Ok(())
168        } else {
169            Err(errors)
170        }
171    }
172
173    pub fn validate_crystallize(&self, decision: &crate::Decision) -> ValidationResult<()> {
174        let mut errors = Vec::new();
175        if decision.shadows.len() < 2 {
176            errors.push(ValidationError::InsufficientOptions);
177        }
178        if errors.is_empty() {
179            Ok(())
180        } else {
181            Err(errors)
182        }
183    }
184
185    /// R4: Validate that a goal does not depend on itself
186    pub fn validate_no_self_dependency(
187        &self,
188        goal_id: GoalId,
189        dependencies: &[GoalId],
190    ) -> ValidationResult<()> {
191        if dependencies.contains(&goal_id) {
192            Err(vec![ValidationError::SelfDependency])
193        } else {
194            Ok(())
195        }
196    }
197
198    /// R4: Validate dream creation — at least 1 scenario required
199    pub fn validate_create_dream(&self, scenarios_count: usize) -> ValidationResult<()> {
200        if scenarios_count < 1 {
201            Err(vec![ValidationError::DreamScenarioRequired])
202        } else {
203            Ok(())
204        }
205    }
206
207    /// R4: Validate federation creation — at least 2 members required
208    pub fn validate_create_federation(&self, member_count: usize) -> ValidationResult<()> {
209        if member_count < 2 {
210            Err(vec![ValidationError::FederationMembersRequired])
211        } else {
212            Ok(())
213        }
214    }
215
216    pub fn validate_create_commitment(
217        &self,
218        request: &CreateCommitmentRequest,
219    ) -> ValidationResult<()> {
220        let mut errors = Vec::new();
221
222        if request.promise.description.trim().is_empty() {
223            errors.push(ValidationError::PromiseRequired);
224        }
225        if request.stakeholder.name.trim().is_empty() {
226            errors.push(ValidationError::StakeholderRequired);
227        }
228
229        // R4: stakeholder importance bounds check
230        if !(0.0..=1.0).contains(&request.stakeholder.importance) {
231            errors.push(ValidationError::StakeholderImportanceOutOfBounds);
232        }
233
234        let weight = self.calculate_commitment_weight(request);
235        if !(0.0..=1.0).contains(&weight) {
236            errors.push(ValidationError::InvalidWeight);
237        }
238
239        if errors.is_empty() {
240            Ok(())
241        } else {
242            Err(errors)
243        }
244    }
245}
246
247pub mod validators {
248    use super::*;
249    use crate::types::{GoalId, Priority, Timestamp};
250
251    pub fn validate_goal_id(value: &serde_json::Value) -> Result<GoalId, String> {
252        let s = value
253            .as_str()
254            .ok_or_else(|| "goal_id must be a string".to_string())?;
255
256        let uuid = Uuid::parse_str(s).map_err(|_| "goal_id must be a valid UUID".to_string())?;
257        Ok(GoalId(uuid))
258    }
259
260    pub fn validate_progress(value: &serde_json::Value) -> Result<f64, String> {
261        let n = value
262            .as_f64()
263            .ok_or_else(|| "progress must be a number".to_string())?;
264        if !(0.0..=1.0).contains(&n) {
265            return Err("progress must be between 0 and 1".to_string());
266        }
267        Ok(n)
268    }
269
270    pub fn validate_priority(value: &serde_json::Value) -> Result<Priority, String> {
271        let s = value
272            .as_str()
273            .ok_or_else(|| "priority must be a string".to_string())?;
274
275        match s.to_lowercase().as_str() {
276            "critical" => Ok(Priority::Critical),
277            "high" => Ok(Priority::High),
278            "medium" => Ok(Priority::Medium),
279            "low" => Ok(Priority::Low),
280            "someday" => Ok(Priority::Someday),
281            _ => Err(format!("invalid priority: {s}")),
282        }
283    }
284
285    pub fn validate_timestamp(value: &serde_json::Value) -> Result<Timestamp, String> {
286        if let Some(n) = value.as_i64() {
287            return Ok(Timestamp(n));
288        }
289        if let Some(s) = value.as_str() {
290            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
291                return Ok(Timestamp(dt.timestamp_nanos_opt().unwrap_or(0)));
292            }
293        }
294        Err("timestamp must be nanos (i64) or ISO 8601 string".to_string())
295    }
296}