pub mod legacy;
mod values;
use oino_ai::JsonValue;
pub use legacy::{LegacyMigration, MigrationWarning};
pub use values::migrate_values;
use crate::entry::{CURRENT_SESSION_VERSION, FileLine, SessionEntry, SessionHeader};
use crate::error::SessionError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileFormat {
V1,
V2,
V3,
Future(u32),
OinoLegacy {
version: u32,
},
Empty,
Unknown,
}
#[must_use]
pub fn detect(first_line: &str) -> FileFormat {
if first_line.trim().is_empty() {
return FileFormat::Empty;
}
let Ok(value) = serde_json::from_str::<JsonValue>(first_line) else {
return FileFormat::Unknown;
};
if value.get("record").is_some() {
let version = value
.pointer("/header/version")
.and_then(JsonValue::as_u64)
.and_then(|v| u32::try_from(v).ok())
.unwrap_or(1);
return FileFormat::OinoLegacy { version };
}
if value.get("type").and_then(JsonValue::as_str) != Some("session") {
return FileFormat::Unknown;
}
let version = value
.get("version")
.and_then(JsonValue::as_u64)
.map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX));
match version {
0 | 1 => FileFormat::V1,
2 => FileFormat::V2,
CURRENT_SESSION_VERSION => FileFormat::V3,
other => FileFormat::Future(other),
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ParsedSession {
pub header: SessionHeader,
pub entries: Vec<SessionEntry>,
pub migrated: bool,
pub skipped_lines: Vec<usize>,
}
pub fn parse_session_text(text: &str) -> Result<Option<ParsedSession>, SessionError> {
let mut values = Vec::new();
let mut line_numbers = Vec::new();
let mut skipped_lines = Vec::new();
for (index, line) in text.split('\n').enumerate() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<JsonValue>(line) {
Ok(value) => {
values.push(value);
line_numbers.push(index + 1);
}
Err(_) => skipped_lines.push(index + 1),
}
}
let Some(first) = values.first() else {
return Ok(None);
};
if first.get("type").and_then(JsonValue::as_str) != Some("session")
|| !first.get("id").is_some_and(JsonValue::is_string)
{
return Ok(None);
}
let version = first
.get("version")
.and_then(JsonValue::as_u64)
.map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX));
if version > CURRENT_SESSION_VERSION {
return Err(SessionError::UnsupportedVersion(version));
}
let migrated = migrate_values(&mut values);
let mut header = None;
let mut entries = Vec::with_capacity(values.len());
for (value, line) in values.into_iter().zip(line_numbers) {
match FileLine::from_value(value) {
Ok(FileLine::Header(parsed)) if header.is_none() => header = Some(parsed),
Ok(FileLine::Entry(entry)) => entries.push(entry),
Ok(FileLine::Header(_)) | Err(_) => skipped_lines.push(line),
}
}
skipped_lines.sort_unstable();
Ok(header.map(|header| ParsedSession {
header,
entries,
migrated,
skipped_lines,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_formats() {
assert_eq!(detect(r#"{"type":"session","id":"x"}"#), FileFormat::V1);
assert_eq!(
detect(r#"{"type":"session","version":2,"id":"x"}"#),
FileFormat::V2
);
assert_eq!(
detect(r#"{"type":"session","version":3,"id":"x"}"#),
FileFormat::V3
);
assert_eq!(
detect(r#"{"type":"session","version":4,"id":"x"}"#),
FileFormat::Future(4)
);
assert_eq!(
detect(r#"{"record":"header","header":{"version":2}}"#),
FileFormat::OinoLegacy { version: 2 }
);
assert_eq!(detect(""), FileFormat::Empty);
assert_eq!(detect("{"), FileFormat::Unknown);
}
#[test]
fn future_versions_are_rejected() {
assert!(matches!(
parse_session_text(r#"{"type":"session","version":9,"id":"x"}"#),
Err(SessionError::UnsupportedVersion(9))
));
}
#[test]
fn malformed_lines_are_skipped() {
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";
let parsed = parse_session_text(text).unwrap().unwrap();
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.skipped_lines, [2]);
assert!(!parsed.migrated);
}
}