1use crate::trace::{Event, EventType, Session};
2use thiserror::Error;
3
4#[derive(Debug, Error)]
5#[non_exhaustive]
6pub enum ValidationError {
7 #[error("missing required field: {field}")]
8 MissingField { field: String },
9 #[error("invalid version: {version}, expected prefix 'hail-'")]
10 InvalidVersion { version: String },
11 #[error("empty session: no events")]
12 EmptySession,
13 #[error("invalid event at index {index}: {reason}")]
14 InvalidEvent { index: usize, reason: String },
15 #[error("events not in chronological order at index {index}")]
16 EventsOutOfOrder { index: usize },
17 #[error("duplicate event_id: {event_id}")]
18 DuplicateEventId { event_id: String },
19}
20
21pub fn validate_session(session: &Session) -> Result<(), Vec<ValidationError>> {
23 let validators: &[fn(&Session) -> Vec<ValidationError>] = &[
24 validate_version,
25 validate_required_fields,
26 validate_not_empty,
27 validate_events,
28 ];
29
30 let errors: Vec<ValidationError> = validators.iter().flat_map(|v| v(session)).collect();
31
32 if errors.is_empty() {
33 Ok(())
34 } else {
35 Err(errors)
36 }
37}
38
39fn validate_version(session: &Session) -> Vec<ValidationError> {
40 if session.version.starts_with("hail-") {
41 vec![]
42 } else {
43 vec![ValidationError::InvalidVersion {
44 version: session.version.clone(),
45 }]
46 }
47}
48
49fn validate_required_fields(session: &Session) -> Vec<ValidationError> {
50 [
51 ("session_id", session.session_id.is_empty()),
52 ("agent.provider", session.agent.provider.is_empty()),
53 ("agent.tool", session.agent.tool.is_empty()),
54 ]
55 .into_iter()
56 .filter(|(_, empty)| *empty)
57 .map(|(field, _)| ValidationError::MissingField {
58 field: field.to_string(),
59 })
60 .collect()
61}
62
63fn validate_not_empty(session: &Session) -> Vec<ValidationError> {
64 if session.events.is_empty() {
65 vec![ValidationError::EmptySession]
66 } else {
67 vec![]
68 }
69}
70
71fn validate_events(session: &Session) -> Vec<ValidationError> {
72 let individual_errors = session.events.iter().enumerate().filter_map(|(i, event)| {
73 validate_event(event)
74 .err()
75 .map(|e| ValidationError::InvalidEvent {
76 index: i,
77 reason: e.to_string(),
78 })
79 });
80
81 let mut seen_ids = std::collections::HashSet::new();
82 let duplicate_errors = session.events.iter().filter_map(move |event| {
83 if seen_ids.insert(&event.event_id) {
84 None
85 } else {
86 Some(ValidationError::DuplicateEventId {
87 event_id: event.event_id.clone(),
88 })
89 }
90 });
91
92 let order_errors = session
93 .events
94 .windows(2)
95 .enumerate()
96 .filter_map(|(i, pair)| {
97 if pair[1].timestamp < pair[0].timestamp {
98 Some(ValidationError::EventsOutOfOrder { index: i + 1 })
99 } else {
100 None
101 }
102 });
103
104 individual_errors
105 .chain(duplicate_errors)
106 .chain(order_errors)
107 .collect()
108}
109
110pub fn validate_event(event: &Event) -> Result<(), ValidationError> {
112 if event.event_id.is_empty() {
113 return Err(ValidationError::MissingField {
114 field: "event_id".to_string(),
115 });
116 }
117
118 match &event.event_type {
120 EventType::ToolCall { name } | EventType::ToolResult { name, .. } => {
121 if name.is_empty() {
122 return Err(ValidationError::MissingField {
123 field: "event_type.name".to_string(),
124 });
125 }
126 }
127 EventType::FileEdit { path, .. }
128 | EventType::FileCreate { path }
129 | EventType::FileDelete { path }
130 | EventType::FileRead { path } => {
131 if path.is_empty() {
132 return Err(ValidationError::MissingField {
133 field: "event_type.path".to_string(),
134 });
135 }
136 }
137 EventType::ShellCommand { command, .. } => {
138 if command.is_empty() {
139 return Err(ValidationError::MissingField {
140 field: "event_type.command".to_string(),
141 });
142 }
143 }
144 _ => {}
145 }
146
147 Ok(())
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::trace::*;
154 use chrono::Utc;
155 use std::collections::HashMap;
156
157 fn make_session_with_events(events: Vec<Event>) -> Session {
158 Session {
159 version: "hail-1.0.0".to_string(),
160 session_id: "test-id".to_string(),
161 agent: Agent {
162 provider: "anthropic".to_string(),
163 model: "claude-opus-4-6".to_string(),
164 tool: "claude-code".to_string(),
165 tool_version: None,
166 },
167 context: SessionContext::default(),
168 events,
169 stats: Stats::default(),
170 }
171 }
172
173 #[test]
174 fn test_valid_session() {
175 let session = make_session_with_events(vec![Event {
176 event_id: "e1".to_string(),
177 timestamp: Utc::now(),
178 event_type: EventType::UserMessage,
179 task_id: None,
180 content: Content::text("hello"),
181 duration_ms: None,
182 attributes: HashMap::new(),
183 }]);
184 assert!(validate_session(&session).is_ok());
185 }
186
187 #[test]
188 fn test_empty_session() {
189 let session = make_session_with_events(vec![]);
190 let errs = validate_session(&session).unwrap_err();
191 assert!(errs
192 .iter()
193 .any(|e| matches!(e, ValidationError::EmptySession)));
194 }
195
196 #[test]
197 fn test_invalid_version() {
198 let mut session = make_session_with_events(vec![Event {
199 event_id: "e1".to_string(),
200 timestamp: Utc::now(),
201 event_type: EventType::UserMessage,
202 task_id: None,
203 content: Content::text("hello"),
204 duration_ms: None,
205 attributes: HashMap::new(),
206 }]);
207 session.version = "bad-version".to_string();
208 let errs = validate_session(&session).unwrap_err();
209 assert!(errs
210 .iter()
211 .any(|e| matches!(e, ValidationError::InvalidVersion { .. })));
212 }
213
214 #[test]
215 fn test_duplicate_event_id() {
216 let now = Utc::now();
217 let session = make_session_with_events(vec![
218 Event {
219 event_id: "e1".to_string(),
220 timestamp: now,
221 event_type: EventType::UserMessage,
222 task_id: None,
223 content: Content::text("hello"),
224 duration_ms: None,
225 attributes: HashMap::new(),
226 },
227 Event {
228 event_id: "e1".to_string(),
229 timestamp: now,
230 event_type: EventType::AgentMessage,
231 task_id: None,
232 content: Content::text("hi"),
233 duration_ms: None,
234 attributes: HashMap::new(),
235 },
236 ]);
237 let errs = validate_session(&session).unwrap_err();
238 assert!(errs
239 .iter()
240 .any(|e| matches!(e, ValidationError::DuplicateEventId { .. })));
241 }
242
243 fn make_event(id: &str, event_type: EventType) -> Event {
244 Event {
245 event_id: id.to_string(),
246 timestamp: Utc::now(),
247 event_type,
248 task_id: None,
249 content: Content::text("test"),
250 duration_ms: None,
251 attributes: HashMap::new(),
252 }
253 }
254
255 #[test]
256 fn test_validate_event_empty_tool_name() {
257 let event = make_event(
258 "e1",
259 EventType::ToolCall {
260 name: "".to_string(),
261 },
262 );
263 let err = validate_event(&event).unwrap_err();
264 assert!(
265 matches!(err, ValidationError::MissingField { field } if field == "event_type.name")
266 );
267 }
268
269 #[test]
270 fn test_validate_event_empty_file_path() {
271 let event = make_event(
272 "e1",
273 EventType::FileEdit {
274 path: "".to_string(),
275 diff: None,
276 },
277 );
278 let err = validate_event(&event).unwrap_err();
279 assert!(
280 matches!(err, ValidationError::MissingField { field } if field == "event_type.path")
281 );
282 }
283
284 #[test]
285 fn test_validate_event_empty_command() {
286 let event = make_event(
287 "e1",
288 EventType::ShellCommand {
289 command: "".to_string(),
290 exit_code: None,
291 },
292 );
293 let err = validate_event(&event).unwrap_err();
294 assert!(
295 matches!(err, ValidationError::MissingField { field } if field == "event_type.command")
296 );
297 }
298
299 #[test]
300 fn test_events_out_of_order() {
301 let now = Utc::now();
302 let earlier = now - chrono::Duration::seconds(10);
303 let session = make_session_with_events(vec![
304 Event {
305 event_id: "e1".to_string(),
306 timestamp: now,
307 event_type: EventType::UserMessage,
308 task_id: None,
309 content: Content::text("first"),
310 duration_ms: None,
311 attributes: HashMap::new(),
312 },
313 Event {
314 event_id: "e2".to_string(),
315 timestamp: earlier,
316 event_type: EventType::AgentMessage,
317 task_id: None,
318 content: Content::text("second"),
319 duration_ms: None,
320 attributes: HashMap::new(),
321 },
322 ]);
323 let errs = validate_session(&session).unwrap_err();
324 assert!(errs
325 .iter()
326 .any(|e| matches!(e, ValidationError::EventsOutOfOrder { index: 1 })));
327 }
328
329 #[test]
330 fn test_session_id_empty() {
331 let mut session = make_session_with_events(vec![make_event("e1", EventType::UserMessage)]);
332 session.session_id = "".to_string();
333 let errs = validate_session(&session).unwrap_err();
334 assert!(errs.iter().any(
335 |e| matches!(e, ValidationError::MissingField { field } if field == "session_id")
336 ));
337 }
338
339 #[test]
340 fn test_valid_all_event_types() {
341 let now = Utc::now();
342 let events: Vec<Event> = [
343 EventType::UserMessage,
344 EventType::AgentMessage,
345 EventType::SystemMessage,
346 EventType::Thinking,
347 EventType::ToolCall {
348 name: "Read".to_string(),
349 },
350 EventType::ToolResult {
351 name: "Read".to_string(),
352 is_error: false,
353 call_id: None,
354 },
355 EventType::FileRead {
356 path: "src/main.rs".to_string(),
357 },
358 EventType::CodeSearch {
359 query: "fn main".to_string(),
360 },
361 EventType::FileSearch {
362 pattern: "*.rs".to_string(),
363 },
364 EventType::FileEdit {
365 path: "src/lib.rs".to_string(),
366 diff: Some("+line".to_string()),
367 },
368 EventType::FileCreate {
369 path: "src/new.rs".to_string(),
370 },
371 EventType::FileDelete {
372 path: "src/old.rs".to_string(),
373 },
374 EventType::ShellCommand {
375 command: "cargo build".to_string(),
376 exit_code: Some(0),
377 },
378 EventType::ImageGenerate {
379 prompt: "a cat".to_string(),
380 },
381 EventType::VideoGenerate {
382 prompt: "a dog".to_string(),
383 },
384 EventType::AudioGenerate {
385 prompt: "a song".to_string(),
386 },
387 EventType::WebSearch {
388 query: "rust docs".to_string(),
389 },
390 EventType::WebFetch {
391 url: "https://example.com".to_string(),
392 },
393 EventType::TaskStart {
394 title: Some("task".to_string()),
395 },
396 EventType::TaskEnd {
397 summary: Some("done".to_string()),
398 },
399 EventType::Custom {
400 kind: "my_event".to_string(),
401 },
402 ]
403 .into_iter()
404 .enumerate()
405 .map(|(i, et)| Event {
406 event_id: format!("e{i}"),
407 timestamp: now + chrono::Duration::milliseconds(i as i64),
408 event_type: et,
409 task_id: None,
410 content: Content::text("test"),
411 duration_ms: None,
412 attributes: HashMap::new(),
413 })
414 .collect();
415
416 let session = make_session_with_events(events);
417 assert!(validate_session(&session).is_ok());
418 }
419}