opensession_core/
validate.rs1use 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 mut errors = Vec::new();
24
25 if !session.version.starts_with("hail-") {
27 errors.push(ValidationError::InvalidVersion {
28 version: session.version.clone(),
29 });
30 }
31
32 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 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 if !seen_ids.insert(&event.event_id) {
65 errors.push(ValidationError::DuplicateEventId {
66 event_id: event.event_id.clone(),
67 });
68 }
69
70 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
83pub 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 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}