Skip to main content

agm_core/memory/
schema.rs

1//! Memory validation functions (spec S28).
2//!
3//! Provides standalone validation functions for memory keys, topics, entries,
4//! and `load_memory` references. These functions are reusable by the validator,
5//! CLI tools, and the Phase 2 runtime.
6
7use std::collections::HashSet;
8use std::sync::OnceLock;
9
10use regex::Regex;
11
12use crate::error::codes::ErrorCode;
13use crate::error::diagnostic::{AgmError, ErrorLocation};
14use crate::model::memory::{MemoryAction, MemoryEntry};
15
16/// Maximum size of a memory entry value in bytes (32 KiB). Spec S28.3.
17pub const MAX_MEMORY_VALUE_BYTES: usize = 32_768;
18
19/// Regex for memory key: starts with lowercase letter, then lowercase
20/// letters, digits, underscores, or dots. Spec S28.3.
21static MEMORY_KEY_RE: OnceLock<Regex> = OnceLock::new();
22
23/// Regex for memory topic: dot-delimited segments of lowercase letters,
24/// digits, and underscores. Each segment must start with a letter.
25static MEMORY_TOPIC_RE: OnceLock<Regex> = OnceLock::new();
26
27fn key_regex() -> &'static Regex {
28    MEMORY_KEY_RE.get_or_init(|| Regex::new(r"^[a-z][a-z0-9_.]*$").unwrap())
29}
30
31fn topic_regex() -> &'static Regex {
32    MEMORY_TOPIC_RE.get_or_init(|| Regex::new(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$").unwrap())
33}
34
35/// Validates a memory key against the spec S28.3 pattern.
36///
37/// Returns `Ok(())` if valid, or `Err(AgmError)` with code V022.
38pub fn validate_memory_key(key: &str, location: ErrorLocation) -> Result<(), AgmError> {
39    if key_regex().is_match(key) {
40        Ok(())
41    } else {
42        Err(AgmError::new(
43            ErrorCode::V022,
44            format!("Memory key does not match required pattern: `{key}`"),
45            location,
46        ))
47    }
48}
49
50/// Validates a memory topic string.
51///
52/// Topics must be dot-delimited, lowercase, each segment starting with a
53/// letter. Returns `Ok(())` if valid, or `Err(AgmError)` with code V025.
54pub fn validate_memory_topic(topic: &str, location: ErrorLocation) -> Result<(), AgmError> {
55    if topic_regex().is_match(topic) {
56        Ok(())
57    } else {
58        Err(AgmError::new(
59            ErrorCode::V025,
60            format!("Memory topic does not match required pattern: `{topic}`"),
61            location,
62        ))
63    }
64}
65
66/// Validates a complete memory entry: key pattern (V022), topic pattern
67/// (V025), and action constraints (V023: upsert requires value, search
68/// requires query).
69#[must_use]
70pub fn validate_memory_entry(entry: &MemoryEntry, location: ErrorLocation) -> Vec<AgmError> {
71    let mut errors = Vec::new();
72
73    if let Err(e) = validate_memory_key(entry.key.as_str(), location.clone()) {
74        errors.push(e);
75    }
76
77    if let Err(e) = validate_memory_topic(entry.topic.as_str(), location.clone()) {
78        errors.push(e);
79    }
80
81    if entry.action == MemoryAction::Upsert && entry.value.is_none() {
82        errors.push(AgmError::new(
83            ErrorCode::V023,
84            format!(
85                "Invalid memory action: `upsert` on key `{}` requires `value`",
86                entry.key
87            ),
88            location.clone(),
89        ));
90    }
91
92    if entry.action == MemoryAction::Search && entry.query.is_none() {
93        errors.push(AgmError::new(
94            ErrorCode::V023,
95            format!(
96                "Invalid memory action: `search` on key `{}` requires `query`",
97                entry.key
98            ),
99            location.clone(),
100        ));
101    }
102
103    if let Some(ref value) = entry.value {
104        if value.len() > MAX_MEMORY_VALUE_BYTES {
105            errors.push(AgmError::new(
106                ErrorCode::V027,
107                format!(
108                    "Memory value exceeds maximum size ({} bytes > {} bytes) for key `{}`",
109                    value.len(),
110                    MAX_MEMORY_VALUE_BYTES,
111                    entry.key
112                ),
113                location,
114            ));
115        }
116    }
117
118    errors
119}
120
121/// Validates `load_memory` topic references from `agent_context`.
122///
123/// Each topic in `topics` is checked against `available_topics` (the set of
124/// all topics declared in `memory:` entries across the file). Unresolved
125/// topics produce V026 warnings.
126#[must_use]
127pub fn validate_load_memory(
128    topics: &[String],
129    available_topics: &HashSet<String>,
130    location: ErrorLocation,
131) -> Vec<AgmError> {
132    let mut errors = Vec::new();
133
134    for topic in topics {
135        if !topic_regex().is_match(topic.as_str()) {
136            errors.push(AgmError::new(
137                ErrorCode::V025,
138                format!("Memory topic does not match required pattern: `{topic}`"),
139                location.clone(),
140            ));
141        } else if !available_topics.contains(topic.as_str()) {
142            errors.push(AgmError::new(
143                ErrorCode::V026,
144                format!(
145                    "Unresolved memory topic `{topic}` in `agent_context.load_memory` of node `{}`",
146                    location.node.as_deref().unwrap_or("<unknown>")
147                ),
148                location.clone(),
149            ));
150        }
151    }
152
153    errors
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn loc() -> ErrorLocation {
161        ErrorLocation::full("test.agm", 5, "test.node")
162    }
163
164    fn get_entry(key: &str, topic: &str) -> MemoryEntry {
165        MemoryEntry {
166            key: key.to_owned(),
167            topic: topic.to_owned(),
168            action: MemoryAction::Get,
169            value: None,
170            scope: None,
171            ttl: None,
172            query: None,
173            max_results: None,
174        }
175    }
176
177    // --- validate_memory_key ---
178
179    #[test]
180    fn test_validate_memory_key_valid_dotted_returns_ok() {
181        assert!(validate_memory_key("repo.pattern", loc()).is_ok());
182    }
183
184    #[test]
185    fn test_validate_memory_key_valid_with_underscores_returns_ok() {
186        assert!(validate_memory_key("repo.kanban_column.row_mapping_pattern", loc()).is_ok());
187    }
188
189    #[test]
190    fn test_validate_memory_key_valid_single_segment_returns_ok() {
191        assert!(validate_memory_key("mykey", loc()).is_ok());
192    }
193
194    #[test]
195    fn test_validate_memory_key_invalid_uppercase_returns_v022() {
196        let err = validate_memory_key("Invalid-Key", loc()).unwrap_err();
197        assert_eq!(err.code, ErrorCode::V022);
198    }
199
200    #[test]
201    fn test_validate_memory_key_invalid_starts_digit_returns_v022() {
202        let err = validate_memory_key("1abc", loc()).unwrap_err();
203        assert_eq!(err.code, ErrorCode::V022);
204    }
205
206    #[test]
207    fn test_validate_memory_key_invalid_hyphen_returns_v022() {
208        let err = validate_memory_key("repo-pattern", loc()).unwrap_err();
209        assert_eq!(err.code, ErrorCode::V022);
210    }
211
212    #[test]
213    fn test_validate_memory_key_empty_returns_v022() {
214        let err = validate_memory_key("", loc()).unwrap_err();
215        assert_eq!(err.code, ErrorCode::V022);
216    }
217
218    // --- validate_memory_topic ---
219
220    #[test]
221    fn test_validate_memory_topic_valid_dotted_returns_ok() {
222        assert!(validate_memory_topic("rust.repository", loc()).is_ok());
223    }
224
225    #[test]
226    fn test_validate_memory_topic_valid_single_returns_ok() {
227        assert!(validate_memory_topic("infrastructure", loc()).is_ok());
228    }
229
230    #[test]
231    fn test_validate_memory_topic_invalid_uppercase_returns_v025() {
232        let err = validate_memory_topic("Rust.models", loc()).unwrap_err();
233        assert_eq!(err.code, ErrorCode::V025);
234    }
235
236    #[test]
237    fn test_validate_memory_topic_invalid_leading_dot_returns_v025() {
238        let err = validate_memory_topic(".rust", loc()).unwrap_err();
239        assert_eq!(err.code, ErrorCode::V025);
240    }
241
242    #[test]
243    fn test_validate_memory_topic_empty_returns_v025() {
244        let err = validate_memory_topic("", loc()).unwrap_err();
245        assert_eq!(err.code, ErrorCode::V025);
246    }
247
248    // --- validate_memory_entry ---
249
250    #[test]
251    fn test_validate_memory_entry_valid_get_returns_empty() {
252        let entry = get_entry("repo.pattern", "rust.repository");
253        assert!(validate_memory_entry(&entry, loc()).is_empty());
254    }
255
256    #[test]
257    fn test_validate_memory_entry_valid_upsert_returns_empty() {
258        let mut entry = get_entry("repo.pattern", "rust.repository");
259        entry.action = MemoryAction::Upsert;
260        entry.value = Some("some value".to_owned());
261        assert!(validate_memory_entry(&entry, loc()).is_empty());
262    }
263
264    #[test]
265    fn test_validate_memory_entry_upsert_no_value_returns_v023() {
266        let mut entry = get_entry("repo.pattern", "rust.repository");
267        entry.action = MemoryAction::Upsert;
268        let errors = validate_memory_entry(&entry, loc());
269        assert!(errors.iter().any(|e| e.code == ErrorCode::V023));
270    }
271
272    #[test]
273    fn test_validate_memory_entry_search_no_query_returns_v023() {
274        let mut entry = get_entry("repo.pattern", "rust.repository");
275        entry.action = MemoryAction::Search;
276        let errors = validate_memory_entry(&entry, loc());
277        assert!(errors.iter().any(|e| e.code == ErrorCode::V023));
278    }
279
280    #[test]
281    fn test_validate_memory_entry_bad_key_and_bad_topic_returns_both() {
282        let entry = get_entry("Bad-Key", "Bad.Topic");
283        let errors = validate_memory_entry(&entry, loc());
284        assert!(errors.iter().any(|e| e.code == ErrorCode::V022));
285        assert!(errors.iter().any(|e| e.code == ErrorCode::V025));
286    }
287
288    // --- validate_load_memory ---
289
290    // --- validate_memory_entry value size ---
291
292    #[test]
293    fn test_validate_memory_entry_value_under_limit_returns_empty() {
294        let mut entry = get_entry("repo.pattern", "rust.repository");
295        entry.action = MemoryAction::Upsert;
296        entry.value = Some("short value".to_owned());
297        assert!(validate_memory_entry(&entry, loc()).is_empty());
298    }
299
300    #[test]
301    fn test_validate_memory_entry_value_at_limit_returns_empty() {
302        let mut entry = get_entry("repo.pattern", "rust.repository");
303        entry.action = MemoryAction::Upsert;
304        entry.value = Some("x".repeat(MAX_MEMORY_VALUE_BYTES));
305        assert!(validate_memory_entry(&entry, loc()).is_empty());
306    }
307
308    #[test]
309    fn test_validate_memory_entry_value_over_limit_returns_v027() {
310        let mut entry = get_entry("repo.pattern", "rust.repository");
311        entry.action = MemoryAction::Upsert;
312        entry.value = Some("x".repeat(MAX_MEMORY_VALUE_BYTES + 1));
313        let errors = validate_memory_entry(&entry, loc());
314        assert!(errors.iter().any(|e| e.code == ErrorCode::V027));
315    }
316
317    #[test]
318    fn test_validate_memory_entry_value_way_over_limit_returns_v027() {
319        let mut entry = get_entry("repo.pattern", "rust.repository");
320        entry.action = MemoryAction::Upsert;
321        entry.value = Some("x".repeat(MAX_MEMORY_VALUE_BYTES * 2));
322        let errors = validate_memory_entry(&entry, loc());
323        assert!(
324            errors.iter().any(|e| e.code == ErrorCode::V027),
325            "Expected V027 for value at 64 KiB"
326        );
327    }
328
329    #[test]
330    fn test_validate_memory_entry_get_with_no_value_ignores_size_check() {
331        let entry = get_entry("repo.pattern", "rust.repository");
332        // action is Get, value is None — no size check triggered
333        assert!(validate_memory_entry(&entry, loc()).is_empty());
334    }
335
336    // --- validate_load_memory ---
337
338    #[test]
339    fn test_validate_load_memory_all_resolved_returns_empty() {
340        let topics = vec!["rust.repository".to_owned()];
341        let mut available = HashSet::new();
342        available.insert("rust.repository".to_owned());
343        assert!(validate_load_memory(&topics, &available, loc()).is_empty());
344    }
345
346    #[test]
347    fn test_validate_load_memory_unresolved_returns_v026() {
348        let topics = vec!["rust.repository".to_owned()];
349        let available = HashSet::new();
350        let errors = validate_load_memory(&topics, &available, loc());
351        assert!(errors.iter().any(|e| e.code == ErrorCode::V026));
352    }
353
354    #[test]
355    fn test_validate_load_memory_invalid_format_returns_v025() {
356        let topics = vec!["Rust.Models".to_owned()];
357        let available = HashSet::new();
358        let errors = validate_load_memory(&topics, &available, loc());
359        assert!(errors.iter().any(|e| e.code == ErrorCode::V025));
360    }
361
362    #[test]
363    fn test_validate_load_memory_empty_list_returns_empty() {
364        let topics: Vec<String> = vec![];
365        let available = HashSet::new();
366        assert!(validate_load_memory(&topics, &available, loc()).is_empty());
367    }
368}