use crate::crdt::{TaskList, TaskListId};
use std::path::PathBuf;
use tokio::fs;
#[derive(Debug, Clone)]
pub struct TaskListStorage {
storage_path: PathBuf,
}
impl TaskListStorage {
#[must_use]
pub fn new(storage_path: PathBuf) -> Self {
Self { storage_path }
}
pub async fn save_task_list(
&self,
list_id: &TaskListId,
task_list: &TaskList,
) -> crate::crdt::error::Result<()> {
fs::create_dir_all(&self.storage_path).await?;
let serialized =
bincode::serialize(task_list).map_err(crate::crdt::error::CrdtError::Serialization)?;
let file_path = self.list_file_path(list_id);
let temp_path = file_path.with_extension("tmp");
fs::write(&temp_path, &serialized).await?;
fs::rename(&temp_path, &file_path).await?;
Ok(())
}
pub async fn load_task_list(
&self,
list_id: &TaskListId,
) -> crate::crdt::error::Result<TaskList> {
let file_path = self.list_file_path(list_id);
let serialized = fs::read(&file_path).await?;
bincode::deserialize(&serialized).map_err(crate::crdt::error::CrdtError::Serialization)
}
pub async fn list_task_lists(&self) -> crate::crdt::error::Result<Vec<String>> {
if !self.storage_path.exists() {
return Ok(Vec::new());
}
let mut dir_entries = fs::read_dir(&self.storage_path).await?;
let mut list_ids = Vec::new();
while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "tmp") {
continue;
}
if path.extension().is_some_and(|ext| ext == "bin") {
if let Some(file_name) = path.file_stem() {
if let Some(id_str) = file_name.to_str() {
list_ids.push(id_str.to_string());
}
}
}
}
Ok(list_ids)
}
pub async fn delete_task_list(&self, list_id: &TaskListId) -> crate::crdt::error::Result<()> {
let file_path = self.list_file_path(list_id);
fs::remove_file(file_path).await?;
Ok(())
}
fn list_file_path(&self, list_id: &TaskListId) -> PathBuf {
self.storage_path.join(format!("{}.bin", list_id))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::crdt::task_list::TaskListId;
use crate::crdt::TaskList;
use saorsa_gossip_types::PeerId;
fn test_peer_id() -> PeerId {
PeerId::new([0xBB; 32])
}
fn test_list_id(byte: u8) -> TaskListId {
TaskListId::new([byte; 32])
}
fn create_test_list(id: TaskListId, name: &str) -> TaskList {
TaskList::new(id, name.to_string(), test_peer_id())
}
#[tokio::test]
async fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let storage = TaskListStorage::new(dir.path().to_path_buf());
let list_id = test_list_id(0x01);
let list = create_test_list(list_id, "test-list");
storage.save_task_list(&list_id, &list).await.unwrap();
let loaded = storage.load_task_list(&list_id).await.unwrap();
assert_eq!(loaded.id(), list.id());
assert_eq!(loaded.name(), "test-list");
}
#[tokio::test]
async fn load_nonexistent_returns_error() {
let dir = tempfile::tempdir().unwrap();
let storage = TaskListStorage::new(dir.path().to_path_buf());
let list_id = test_list_id(0x02);
let result = storage.load_task_list(&list_id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn list_task_lists_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let storage = TaskListStorage::new(dir.path().to_path_buf());
let lists = storage.list_task_lists().await.unwrap();
assert!(lists.is_empty());
}
#[tokio::test]
async fn list_task_lists_after_save() {
let dir = tempfile::tempdir().unwrap();
let storage = TaskListStorage::new(dir.path().to_path_buf());
let list_id = test_list_id(0x03);
let list = create_test_list(list_id, "list-me");
storage.save_task_list(&list_id, &list).await.unwrap();
let lists = storage.list_task_lists().await.unwrap();
assert_eq!(lists.len(), 1);
assert!(lists[0].contains("03"));
}
#[tokio::test]
async fn delete_task_list_removes_file() {
let dir = tempfile::tempdir().unwrap();
let storage = TaskListStorage::new(dir.path().to_path_buf());
let list_id = test_list_id(0x04);
let list = create_test_list(list_id, "delete-me");
storage.save_task_list(&list_id, &list).await.unwrap();
storage.delete_task_list(&list_id).await.unwrap();
let result = storage.load_task_list(&list_id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn save_creates_directory_automatically() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("nested").join("deep");
let storage = TaskListStorage::new(nested);
let list_id = test_list_id(0x05);
let list = create_test_list(list_id, "nested-test");
storage.save_task_list(&list_id, &list).await.unwrap();
let loaded = storage.load_task_list(&list_id).await.unwrap();
assert_eq!(loaded.name(), "nested-test");
}
#[tokio::test]
async fn list_skips_tmp_files() {
let dir = tempfile::tempdir().unwrap();
let storage = TaskListStorage::new(dir.path().to_path_buf());
let list_id = test_list_id(0x06);
let list = create_test_list(list_id, "tmp-skip");
storage.save_task_list(&list_id, &list).await.unwrap();
let tmp_path = dir.path().join(format!("{}.tmp", list_id));
tokio::fs::write(&tmp_path, b"garbage").await.unwrap();
let lists = storage.list_task_lists().await.unwrap();
assert_eq!(lists.len(), 1); }
#[tokio::test]
async fn multiple_lists_independent() {
let dir = tempfile::tempdir().unwrap();
let storage = TaskListStorage::new(dir.path().to_path_buf());
let id_a = test_list_id(0x0A);
let id_b = test_list_id(0x0B);
let list_a = create_test_list(id_a, "list-a");
let list_b = create_test_list(id_b, "list-b");
storage.save_task_list(&id_a, &list_a).await.unwrap();
storage.save_task_list(&id_b, &list_b).await.unwrap();
let loaded_a = storage.load_task_list(&id_a).await.unwrap();
let loaded_b = storage.load_task_list(&id_b).await.unwrap();
assert_eq!(loaded_a.name(), "list-a");
assert_eq!(loaded_b.name(), "list-b");
let lists = storage.list_task_lists().await.unwrap();
assert_eq!(lists.len(), 2);
}
}