Skip to main content

qcli/
store.rs

1//! Where conversations are kept: one file for each working folder, so opening qcli in a folder
2//! goes on where the last time in that folder stopped.
3//!
4//! ```text
5//! ~/.local/state/quvyta/cli/conversations/
6//!     myproject-3f2a9c1d5e7b8a60.json                 the folder's conversation
7//!     myproject-3f2a9c1d5e7b8a60-1790000000.json      one put aside by a new conversation
8//! ```
9//!
10//! The name carries the folder's name, for a person looking through the files, and a hash of
11//! its whole path, since two folders can share a name.
12
13use 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/// The conversations of one working folder.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Store {
25    file: PathBuf,
26    /// The file is there but is not a conversation this program can read, so the next save keeps
27    /// it under a dated name instead of writing over it. [`load`](Self::load) sets this.
28    unreadable: bool,
29}
30
31impl Store {
32    /// The store for `folder` under `root`, the folder conversations are kept in.
33    #[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    /// The file the conversation is kept in.
43    #[must_use]
44    pub fn file(&self) -> &Path {
45        &self.file
46    }
47
48    /// The conversation kept for the folder; an empty one when there is none yet, or when the
49    /// file is not a conversation this program can read.
50    ///
51    /// Such a file is left exactly as it is: the next [`save`](Self::save) moves it aside under a
52    /// name with the time in it rather than writing over it, so a thread this program cannot read
53    /// is never lost.
54    pub fn load(&mut self) -> Conversation {
55        let Ok(text) = std::fs::read_to_string(&self.file) else {
56            // No file at all is a folder nobody has talked in yet. A file that is there and
57            // cannot be read is somebody's conversation in a shape this program does not know.
58            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    /// Writes `conversation` in one step, so a crash leaves the old file or the new one.
68    ///
69    /// A file that [`load`](Self::load) could not read as a conversation is moved aside first, the
70    /// way `ctrl+n` keeps the conversation it starts a new one over.
71    ///
72    /// # Errors
73    ///
74    /// When the folder cannot be made or the file cannot be written.
75    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            // What was read and what commands printed are in the conversations, secrets among
83            // them, so the folder is its owner's alone, also when an older version made it open.
84            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    /// Moves the folder's conversation aside under a name with the time in it, so a new one can
92    /// start without the old one being lost. Nothing happens when there is none.
93    ///
94    /// # Errors
95    ///
96    /// When the file cannot be moved.
97    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
113/// The 64-bit FNV-1a hash of `bytes`: small, stable across versions and platforms, which a file
114/// name that must be found again needs more than strength.
115fn 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    /// The store of `folder` with `root` made, the way a start finds it.
130    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    /// A conversation holds what was read and what commands printed, secrets among them: only
141    /// its owner can open the folder or the file, and a folder an older version made open is
142    /// closed on the next save.
143    #[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        // A folder that is not there yet is made that way.
158        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    /// Bytes that are a file and are not a conversation: nothing in them is anybody's thread.
199    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            // The default a person gets when the file cannot be read: an empty conversation.
216            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            // What was in the file is still there, under the dated name `put_aside` gives.
223            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}