Skip to main content

opensession_core/
validate.rs

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
21/// Validate a complete session by composing independent validators.
22pub 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
110/// Validate a single event
111pub 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    // Validate event-type-specific constraints
119    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}