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
22pub fn validate_session(session: &Session) -> Result<(), Vec<ValidationError>> {
23    let mut errors = Vec::new();
24
25    // Version check
26    if !session.version.starts_with("hail-") {
27        errors.push(ValidationError::InvalidVersion {
28            version: session.version.clone(),
29        });
30    }
31
32    // Required fields
33    if session.session_id.is_empty() {
34        errors.push(ValidationError::MissingField {
35            field: "session_id".to_string(),
36        });
37    }
38    if session.agent.provider.is_empty() {
39        errors.push(ValidationError::MissingField {
40            field: "agent.provider".to_string(),
41        });
42    }
43    if session.agent.tool.is_empty() {
44        errors.push(ValidationError::MissingField {
45            field: "agent.tool".to_string(),
46        });
47    }
48
49    // Events validation
50    if session.events.is_empty() {
51        errors.push(ValidationError::EmptySession);
52    }
53
54    let mut seen_ids = std::collections::HashSet::new();
55    for (i, event) in session.events.iter().enumerate() {
56        if let Err(e) = validate_event(event) {
57            errors.push(ValidationError::InvalidEvent {
58                index: i,
59                reason: e.to_string(),
60            });
61        }
62
63        // Check for duplicate event IDs
64        if !seen_ids.insert(&event.event_id) {
65            errors.push(ValidationError::DuplicateEventId {
66                event_id: event.event_id.clone(),
67            });
68        }
69
70        // Check chronological order
71        if i > 0 && event.timestamp < session.events[i - 1].timestamp {
72            errors.push(ValidationError::EventsOutOfOrder { index: i });
73        }
74    }
75
76    if errors.is_empty() {
77        Ok(())
78    } else {
79        Err(errors)
80    }
81}
82
83/// Validate a single event
84pub fn validate_event(event: &Event) -> Result<(), ValidationError> {
85    if event.event_id.is_empty() {
86        return Err(ValidationError::MissingField {
87            field: "event_id".to_string(),
88        });
89    }
90
91    // Validate event-type-specific constraints
92    match &event.event_type {
93        EventType::ToolCall { name } | EventType::ToolResult { name, .. } => {
94            if name.is_empty() {
95                return Err(ValidationError::MissingField {
96                    field: "event_type.name".to_string(),
97                });
98            }
99        }
100        EventType::FileEdit { path, .. }
101        | EventType::FileCreate { path }
102        | EventType::FileDelete { path }
103        | EventType::FileRead { path } => {
104            if path.is_empty() {
105                return Err(ValidationError::MissingField {
106                    field: "event_type.path".to_string(),
107                });
108            }
109        }
110        EventType::ShellCommand { command, .. } => {
111            if command.is_empty() {
112                return Err(ValidationError::MissingField {
113                    field: "event_type.command".to_string(),
114                });
115            }
116        }
117        _ => {}
118    }
119
120    Ok(())
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::trace::*;
127    use chrono::Utc;
128    use std::collections::HashMap;
129
130    fn make_session_with_events(events: Vec<Event>) -> Session {
131        Session {
132            version: "hail-1.0.0".to_string(),
133            session_id: "test-id".to_string(),
134            agent: Agent {
135                provider: "anthropic".to_string(),
136                model: "claude-opus-4-6".to_string(),
137                tool: "claude-code".to_string(),
138                tool_version: None,
139            },
140            context: SessionContext::default(),
141            events,
142            stats: Stats::default(),
143        }
144    }
145
146    #[test]
147    fn test_valid_session() {
148        let session = make_session_with_events(vec![Event {
149            event_id: "e1".to_string(),
150            timestamp: Utc::now(),
151            event_type: EventType::UserMessage,
152            task_id: None,
153            content: Content::text("hello"),
154            duration_ms: None,
155            attributes: HashMap::new(),
156        }]);
157        assert!(validate_session(&session).is_ok());
158    }
159
160    #[test]
161    fn test_empty_session() {
162        let session = make_session_with_events(vec![]);
163        let errs = validate_session(&session).unwrap_err();
164        assert!(errs
165            .iter()
166            .any(|e| matches!(e, ValidationError::EmptySession)));
167    }
168
169    #[test]
170    fn test_invalid_version() {
171        let mut session = make_session_with_events(vec![Event {
172            event_id: "e1".to_string(),
173            timestamp: Utc::now(),
174            event_type: EventType::UserMessage,
175            task_id: None,
176            content: Content::text("hello"),
177            duration_ms: None,
178            attributes: HashMap::new(),
179        }]);
180        session.version = "bad-version".to_string();
181        let errs = validate_session(&session).unwrap_err();
182        assert!(errs
183            .iter()
184            .any(|e| matches!(e, ValidationError::InvalidVersion { .. })));
185    }
186
187    #[test]
188    fn test_duplicate_event_id() {
189        let now = Utc::now();
190        let session = make_session_with_events(vec![
191            Event {
192                event_id: "e1".to_string(),
193                timestamp: now,
194                event_type: EventType::UserMessage,
195                task_id: None,
196                content: Content::text("hello"),
197                duration_ms: None,
198                attributes: HashMap::new(),
199            },
200            Event {
201                event_id: "e1".to_string(),
202                timestamp: now,
203                event_type: EventType::AgentMessage,
204                task_id: None,
205                content: Content::text("hi"),
206                duration_ms: None,
207                attributes: HashMap::new(),
208            },
209        ]);
210        let errs = validate_session(&session).unwrap_err();
211        assert!(errs
212            .iter()
213            .any(|e| matches!(e, ValidationError::DuplicateEventId { .. })));
214    }
215}