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    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        // no executed_by
172        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        // no executed_at
186        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}