use crate::cm_types::Message;
use serde::{Deserialize, Serialize};
pub const CHAT_SESSION_FILE_VERSION: u32 = 1;
pub const CHAT_EXPORT_SCHEMA_ID: &str = "crabmate.chat_session";
pub const CHAT_EXPORT_SCHEMA_VERSION: &str = "2.0.0";
pub const CHAT_EXPORT_PROJECTION_RAW: &str = "raw";
pub const CHAT_EXPORT_PROJECTION_DISPLAY: &str = "display";
#[must_use]
pub fn projection_is_raw(projection: &str) -> bool {
projection
.trim()
.eq_ignore_ascii_case(CHAT_EXPORT_PROJECTION_RAW)
}
#[must_use]
pub fn projection_is_display(projection: &str) -> bool {
projection
.trim()
.eq_ignore_ascii_case(CHAT_EXPORT_PROJECTION_DISPLAY)
}
pub fn ensure_raw_projection(projection: &str) -> Result<(), String> {
if projection_is_raw(projection) {
return Ok(());
}
Err(format!(
"该文件 projection={projection:?} 不是完整会话(需要 {CHAT_EXPORT_PROJECTION_RAW:?});Web 展示导出请用 CLI/TUI save-session 或会话落盘 JSON"
))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatSessionFile {
pub schema: String,
pub schema_version: String,
pub projection: String,
pub version: u32,
pub messages: Vec<Message>,
}
impl ChatSessionFile {
pub fn new(messages: Vec<Message>) -> Self {
Self {
schema: CHAT_EXPORT_SCHEMA_ID.to_string(),
schema_version: CHAT_EXPORT_SCHEMA_VERSION.to_string(),
projection: CHAT_EXPORT_PROJECTION_RAW.to_string(),
version: CHAT_SESSION_FILE_VERSION,
messages,
}
}
pub fn from_slice(messages: &[Message]) -> Self {
Self::new(messages.to_vec())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DisplayExportMessage {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisplayChatSessionFile {
pub schema: String,
pub schema_version: String,
pub projection: String,
pub version: u32,
pub messages: Vec<DisplayExportMessage>,
}
impl DisplayChatSessionFile {
pub fn new(messages: Vec<DisplayExportMessage>) -> Self {
Self {
schema: CHAT_EXPORT_SCHEMA_ID.to_string(),
schema_version: CHAT_EXPORT_SCHEMA_VERSION.to_string(),
projection: CHAT_EXPORT_PROJECTION_DISPLAY.to_string(),
version: CHAT_SESSION_FILE_VERSION,
messages,
}
}
}
pub fn session_to_json_pretty(file: &ChatSessionFile) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(file)
}
pub fn display_session_to_json_pretty(
file: &DisplayChatSessionFile,
) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(file)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cm_types::Message;
fn msg(role: &str, content: &str) -> Message {
Message {
role: role.to_string(),
content: Some(content.into()),
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: None,
tool_call_id: None,
}
}
#[test]
fn session_file_roundtrip_includes_raw_projection() {
let file = ChatSessionFile::new(vec![msg("user", "x")]);
let s = session_to_json_pretty(&file).unwrap();
assert!(s.contains(CHAT_EXPORT_SCHEMA_ID));
assert!(s.contains(CHAT_EXPORT_SCHEMA_VERSION));
assert!(s.contains(CHAT_EXPORT_PROJECTION_RAW));
let back: ChatSessionFile = serde_json::from_str(&s).unwrap();
assert_eq!(back.schema, CHAT_EXPORT_SCHEMA_ID);
assert_eq!(back.schema_version, CHAT_EXPORT_SCHEMA_VERSION);
assert_eq!(back.projection, CHAT_EXPORT_PROJECTION_RAW);
assert_eq!(back.version, CHAT_SESSION_FILE_VERSION);
assert_eq!(back.messages.len(), 1);
assert_eq!(back.messages[0].role, "user");
assert!(ensure_raw_projection(&back.projection).is_ok());
}
#[test]
fn session_file_rejects_missing_envelope_fields() {
let json = r#"{"version":1,"messages":[]}"#;
assert!(serde_json::from_str::<ChatSessionFile>(json).is_err());
let no_projection = r#"{"schema":"crabmate.chat_session","schema_version":"2.0.0","version":1,"messages":[]}"#;
assert!(serde_json::from_str::<ChatSessionFile>(no_projection).is_err());
}
#[test]
fn display_file_marks_projection_and_rejects_as_raw() {
let file = DisplayChatSessionFile::new(vec![DisplayExportMessage {
role: "user".into(),
content: Some("hi".into()),
name: None,
}]);
let s = display_session_to_json_pretty(&file).unwrap();
assert!(s.contains(CHAT_EXPORT_PROJECTION_DISPLAY));
assert!(ensure_raw_projection(&file.projection).is_err());
let back: DisplayChatSessionFile = serde_json::from_str(&s).unwrap();
assert!(projection_is_display(&back.projection));
assert_eq!(back.messages[0].content.as_deref(), Some("hi"));
}
#[test]
fn empty_projection_is_not_raw() {
assert!(!projection_is_raw(""));
assert!(ensure_raw_projection("").is_err());
}
}