1use std::io;
14use std::os::unix::fs::PermissionsExt as _;
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use qframe::storage::atomic_write;
19
20use crate::conversation::Conversation;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Store {
25 file: PathBuf,
26 unreadable: bool,
29}
30
31impl Store {
32 #[must_use]
34 pub fn new(root: &Path, folder: &Path) -> Self {
35 let name = folder.file_name().map_or_else(|| "root".to_owned(), |name| name.to_string_lossy().into_owned());
36 let name: String =
37 name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
38 let file = root.join(format!("{name}-{:016x}.json", fnv(folder.as_os_str().as_encoded_bytes())));
39 Self { file, unreadable: false }
40 }
41
42 #[must_use]
44 pub fn file(&self) -> &Path {
45 &self.file
46 }
47
48 pub fn load(&mut self) -> Conversation {
55 let Ok(text) = std::fs::read_to_string(&self.file) else {
56 self.unreadable = std::fs::symlink_metadata(&self.file).is_ok();
59 return Conversation::default();
60 };
61 let conversation =
62 serde_json::from_str(&text).ok().filter(Conversation::holds).map(|value| Conversation::from_json(&value));
63 self.unreadable = conversation.is_none();
64 conversation.unwrap_or_default()
65 }
66
67 pub fn save(&mut self, conversation: &Conversation) -> io::Result<()> {
76 if self.unreadable {
77 self.put_aside()?;
78 self.unreadable = false;
79 }
80 if let Some(parent) = self.file.parent() {
81 std::fs::create_dir_all(parent)?;
82 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
85 }
86 let text = serde_json::to_string_pretty(&conversation.to_json()).map_err(io::Error::other)?;
87 atomic_write(&self.file, text.as_bytes())?;
88 std::fs::set_permissions(&self.file, std::fs::Permissions::from_mode(0o600))
89 }
90
91 pub fn put_aside(&self) -> io::Result<()> {
98 if !self.file.exists() {
99 return Ok(());
100 }
101 let seconds = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_secs());
102 let stem = self.file.file_stem().map(|stem| stem.to_string_lossy().into_owned()).unwrap_or_default();
103 let mut aside = self.file.with_file_name(format!("{stem}-{seconds}.json"));
104 let mut extra = 1;
105 while aside.exists() {
106 aside = self.file.with_file_name(format!("{stem}-{seconds}-{extra}.json"));
107 extra += 1;
108 }
109 std::fs::rename(&self.file, aside)
110 }
111}
112
113fn fnv(bytes: &[u8]) -> u64 {
116 bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3))
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 fn root(name: &str) -> PathBuf {
124 let root = std::env::temp_dir().join(format!("qcli-store-{name}-{}", std::process::id()));
125 let _ = std::fs::remove_dir_all(&root);
126 root
127 }
128
129 fn store_in(root: &Path, folder: &str) -> Store {
131 std::fs::create_dir_all(root).expect("folder");
132 Store::new(root, Path::new(folder))
133 }
134
135 fn mode(path: &Path) -> u32 {
136 use std::os::unix::fs::PermissionsExt as _;
137 std::fs::metadata(path).expect("there").permissions().mode() & 0o777
138 }
139
140 #[test]
144 fn conversations_are_kept_where_only_their_owner_can_read_them() {
145 use std::os::unix::fs::PermissionsExt as _;
146 let base = root("private");
147 let conversations = base.join("conversations");
148 std::fs::create_dir_all(&conversations).expect("folder");
149 std::fs::set_permissions(&conversations, std::fs::Permissions::from_mode(0o755)).expect("open, as before");
150 let mut store = Store::new(&conversations, Path::new("/work/secret"));
151 let mut conversation = Conversation::default();
152 conversation.say("the token is in .env");
153 store.save(&conversation).expect("saved");
154 assert_eq!(mode(&conversations), 0o700, "the folder is its owner's alone");
155 assert_eq!(mode(store.file()), 0o600, "and so is the file");
156
157 let fresh = base.join("fresh").join("conversations");
159 let mut store = Store::new(&fresh, Path::new("/work/secret"));
160 store.save(&conversation).expect("saved");
161 assert_eq!(mode(&fresh), 0o700);
162 assert_eq!(mode(store.file()), 0o600);
163 let _ = std::fs::remove_dir_all(&base);
164 }
165
166 #[test]
167 fn a_folder_gets_its_conversation_back_and_two_folders_of_one_name_do_not_share() {
168 let root = root("round");
169 let mut store = store_in(&root, "/work/my project");
170 assert!(store.load().is_empty(), "nothing yet");
171 let mut conversation = Conversation::default();
172 conversation.say("hello");
173 store.save(&conversation).expect("saved");
174 let mut again = Store::new(&root, Path::new("/work/my project"));
175 assert_eq!(again.load(), conversation);
176 assert!(store.file().file_name().is_some_and(|name| name.to_string_lossy().starts_with("my_project-")));
177 assert_ne!(Store::new(&root, Path::new("/other/my project")).file(), store.file());
178 std::fs::remove_dir_all(&root).expect("clean up");
179 }
180
181 #[test]
182 fn a_new_conversation_puts_the_old_one_aside_and_a_broken_file_reads_as_empty() {
183 let root = root("aside");
184 let mut store = store_in(&root, "/work/a");
185 let mut conversation = Conversation::default();
186 conversation.say("keep me");
187 store.save(&conversation).expect("saved");
188 store.put_aside().expect("moved");
189 assert!(store.load().is_empty());
190 let kept: Vec<_> = std::fs::read_dir(&root).expect("folder").collect();
191 assert_eq!(kept.len(), 1, "the old conversation is still there");
192
193 std::fs::write(store.file(), "{ not json").expect("write");
194 assert!(store.load().is_empty());
195 std::fs::remove_dir_all(&root).expect("clean up");
196 }
197
198 const NOT_CONVERSATIONS: [(&str, &[u8]); 6] = [
200 ("empty", b""),
201 ("one-byte", b"x"),
202 ("latin1", &[0xff, 0xfe, 0x7b, 0x7d]),
203 ("list", b"[1,2,3]"),
204 ("messages-number", br#"{"messages": 5}"#),
205 ("half-json", br#"{"messages": [{"role": "user", "content": [{"type": "text", "text": "cut off here"#),
206 ];
207
208 #[test]
209 fn a_file_that_is_not_a_conversation_reads_as_empty_and_is_never_written_over() {
210 for (name, bytes) in NOT_CONVERSATIONS {
211 let root = root(&format!("not-{name}"));
212 let mut store = store_in(&root, "/work/a");
213 std::fs::write(store.file(), bytes).expect("write");
214
215 assert!(store.load().is_empty(), "{name}: nothing to show");
217
218 let mut conversation = Conversation::default();
219 conversation.say("a new thread");
220 store.save(&conversation).expect("saved");
221
222 let others: Vec<PathBuf> = std::fs::read_dir(&root)
224 .expect("folder")
225 .map(|entry| entry.expect("an entry").path())
226 .filter(|path| path != store.file())
227 .collect();
228 assert_eq!(others.len(), 1, "{name}: the file is kept, not overwritten");
229 assert_eq!(std::fs::read(&others[0]).expect("read"), bytes, "{name}: the very same bytes");
230
231 let mut again = Store::new(&root, Path::new("/work/a"));
232 assert_eq!(again.load(), conversation, "{name}: the new thread is saved in its place");
233 std::fs::remove_dir_all(&root).expect("clean up");
234 }
235 }
236
237 #[test]
238 fn a_saved_conversation_is_written_over_plainly_the_next_time() {
239 let root = root("save-twice");
240 let mut store = store_in(&root, "/work/a");
241 std::fs::write(store.file(), "not a conversation").expect("write");
242 assert!(store.load().is_empty());
243 let mut conversation = Conversation::default();
244 conversation.say("first");
245 store.save(&conversation).expect("saved");
246 conversation.say("second");
247 store.save(&conversation).expect("saved again");
248 assert_eq!(std::fs::read_dir(&root).expect("folder").count(), 2, "the kept file and the conversation");
249 assert_eq!(store.load(), conversation, "the last save is the file now");
250 std::fs::remove_dir_all(&root).expect("clean up");
251 }
252
253 #[test]
254 fn a_hundred_thousand_messages_come_back_one_by_one() {
255 let root = root("many-messages");
256 let mut store = store_in(&root, "/work/a");
257 let mut file = std::fs::File::create(store.file()).expect("create");
258 {
259 use std::io::Write as _;
260 file.write_all(br#"{"version": 1, "messages": ["#).expect("write");
261 for number in 0..100_000 {
262 let message = format!(r#"{{"role": "user", "content": [{{"type": "text", "text": "m{number}"}}]}}"#);
263 file.write_all(message.as_bytes()).expect("write");
264 file.write_all(if number + 1 == 100_000 { b"]}" } else { b"," }).expect("write");
265 }
266 }
267 drop(file);
268 let conversation = store.load();
269 assert_eq!(conversation.messages().len(), 100_000, "every message of the file");
270 assert_eq!(conversation.messages()[0]["content"][0]["text"], "m0");
271 assert_eq!(conversation.messages()[99_999]["content"][0]["text"], "m99999");
272 std::fs::remove_dir_all(&root).expect("clean up");
273 }
274
275 #[test]
276 fn a_ten_megabyte_conversation_file_comes_back_whole() {
277 let root = root("ten-megabytes");
278 let mut store = store_in(&root, "/work/a");
279 let piece = "y".repeat(64 * 1024);
280 let mut file = std::fs::File::create(store.file()).expect("create");
281 {
282 use std::io::Write as _;
283 file.write_all(br#"{"version": 1, "messages": [{"role": "user", "content": [{"type": "text", "text": ""#)
284 .expect("write");
285 for _ in 0..160 {
286 file.write_all(piece.as_bytes()).expect("write");
287 }
288 file.write_all(b"\"}]}]}").expect("write");
289 }
290 drop(file);
291 assert!(std::fs::metadata(store.file()).expect("the file").len() >= 10 * 1024 * 1024, "ten megabytes");
292 let conversation = store.load();
293 let text = conversation.messages()[0]["content"][0]["text"].as_str().expect("the text");
294 assert_eq!(text.len(), 160 * piece.len(), "nothing of it was cut");
295 std::fs::remove_dir_all(&root).expect("clean up");
296 }
297
298 #[test]
299 fn one_message_of_five_thousand_calls_comes_back_with_every_call_answered() {
300 let root = root("many-blocks");
301 let mut store = store_in(&root, "/work/a");
302 let mut file = String::from(r#"{"version": 1, "messages": [{"role": "assistant", "content": ["#);
303 for number in 0..5000 {
304 file.push_str(&format!(
305 r#"{{"type": "tool_use", "id": "c{number}", "name": "list_dir", "input": {{"path": "."}}}}"#
306 ));
307 if number + 1 < 5000 {
308 file.push(',');
309 }
310 }
311 file.push_str("]}]}");
312 std::fs::write(store.file(), file).expect("write");
313
314 let conversation = store.load();
315 assert!(conversation.open_calls().is_empty(), "no call is left waiting");
316 let answered = conversation.messages().last().expect("the results");
317 let ids: Vec<_> = crate::conversation::blocks(answered)
318 .map(|block| block["tool_use_id"].as_str().unwrap_or_default().to_owned())
319 .collect();
320 assert_eq!(ids.len(), 5000, "every call of the reply is answered");
321 assert_eq!((ids.first().map(String::as_str), ids.last().map(String::as_str)), (Some("c0"), Some("c4999")));
322 std::fs::remove_dir_all(&root).expect("clean up");
323 }
324}