ailoop_history/
snapshot.rs1use ailoop_core::Message;
5use serde::{Deserialize, Serialize};
6
7use crate::errors::FromMessagesError;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[serde(try_from = "RawConversationSnapshot")]
22#[non_exhaustive]
23pub struct ConversationSnapshot {
24 pub version: u32,
29 pub messages: Vec<Message>,
31 pub pinned: Vec<bool>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39struct RawConversationSnapshot {
40 version: u32,
41 messages: Vec<Message>,
42 pinned: Vec<bool>,
43}
44
45impl ConversationSnapshot {
46 pub const VERSION: u32 = 1;
50
51 pub fn new(messages: Vec<Message>, pinned: Vec<bool>) -> Result<Self, FromMessagesError> {
57 if messages.len() != pinned.len() {
58 return Err(FromMessagesError::LengthMismatch {
59 messages: messages.len(),
60 pinned: pinned.len(),
61 });
62 }
63 Ok(Self {
64 version: Self::VERSION,
65 messages,
66 pinned,
67 })
68 }
69}
70
71impl TryFrom<RawConversationSnapshot> for ConversationSnapshot {
72 type Error = String;
73
74 fn try_from(raw: RawConversationSnapshot) -> Result<Self, Self::Error> {
75 if raw.version != Self::VERSION {
76 return Err(format!(
77 "unsupported snapshot version {} (expected {})",
78 raw.version,
79 Self::VERSION
80 ));
81 }
82 if raw.messages.len() != raw.pinned.len() {
83 return Err(format!(
84 "messages/pinned length mismatch: messages={}, pinned={}",
85 raw.messages.len(),
86 raw.pinned.len()
87 ));
88 }
89 Ok(Self {
90 version: raw.version,
91 messages: raw.messages,
92 pinned: raw.pinned,
93 })
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use ailoop_core::{AssistantBlock, Message};
101 use serde_json::json;
102
103 #[test]
104 fn round_trip_through_json() {
105 let snap = ConversationSnapshot::new(
106 vec![
107 Message::user("hi"),
108 Message::Assistant {
109 blocks: vec![AssistantBlock::tool_call("c1", "fetch", json!({"x": 1}))],
110 },
111 ],
112 vec![true, false],
113 )
114 .expect("valid lengths");
115
116 let s = serde_json::to_string(&snap).unwrap();
117 let back: ConversationSnapshot = serde_json::from_str(&s).unwrap();
118 assert_eq!(back, snap);
119 }
120
121 #[test]
122 fn deserialize_rejects_unsupported_version() {
123 let bad = json!({
124 "version": 999,
125 "messages": [],
126 "pinned": []
127 })
128 .to_string();
129 let err = serde_json::from_str::<ConversationSnapshot>(&bad)
130 .expect_err("expected version mismatch error");
131 let msg = err.to_string();
132 assert!(
133 msg.contains("unsupported snapshot version 999"),
134 "unexpected error message: {msg}"
135 );
136 }
137
138 #[test]
139 fn deserialize_rejects_length_mismatch() {
140 let bad = json!({
141 "version": 1,
142 "messages": [{ "User": { "blocks": [{ "Text": { "text": "hi" } }] } }],
143 "pinned": []
144 })
145 .to_string();
146 let err = serde_json::from_str::<ConversationSnapshot>(&bad)
147 .expect_err("expected length mismatch error");
148 assert!(
149 err.to_string().contains("length mismatch"),
150 "unexpected error: {err}"
151 );
152 }
153
154 #[test]
155 fn new_rejects_length_mismatch() {
156 let err = ConversationSnapshot::new(vec![Message::user("hi")], vec![]).unwrap_err();
157 assert!(matches!(
158 err,
159 FromMessagesError::LengthMismatch {
160 messages: 1,
161 pinned: 0
162 }
163 ));
164 }
165}