oino_session/migrate/
mod.rs1pub mod legacy;
10mod values;
11
12use oino_ai::JsonValue;
13
14pub use legacy::{LegacyMigration, MigrationWarning};
15pub use values::migrate_values;
16
17use crate::entry::{CURRENT_SESSION_VERSION, FileLine, SessionEntry, SessionHeader};
18use crate::error::SessionError;
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum FileFormat {
23 V1,
25 V2,
27 V3,
29 Future(u32),
31 OinoLegacy {
33 version: u32,
35 },
36 Empty,
38 Unknown,
40}
41
42#[must_use]
45pub fn detect(first_line: &str) -> FileFormat {
46 if first_line.trim().is_empty() {
47 return FileFormat::Empty;
48 }
49 let Ok(value) = serde_json::from_str::<JsonValue>(first_line) else {
50 return FileFormat::Unknown;
51 };
52 if value.get("record").is_some() {
53 let version = value
54 .pointer("/header/version")
55 .and_then(JsonValue::as_u64)
56 .and_then(|v| u32::try_from(v).ok())
57 .unwrap_or(1);
58 return FileFormat::OinoLegacy { version };
59 }
60 if value.get("type").and_then(JsonValue::as_str) != Some("session") {
61 return FileFormat::Unknown;
62 }
63 let version = value
64 .get("version")
65 .and_then(JsonValue::as_u64)
66 .map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX));
67 match version {
68 0 | 1 => FileFormat::V1,
69 2 => FileFormat::V2,
70 CURRENT_SESSION_VERSION => FileFormat::V3,
71 other => FileFormat::Future(other),
72 }
73}
74
75#[derive(Clone, Debug, PartialEq)]
77pub struct ParsedSession {
78 pub header: SessionHeader,
80 pub entries: Vec<SessionEntry>,
82 pub migrated: bool,
84 pub skipped_lines: Vec<usize>,
86}
87
88pub fn parse_session_text(text: &str) -> Result<Option<ParsedSession>, SessionError> {
95 let mut values = Vec::new();
96 let mut line_numbers = Vec::new();
97 let mut skipped_lines = Vec::new();
98 for (index, line) in text.split('\n').enumerate() {
99 if line.trim().is_empty() {
100 continue;
101 }
102 match serde_json::from_str::<JsonValue>(line) {
103 Ok(value) => {
104 values.push(value);
105 line_numbers.push(index + 1);
106 }
107 Err(_) => skipped_lines.push(index + 1),
108 }
109 }
110 let Some(first) = values.first() else {
111 return Ok(None);
112 };
113 if first.get("type").and_then(JsonValue::as_str) != Some("session")
114 || !first.get("id").is_some_and(JsonValue::is_string)
115 {
116 return Ok(None);
117 }
118 let version = first
119 .get("version")
120 .and_then(JsonValue::as_u64)
121 .map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX));
122 if version > CURRENT_SESSION_VERSION {
123 return Err(SessionError::UnsupportedVersion(version));
124 }
125 let migrated = migrate_values(&mut values);
126 let mut header = None;
127 let mut entries = Vec::with_capacity(values.len());
128 for (value, line) in values.into_iter().zip(line_numbers) {
129 match FileLine::from_value(value) {
130 Ok(FileLine::Header(parsed)) if header.is_none() => header = Some(parsed),
131 Ok(FileLine::Entry(entry)) => entries.push(entry),
132 Ok(FileLine::Header(_)) | Err(_) => skipped_lines.push(line),
133 }
134 }
135 skipped_lines.sort_unstable();
136 Ok(header.map(|header| ParsedSession {
137 header,
138 entries,
139 migrated,
140 skipped_lines,
141 }))
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn detects_formats() {
150 assert_eq!(detect(r#"{"type":"session","id":"x"}"#), FileFormat::V1);
151 assert_eq!(
152 detect(r#"{"type":"session","version":2,"id":"x"}"#),
153 FileFormat::V2
154 );
155 assert_eq!(
156 detect(r#"{"type":"session","version":3,"id":"x"}"#),
157 FileFormat::V3
158 );
159 assert_eq!(
160 detect(r#"{"type":"session","version":4,"id":"x"}"#),
161 FileFormat::Future(4)
162 );
163 assert_eq!(
164 detect(r#"{"record":"header","header":{"version":2}}"#),
165 FileFormat::OinoLegacy { version: 2 }
166 );
167 assert_eq!(detect(""), FileFormat::Empty);
168 assert_eq!(detect("{"), FileFormat::Unknown);
169 }
170
171 #[test]
172 fn future_versions_are_rejected() {
173 assert!(matches!(
174 parse_session_text(r#"{"type":"session","version":9,"id":"x"}"#),
175 Err(SessionError::UnsupportedVersion(9))
176 ));
177 }
178
179 #[test]
180 fn malformed_lines_are_skipped() {
181 let text = "{\"type\":\"session\",\"version\":3,\"id\":\"s\",\"timestamp\":\"t\",\"cwd\":\"/\"}\n{oops\n{\"type\":\"session_info\",\"id\":\"a\",\"parentId\":null,\"timestamp\":\"t\",\"name\":\"n\"}\n";
182 let parsed = parse_session_text(text).unwrap().unwrap();
183 assert_eq!(parsed.entries.len(), 1);
184 assert_eq!(parsed.skipped_lines, [2]);
185 assert!(!parsed.migrated);
186 }
187}