Skip to main content

agm_core/validator/
execution.rs

1//! Execution state validation (spec S26).
2//!
3//! Pass 3 (structural): validates execution_status and associated metadata fields.
4
5use crate::error::codes::ErrorCode;
6use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
7use crate::model::execution::ExecutionStatus;
8use crate::model::node::Node;
9
10/// Validates execution-related fields on a node.
11///
12/// Rules:
13/// - V010 (warning): `completed` without `executed_by` or `executed_at`
14/// - V006 (warning): `executed_at` with unparseable timestamp
15/// - V020 (stub): runtime-only transition validation — no-op at static validation time
16#[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    // V006 — validate executed_at timestamp format (warning)
23    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    // V010 — completed node should have execution metadata (warning)
35    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    // V020 — execution status transition validation
59    // This rule requires before/after state comparison and is only applicable
60    // at runtime (not during static file validation). Left as a no-op stub.
61    // The runtime phase (Phase 2) will implement full transition validation.
62
63    errors
64}
65
66/// Checks whether a string resembles an ISO 8601 date or datetime.
67///
68/// Accepts `YYYY-MM-DD` (date-only) and `YYYY-MM-DDTHH:MM:SS` with optional
69/// timezone or fractional seconds. This is a lightweight heuristic check that
70/// avoids pulling in a date library.
71fn looks_like_iso8601(s: &str) -> bool {
72    let s = s.trim();
73
74    // Must start with a 4-digit year
75    if s.len() < 10 {
76        return false;
77    }
78
79    let bytes = s.as_bytes();
80
81    // YYYY-MM-DD
82    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    // Date-only is acceptable
93    if s.len() == 10 {
94        return true;
95    }
96
97    // T separator for datetime
98    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        // no executed_by
132        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        // no executed_at
146        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}