Skip to main content

oino_session/migrate/
mod.rs

1//! Format detection, parsing and migration to v3.
2//!
3//! - [`detect`] classifies a file by its first line.
4//! - [`parse_session_text`] parses session files (v1/v2 migrated in memory, exactly like the reference
5//!   `migrateToCurrentVersion`).
6//! - [`legacy::migrate_oino_legacy_text`] maps pre-v1 Oino `record` files to v3 entries
7//!   (session-v3 ยง5.2); the file-moving wrapper lives in the manager.
8
9pub 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/// File format of a session file.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum FileFormat {
23    /// Format v1 (linear, no ids).
24    V1,
25    /// Format v2 (tree, `hookMessage`).
26    V2,
27    /// Format v3 (current).
28    V3,
29    /// Format version newer than supported.
30    Future(u32),
31    /// Pre-v1 Oino `record` file.
32    OinoLegacy {
33        /// Legacy header version (1 or 2).
34        version: u32,
35    },
36    /// Blank first line / empty file.
37    Empty,
38    /// Not a session file.
39    Unknown,
40}
41
42/// Classifies a file from its first line: key `"record"` โ‡’ Oino legacy; `"type":"session"`
43/// โ‡’ session format (missing `version` โ‡’ 1).
44#[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/// A parsed session file.
76#[derive(Clone, Debug, PartialEq)]
77pub struct ParsedSession {
78    /// Header (version 3 after migration).
79    pub header: SessionHeader,
80    /// Entries in file order.
81    pub entries: Vec<SessionEntry>,
82    /// Whether migration changed the data (the file should be rewritten).
83    pub migrated: bool,
84    /// Lines that were not valid JSON or not valid v3 entries (1-based numbers).
85    pub skipped_lines: Vec<usize>,
86}
87
88/// Parses session text. Returns `Ok(None)` when the first valid JSON line is not a
89/// `session` header with a string id (`loadEntriesFromFile` returns `[]`).
90///
91/// # Errors
92///
93/// [`SessionError::UnsupportedVersion`] for versions above 3.
94pub 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}