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::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use qframe::storage::atomic_write;
18
19use crate::conversation::Conversation;
20
21/// The conversations of one working folder.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Store {
24    file: PathBuf,
25}
26
27impl Store {
28    /// The store for `folder` under `root`, the folder conversations are kept in.
29    #[must_use]
30    pub fn new(root: &Path, folder: &Path) -> Self {
31        let name = folder.file_name().map_or_else(|| "root".to_owned(), |name| name.to_string_lossy().into_owned());
32        let name: String =
33            name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
34        Self { file: root.join(format!("{name}-{:016x}.json", fnv(folder.as_os_str().as_encoded_bytes()))) }
35    }
36
37    /// The file the conversation is kept in.
38    #[must_use]
39    pub fn file(&self) -> &Path {
40        &self.file
41    }
42
43    /// The conversation kept for the folder; an empty one when there is none yet, or when the
44    /// file cannot be read as one (it is then left as it is, not overwritten until the next
45    /// save).
46    #[must_use]
47    pub fn load(&self) -> Conversation {
48        std::fs::read_to_string(&self.file)
49            .ok()
50            .and_then(|text| serde_json::from_str(&text).ok())
51            .map(|value| Conversation::from_json(&value))
52            .unwrap_or_default()
53    }
54
55    /// Writes `conversation` in one step, so a crash leaves the old file or the new one.
56    ///
57    /// # Errors
58    ///
59    /// When the folder cannot be made or the file cannot be written.
60    pub fn save(&self, conversation: &Conversation) -> io::Result<()> {
61        if let Some(parent) = self.file.parent() {
62            std::fs::create_dir_all(parent)?;
63        }
64        let text = serde_json::to_string_pretty(&conversation.to_json()).map_err(io::Error::other)?;
65        atomic_write(&self.file, text.as_bytes())
66    }
67
68    /// Moves the folder's conversation aside under a name with the time in it, so a new one can
69    /// start without the old one being lost. Nothing happens when there is none.
70    ///
71    /// # Errors
72    ///
73    /// When the file cannot be moved.
74    pub fn put_aside(&self) -> io::Result<()> {
75        if !self.file.exists() {
76            return Ok(());
77        }
78        let seconds = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_secs());
79        let stem = self.file.file_stem().map(|stem| stem.to_string_lossy().into_owned()).unwrap_or_default();
80        let mut aside = self.file.with_file_name(format!("{stem}-{seconds}.json"));
81        let mut extra = 1;
82        while aside.exists() {
83            aside = self.file.with_file_name(format!("{stem}-{seconds}-{extra}.json"));
84            extra += 1;
85        }
86        std::fs::rename(&self.file, aside)
87    }
88}
89
90/// The 64-bit FNV-1a hash of `bytes`: small, stable across versions and platforms, which a file
91/// name that must be found again needs more than strength.
92fn fnv(bytes: &[u8]) -> u64 {
93    bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn root(name: &str) -> PathBuf {
101        let root = std::env::temp_dir().join(format!("qcli-store-{name}-{}", std::process::id()));
102        let _ = std::fs::remove_dir_all(&root);
103        root
104    }
105
106    #[test]
107    fn a_folder_gets_its_conversation_back_and_two_folders_of_one_name_do_not_share() {
108        let root = root("round");
109        let store = Store::new(&root, Path::new("/work/my project"));
110        assert!(store.load().is_empty(), "nothing yet");
111        let mut conversation = Conversation::default();
112        conversation.say("hello");
113        store.save(&conversation).expect("saved");
114        assert_eq!(Store::new(&root, Path::new("/work/my project")).load(), conversation);
115        assert!(store.file().file_name().is_some_and(|name| name.to_string_lossy().starts_with("my_project-")));
116        assert_ne!(Store::new(&root, Path::new("/other/my project")).file(), store.file());
117        std::fs::remove_dir_all(&root).expect("clean up");
118    }
119
120    #[test]
121    fn a_new_conversation_puts_the_old_one_aside_and_a_broken_file_reads_as_empty() {
122        let root = root("aside");
123        let store = Store::new(&root, Path::new("/work/a"));
124        let mut conversation = Conversation::default();
125        conversation.say("keep me");
126        store.save(&conversation).expect("saved");
127        store.put_aside().expect("moved");
128        assert!(store.load().is_empty());
129        let kept: Vec<_> = std::fs::read_dir(&root).expect("folder").collect();
130        assert_eq!(kept.len(), 1, "the old conversation is still there");
131
132        std::fs::write(store.file(), "{ not json").expect("write");
133        assert!(store.load().is_empty());
134        std::fs::remove_dir_all(&root).expect("clean up");
135    }
136}