onyx-tasks/crates/onyx-core/src/storage.rs
Tristan Michael 72475a552a fix: use has_time flag for due date time tracking
Replace the hours==0 && minutes==0 heuristic with an explicit has_time
bool field on Task. Existing files without the field deserialize as false
(date-only), preserving current behavior. Frontend components pass and
receive has_time through DateTimePicker's onchange callback.
2026-04-01 01:06:10 -07:00

819 lines
28 KiB
Rust

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{Error, Result};
use crate::models::{Task, TaskList, TaskStatus};
/// Metadata stored in root .metadata.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootMetadata {
pub version: u32,
pub list_order: Vec<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_opened_list: Option<Uuid>,
}
impl Default for RootMetadata {
fn default() -> Self {
Self {
version: 1,
list_order: Vec::new(),
last_opened_list: None,
}
}
}
/// Metadata stored in each list's .listdata.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMetadata {
pub id: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub group_by_due_date: bool,
pub task_order: Vec<Uuid>,
}
impl ListMetadata {
pub fn new(id: Uuid) -> Self {
let now = Utc::now();
Self {
id,
created_at: now,
updated_at: now,
group_by_due_date: false,
task_order: Vec::new(),
}
}
}
/// Frontmatter for task markdown files
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskFrontmatter {
pub id: Uuid,
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub due: Option<DateTime<Utc>>,
#[serde(default)]
pub has_time: bool,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<Uuid>,
}
impl From<&Task> for TaskFrontmatter {
fn from(task: &Task) -> Self {
Self {
id: task.id,
status: task.status,
due: task.due_date,
has_time: task.has_time,
created: task.created_at,
updated: task.updated_at,
parent: task.parent_id,
}
}
}
pub trait Storage {
fn read_task(&self, list_id: Uuid, task_id: Uuid) -> Result<Task>;
fn write_task(&mut self, list_id: Uuid, task: &Task) -> Result<()>;
fn delete_task(&mut self, list_id: Uuid, task_id: Uuid) -> Result<()>;
fn list_tasks(&self, list_id: Uuid) -> Result<Vec<Task>>;
fn create_list(&mut self, name: String) -> Result<TaskList>;
fn get_lists(&self) -> Result<Vec<TaskList>>;
fn delete_list(&mut self, list_id: Uuid) -> Result<()>;
fn read_root_metadata(&self) -> Result<RootMetadata>;
fn write_root_metadata(&mut self, metadata: &RootMetadata) -> Result<()>;
fn rename_list(&mut self, list_id: Uuid, new_name: String) -> Result<()>;
fn read_list_metadata(&self, list_id: Uuid) -> Result<ListMetadata>;
fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()>;
}
#[derive(Debug)]
pub struct FileSystemStorage {
root_path: PathBuf,
}
impl FileSystemStorage {
pub fn new(root_path: PathBuf) -> Result<Self> {
if !root_path.exists() {
return Err(Error::NotFound(format!("Path does not exist: {:?}", root_path)));
}
Ok(Self { root_path })
}
pub fn init(root_path: PathBuf) -> Result<Self> {
fs::create_dir_all(&root_path)?;
let storage = Self { root_path };
// Create default metadata if it doesn't exist
if !storage.metadata_path().exists() {
storage.write_root_metadata_internal(&RootMetadata::default())?;
}
Ok(storage)
}
fn metadata_path(&self) -> PathBuf {
self.root_path.join(".metadata.json")
}
fn list_dir_path(&self, list_id: Uuid) -> Result<PathBuf> {
// Find the directory with this list ID
let entries = fs::read_dir(&self.root_path)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let listdata_path = path.join(".listdata.json");
if listdata_path.exists() {
let content = fs::read_to_string(&listdata_path)?;
let list_metadata: ListMetadata = serde_json::from_str(&content)?;
if list_metadata.id == list_id {
return Ok(path);
}
}
}
}
Err(Error::ListNotFound(list_id.to_string()))
}
fn list_dir_path_by_name(&self, name: &str) -> PathBuf {
self.root_path.join(name)
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
'\0'..='\x1f' => '_',
_ => c,
})
.collect::<String>()
.trim_matches(|c: char| c == '.' || c == ' ')
.to_string()
}
fn task_file_path(&self, list_dir: &Path, task: &Task) -> PathBuf {
let safe_title = Self::sanitize_filename(&task.title);
let filename = if safe_title.is_empty() {
task.id.to_string()
} else {
safe_title
};
list_dir.join(format!("{}.md", filename))
}
fn parse_markdown_with_frontmatter(&self, content: &str) -> Result<(TaskFrontmatter, String)> {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() || lines[0] != "---" {
return Err(Error::InvalidData("Missing frontmatter delimiter".to_string()));
}
// Find closing ---
let end_idx = lines[1..]
.iter()
.position(|&line| line == "---")
.ok_or_else(|| Error::InvalidData("Missing closing frontmatter delimiter".to_string()))?;
let frontmatter_lines = &lines[1..=end_idx];
let frontmatter_str = frontmatter_lines.join("\n");
let frontmatter: TaskFrontmatter = serde_yaml::from_str(&frontmatter_str)?;
let description = if end_idx + 2 < lines.len() {
lines[end_idx + 2..].join("\n")
} else {
String::new()
};
Ok((frontmatter, description.trim().to_string()))
}
fn write_markdown_with_frontmatter(&self, task: &Task) -> Result<String> {
let frontmatter = TaskFrontmatter::from(task);
let yaml = serde_yaml::to_string(&frontmatter)?;
let mut content = String::new();
content.push_str("---\n");
content.push_str(&yaml);
content.push_str("---\n\n");
content.push_str(&task.description);
Ok(content)
}
fn read_root_metadata_internal(&self) -> Result<RootMetadata> {
let path = self.metadata_path();
if !path.exists() {
return Ok(RootMetadata::default());
}
let content = fs::read_to_string(&path)?;
let metadata = serde_json::from_str(&content)?;
Ok(metadata)
}
fn write_root_metadata_internal(&self, metadata: &RootMetadata) -> Result<()> {
let path = self.metadata_path();
let content = serde_json::to_string_pretty(&metadata)?;
fs::write(&path, content)?;
Ok(())
}
}
impl Storage for FileSystemStorage {
fn read_task(&self, list_id: Uuid, task_id: Uuid) -> Result<Task> {
let list_dir = self.list_dir_path(list_id)?;
// Read all task files in the list directory
let entries = fs::read_dir(&list_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
let content = fs::read_to_string(&path)?;
let (frontmatter, description) = self.parse_markdown_with_frontmatter(&content)?;
if frontmatter.id == task_id {
let title = path.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| Error::InvalidData("Invalid filename".to_string()))?
.to_string();
return Ok(Task {
id: frontmatter.id,
title,
description,
status: frontmatter.status,
due_date: frontmatter.due,
has_time: frontmatter.has_time,
created_at: frontmatter.created,
updated_at: frontmatter.updated,
parent_id: frontmatter.parent,
});
}
}
}
Err(Error::TaskNotFound(task_id.to_string()))
}
fn write_task(&mut self, list_id: Uuid, task: &Task) -> Result<()> {
let list_dir = self.list_dir_path(list_id)?;
let task_path = self.task_file_path(&list_dir, task);
// Remove old file if task was renamed (different filename, same ID)
for entry in fs::read_dir(&list_dir)? {
let entry = entry?;
let path = entry.path();
if path == task_path { continue; }
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok((fm, _)) = self.parse_markdown_with_frontmatter(&content) {
if fm.id == task.id {
fs::remove_file(&path)?;
break;
}
}
}
}
}
let content = self.write_markdown_with_frontmatter(task)?;
fs::write(&task_path, content)?;
// Update list metadata to include this task in task_order if not already present
let mut list_metadata = self.read_list_metadata(list_id)?;
if !list_metadata.task_order.contains(&task.id) {
list_metadata.task_order.push(task.id);
list_metadata.updated_at = Utc::now();
self.write_list_metadata(&list_metadata)?;
}
Ok(())
}
fn delete_task(&mut self, list_id: Uuid, task_id: Uuid) -> Result<()> {
let task = self.read_task(list_id, task_id)?;
let list_dir = self.list_dir_path(list_id)?;
let task_path = self.task_file_path(&list_dir, &task);
fs::remove_file(&task_path)?;
// Remove from task_order
let mut list_metadata = self.read_list_metadata(list_id)?;
list_metadata.task_order.retain(|&id| id != task_id);
list_metadata.updated_at = Utc::now();
self.write_list_metadata(&list_metadata)?;
Ok(())
}
fn list_tasks(&self, list_id: Uuid) -> Result<Vec<Task>> {
let list_dir = self.list_dir_path(list_id)?;
let list_metadata = self.read_list_metadata(list_id)?;
let mut tasks = Vec::new();
let entries = fs::read_dir(&list_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
let content = fs::read_to_string(&path)?;
let (frontmatter, description) = self.parse_markdown_with_frontmatter(&content)?;
let title = path.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| Error::InvalidData("Invalid filename".to_string()))?
.to_string();
let task = Task {
id: frontmatter.id,
title,
description,
status: frontmatter.status,
due_date: frontmatter.due,
has_time: frontmatter.has_time,
created_at: frontmatter.created,
updated_at: frontmatter.updated,
parent_id: frontmatter.parent,
};
tasks.push(task);
}
}
// Sort by task_order
let order_map: HashMap<Uuid, usize> = list_metadata.task_order
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
tasks.sort_by_key(|task| order_map.get(&task.id).copied().unwrap_or(usize::MAX));
Ok(tasks)
}
fn create_list(&mut self, name: String) -> Result<TaskList> {
let list_dir = self.list_dir_path_by_name(&name);
if list_dir.exists() {
return Err(Error::InvalidData(format!("List '{}' already exists", name)));
}
fs::create_dir_all(&list_dir)?;
let list_id = Uuid::new_v4();
let list_metadata = ListMetadata::new(list_id);
let metadata_path = list_dir.join(".listdata.json");
let content = serde_json::to_string_pretty(&list_metadata)?;
fs::write(&metadata_path, content)?;
// Add to root metadata
let mut root_metadata = self.read_root_metadata_internal()?;
root_metadata.list_order.push(list_id);
if root_metadata.last_opened_list.is_none() {
root_metadata.last_opened_list = Some(list_id);
}
self.write_root_metadata_internal(&root_metadata)?;
let task_list = TaskList {
id: list_id,
title: name,
tasks: Vec::new(),
created_at: list_metadata.created_at,
updated_at: list_metadata.updated_at,
group_by_due_date: list_metadata.group_by_due_date,
};
Ok(task_list)
}
fn get_lists(&self) -> Result<Vec<TaskList>> {
let root_metadata = self.read_root_metadata_internal()?;
let mut lists = Vec::new();
let entries = fs::read_dir(&self.root_path)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let listdata_path = path.join(".listdata.json");
if listdata_path.exists() {
let content = fs::read_to_string(&listdata_path)?;
let list_metadata: ListMetadata = serde_json::from_str(&content)?;
let title = path.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| Error::InvalidData("Invalid directory name".to_string()))?
.to_string();
let tasks = self.list_tasks(list_metadata.id)?;
let task_list = TaskList {
id: list_metadata.id,
title,
tasks,
created_at: list_metadata.created_at,
updated_at: list_metadata.updated_at,
group_by_due_date: list_metadata.group_by_due_date,
};
lists.push(task_list);
}
}
}
// Sort by list_order
let order_map: HashMap<Uuid, usize> = root_metadata.list_order
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
lists.sort_by_key(|list| order_map.get(&list.id).copied().unwrap_or(usize::MAX));
Ok(lists)
}
fn delete_list(&mut self, list_id: Uuid) -> Result<()> {
let list_dir = self.list_dir_path(list_id)?;
fs::remove_dir_all(&list_dir)?;
// Remove from root metadata
let mut root_metadata = self.read_root_metadata_internal()?;
root_metadata.list_order.retain(|&id| id != list_id);
if root_metadata.last_opened_list == Some(list_id) {
root_metadata.last_opened_list = root_metadata.list_order.first().copied();
}
self.write_root_metadata_internal(&root_metadata)?;
Ok(())
}
fn rename_list(&mut self, list_id: Uuid, new_name: String) -> Result<()> {
let old_dir = self.list_dir_path(list_id)?;
let new_dir = self.list_dir_path_by_name(&new_name);
if new_dir.exists() {
return Err(Error::InvalidData(format!("A list named '{}' already exists", new_name)));
}
fs::rename(&old_dir, &new_dir)?;
// Update metadata timestamp
let metadata_path = new_dir.join(".listdata.json");
let content = fs::read_to_string(&metadata_path)?;
let mut metadata: ListMetadata = serde_json::from_str(&content)?;
metadata.updated_at = Utc::now();
let json = serde_json::to_string_pretty(&metadata)?;
fs::write(&metadata_path, json)?;
Ok(())
}
fn read_root_metadata(&self) -> Result<RootMetadata> {
self.read_root_metadata_internal()
}
fn write_root_metadata(&mut self, metadata: &RootMetadata) -> Result<()> {
self.write_root_metadata_internal(metadata)
}
fn read_list_metadata(&self, list_id: Uuid) -> Result<ListMetadata> {
let list_dir = self.list_dir_path(list_id)?;
let metadata_path = list_dir.join(".listdata.json");
if !metadata_path.exists() {
return Err(Error::NotFound(format!("List metadata not found: {}", list_id)));
}
let content = fs::read_to_string(&metadata_path)?;
let metadata = serde_json::from_str(&content)?;
Ok(metadata)
}
fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()> {
let list_dir = self.list_dir_path(metadata.id)?;
let metadata_path = list_dir.join(".listdata.json");
let content = serde_json::to_string_pretty(&metadata)?;
fs::write(&metadata_path, content)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Task;
use tempfile::TempDir;
fn init_storage(temp_dir: &TempDir) -> FileSystemStorage {
FileSystemStorage::init(temp_dir.path().to_path_buf()).unwrap()
}
// --- Frontmatter parsing ---
#[test]
fn test_parse_valid_frontmatter() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\n\nSome description";
let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(fm.status, TaskStatus::Backlog);
assert_eq!(desc, "Some description");
}
#[test]
fn test_parse_frontmatter_no_body() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: completed\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---";
let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.status, TaskStatus::Completed);
assert!(desc.is_empty());
}
#[test]
fn test_parse_frontmatter_missing_opening_delimiter() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let result = storage.parse_markdown_with_frontmatter("no frontmatter here");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_parse_frontmatter_missing_closing_delimiter() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\n";
let result = storage.parse_markdown_with_frontmatter(content);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_parse_frontmatter_empty_content() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let result = storage.parse_markdown_with_frontmatter("");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_parse_frontmatter_invalid_yaml() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\n: : : not valid yaml\n---\n";
let result = storage.parse_markdown_with_frontmatter(content);
assert!(result.is_err());
}
#[test]
fn test_parse_frontmatter_with_optional_fields() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ndue: 2026-06-15T12:00:00Z\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\nparent: 660e8400-e29b-41d4-a716-446655440001\n---\n\nNotes";
let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert!(fm.due.is_some());
assert!(fm.parent.is_some());
}
// --- Markdown write/read roundtrip ---
#[test]
fn test_markdown_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let task = Task::new("Test".to_string())
.with_description("Line 1\n\nLine 3".to_string());
let markdown = storage.write_markdown_with_frontmatter(&task).unwrap();
let (fm, desc) = storage.parse_markdown_with_frontmatter(&markdown).unwrap();
assert_eq!(fm.id, task.id);
assert_eq!(fm.status, task.status);
assert_eq!(desc, "Line 1\n\nLine 3");
}
// --- FileSystemStorage init/new ---
#[test]
fn test_new_nonexistent_path() {
let result = FileSystemStorage::new(PathBuf::from("/does/not/exist"));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotFound(_)));
}
#[test]
fn test_init_creates_metadata() {
let temp_dir = TempDir::new().unwrap();
let _storage = init_storage(&temp_dir);
assert!(temp_dir.path().join(".metadata.json").exists());
}
#[test]
fn test_init_idempotent() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_path_buf();
let mut s = FileSystemStorage::init(path.clone()).unwrap();
let list = s.create_list("Keep Me".to_string()).unwrap();
// Re-init should not destroy existing data
let s2 = FileSystemStorage::init(path).unwrap();
let lists = s2.get_lists().unwrap();
assert_eq!(lists.len(), 1);
assert_eq!(lists[0].id, list.id);
}
// --- Root metadata ---
#[test]
fn test_root_metadata_defaults_when_missing() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
// Delete the metadata file to simulate missing
fs::remove_file(temp_dir.path().join(".metadata.json")).unwrap();
let meta = storage.read_root_metadata().unwrap();
assert_eq!(meta.version, 1);
assert!(meta.list_order.is_empty());
}
// --- List operations ---
#[test]
fn test_create_list_already_exists() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
storage.create_list("Dupes".to_string()).unwrap();
let result = storage.create_list("Dupes".to_string());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidData(_)));
}
#[test]
fn test_delete_list_cleans_up_root_metadata() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("To Delete".to_string()).unwrap();
let meta_before = storage.read_root_metadata().unwrap();
assert!(meta_before.list_order.contains(&list.id));
storage.delete_list(list.id).unwrap();
let meta_after = storage.read_root_metadata().unwrap();
assert!(!meta_after.list_order.contains(&list.id));
}
#[test]
fn test_list_dir_path_nonexistent_list() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let result = storage.list_dir_path(Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::ListNotFound(_)));
}
// --- Task file operations ---
#[test]
fn test_write_and_read_task() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let task = Task::new("Hello".to_string());
storage.write_task(list.id, &task).unwrap();
let read_back = storage.read_task(list.id, task.id).unwrap();
assert_eq!(read_back.title, "Hello");
assert_eq!(read_back.id, task.id);
}
#[test]
fn test_read_task_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let result = storage.read_task(list.id, Uuid::new_v4());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_)));
}
#[test]
fn test_write_task_adds_to_task_order() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let t1 = Task::new("First".to_string());
let t2 = Task::new("Second".to_string());
storage.write_task(list.id, &t1).unwrap();
storage.write_task(list.id, &t2).unwrap();
let meta = storage.read_list_metadata(list.id).unwrap();
assert_eq!(meta.task_order.len(), 2);
assert_eq!(meta.task_order[0], t1.id);
assert_eq!(meta.task_order[1], t2.id);
}
#[test]
fn test_write_task_idempotent_order() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let task = Task::new("Once".to_string());
storage.write_task(list.id, &task).unwrap();
storage.write_task(list.id, &task).unwrap(); // Write again
let meta = storage.read_list_metadata(list.id).unwrap();
assert_eq!(meta.task_order.len(), 1); // Should not duplicate
}
#[test]
fn test_delete_task_removes_from_order() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let task = Task::new("Bye".to_string());
storage.write_task(list.id, &task).unwrap();
storage.delete_task(list.id, task.id).unwrap();
let meta = storage.read_list_metadata(list.id).unwrap();
assert!(!meta.task_order.contains(&task.id));
}
#[test]
fn test_list_tasks_respects_order() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let t1 = Task::new("Alpha".to_string());
let t2 = Task::new("Beta".to_string());
let t3 = Task::new("Gamma".to_string());
storage.write_task(list.id, &t1).unwrap();
storage.write_task(list.id, &t2).unwrap();
storage.write_task(list.id, &t3).unwrap();
// Rewrite metadata with reversed order
let mut meta = storage.read_list_metadata(list.id).unwrap();
meta.task_order = vec![t3.id, t1.id, t2.id];
storage.write_list_metadata(&meta).unwrap();
let tasks = storage.list_tasks(list.id).unwrap();
assert_eq!(tasks[0].id, t3.id);
assert_eq!(tasks[1].id, t1.id);
assert_eq!(tasks[2].id, t2.id);
}
#[test]
fn test_list_tasks_empty_list() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Empty".to_string()).unwrap();
let tasks = storage.list_tasks(list.id).unwrap();
assert!(tasks.is_empty());
}
}