use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use uuid::Uuid;
use crate::entities::chat::Chat;
use crate::entities::profile::Profile;
use crate::shared::config::AppConfig;
use crate::shared::paths::Paths;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatFileInfo {
pub id: Uuid,
pub mtime_ms: i64,
pub size: u64,
}
fn mtime_ms(meta: &fs::Metadata) -> i64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub struct JsonStore {
paths: Paths,
}
impl JsonStore {
pub fn new(paths: Paths) -> Self {
Self { paths }
}
pub fn app_dirs(&self) -> Vec<std::path::PathBuf> {
self.paths.app_dirs()
}
pub fn sandbox_dir(&self) -> std::path::PathBuf {
self.paths.sandbox_dir()
}
pub fn workspace_dir(&self) -> std::path::PathBuf {
self.paths.workspace_dir()
}
pub fn files_dir(&self) -> std::path::PathBuf {
self.paths.files_dir()
}
pub fn load_config(&self) -> Result<AppConfig> {
Ok(read_json(&self.paths.settings_file())?.unwrap_or_default())
}
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
write_json(&self.paths.settings_file(), config)
}
pub fn load_profiles(&self) -> Result<Vec<Profile>> {
Ok(read_json(&self.paths.profiles_file())?.unwrap_or_default())
}
pub fn save_profiles(&self, profiles: &[Profile]) -> Result<()> {
write_json(&self.paths.profiles_file(), &profiles)
}
pub fn upsert_profile(&self, profile: &Profile) -> Result<()> {
let mut profiles = self.load_profiles()?;
match profiles.iter_mut().find(|p| p.id == profile.id) {
Some(existing) => *existing = profile.clone(),
None => profiles.push(profile.clone()),
}
self.save_profiles(&profiles)
}
pub fn hide_profile(&self, id: Uuid) -> Result<bool> {
let mut profiles = self.load_profiles()?;
let Some(p) = profiles.iter_mut().find(|p| p.id == id) else {
return Ok(false);
};
p.is_hidden = true;
self.save_profiles(&profiles)?;
Ok(true)
}
pub fn save_chat(&self, chat: &Chat) -> Result<()> {
write_json(&self.paths.chat_file(&chat.id.to_string()), chat)
}
pub fn load_chat(&self, id: Uuid) -> Result<Option<Chat>> {
read_json(&self.paths.chat_file(&id.to_string()))
}
pub fn load_chats(&self) -> Result<Vec<Chat>> {
let dir = self.paths.chats_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut chats = Vec::new();
for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match read_json::<Chat>(&path) {
Ok(Some(chat)) => chats.push(chat),
Ok(None) => {}
Err(err) => {
tracing::warn!(file = %path.display(), error = %err,
"skipped a corrupted chat file");
}
}
}
Ok(chats)
}
pub fn chat_files(&self) -> Result<Vec<ChatFileInfo>> {
let dir = self.paths.chats_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(id) = path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| Uuid::parse_str(s).ok())
else {
continue;
};
let Ok(meta) = entry.metadata() else { continue };
out.push(ChatFileInfo {
id,
mtime_ms: mtime_ms(&meta),
size: meta.len(),
});
}
Ok(out)
}
pub fn chat_file_info(&self, id: Uuid) -> Option<ChatFileInfo> {
let meta = fs::metadata(self.paths.chat_file(&id.to_string())).ok()?;
Some(ChatFileInfo {
id,
mtime_ms: mtime_ms(&meta),
size: meta.len(),
})
}
pub fn hide_chat(&self, id: Uuid) -> Result<bool> {
let Some(mut chat) = self.load_chat(id)? else {
return Ok(false);
};
chat.is_hidden = true;
self.save_chat(&chat)?;
Ok(true)
}
pub fn hide_chats_of_profile(&self, profile_id: Uuid) -> Result<usize> {
let mut count = 0;
for mut chat in self.load_chats()? {
if chat.profile_id == profile_id && !chat.is_hidden {
chat.is_hidden = true;
self.save_chat(&chat)?;
count += 1;
}
}
Ok(count)
}
}
pub(crate) fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
match fs::read(path) {
Ok(bytes) => {
let value = serde_json::from_slice(&bytes)
.with_context(|| format!("parsing {}", path.display()))?;
Ok(Some(value))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
fn write_private(path: &Path, data: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(data)
}
#[cfg(not(unix))]
fs::write(path, data)
}
pub(crate) fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating dir {}", parent.display()))?;
}
if path.exists() {
let backup = path.with_extension("bak");
let _ = fs::copy(path, &backup);
}
let data = serde_json::to_vec_pretty(value).context("serializing to JSON")?;
let tmp = path.with_extension("tmp");
write_private(&tmp, &data).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("renaming into {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::message::Message;
fn store() -> (tempfile::TempDir, JsonStore) {
let dir = tempfile::tempdir().unwrap();
let store = JsonStore::new(Paths::with_root(dir.path()));
(dir, store)
}
#[test]
fn config_defaults_when_missing_then_roundtrips() {
let (_d, s) = store();
let cfg = s.load_config().unwrap();
assert_eq!(cfg, AppConfig::default());
let mut changed = cfg;
changed.max_tool_rounds = 3;
s.save_config(&changed).unwrap();
assert_eq!(s.load_config().unwrap().max_tool_rounds, 3);
}
#[test]
fn profile_upsert_and_hide() {
let (_d, s) = store();
let mut p = Profile::new("Joyce", "sys");
s.upsert_profile(&p).unwrap();
assert_eq!(s.load_profiles().unwrap().len(), 1);
p.name = "Joyce 2".into();
s.upsert_profile(&p).unwrap();
let all = s.load_profiles().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].name, "Joyce 2");
assert!(s.hide_profile(p.id).unwrap());
assert!(s.load_profiles().unwrap()[0].is_hidden);
assert!(!s.hide_profile(Uuid::new_v4()).unwrap());
}
#[test]
fn chat_save_load_list_hide() {
let (_d, s) = store();
let p = Profile::new("X", "s");
let chat = Chat::from_profile(&p, "Чат 1");
s.save_chat(&chat).unwrap();
assert_eq!(s.load_chat(chat.id).unwrap().unwrap().title, "Чат 1");
assert_eq!(s.load_chats().unwrap().len(), 1);
assert!(s.hide_chat(chat.id).unwrap());
assert!(s.load_chat(chat.id).unwrap().unwrap().is_hidden);
}
#[test]
fn chat_files_lists_ids_with_a_change_signal() {
let (d, s) = store();
let p = Profile::new("X", "s");
let a = Chat::from_profile(&p, "a");
let b = Chat::from_profile(&p, "b");
s.save_chat(&a).unwrap();
s.save_chat(&b).unwrap();
std::fs::write(d.path().join("chats").join("scratch.txt"), "x").unwrap();
std::fs::write(d.path().join("chats").join("readme.json"), "{}").unwrap();
let mut files = s.chat_files().unwrap();
files.sort_by_key(|f| f.id);
let mut expected = vec![a.id, b.id];
expected.sort();
assert_eq!(files.iter().map(|f| f.id).collect::<Vec<_>>(), expected);
assert!(files.iter().all(|f| f.size > 0), "{files:?}");
let before = s.chat_file_info(a.id).unwrap();
let mut grown = a.clone();
grown.push_message(Message::user("новое сообщение делает файл длиннее"));
s.save_chat(&grown).unwrap();
let after = s.chat_file_info(a.id).unwrap();
assert_ne!(before, after);
assert!(after.size > before.size);
}
#[test]
fn chat_files_is_empty_without_a_chats_dir() {
let dir = tempfile::tempdir().unwrap();
let s = JsonStore::new(Paths::with_root(dir.path().join("nothing-here")));
assert!(s.chat_files().unwrap().is_empty());
assert_eq!(s.chat_file_info(Uuid::new_v4()), None);
}
#[test]
fn hide_chats_of_profile_cascades() {
let (_d, s) = store();
let p1 = Profile::new("A", "s");
let p2 = Profile::new("B", "s");
s.save_chat(&Chat::from_profile(&p1, "c1")).unwrap();
s.save_chat(&Chat::from_profile(&p1, "c2")).unwrap();
s.save_chat(&Chat::from_profile(&p2, "c3")).unwrap();
assert_eq!(s.hide_chats_of_profile(p1.id).unwrap(), 2);
let visible = s
.load_chats()
.unwrap()
.into_iter()
.filter(|c| !c.is_hidden)
.count();
assert_eq!(visible, 1);
}
#[test]
fn write_creates_backup_of_previous() {
let (d, s) = store();
s.save_config(&AppConfig::default()).unwrap();
let c = AppConfig {
max_tool_rounds: 99,
..Default::default()
};
s.save_config(&c).unwrap();
assert!(d.path().join("settings.bak").exists());
}
#[test]
fn chat_save_backs_up_previous_version() {
let (_d, s) = store();
let p = Profile::new("X", "s");
let mut chat = Chat::from_profile(&p, "Чат");
s.save_chat(&chat).unwrap();
chat.push_message(Message::user("привет"));
s.save_chat(&chat).unwrap();
let bak = s
.paths
.chat_file(&chat.id.to_string())
.with_extension("bak");
assert!(bak.exists(), "expected a chat file backup");
let backed: Chat = serde_json::from_slice(&fs::read(&bak).unwrap()).unwrap();
assert!(backed.messages.is_empty());
}
#[cfg(unix)]
#[test]
fn a_written_file_is_readable_by_its_owner_alone() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
write_json(
&path,
&serde_json::json!({"api_keys": {"openai": "secret"}}),
)
.unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "{mode:o}");
}
}