1use 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#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Store {
24 file: PathBuf,
25}
26
27impl Store {
28 #[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 #[must_use]
39 pub fn file(&self) -> &Path {
40 &self.file
41 }
42
43 #[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 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 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
90fn 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}