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
104 use super::*;
105 use crate::model::execution::ExecutionStatus;
106 use crate::model::fields::{NodeType, Span};
107 use crate::model::node::Node;
108
109 fn minimal_node() -> Node {
110 Node {
111 id: "test.node".to_owned(),
112 node_type: NodeType::Facts,
113 summary: "a test node".to_owned(),
114 span: Span::new(5, 7),
115 ..Default::default()
116 }
117 }
118
119 #[test]
120 fn test_validate_execution_no_status_returns_empty() {
121 let node = minimal_node();
122 let errors = validate_execution(&node, "test.agm");
123 assert!(errors.is_empty());
124 }
125
126 #[test]
127 fn test_validate_execution_completed_no_executed_by_returns_v010() {
128 let mut node = minimal_node();
129 node.execution_status = Some(ExecutionStatus::Completed);
130 node.executed_at = Some("2025-01-01".to_owned());
131 let errors = validate_execution(&node, "test.agm");
133 assert!(
134 errors
135 .iter()
136 .any(|e| e.code == ErrorCode::V010 && e.message.contains("executed_by"))
137 );
138 }
139
140 #[test]
141 fn test_validate_execution_completed_no_executed_at_returns_v010() {
142 let mut node = minimal_node();
143 node.execution_status = Some(ExecutionStatus::Completed);
144 node.executed_by = Some("agent-01".to_owned());
145 let errors = validate_execution(&node, "test.agm");
147 assert!(
148 errors
149 .iter()
150 .any(|e| e.code == ErrorCode::V010 && e.message.contains("executed_at"))
151 );
152 }
153
154 #[test]
155 fn test_validate_execution_completed_with_metadata_returns_empty() {
156 let mut node = minimal_node();
157 node.execution_status = Some(ExecutionStatus::Completed);
158 node.executed_by = Some("agent-01".to_owned());
159 node.executed_at = Some("2025-06-15T14:30:00Z".to_owned());
160 let errors = validate_execution(&node, "test.agm");
161 assert!(errors.is_empty());
162 }
163
164 #[test]
165 fn test_validate_execution_invalid_timestamp_returns_v006() {
166 let mut node = minimal_node();
167 node.executed_at = Some("not-a-date".to_owned());
168 let errors = validate_execution(&node, "test.agm");
169 assert!(errors.iter().any(|e| e.code == ErrorCode::V006));
170 }
171
172 #[test]
173 fn test_validate_execution_date_only_timestamp_valid() {
174 let mut node = minimal_node();
175 node.executed_at = Some("2025-06-15".to_owned());
176 let errors = validate_execution(&node, "test.agm");
177 assert!(!errors.iter().any(|e| e.code == ErrorCode::V006));
178 }
179
180 #[test]
181 fn test_validate_execution_pending_status_no_warnings() {
182 let mut node = minimal_node();
183 node.execution_status = Some(ExecutionStatus::Pending);
184 let errors = validate_execution(&node, "test.agm");
185 assert!(errors.is_empty());
186 }
187
188 #[test]
189 fn test_looks_like_iso8601_date_only() {
190 assert!(looks_like_iso8601("2025-01-15"));
191 }
192
193 #[test]
194 fn test_looks_like_iso8601_datetime() {
195 assert!(looks_like_iso8601("2025-01-15T10:30:00Z"));
196 }
197
198 #[test]
199 fn test_looks_like_iso8601_invalid_returns_false() {
200 assert!(!looks_like_iso8601("not-a-date"));
201 assert!(!looks_like_iso8601("2025/01/15"));
202 assert!(!looks_like_iso8601(""));
203 }
204}