use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use qframe::storage::atomic_write;
use crate::conversation::Conversation;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Store {
file: PathBuf,
}
impl Store {
#[must_use]
pub fn new(root: &Path, folder: &Path) -> Self {
let name = folder.file_name().map_or_else(|| "root".to_owned(), |name| name.to_string_lossy().into_owned());
let name: String =
name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
Self { file: root.join(format!("{name}-{:016x}.json", fnv(folder.as_os_str().as_encoded_bytes()))) }
}
#[must_use]
pub fn file(&self) -> &Path {
&self.file
}
#[must_use]
pub fn load(&self) -> Conversation {
std::fs::read_to_string(&self.file)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.map(|value| Conversation::from_json(&value))
.unwrap_or_default()
}
pub fn save(&self, conversation: &Conversation) -> io::Result<()> {
if let Some(parent) = self.file.parent() {
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(&conversation.to_json()).map_err(io::Error::other)?;
atomic_write(&self.file, text.as_bytes())
}
pub fn put_aside(&self) -> io::Result<()> {
if !self.file.exists() {
return Ok(());
}
let seconds = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_secs());
let stem = self.file.file_stem().map(|stem| stem.to_string_lossy().into_owned()).unwrap_or_default();
let mut aside = self.file.with_file_name(format!("{stem}-{seconds}.json"));
let mut extra = 1;
while aside.exists() {
aside = self.file.with_file_name(format!("{stem}-{seconds}-{extra}.json"));
extra += 1;
}
std::fs::rename(&self.file, aside)
}
}
fn fnv(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3))
}
#[cfg(test)]
mod tests {
use super::*;
fn root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("qcli-store-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
root
}
#[test]
fn a_folder_gets_its_conversation_back_and_two_folders_of_one_name_do_not_share() {
let root = root("round");
let store = Store::new(&root, Path::new("/work/my project"));
assert!(store.load().is_empty(), "nothing yet");
let mut conversation = Conversation::default();
conversation.say("hello");
store.save(&conversation).expect("saved");
assert_eq!(Store::new(&root, Path::new("/work/my project")).load(), conversation);
assert!(store.file().file_name().is_some_and(|name| name.to_string_lossy().starts_with("my_project-")));
assert_ne!(Store::new(&root, Path::new("/other/my project")).file(), store.file());
std::fs::remove_dir_all(&root).expect("clean up");
}
#[test]
fn a_new_conversation_puts_the_old_one_aside_and_a_broken_file_reads_as_empty() {
let root = root("aside");
let store = Store::new(&root, Path::new("/work/a"));
let mut conversation = Conversation::default();
conversation.say("keep me");
store.save(&conversation).expect("saved");
store.put_aside().expect("moved");
assert!(store.load().is_empty());
let kept: Vec<_> = std::fs::read_dir(&root).expect("folder").collect();
assert_eq!(kept.len(), 1, "the old conversation is still there");
std::fs::write(store.file(), "{ not json").expect("write");
assert!(store.load().is_empty());
std::fs::remove_dir_all(&root).expect("clean up");
}
}