diff --git a/crates/bevy-tasks-core/src/config.rs b/crates/bevy-tasks-core/src/config.rs index a8c0af3..2e042ae 100644 --- a/crates/bevy-tasks-core/src/config.rs +++ b/crates/bevy-tasks-core/src/config.rs @@ -83,3 +83,122 @@ impl AppConfig { config_dir.config_dir().join("config.json") } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_get_current_workspace_none_set() { + let config = AppConfig::new(); + let result = config.get_current_workspace(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_))); + } + + #[test] + fn test_get_current_workspace_name_points_to_removed_workspace() { + let mut config = AppConfig::new(); + config.add_workspace("test".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); + config.current_workspace = Some("test".to_string()); + config.workspaces.remove("test"); + + let result = config.get_current_workspace(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_))); + } + + #[test] + fn test_set_current_workspace_nonexistent() { + let mut config = AppConfig::new(); + let result = config.set_current_workspace("ghost".to_string()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_))); + } + + #[test] + fn test_set_current_workspace_valid() { + let mut config = AppConfig::new(); + config.add_workspace("real".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); + assert!(config.set_current_workspace("real".to_string()).is_ok()); + assert_eq!(config.current_workspace.as_deref(), Some("real")); + } + + #[test] + fn test_remove_current_workspace_clears_current() { + let mut config = AppConfig::new(); + config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); + config.set_current_workspace("ws".to_string()).unwrap(); + + config.remove_workspace("ws"); + assert!(config.current_workspace.is_none()); + assert!(config.get_workspace("ws").is_none()); + } + + #[test] + fn test_remove_noncurrent_workspace_keeps_current() { + let mut config = AppConfig::new(); + config.add_workspace("a".to_string(), WorkspaceConfig::new(PathBuf::from("/a"))); + config.add_workspace("b".to_string(), WorkspaceConfig::new(PathBuf::from("/b"))); + config.set_current_workspace("a".to_string()).unwrap(); + + config.remove_workspace("b"); + assert_eq!(config.current_workspace.as_deref(), Some("a")); + } + + #[test] + fn test_save_and_load_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.json"); + + let mut config = AppConfig::new(); + config.add_workspace("ws1".to_string(), WorkspaceConfig::new(PathBuf::from("/path/one"))); + config.add_workspace("ws2".to_string(), WorkspaceConfig::new(PathBuf::from("/path/two"))); + config.set_current_workspace("ws1".to_string()).unwrap(); + config.save_to_file(&config_path).unwrap(); + + let loaded = AppConfig::load_from_file(&config_path).unwrap(); + assert_eq!(loaded.current_workspace.as_deref(), Some("ws1")); + assert_eq!(loaded.workspaces.len(), 2); + assert_eq!(loaded.get_workspace("ws1").unwrap().path, PathBuf::from("/path/one")); + assert_eq!(loaded.get_workspace("ws2").unwrap().path, PathBuf::from("/path/two")); + } + + #[test] + fn test_load_missing_file_returns_default() { + let config = AppConfig::load_from_file(&PathBuf::from("/nonexistent/config.json")).unwrap(); + assert!(config.workspaces.is_empty()); + assert!(config.current_workspace.is_none()); + } + + #[test] + fn test_load_corrupt_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.json"); + std::fs::write(&config_path, "not valid json {{{").unwrap(); + + let result = AppConfig::load_from_file(&config_path); + assert!(result.is_err()); + } + + #[test] + fn test_save_creates_parent_dirs() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("nested").join("dir").join("config.json"); + + let config = AppConfig::new(); + assert!(config.save_to_file(&config_path).is_ok()); + assert!(config_path.exists()); + } + + #[test] + fn test_add_workspace_overwrites_existing() { + let mut config = AppConfig::new(); + config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/old"))); + config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/new"))); + + assert_eq!(config.get_workspace("ws").unwrap().path, PathBuf::from("/new")); + assert_eq!(config.workspaces.len(), 1); + } +} diff --git a/crates/bevy-tasks-core/src/repository.rs b/crates/bevy-tasks-core/src/repository.rs index fe7f59d..518624f 100644 --- a/crates/bevy-tasks-core/src/repository.rs +++ b/crates/bevy-tasks-core/src/repository.rs @@ -205,4 +205,191 @@ mod tests { repo.set_group_by_due_date(list.id, false).unwrap(); assert!(!repo.get_group_by_due_date(list.id).unwrap()); } + + // --- Error path tests --- + + #[test] + fn test_get_task_not_found() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + let list = repo.create_list("Test".to_string()).unwrap(); + + let result = repo.get_task(list.id, Uuid::new_v4()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_))); + } + + #[test] + fn test_update_nonexistent_task() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + let list = repo.create_list("Test".to_string()).unwrap(); + + let task = Task::new("Ghost".to_string()); + let result = repo.update_task(list.id, task); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_))); + } + + #[test] + fn test_delete_nonexistent_task() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + let list = repo.create_list("Test".to_string()).unwrap(); + + let result = repo.delete_task(list.id, Uuid::new_v4()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_))); + } + + #[test] + fn test_get_list_not_found() { + let temp_dir = TempDir::new().unwrap(); + let repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + + let result = repo.get_list(Uuid::new_v4()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::ListNotFound(_))); + } + + #[test] + fn test_delete_nonexistent_list() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + + let result = repo.delete_list(Uuid::new_v4()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::ListNotFound(_))); + } + + #[test] + fn test_list_tasks_nonexistent_list() { + let temp_dir = TempDir::new().unwrap(); + let repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + + let result = repo.list_tasks(Uuid::new_v4()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::ListNotFound(_))); + } + + #[test] + fn test_reorder_task_not_in_list() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + let list = repo.create_list("Test".to_string()).unwrap(); + repo.create_task(list.id, Task::new("A".to_string())).unwrap(); + + let result = repo.reorder_task(list.id, Uuid::new_v4(), 0); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::TaskNotFound(_))); + } + + #[test] + fn test_reorder_task_position_clamped() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + let list = repo.create_list("Test".to_string()).unwrap(); + + let t1 = repo.create_task(list.id, Task::new("A".to_string())).unwrap(); + let t2 = repo.create_task(list.id, Task::new("B".to_string())).unwrap(); + + // Position 999 should clamp to end + repo.reorder_task(list.id, t1.id, 999).unwrap(); + let order = repo.get_task_order(list.id).unwrap(); + assert_eq!(order[0], t2.id); + assert_eq!(order[1], t1.id); + } + + #[test] + fn test_create_duplicate_list() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + repo.create_list("Dupes".to_string()).unwrap(); + + let result = repo.create_list("Dupes".to_string()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::InvalidData(_))); + } + + #[test] + fn test_get_lists_empty() { + let temp_dir = TempDir::new().unwrap(); + let repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + + let lists = repo.get_lists().unwrap(); + assert!(lists.is_empty()); + } + + #[test] + fn test_delete_list_removes_from_root_metadata() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + + let list1 = repo.create_list("A".to_string()).unwrap(); + let list2 = repo.create_list("B".to_string()).unwrap(); + + repo.delete_list(list1.id).unwrap(); + + let lists = repo.get_lists().unwrap(); + assert_eq!(lists.len(), 1); + assert_eq!(lists[0].id, list2.id); + } + + #[test] + fn test_new_on_nonexistent_path() { + let result = TaskRepository::new(PathBuf::from("/nonexistent/path/that/does/not/exist")); + assert!(result.is_err()); + } + + #[test] + fn test_task_with_description_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + let list = repo.create_list("Test".to_string()).unwrap(); + + let task = Task::new("Has Description".to_string()) + .with_description("Some **markdown** notes".to_string()); + let created = repo.create_task(list.id, task).unwrap(); + + let retrieved = repo.get_task(list.id, created.id).unwrap(); + assert_eq!(retrieved.description, "Some **markdown** notes"); + } + + #[test] + fn test_task_rename_removes_old_file() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + let list = repo.create_list("Test".to_string()).unwrap(); + + let mut task = repo.create_task(list.id, Task::new("Old Name".to_string())).unwrap(); + task.title = "New Name".to_string(); + repo.update_task(list.id, task.clone()).unwrap(); + + // Old file should be gone, new file should exist + let tasks = repo.list_tasks(list.id).unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].title, "New Name"); + + // Verify old .md file no longer on disk + let old_path = temp_dir.path().join("Test").join("Old Name.md"); + assert!(!old_path.exists()); + } + + #[test] + fn test_task_order_after_delete() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + let list = repo.create_list("Test".to_string()).unwrap(); + + let t1 = repo.create_task(list.id, Task::new("A".to_string())).unwrap(); + let t2 = repo.create_task(list.id, Task::new("B".to_string())).unwrap(); + let t3 = repo.create_task(list.id, Task::new("C".to_string())).unwrap(); + + repo.delete_task(list.id, t2.id).unwrap(); + + let order = repo.get_task_order(list.id).unwrap(); + assert_eq!(order.len(), 2); + assert_eq!(order[0], t1.id); + assert_eq!(order[1], t3.id); + } } diff --git a/crates/bevy-tasks-core/src/storage.rs b/crates/bevy-tasks-core/src/storage.rs index eada538..74b4dae 100644 --- a/crates/bevy-tasks-core/src/storage.rs +++ b/crates/bevy-tasks-core/src/storage.rs @@ -92,6 +92,7 @@ pub trait Storage { fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()>; } +#[derive(Debug)] pub struct FileSystemStorage { root_path: PathBuf, } @@ -475,3 +476,297 @@ impl Storage for FileSystemStorage { 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()); + } +}