Rework WebDAV workspace setup to use .onyx-workspace.json instead of .metadata.json and to let users pick a remote folder instead of forcing an Onyx/ subfolder. This updates storage, sync, config types, tests, and CLI/Tauri commands to store a webdav_path in WorkspaceConfig and to combine webdav_url + webdav_path for sync. Changes include: - Rename .metadata.json → .onyx-workspace.json across storage, sync, and tests so workspace detection and root metadata use the new filename. - Remove hardcoded automatic "Onyx/" subfolder in sync and use the user-selected remote path directly. - Add webdav_path field to WorkspaceConfig (Rust and TypeScript types) and thread it through add_webdav_workspace and frontend addWebdavWorkspace. - Add three Tauri commands (list_remote_folder, inspect_remote_workspace, create_remote_workspace) to support remote folder browsing, workspace preview, and remote workspace creation. - Rewrite SetupScreen WebDAV flow to Connect → Browse (lazy folder explorer) → Preview or Create, and wire UI state/handlers to the new commands. - Update CLAUDE.md to document the new on-disk filename and note development phase allowing breaking changes.
836 lines
29 KiB
Rust
836 lines
29 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 .onyx-workspace.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(".onyx-workspace.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) -> Result<PathBuf> {
|
|
// Reject names containing path separators or traversal components
|
|
if name.contains('/') || name.contains('\\') || name == ".." || name.starts_with("../") || name.starts_with("..\\") {
|
|
return Err(Error::InvalidData("Invalid list name: path traversal not allowed".to_string()));
|
|
}
|
|
let path = self.root_path.join(name);
|
|
// Verify resolved path stays within root
|
|
let canonical_root = self.root_path.canonicalize()
|
|
.map_err(Error::Io)?;
|
|
let canonical_path = if path.exists() {
|
|
path.canonicalize().map_err(Error::Io)?
|
|
} else {
|
|
// Parent must exist and be canonicalizable (it's root_path)
|
|
canonical_root.join(path.file_name().unwrap_or_default())
|
|
};
|
|
if !canonical_path.starts_with(&canonical_root) {
|
|
return Err(Error::InvalidData("Invalid list name: path escapes workspace".to_string()));
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
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(".onyx-workspace.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(".onyx-workspace.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());
|
|
}
|
|
}
|