agm_core/validator/
execution.rs1use crate::error::codes::ErrorCode;
6use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
7use crate::model::execution::ExecutionStatus;
8use crate::model::node::Node;
9
10#[must_use]
17pub fn validate_execution(node: &Node, file_name: &str) -> Vec<AgmError> {
18 let mut errors = Vec::new();
19 let line = node.span.start_line;
20 let id = node.id.as_str();
21
22 if let Some(ref ts) = node.executed_at {
24 if !looks_like_iso8601(ts) {
25 errors.push(AgmError::with_severity(
26 ErrorCode::V006,
27 Severity::Warning,
28 format!("Invalid `execution_status` value: `executed_at` timestamp `{ts}` is not a valid ISO 8601 date/datetime"),
29 ErrorLocation::full(file_name, line, id),
30 ));
31 }
32 }
33
34 if node.execution_status == Some(ExecutionStatus::Completed) {
36 if node.executed_by.is_none() {
37 errors.push(AgmError::with_severity(
38 ErrorCode::V010,
39 Severity::Warning,
40 format!(
41 "Node type `{id}` typically includes field `executed_by` (missing) for completed nodes"
42 ),
43 ErrorLocation::full(file_name, line, id),
44 ));
45 }
46 if node.executed_at.is_none() {
47 errors.push(AgmError::with_severity(
48 ErrorCode::V010,
49 Severity::Warning,
50 format!(
51 "Node type `{id}` typically includes field `executed_at` (missing) for completed nodes"
52 ),
53 ErrorLocation::full(file_name, line, id),
54 ));
55 }
56 }
57
58 errors
64}
65
66fn looks_like_iso8601(s: &str) -> bool {
72 let s = s.trim();
73
74 if s.len() < 10 {
76 return false;
77 }
78
79 let bytes = s.as_bytes();
80
81 let year_ok = bytes[0..4].iter().all(|b| b.is_ascii_digit());
83 let dash1 = bytes[4] == b'-';
84 let month_ok = bytes[5..7].iter().all(|b| b.is_ascii_digit());
85 let dash2 = bytes[7] == b'-';
86 let day_ok = bytes[8..10].iter().all(|b| b.is_ascii_digit());
87
88 if !(year_ok && dash1 && month_ok && dash2 && day_ok) {
89 return false;
90 }
91
92 if s.len() == 10 {
94 return true;
95 }
96
97 bytes[10] == b'T' || bytes[10] == b' '
99}
100
101#[cfg(test)]
102mod tests {
103 use std::collections::BTreeMap;
104
105 use super::*;
106 use crate::model::execution::ExecutionStatus;
107 use crate::model::fields::{NodeType, Span};
108 use crate::model::node::Node;
109
110 fn minimal_node() -> Node {
111 Node {
112 id: "test.node".to_owned(),
113 node_type: NodeType::Facts,
114 summary: "a test node".to_owned(),
115 priority: None,
116 stability: None,
117 confidence: None,
118 status: None,
119 depends: None,
120 related_to: None,
121 replaces: None,
122 conflicts: None,
123 see_also: None,
124 items: None,
125 steps: None,
126 fields: None,
127 input: None,
128 output: None,
129 detail: None,
130 rationale: None,
131 tradeoffs: None,
132 resolution: None,
133 examples: None,
134 notes: None,
135 code: None,
136 code_blocks: None,
137 verify: None,
138 agent_context: None,
139 target: None,
140 execution_status: None,
141 executed_by: None,
142 executed_at: None,
143 execution_log: None,
144 retry_count: None,
145 parallel_groups: None,
146 memory: None,
147 scope: None,
148 applies_when: None,
149 valid_from: None,
150 valid_until: None,
151 tags: None,
152 aliases: None,
153 keywords: None,
154 extra_fields: BTreeMap::new(),
155 span: Span::new(5, 7),
156 }
157 }
158
159 #[test]
160 fn test_validate_execution_no_status_returns_empty() {
161 let node = minimal_node();
162 let errors = validate_execution(&node, "test.agm");
163 assert!(errors.is_empty());
164 }
165
166 #[test]
167 fn test_validate_execution_completed_no_executed_by_returns_v010() {
168 let mut node = minimal_node();
169 node.execution_status = Some(ExecutionStatus::Completed);
170 node.executed_at = Some("2025-01-01".to_owned());
171 let errors = validate_execution(&node, "test.agm");
173 assert!(
174 errors
175 .iter()
176 .any(|e| e.code == ErrorCode::V010 && e.message.contains("executed_by"))
177 );
178 }
179
180 #[test]
181 fn test_validate_execution_completed_no_executed_at_returns_v010() {
182 let mut node = minimal_node();
183 node.execution_status = Some(ExecutionStatus::Completed);
184 node.executed_by = Some("agent-01".to_owned());
185 let errors = validate_execution(&node, "test.agm");
187 assert!(
188 errors
189 .iter()
190 .any(|e| e.code == ErrorCode::V010 && e.message.contains("executed_at"))
191 );
192 }
193
194 #[test]
195 fn test_validate_execution_completed_with_metadata_returns_empty() {
196 let mut node = minimal_node();
197 node.execution_status = Some(ExecutionStatus::Completed);
198 node.executed_by = Some("agent-01".to_owned());
199 node.executed_at = Some("2025-06-15T14:30:00Z".to_owned());
200 let errors = validate_execution(&node, "test.agm");
201 assert!(errors.is_empty());
202 }
203
204 #[test]
205 fn test_validate_execution_invalid_timestamp_returns_v006() {
206 let mut node = minimal_node();
207 node.executed_at = Some("not-a-date".to_owned());
208 let errors = validate_execution(&node, "test.agm");
209 assert!(errors.iter().any(|e| e.code == ErrorCode::V006));
210 }
211
212 #[test]
213 fn test_validate_execution_date_only_timestamp_valid() {
214 let mut node = minimal_node();
215 node.executed_at = Some("2025-06-15".to_owned());
216 let errors = validate_execution(&node, "test.agm");
217 assert!(!errors.iter().any(|e| e.code == ErrorCode::V006));
218 }
219
220 #[test]
221 fn test_validate_execution_pending_status_no_warnings() {
222 let mut node = minimal_node();
223 node.execution_status = Some(ExecutionStatus::Pending);
224 let errors = validate_execution(&node, "test.agm");
225 assert!(errors.is_empty());
226 }
227
228 #[test]
229 fn test_looks_like_iso8601_date_only() {
230 assert!(looks_like_iso8601("2025-01-15"));
231 }
232
233 #[test]
234 fn test_looks_like_iso8601_datetime() {
235 assert!(looks_like_iso8601("2025-01-15T10:30:00Z"));
236 }
237
238 #[test]
239 fn test_looks_like_iso8601_invalid_returns_false() {
240 assert!(!looks_like_iso8601("not-a-date"));
241 assert!(!looks_like_iso8601("2025/01/15"));
242 assert!(!looks_like_iso8601(""));
243 }
244}