agentic_planning/
validation.rs1use 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 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 if deadline < Timestamp::now() {
78 errors.push(ValidationError::DeadlineInPast);
79 }
80 }
81 if let Some(parent_id) = request.parent {
82 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 for dep_id in deps {
90 if request.parent == Some(*dep_id) {
91 } else if !self.goal_store.contains_key(dep_id) {
93 }
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 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 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 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 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}