1use 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
16pub const MAX_MEMORY_VALUE_BYTES: usize = 32_768;
18
19static MEMORY_KEY_RE: OnceLock<Regex> = OnceLock::new();
22
23static 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
35pub 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
50pub 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#[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#[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 #[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 #[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 #[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 #[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 assert!(validate_memory_entry(&entry, loc()).is_empty());
334 }
335
336 #[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}