diff --git a/.gitignore b/.gitignore index ea8c4bf..5cfae5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,15 @@ +# Rust /target +**/target/ +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/Cargo.toml b/Cargo.toml index d192d44..8f00525 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,14 @@ -[package] -name = "bevy-tasks" -version = "0.1.0" -edition = "2024" +[workspace] +members = [ + "crates/bevy-tasks-core", + "crates/bevy-tasks-cli", + "crates/bevy-tasks-gui", +] +resolver = "2" -[dependencies] -bevy = "0.16.1" \ No newline at end of file +[workspace.dependencies] +serde = { version = "1.0", features = ["derive"] } +uuid = { version = "1.0", features = ["serde", "v4"] } +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1.0" +tokio = { version = "1.40", features = ["full"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc4c6c6 --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# Bevy Tasks + +A **local-first, cross-platform tasks application** built with Rust. Inspired by Google Tasks, designed for speed and flexibility. + +## Core Principles + +- **Local-First**: Your data, your folder, your control +- **Fast**: Sub-second startup, instant response +- **Cross-Platform**: Single codebase, all platforms +- **Flexible**: Multiple workspaces for different contexts + +## Project Structure + +``` +bevy-tasks/ +├── Cargo.toml # Workspace definition +├── PLAN.md # Detailed project plan +├── README.md # This file +├── crates/ +│ ├── bevy-tasks-core/ # Core library (backend) +│ ├── bevy-tasks-cli/ # CLI frontend +│ └── bevy-tasks-gui/ # GUI frontend (Phase 3+) +└── docs/ +``` + +## Phase 1 Status: Core Library & CLI MVP ✅ + +Phase 1 implementation is complete with the following features: + +### Core Library (`bevy-tasks-core`) +- ✅ Data models (Task, TaskList, AppConfig, WorkspaceConfig) +- ✅ Markdown file I/O with YAML frontmatter +- ✅ Local storage implementation +- ✅ Repository pattern with clean API +- ✅ Multiple workspace support +- ✅ Task ordering and grouping + +### CLI (`bevy-tasks-cli`) +- ✅ Workspace management (init, add, list, switch, remove, retarget, migrate) +- ✅ Task list management (create, show, delete) +- ✅ Task operations (add, complete, delete, edit) +- ✅ Group-by-due-date toggle +- ✅ Support for `--workspace` flag on all commands + +## Development Setup + +### Prerequisites + +- Rust 1.70+ (install from [rustup.rs](https://rustup.rs/)) +- Git + +### Build + +```bash +# Clone and build +git clone +cd bevy-tasks +cargo build + +# Run tests +cargo test -p bevy-tasks-core + +# Run CLI +cargo run -p bevy-tasks-cli -- --help +``` + +## Quick Start + +### Initialize your first workspace + +```bash +# Initialize a new workspace +cargo run -p bevy-tasks-cli -- init ~/Documents/Tasks --name personal + +# This creates: +# - A workspace named "personal" at ~/Documents/Tasks +# - A default list called "My Tasks" +# - Sets "personal" as the current workspace +``` + +### Add and manage tasks + +```bash +# Add a task +cargo run -p bevy-tasks-cli -- add "Buy groceries" + +# Add a task with due date +cargo run -p bevy-tasks-cli -- add "Review PR #123" --list "Work" --due "2025-11-15" + +# List all tasks +cargo run -p bevy-tasks-cli -- list show + +# Complete a task +cargo run -p bevy-tasks-cli -- complete + +# Edit a task (opens in $EDITOR) +cargo run -p bevy-tasks-cli -- edit + +# Delete a task +cargo run -p bevy-tasks-cli -- delete +``` + +### Manage workspaces + +```bash +# Add another workspace +cargo run -p bevy-tasks-cli -- workspace add shared ~/Dropbox/TeamTasks + +# List workspaces +cargo run -p bevy-tasks-cli -- workspace list + +# Switch workspace +cargo run -p bevy-tasks-cli -- workspace switch shared + +# Use specific workspace for a command +cargo run -p bevy-tasks-cli -- add "Team meeting" --workspace shared +``` + +### Manage task lists + +```bash +# Create a new list +cargo run -p bevy-tasks-cli -- list create "Work" + +# Show tasks in a specific list +cargo run -p bevy-tasks-cli -- list show --list "Work" + +# Delete a list +cargo run -p bevy-tasks-cli -- list delete "Work" +``` + +## Data Format + +Tasks are stored as markdown files with YAML frontmatter (Obsidian-compatible): + +```markdown +--- +id: 550e8400-e29b-41d4-a716-446655440000 +status: backlog +due: 2025-11-15T14:00:00Z +created: 2025-10-26T10:00:00Z +updated: 2025-10-26T12:30:00Z +--- + +Task description and notes go here in **markdown** format. + +- Can include lists +- Rich formatting +- Links, etc. +``` + +## File System Structure + +``` +~/Documents/Tasks/ # User-selected folder +├── .metadata.json # Global: list ordering, last opened list +├── My Tasks/ # Task list folder +│ ├── .listdata.json # List metadata: task order, id, timestamps +│ ├── Buy groceries.md # Individual task files +│ └── Call dentist.md +└── Work/ + ├── .listdata.json + ├── Review PRs.md + └── Team meeting prep.md +``` + +## Testing + +Run the test suite: + +```bash +# Run all tests +cargo test + +# Run tests for specific crate +cargo test -p bevy-tasks-core + +# Run tests with output +cargo test -- --nocapture +``` + +## What's Next? + +- **Phase 2**: WebDAV sync for cross-device synchronization +- **Phase 3**: GUI with egui for desktop platforms +- **Phase 4**: Mobile support (iOS & Android) +- **Phase 5**: Advanced features and polish +- **Phase 6**: Platform-specific integrations +- **Phase 7**: Google Tasks importer and unique features + +See [PLAN.md](PLAN.md) for detailed roadmap. + +## License + +[GNU General Public License v3.0 (GPL-3.0)](https://www.gnu.org/licenses/gpl-3.0.en.html) + +This project is free and open-source software. diff --git a/crates/bevy-tasks-cli/Cargo.toml b/crates/bevy-tasks-cli/Cargo.toml new file mode 100644 index 0000000..3c2bbaf --- /dev/null +++ b/crates/bevy-tasks-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bevy-tasks-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "bevy-tasks" +path = "src/main.rs" + +[dependencies] +bevy-tasks-core = { path = "../bevy-tasks-core" } +clap = { version = "4.5", features = ["derive", "env"] } +colored = "2.0" +indicatif = "0.17" +anyhow = { workspace = true } +tokio = { workspace = true } +fs_extra = "1.3" diff --git a/crates/bevy-tasks-cli/src/commands/group.rs b/crates/bevy-tasks-cli/src/commands/group.rs new file mode 100644 index 0000000..92d512d --- /dev/null +++ b/crates/bevy-tasks-cli/src/commands/group.rs @@ -0,0 +1,39 @@ +use anyhow::{Context, Result}; +use crate::output; +use crate::commands::get_repository; + +pub fn enable(list_name: String, workspace: Option) -> Result<()> { + let (mut repo, workspace_name) = get_repository(workspace)?; + + let lists = repo.get_lists() + .context("Failed to get lists")?; + + let list = lists.iter() + .find(|l| l.title == list_name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", list_name))?; + + repo.set_group_by_due_date(list.id, true) + .context("Failed to enable grouping")?; + + output::success(&format!("Enabled group-by-due-date for list \"{}\"", list_name)); + + Ok(()) +} + +pub fn disable(list_name: String, workspace: Option) -> Result<()> { + let (mut repo, workspace_name) = get_repository(workspace)?; + + let lists = repo.get_lists() + .context("Failed to get lists")?; + + let list = lists.iter() + .find(|l| l.title == list_name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", list_name))?; + + repo.set_group_by_due_date(list.id, false) + .context("Failed to disable grouping")?; + + output::success(&format!("Disabled group-by-due-date for list \"{}\"", list_name)); + + Ok(()) +} diff --git a/crates/bevy-tasks-cli/src/commands/init.rs b/crates/bevy-tasks-cli/src/commands/init.rs new file mode 100644 index 0000000..2bfae3c --- /dev/null +++ b/crates/bevy-tasks-cli/src/commands/init.rs @@ -0,0 +1,40 @@ +use anyhow::{Context, Result}; +use bevy_tasks_core::{AppConfig, TaskRepository, WorkspaceConfig}; +use std::path::PathBuf; +use crate::output; + +pub fn execute(path: String, name: String) -> Result<()> { + let path_buf = PathBuf::from(path); + let path_buf = if path_buf.is_relative() { + std::env::current_dir()?.join(path_buf) + } else { + path_buf + }; + + // Initialize the repository + let mut repo = TaskRepository::init(path_buf.clone()) + .context("Failed to initialize tasks folder")?; + + // Create default list + repo.create_list("My Tasks".to_string()) + .context("Failed to create default list")?; + + // Load or create config + let config_path = AppConfig::get_config_path(); + let mut config = AppConfig::load_from_file(&config_path) + .unwrap_or_else(|_| AppConfig::new()); + + // Add workspace + config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); + config.set_current_workspace(name.clone())?; + + // Save config + config.save_to_file(&config_path) + .context("Failed to save config")?; + + output::success(&format!("Initialized workspace \"{}\" at {:?}", name, path_buf)); + output::success("Created default list \"My Tasks\""); + output::success(&format!("Set \"{}\" as current workspace", name)); + + Ok(()) +} diff --git a/crates/bevy-tasks-cli/src/commands/list.rs b/crates/bevy-tasks-cli/src/commands/list.rs new file mode 100644 index 0000000..3685539 --- /dev/null +++ b/crates/bevy-tasks-cli/src/commands/list.rs @@ -0,0 +1,116 @@ +use anyhow::{Context, Result}; +use colored::*; +use crate::output; +use crate::commands::get_repository; + +pub fn create(name: String, workspace: Option) -> Result<()> { + let (mut repo, workspace_name) = get_repository(workspace)?; + + repo.create_list(name.clone()) + .context("Failed to create list")?; + + output::success(&format!("Created list \"{}\"", name)); + + Ok(()) +} + +pub fn show(list_name: Option, workspace: Option) -> Result<()> { + let (repo, workspace_name) = get_repository(workspace)?; + + let lists = repo.get_lists() + .context("Failed to get lists")?; + + if lists.is_empty() { + println!("No lists found. Create one with 'bevy-tasks list create '"); + return Ok(()); + } + + // If a specific list is requested, show only that one + if let Some(name) = list_name { + let list = lists.iter() + .find(|l| l.title == name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", name))?; + + println!("{} {} {}", list.title.bold(), format!("({} tasks)", list.tasks.len()).dimmed(), ""); + + if list.tasks.is_empty() { + println!(" No tasks"); + } else { + for task in &list.tasks { + let checkbox = if task.status == bevy_tasks_core::TaskStatus::Completed { + "[✓]".green() + } else { + "[ ]".normal() + }; + + let due_str = if let Some(due) = task.due_date { + format!(" (due: {})", due.format("%Y-%m-%d")).yellow().to_string() + } else { + String::new() + }; + + println!(" {} {}{}", checkbox, task.title, due_str); + } + } + } else { + // Show all lists + for list in &lists { + println!("{} {}", list.title.bold(), format!("({} tasks)", list.tasks.len()).dimmed()); + + if list.tasks.is_empty() { + println!(" No tasks"); + } else { + for task in &list.tasks { + let checkbox = if task.status == bevy_tasks_core::TaskStatus::Completed { + "[✓]".green() + } else { + "[ ]".normal() + }; + + let due_str = if let Some(due) = task.due_date { + format!(" (due: {})", due.format("%Y-%m-%d")).yellow().to_string() + } else { + String::new() + }; + + println!(" {} {}{}", checkbox, task.title, due_str); + } + } + println!(); + } + } + + Ok(()) +} + +pub fn delete(name: String, workspace: Option) -> Result<()> { + let (mut repo, workspace_name) = get_repository(workspace)?; + + let lists = repo.get_lists() + .context("Failed to get lists")?; + + let list = lists.iter() + .find(|l| l.title == name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", name))?; + + // Confirm + output::warning(&format!("This will delete list \"{}\" and all its tasks", name)); + print!("Continue? (y/n): "); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().to_lowercase() != "y" { + println!("Cancelled"); + return Ok(()); + } + + repo.delete_list(list.id) + .context("Failed to delete list")?; + + output::success(&format!("Deleted list \"{}\"", name)); + + Ok(()) +} diff --git a/crates/bevy-tasks-cli/src/commands/mod.rs b/crates/bevy-tasks-cli/src/commands/mod.rs new file mode 100644 index 0000000..d65ef91 --- /dev/null +++ b/crates/bevy-tasks-cli/src/commands/mod.rs @@ -0,0 +1,42 @@ +pub mod init; +pub mod workspace; +pub mod list; +pub mod task; +pub mod group; + +use bevy_tasks_core::{AppConfig, TaskRepository}; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +pub fn get_config_path() -> PathBuf { + AppConfig::get_config_path() +} + +pub fn load_config() -> Result { + let path = get_config_path(); + AppConfig::load_from_file(&path).context("Failed to load config") +} + +pub fn save_config(config: &AppConfig) -> Result<()> { + let path = get_config_path(); + config.save_to_file(&path).context("Failed to save config") +} + +pub fn get_repository(workspace_name: Option) -> Result<(TaskRepository, String)> { + let config = load_config()?; + + let (name, workspace_config) = if let Some(name) = workspace_name { + let workspace_config = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?; + (name, workspace_config.clone()) + } else { + let (name, workspace_config) = config.get_current_workspace() + .context("No workspace set. Use 'bevy-tasks init' to create one.")?; + (name.clone(), workspace_config.clone()) + }; + + let repo = TaskRepository::new(workspace_config.path.clone()) + .context(format!("Failed to open workspace '{}'", name))?; + + Ok((repo, name)) +} diff --git a/crates/bevy-tasks-cli/src/commands/task.rs b/crates/bevy-tasks-cli/src/commands/task.rs new file mode 100644 index 0000000..ae27f74 --- /dev/null +++ b/crates/bevy-tasks-cli/src/commands/task.rs @@ -0,0 +1,212 @@ +use anyhow::{Context, Result}; +use bevy_tasks_core::{Task, TaskStatus}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; +use std::io::Write; +use crate::output; +use crate::commands::get_repository; + +pub fn add(title: String, list_name: Option, due_str: Option, workspace: Option) -> Result<()> { + let (mut repo, workspace_name) = get_repository(workspace)?; + + // Get lists + let lists = repo.get_lists() + .context("Failed to get lists")?; + + if lists.is_empty() { + anyhow::bail!("No lists found. Create one with 'bevy-tasks list create '"); + } + + // Find the target list + let list = if let Some(name) = list_name { + lists.iter() + .find(|l| l.title == name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", name))? + } else { + // Use the first list + &lists[0] + }; + + // Create task + let mut task = Task::new(title.clone()); + + // Parse due date if provided + if let Some(due_str) = due_str { + let due_date = parse_due_date(&due_str)?; + task.due_date = Some(due_date); + } + + // Save task + repo.create_task(list.id, task.clone()) + .context("Failed to create task")?; + + let due_info = if let Some(due) = task.due_date { + format!("\n Due: {}", due.format("%Y-%m-%d")) + } else { + String::new() + }; + + output::success(&format!("Created task \"{}\" ({}){}", title, task.id, due_info)); + + Ok(()) +} + +pub fn complete(task_id_str: String, workspace: Option) -> Result<()> { + let (mut repo, workspace_name) = get_repository(workspace)?; + + let task_id = Uuid::parse_str(&task_id_str) + .context("Invalid task ID")?; + + // Find the task across all lists + let lists = repo.get_lists()?; + let mut found = false; + + for list in lists { + if let Some(mut task) = list.tasks.iter().find(|t| t.id == task_id).cloned() { + task.complete(); + repo.update_task(list.id, task.clone()) + .context("Failed to update task")?; + + output::success(&format!("Completed task \"{}\"", task.title)); + found = true; + break; + } + } + + if !found { + anyhow::bail!("Task not found: {}", task_id_str); + } + + Ok(()) +} + +pub fn delete(task_id_str: String, workspace: Option) -> Result<()> { + let (mut repo, workspace_name) = get_repository(workspace)?; + + let task_id = Uuid::parse_str(&task_id_str) + .context("Invalid task ID")?; + + // Find the task across all lists + let lists = repo.get_lists()?; + let mut found = false; + + for list in lists { + if let Some(task) = list.tasks.iter().find(|t| t.id == task_id) { + let title = task.title.clone(); + + repo.delete_task(list.id, task_id) + .context("Failed to delete task")?; + + output::success(&format!("Deleted task \"{}\"", title)); + found = true; + break; + } + } + + if !found { + anyhow::bail!("Task not found: {}", task_id_str); + } + + Ok(()) +} + +pub fn edit(task_id_str: String, workspace: Option) -> Result<()> { + let (mut repo, workspace_name) = get_repository(workspace)?; + + let task_id = Uuid::parse_str(&task_id_str) + .context("Invalid task ID")?; + + // Find the task across all lists + let lists = repo.get_lists()?; + let mut task_list_id = None; + let mut task_to_edit = None; + + for list in lists { + if let Some(task) = list.tasks.iter().find(|t| t.id == task_id).cloned() { + task_list_id = Some(list.id); + task_to_edit = Some(task); + break; + } + } + + let (list_id, task) = match (task_list_id, task_to_edit) { + (Some(lid), Some(t)) => (lid, t), + _ => anyhow::bail!("Task not found: {}", task_id_str), + }; + + // Create temporary file with task content + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join(format!("bevy-tasks-{}.md", task.id)); + + // Write current task content to temp file + let content = format!("# {}\n\n{}", task.title, task.description); + std::fs::write(&temp_file, content)?; + + // Get editor from environment + let editor = std::env::var("EDITOR").unwrap_or_else(|_| { + if cfg!(windows) { + "notepad".to_string() + } else { + "nano".to_string() + } + }); + + // Open editor + let status = std::process::Command::new(&editor) + .arg(&temp_file) + .status() + .context(format!("Failed to open editor: {}", editor))?; + + if !status.success() { + anyhow::bail!("Editor exited with non-zero status"); + } + + // Read updated content + let updated_content = std::fs::read_to_string(&temp_file)?; + + // Parse the content + let lines: Vec<&str> = updated_content.lines().collect(); + let (title, description) = if !lines.is_empty() && lines[0].starts_with("# ") { + let title = lines[0].trim_start_matches("# ").trim().to_string(); + let description = if lines.len() > 2 { + lines[2..].join("\n").trim().to_string() + } else { + String::new() + }; + (title, description) + } else { + (task.title.clone(), updated_content.trim().to_string()) + }; + + // Update task + let mut updated_task = task.clone(); + updated_task.title = title; + updated_task.description = description; + updated_task.updated_at = Utc::now(); + + repo.update_task(list_id, updated_task.clone()) + .context("Failed to update task")?; + + // Clean up temp file + std::fs::remove_file(&temp_file).ok(); + + output::success(&format!("Updated task \"{}\"", updated_task.title)); + + Ok(()) +} + +fn parse_due_date(s: &str) -> Result> { + // Try parsing as date only (YYYY-MM-DD) + if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let naive_datetime = naive_date.and_hms_opt(0, 0, 0) + .ok_or_else(|| anyhow::anyhow!("Invalid date"))?; + return Ok(DateTime::from_naive_utc_and_offset(naive_datetime, Utc)); + } + + // Try parsing as full datetime (ISO 8601) + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt.with_timezone(&Utc)); + } + + anyhow::bail!("Invalid date format. Use YYYY-MM-DD or ISO 8601 format (YYYY-MM-DDTHH:MM:SS)") +} diff --git a/crates/bevy-tasks-cli/src/commands/workspace.rs b/crates/bevy-tasks-cli/src/commands/workspace.rs new file mode 100644 index 0000000..a68ed64 --- /dev/null +++ b/crates/bevy-tasks-cli/src/commands/workspace.rs @@ -0,0 +1,203 @@ +use anyhow::{Context, Result}; +use bevy_tasks_core::{AppConfig, TaskRepository, WorkspaceConfig}; +use std::path::PathBuf; +use colored::*; +use crate::output; +use crate::commands::{load_config, save_config}; + +pub fn add(name: String, path: String) -> Result<()> { + let path_buf = PathBuf::from(path); + let path_buf = if path_buf.is_relative() { + std::env::current_dir()?.join(path_buf) + } else { + path_buf + }; + + // Initialize the repository + let mut repo = TaskRepository::init(path_buf.clone()) + .context("Failed to initialize tasks folder")?; + + // Create default list + repo.create_list("My Tasks".to_string()) + .context("Failed to create default list")?; + + // Load config + let mut config = load_config()?; + + // Check if workspace already exists + if config.get_workspace(&name).is_some() { + anyhow::bail!("Workspace '{}' already exists", name); + } + + // Add workspace + config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); + + // Save config + save_config(&config)?; + + output::success(&format!("Added workspace \"{}\" at {:?}", name, path_buf)); + output::success("Created default list \"My Tasks\""); + + Ok(()) +} + +pub fn list() -> Result<()> { + let config = load_config()?; + + if config.workspaces.is_empty() { + println!("No workspaces configured. Use 'bevy-tasks init' to create one."); + return Ok(()); + } + + let current = config.current_workspace.as_deref(); + + for (name, workspace_config) in &config.workspaces { + let marker = if Some(name.as_str()) == current { + " (current)".green() + } else { + "".normal() + }; + println!(" {}: {:?}{}", name, workspace_config.path, marker); + } + + Ok(()) +} + +pub fn switch(name: String) -> Result<()> { + let mut config = load_config()?; + + // Verify workspace exists + if config.get_workspace(&name).is_none() { + anyhow::bail!("Workspace '{}' not found", name); + } + + config.set_current_workspace(name.clone())?; + save_config(&config)?; + + output::success(&format!("Switched to workspace \"{}\"", name)); + + Ok(()) +} + +pub fn remove(name: String) -> Result<()> { + let mut config = load_config()?; + + // Verify workspace exists + if config.get_workspace(&name).is_none() { + anyhow::bail!("Workspace '{}' not found", name); + } + + // Confirm + output::warning("This will delete workspace config (files remain on disk)"); + print!("Continue? (y/n): "); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().to_lowercase() != "y" { + println!("Cancelled"); + return Ok(()); + } + + config.remove_workspace(&name); + save_config(&config)?; + + output::success(&format!("Removed workspace \"{}\"", name)); + + Ok(()) +} + +pub fn retarget(name: String, path: String) -> Result<()> { + let path_buf = PathBuf::from(path); + let path_buf = if path_buf.is_relative() { + std::env::current_dir()?.join(path_buf) + } else { + path_buf + }; + + let mut config = load_config()?; + + // Verify workspace exists + if config.get_workspace(&name).is_none() { + anyhow::bail!("Workspace '{}' not found", name); + } + + // Update path + config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); + save_config(&config)?; + + output::success(&format!("Workspace \"{}\" now points to {:?}", name, path_buf)); + + Ok(()) +} + +pub fn migrate(name: String, new_path: String) -> Result<()> { + let new_path_buf = PathBuf::from(new_path); + let new_path_buf = if new_path_buf.is_relative() { + std::env::current_dir()?.join(new_path_buf) + } else { + new_path_buf + }; + + let mut config = load_config()?; + + // Get current workspace config + let old_path = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? + .path.clone(); + + // Confirm + output::warning(&format!("This will move all files from {:?} to {:?}", old_path, new_path_buf)); + print!("Continue? (y/n): "); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().to_lowercase() != "y" { + println!("Cancelled"); + return Ok(()); + } + + // Create destination directory + std::fs::create_dir_all(&new_path_buf)?; + + // Move files + println!("Moving files..."); + let entries = std::fs::read_dir(&old_path)?; + let mut count = 0; + + for entry in entries { + let entry = entry?; + let file_name = entry.file_name(); + let dest = new_path_buf.join(&file_name); + + if entry.path().is_dir() { + let mut options = fs_extra::dir::CopyOptions::new(); + options.copy_inside = true; + fs_extra::dir::move_dir(entry.path(), &new_path_buf, &options)?; + println!(" Moved {:?}/", file_name); + } else { + std::fs::rename(entry.path(), dest)?; + println!(" Moved {:?}", file_name); + } + count += 1; + } + + // Remove old directory if empty + if old_path.read_dir()?.next().is_none() { + std::fs::remove_dir(&old_path)?; + } + + // Update config + config.add_workspace(name.clone(), WorkspaceConfig::new(new_path_buf.clone())); + save_config(&config)?; + + output::success(&format!("Migrated {} items to {:?}", count, new_path_buf)); + output::success(&format!("Workspace \"{}\" now points to {:?}", name, new_path_buf)); + + Ok(()) +} diff --git a/crates/bevy-tasks-cli/src/main.rs b/crates/bevy-tasks-cli/src/main.rs new file mode 100644 index 0000000..2d47d0b --- /dev/null +++ b/crates/bevy-tasks-cli/src/main.rs @@ -0,0 +1,240 @@ +mod commands; +mod output; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use commands::*; + +#[derive(Parser)] +#[command(name = "bevy-tasks")] +#[command(about = "A local-first, cross-platform tasks application", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new workspace + Init { + /// Path to store tasks + path: String, + /// Name of the workspace + #[arg(short, long)] + name: String, + }, + + /// Manage workspaces + #[command(subcommand)] + Workspace(WorkspaceCommands), + + /// Manage task lists + #[command(subcommand)] + List(ListCommands), + + /// Add a new task + Add { + /// Task title + title: String, + /// List to add task to + #[arg(short, long)] + list: Option, + /// Due date (ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) + #[arg(short, long)] + due: Option, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Mark a task as complete + Complete { + /// Task ID + task_id: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Delete a task + Delete { + /// Task ID + task_id: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Edit a task + Edit { + /// Task ID + task_id: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Toggle group-by-due-date for a list + #[command(subcommand)] + Group(GroupCommands), +} + +#[derive(Subcommand)] +enum WorkspaceCommands { + /// Add a new workspace + Add { + /// Name of the workspace + name: String, + /// Path to store tasks + path: String, + }, + + /// List all workspaces + List, + + /// Switch to a different workspace + Switch { + /// Name of the workspace + name: String, + }, + + /// Remove a workspace + Remove { + /// Name of the workspace + name: String, + }, + + /// Update workspace path without moving files + Retarget { + /// Name of the workspace + name: String, + /// New path + path: String, + }, + + /// Move workspace files to a new location + Migrate { + /// Name of the workspace + name: String, + /// New path + path: String, + }, +} + +#[derive(Subcommand)] +enum ListCommands { + /// Create a new task list + Create { + /// Name of the list + name: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Show all tasks (or tasks in a specific list) + Show { + /// Name of the list to show + #[arg(short, long)] + list: Option, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Delete a task list + Delete { + /// Name of the list to delete + name: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, +} + +#[derive(Subcommand)] +enum GroupCommands { + /// Enable group-by-due-date for a list + Enable { + /// Name of the list + #[arg(short, long)] + list: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Disable group-by-due-date for a list + Disable { + /// Name of the list + #[arg(short, long)] + list: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Init { path, name } => { + init::execute(path, name)?; + } + Commands::Workspace(cmd) => match cmd { + WorkspaceCommands::Add { name, path } => { + workspace::add(name, path)?; + } + WorkspaceCommands::List => { + workspace::list()?; + } + WorkspaceCommands::Switch { name } => { + workspace::switch(name)?; + } + WorkspaceCommands::Remove { name } => { + workspace::remove(name)?; + } + WorkspaceCommands::Retarget { name, path } => { + workspace::retarget(name, path)?; + } + WorkspaceCommands::Migrate { name, path } => { + workspace::migrate(name, path)?; + } + }, + Commands::List(cmd) => match cmd { + ListCommands::Create { name, workspace } => { + list::create(name, workspace)?; + } + ListCommands::Show { list, workspace } => { + list::show(list, workspace)?; + } + ListCommands::Delete { name, workspace } => { + list::delete(name, workspace)?; + } + }, + Commands::Add { title, list, due, workspace } => { + task::add(title, list, due, workspace)?; + } + Commands::Complete { task_id, workspace } => { + task::complete(task_id, workspace)?; + } + Commands::Delete { task_id, workspace } => { + task::delete(task_id, workspace)?; + } + Commands::Edit { task_id, workspace } => { + task::edit(task_id, workspace)?; + } + Commands::Group(cmd) => match cmd { + GroupCommands::Enable { list, workspace } => { + group::enable(list, workspace)?; + } + GroupCommands::Disable { list, workspace } => { + group::disable(list, workspace)?; + } + }, + } + + Ok(()) +} diff --git a/crates/bevy-tasks-cli/src/output.rs b/crates/bevy-tasks-cli/src/output.rs new file mode 100644 index 0000000..cb535ba --- /dev/null +++ b/crates/bevy-tasks-cli/src/output.rs @@ -0,0 +1,17 @@ +use colored::*; + +pub fn success(message: &str) { + println!("{} {}", "✓".green(), message); +} + +pub fn error(message: &str) { + eprintln!("{} {}", "✗".red(), message); +} + +pub fn warning(message: &str) { + println!("{} {}", "⚠".yellow(), message); +} + +pub fn info(message: &str) { + println!("{} {}", "ℹ".blue(), message); +} diff --git a/crates/bevy-tasks-core/Cargo.toml b/crates/bevy-tasks-core/Cargo.toml new file mode 100644 index 0000000..62c6124 --- /dev/null +++ b/crates/bevy-tasks-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "bevy-tasks-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true } +serde_json = "1.0" +serde_yaml = "0.9" +uuid = { workspace = true } +chrono = { workspace = true } +directories = "5.0" +anyhow = { workspace = true } + +[dev-dependencies] +tempfile = "3.0" diff --git a/crates/bevy-tasks-core/src/config.rs b/crates/bevy-tasks-core/src/config.rs new file mode 100644 index 0000000..4fcd5a2 --- /dev/null +++ b/crates/bevy-tasks-core/src/config.rs @@ -0,0 +1,86 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use crate::error::{Error, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceConfig { + pub path: PathBuf, +} + +impl WorkspaceConfig { + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AppConfig { + pub workspaces: HashMap, + pub current_workspace: Option, +} + +impl AppConfig { + pub fn new() -> Self { + Self { + workspaces: HashMap::new(), + current_workspace: None, + } + } + + pub fn add_workspace(&mut self, name: String, config: WorkspaceConfig) { + self.workspaces.insert(name, config); + } + + pub fn remove_workspace(&mut self, name: &str) -> Option { + if self.current_workspace.as_deref() == Some(name) { + self.current_workspace = None; + } + self.workspaces.remove(name) + } + + pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceConfig> { + self.workspaces.get(name) + } + + pub fn get_current_workspace(&self) -> Result<(&String, &WorkspaceConfig)> { + let name = self.current_workspace.as_ref() + .ok_or_else(|| Error::WorkspaceNotFound("No current workspace set".to_string()))?; + let config = self.workspaces.get(name) + .ok_or_else(|| Error::WorkspaceNotFound(name.clone()))?; + Ok((name, config)) + } + + pub fn set_current_workspace(&mut self, name: String) -> Result<()> { + if !self.workspaces.contains_key(&name) { + return Err(Error::WorkspaceNotFound(name)); + } + self.current_workspace = Some(name); + Ok(()) + } + + pub fn load_from_file(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(Self::new()); + } + let content = std::fs::read_to_string(path)?; + let config = serde_json::from_str(&content)?; + Ok(config) + } + + pub fn save_to_file(&self, path: &PathBuf) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(&self)?; + std::fs::write(path, content)?; + Ok(()) + } + + pub fn get_config_path() -> PathBuf { + let config_dir = directories::ProjectDirs::from("", "", "bevy-tasks") + .expect("Failed to determine config directory"); + config_dir.config_dir().join("config.json") + } +} diff --git a/crates/bevy-tasks-core/src/error.rs b/crates/bevy-tasks-core/src/error.rs new file mode 100644 index 0000000..d80de41 --- /dev/null +++ b/crates/bevy-tasks-core/src/error.rs @@ -0,0 +1,49 @@ +use std::io; +use std::fmt; + +#[derive(Debug)] +pub enum Error { + Io(io::Error), + Serialization(String), + NotFound(String), + InvalidData(String), + WorkspaceNotFound(String), + ListNotFound(String), + TaskNotFound(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(e) => write!(f, "IO error: {}", e), + Error::Serialization(msg) => write!(f, "Serialization error: {}", msg), + Error::NotFound(msg) => write!(f, "Not found: {}", msg), + Error::InvalidData(msg) => write!(f, "Invalid data: {}", msg), + Error::WorkspaceNotFound(name) => write!(f, "Workspace not found: {}", name), + Error::ListNotFound(id) => write!(f, "List not found: {}", id), + Error::TaskNotFound(id) => write!(f, "Task not found: {}", id), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(err: io::Error) -> Self { + Error::Io(err) + } +} + +impl From for Error { + fn from(err: serde_json::Error) -> Self { + Error::Serialization(err.to_string()) + } +} + +impl From for Error { + fn from(err: serde_yaml::Error) -> Self { + Error::Serialization(err.to_string()) + } +} + +pub type Result = std::result::Result; diff --git a/crates/bevy-tasks-core/src/lib.rs b/crates/bevy-tasks-core/src/lib.rs new file mode 100644 index 0000000..ff02f00 --- /dev/null +++ b/crates/bevy-tasks-core/src/lib.rs @@ -0,0 +1,10 @@ +pub mod models; +pub mod storage; +pub mod repository; +pub mod config; +pub mod error; + +pub use models::{Task, TaskStatus, TaskList}; +pub use repository::TaskRepository; +pub use config::{AppConfig, WorkspaceConfig}; +pub use error::{Error, Result}; diff --git a/crates/bevy-tasks-core/src/models.rs b/crates/bevy-tasks-core/src/models.rs new file mode 100644 index 0000000..b614af6 --- /dev/null +++ b/crates/bevy-tasks-core/src/models.rs @@ -0,0 +1,121 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TaskStatus { + Backlog, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Task { + pub id: Uuid, + pub title: String, + pub description: String, + pub status: TaskStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub due_date: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, +} + +impl Task { + pub fn new(title: String) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + title, + description: String::new(), + status: TaskStatus::Backlog, + due_date: None, + created_at: now, + updated_at: now, + parent_id: None, + } + } + + pub fn with_description(mut self, description: String) -> Self { + self.description = description; + self + } + + pub fn with_due_date(mut self, due_date: DateTime) -> Self { + self.due_date = Some(due_date); + self + } + + pub fn with_parent(mut self, parent_id: Uuid) -> Self { + self.parent_id = Some(parent_id); + self + } + + pub fn complete(&mut self) { + self.status = TaskStatus::Completed; + self.updated_at = Utc::now(); + } + + pub fn uncomplete(&mut self) { + self.status = TaskStatus::Backlog; + self.updated_at = Utc::now(); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskList { + pub id: Uuid, + pub title: String, + pub tasks: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, + pub group_by_due_date: bool, +} + +impl TaskList { + pub fn new(title: String) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + title, + tasks: Vec::new(), + created_at: now, + updated_at: now, + group_by_due_date: false, + } + } + + pub fn add_task(&mut self, task: Task) { + self.tasks.push(task); + self.updated_at = Utc::now(); + } + + pub fn remove_task(&mut self, task_id: Uuid) -> Option { + if let Some(pos) = self.tasks.iter().position(|t| t.id == task_id) { + self.updated_at = Utc::now(); + Some(self.tasks.remove(pos)) + } else { + None + } + } + + pub fn get_task(&self, task_id: Uuid) -> Option<&Task> { + self.tasks.iter().find(|t| t.id == task_id) + } + + pub fn get_task_mut(&mut self, task_id: Uuid) -> Option<&mut Task> { + self.tasks.iter_mut().find(|t| t.id == task_id) + } + + pub fn update_task(&mut self, task: Task) -> bool { + if let Some(existing) = self.get_task_mut(task.id) { + *existing = task; + self.updated_at = Utc::now(); + true + } else { + false + } + } +} diff --git a/crates/bevy-tasks-core/src/repository.rs b/crates/bevy-tasks-core/src/repository.rs new file mode 100644 index 0000000..fe7f59d --- /dev/null +++ b/crates/bevy-tasks-core/src/repository.rs @@ -0,0 +1,208 @@ +use std::path::PathBuf; +use uuid::Uuid; +use crate::error::{Error, Result}; +use crate::models::{Task, TaskList}; +use crate::storage::{FileSystemStorage, Storage}; + +pub struct TaskRepository { + storage: Box, +} + +impl TaskRepository { + pub fn new(tasks_folder: PathBuf) -> Result { + let storage = FileSystemStorage::new(tasks_folder)?; + Ok(Self { + storage: Box::new(storage), + }) + } + + pub fn init(tasks_folder: PathBuf) -> Result { + let storage = FileSystemStorage::init(tasks_folder)?; + Ok(Self { + storage: Box::new(storage), + }) + } + + // Task operations + pub fn create_task(&mut self, list_id: Uuid, task: Task) -> Result { + self.storage.write_task(list_id, &task)?; + Ok(task) + } + + pub fn get_task(&self, list_id: Uuid, task_id: Uuid) -> Result { + self.storage.read_task(list_id, task_id) + } + + pub fn update_task(&mut self, list_id: Uuid, task: Task) -> Result<()> { + // Verify task exists first + let _ = self.storage.read_task(list_id, task.id)?; + self.storage.write_task(list_id, &task)?; + Ok(()) + } + + pub fn delete_task(&mut self, list_id: Uuid, task_id: Uuid) -> Result<()> { + self.storage.delete_task(list_id, task_id) + } + + pub fn list_tasks(&self, list_id: Uuid) -> Result> { + self.storage.list_tasks(list_id) + } + + // List operations + pub fn create_list(&mut self, name: String) -> Result { + self.storage.create_list(name) + } + + pub fn get_lists(&self) -> Result> { + self.storage.get_lists() + } + + pub fn get_list(&self, list_id: Uuid) -> Result { + let lists = self.get_lists()?; + lists.into_iter() + .find(|list| list.id == list_id) + .ok_or_else(|| Error::ListNotFound(list_id.to_string())) + } + + pub fn delete_list(&mut self, list_id: Uuid) -> Result<()> { + self.storage.delete_list(list_id) + } + + // Task ordering + pub fn reorder_task(&mut self, list_id: Uuid, task_id: Uuid, new_position: usize) -> Result<()> { + let mut metadata = self.storage.read_list_metadata(list_id)?; + + // Find current position + let current_pos = metadata.task_order.iter().position(|&id| id == task_id) + .ok_or_else(|| Error::TaskNotFound(task_id.to_string()))?; + + // Remove from current position + metadata.task_order.remove(current_pos); + + // Insert at new position + let new_pos = new_position.min(metadata.task_order.len()); + metadata.task_order.insert(new_pos, task_id); + + metadata.updated_at = chrono::Utc::now(); + self.storage.write_list_metadata(&metadata)?; + + Ok(()) + } + + pub fn get_task_order(&self, list_id: Uuid) -> Result> { + let metadata = self.storage.read_list_metadata(list_id)?; + Ok(metadata.task_order) + } + + // Grouping preference + pub fn set_group_by_due_date(&mut self, list_id: Uuid, enabled: bool) -> Result<()> { + let mut metadata = self.storage.read_list_metadata(list_id)?; + metadata.group_by_due_date = enabled; + metadata.updated_at = chrono::Utc::now(); + self.storage.write_list_metadata(&metadata)?; + Ok(()) + } + + pub fn get_group_by_due_date(&self, list_id: Uuid) -> Result { + let metadata = self.storage.read_list_metadata(list_id)?; + Ok(metadata.group_by_due_date) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_init_repository() { + let temp_dir = TempDir::new().unwrap(); + let repo = TaskRepository::init(temp_dir.path().to_path_buf()); + assert!(repo.is_ok()); + } + + #[test] + fn test_create_and_list_tasks() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + + // Create a list + let list = repo.create_list("Test List".to_string()).unwrap(); + + // Create a task + let task = Task::new("Test Task".to_string()); + let created_task = repo.create_task(list.id, task).unwrap(); + + // List tasks + let tasks = repo.list_tasks(list.id).unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].title, "Test Task"); + } + + #[test] + fn test_update_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 List".to_string()).unwrap(); + let mut task = Task::new("Original".to_string()); + task = repo.create_task(list.id, task).unwrap(); + + task.title = "Updated".to_string(); + repo.update_task(list.id, task.clone()).unwrap(); + + let retrieved = repo.get_task(list.id, task.id).unwrap(); + assert_eq!(retrieved.title, "Updated"); + } + + #[test] + fn test_delete_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 List".to_string()).unwrap(); + let task = Task::new("To Delete".to_string()); + let task = repo.create_task(list.id, task).unwrap(); + + repo.delete_task(list.id, task.id).unwrap(); + + let tasks = repo.list_tasks(list.id).unwrap(); + assert_eq!(tasks.len(), 0); + } + + #[test] + fn test_reorder_tasks() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + + let list = repo.create_list("Test List".to_string()).unwrap(); + + let task1 = repo.create_task(list.id, Task::new("Task 1".to_string())).unwrap(); + let task2 = repo.create_task(list.id, Task::new("Task 2".to_string())).unwrap(); + let task3 = repo.create_task(list.id, Task::new("Task 3".to_string())).unwrap(); + + // Move task3 to position 0 + repo.reorder_task(list.id, task3.id, 0).unwrap(); + + let order = repo.get_task_order(list.id).unwrap(); + assert_eq!(order[0], task3.id); + assert_eq!(order[1], task1.id); + assert_eq!(order[2], task2.id); + } + + #[test] + fn test_group_by_due_date() { + let temp_dir = TempDir::new().unwrap(); + let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + + let list = repo.create_list("Test List".to_string()).unwrap(); + + assert!(!repo.get_group_by_due_date(list.id).unwrap()); + + repo.set_group_by_due_date(list.id, true).unwrap(); + assert!(repo.get_group_by_due_date(list.id).unwrap()); + + repo.set_group_by_due_date(list.id, false).unwrap(); + assert!(!repo.get_group_by_due_date(list.id).unwrap()); + } +} diff --git a/crates/bevy-tasks-core/src/storage.rs b/crates/bevy-tasks-core/src/storage.rs new file mode 100644 index 0000000..999dedb --- /dev/null +++ b/crates/bevy-tasks-core/src/storage.rs @@ -0,0 +1,461 @@ +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, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_opened_list: Option, +} + +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, + pub updated_at: DateTime, + pub group_by_due_date: bool, + pub task_order: Vec, +} + +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>, + pub created: DateTime, + pub updated: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, +} + +impl From<&Task> for TaskFrontmatter { + fn from(task: &Task) -> Self { + Self { + id: task.id, + status: task.status, + due: task.due_date, + 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; + 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>; + + fn create_list(&mut self, name: String) -> Result; + fn get_lists(&self) -> Result>; + fn delete_list(&mut self, list_id: Uuid) -> Result<()>; + + fn read_root_metadata(&self) -> Result; + fn write_root_metadata(&mut self, metadata: &RootMetadata) -> Result<()>; + + fn read_list_metadata(&self, list_id: Uuid) -> Result; + fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()>; +} + +pub struct FileSystemStorage { + root_path: PathBuf, +} + +impl FileSystemStorage { + pub fn new(root_path: PathBuf) -> Result { + 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 { + 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 { + // Find the directory with this list ID + let metadata = self.read_root_metadata()?; + 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 task_file_path(&self, list_dir: &Path, task: &Task) -> PathBuf { + list_dir.join(format!("{}.md", task.title)) + } + + 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 { + 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 { + 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 { + 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, + 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); + + 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> { + 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, + created_at: frontmatter.created, + updated_at: frontmatter.updated, + parent_id: frontmatter.parent, + }; + + tasks.push(task); + } + } + + // Sort by task_order + let order_map: HashMap = 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 { + 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> { + 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 = 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 read_root_metadata(&self) -> Result { + 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 { + 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(()) + } +} diff --git a/crates/bevy-tasks-gui/Cargo.toml b/crates/bevy-tasks-gui/Cargo.toml new file mode 100644 index 0000000..8ebd01e --- /dev/null +++ b/crates/bevy-tasks-gui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bevy-tasks-gui" +version = "0.1.0" +edition = "2021" + +[dependencies] +bevy-tasks-core = { path = "../bevy-tasks-core" } +anyhow = { workspace = true } + +# GUI dependencies (Phase 3+) +# eframe = "0.31" +# egui = "0.31" diff --git a/crates/bevy-tasks-gui/src/main.rs b/crates/bevy-tasks-gui/src/main.rs new file mode 100644 index 0000000..03b2eb9 --- /dev/null +++ b/crates/bevy-tasks-gui/src/main.rs @@ -0,0 +1,7 @@ +// GUI implementation (Phase 3+) +// This is a placeholder for future development + +fn main() { + println!("GUI is not yet implemented. Use the CLI for now:"); + println!(" bevy-tasks --help"); +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..d5b6f9c --- /dev/null +++ b/docs/API.md @@ -0,0 +1,363 @@ +# Bevy Tasks Core - API Documentation + +## Overview + +The `bevy-tasks-core` library provides a complete backend for managing tasks in a local-first manner. Tasks are stored as markdown files with YAML frontmatter, compatible with Obsidian and other markdown editors. + +## Core Concepts + +### Data Models + +#### Task + +Represents an individual task. + +```rust +pub struct Task { + pub id: Uuid, + pub title: String, + pub description: String, + pub status: TaskStatus, + pub due_date: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub parent_id: Option, +} + +pub enum TaskStatus { + Backlog, // Not yet completed + Completed, // Done +} +``` + +**Creating a Task:** + +```rust +use bevy_tasks_core::Task; + +// Simple task +let task = Task::new("Buy groceries".to_string()); + +// Task with description and due date +let task = Task::new("Review PR #123".to_string()) + .with_description("Check the authentication changes".to_string()) + .with_due_date(chrono::Utc::now() + chrono::Duration::days(2)); +``` + +#### TaskList + +Represents a collection of tasks. + +```rust +pub struct TaskList { + pub id: Uuid, + pub title: String, + pub tasks: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, + pub group_by_due_date: bool, +} +``` + +### Configuration + +#### AppConfig + +Global application configuration supporting multiple workspaces. + +```rust +pub struct AppConfig { + pub workspaces: HashMap, + pub current_workspace: Option, +} +``` + +**Location:** +- Windows: `%APPDATA%/bevy-tasks/config.json` +- Linux: `~/.config/bevy-tasks/config.json` +- macOS: `~/Library/Application Support/bevy-tasks/config.json` + +**Usage:** + +```rust +use bevy_tasks_core::AppConfig; + +// Load config +let config_path = AppConfig::get_config_path(); +let mut config = AppConfig::load_from_file(&config_path)?; + +// Add workspace +config.add_workspace( + "personal".to_string(), + WorkspaceConfig::new(PathBuf::from("/home/user/tasks")) +); + +// Set current workspace +config.set_current_workspace("personal".to_string())?; + +// Save config +config.save_to_file(&config_path)?; +``` + +#### WorkspaceConfig + +Configuration for a single workspace. + +```rust +pub struct WorkspaceConfig { + pub path: PathBuf, +} +``` + +## TaskRepository API + +The main interface for interacting with tasks and lists. + +### Initialization + +```rust +use bevy_tasks_core::TaskRepository; +use std::path::PathBuf; + +// Open existing repository +let repo = TaskRepository::new(PathBuf::from("/path/to/tasks"))?; + +// Initialize new repository +let repo = TaskRepository::init(PathBuf::from("/path/to/tasks"))?; +``` + +### Task Operations + +#### Create Task + +```rust +let task = Task::new("My task".to_string()); +let created_task = repo.create_task(list_id, task)?; +``` + +#### Get Task + +```rust +let task = repo.get_task(list_id, task_id)?; +``` + +#### Update Task + +```rust +let mut task = repo.get_task(list_id, task_id)?; +task.title = "Updated title".to_string(); +task.complete(); +repo.update_task(list_id, task)?; +``` + +#### Delete Task + +```rust +repo.delete_task(list_id, task_id)?; +``` + +#### List Tasks + +```rust +let tasks = repo.list_tasks(list_id)?; +``` + +### List Operations + +#### Create List + +```rust +let list = repo.create_list("My List".to_string())?; +``` + +#### Get Lists + +```rust +let lists = repo.get_lists()?; +``` + +#### Get Specific List + +```rust +let list = repo.get_list(list_id)?; +``` + +#### Delete List + +```rust +repo.delete_list(list_id)?; +``` + +### Task Ordering + +#### Reorder Task + +```rust +// Move task to position 0 (first) +repo.reorder_task(list_id, task_id, 0)?; +``` + +#### Get Task Order + +```rust +let order = repo.get_task_order(list_id)?; +// Returns: Vec - ordered list of task IDs +``` + +### Grouping + +#### Enable/Disable Group by Due Date + +```rust +// Enable grouping +repo.set_group_by_due_date(list_id, true)?; + +// Disable grouping +repo.set_group_by_due_date(list_id, false)?; + +// Check current setting +let is_grouped = repo.get_group_by_due_date(list_id)?; +``` + +## File Format + +### Task Files + +Tasks are stored as `.md` files with YAML frontmatter: + +```markdown +--- +id: 550e8400-e29b-41d4-a716-446655440000 +status: backlog +due: 2025-11-15T14:00:00Z +created: 2025-10-26T10:00:00Z +updated: 2025-10-26T12:30:00Z +parent: 550e8400-e29b-41d4-a716-446655440001 +--- + +Task description and notes go here in **markdown** format. + +- Can include lists +- Rich formatting +- Links, etc. +``` + +The filename (without `.md`) becomes the task title. + +### List Metadata + +Each list folder contains a `.listdata.json` file: + +```json +{ + "id": "list-uuid-1", + "created_at": "2025-10-26T10:00:00Z", + "updated_at": "2025-10-27T14:30:00Z", + "group_by_due_date": false, + "task_order": [ + "task-uuid-1", + "task-uuid-2", + "task-uuid-3" + ] +} +``` + +### Root Metadata + +The root folder contains a `.metadata.json` file: + +```json +{ + "version": 1, + "list_order": ["list-uuid-1", "list-uuid-2"], + "last_opened_list": "list-uuid-1" +} +``` + +## Error Handling + +All operations return `Result` where `Error` is: + +```rust +pub enum Error { + Io(io::Error), + Serialization(String), + NotFound(String), + InvalidData(String), + WorkspaceNotFound(String), + ListNotFound(String), + TaskNotFound(String), +} +``` + +## Example: Complete Workflow + +```rust +use bevy_tasks_core::{TaskRepository, Task, AppConfig, WorkspaceConfig}; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + // Initialize repository + let path = PathBuf::from("/home/user/tasks"); + let mut repo = TaskRepository::init(path.clone())?; + + // Create a list + let list = repo.create_list("My Tasks".to_string())?; + + // Create tasks + let task1 = Task::new("Buy groceries".to_string()); + let task1 = repo.create_task(list.id, task1)?; + + let task2 = Task::new("Call dentist".to_string()) + .with_due_date(chrono::Utc::now() + chrono::Duration::days(1)); + let task2 = repo.create_task(list.id, task2)?; + + // List all tasks + let tasks = repo.list_tasks(list.id)?; + for task in tasks { + println!("- [{}] {}", + if task.status == TaskStatus::Completed { "✓" } else { " " }, + task.title + ); + } + + // Complete a task + let mut task = repo.get_task(list.id, task1.id)?; + task.complete(); + repo.update_task(list.id, task)?; + + // Configure workspace + let mut config = AppConfig::new(); + config.add_workspace("personal".to_string(), WorkspaceConfig::new(path)); + config.set_current_workspace("personal".to_string())?; + config.save_to_file(&AppConfig::get_config_path())?; + + Ok(()) +} +``` + +## Testing + +The core library includes comprehensive tests. Run them with: + +```bash +cargo test -p bevy-tasks-core +``` + +Key test areas: +- Task CRUD operations +- List management +- Task ordering +- Markdown parsing +- Metadata persistence +- Error handling + +## Thread Safety + +**Note:** The current implementation is not thread-safe. If you need concurrent access: + +1. Use external synchronization (e.g., `Mutex`) +2. Create separate repository instances per thread (file system will handle locking) +3. Consider implementing a service layer with proper locking + +Future versions may include built-in concurrency support. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..cbabb6d --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,335 @@ +# Development Guide + +## Getting Started + +### Prerequisites + +- Rust 1.70 or higher +- Git +- A text editor or IDE with Rust support (VS Code with rust-analyzer recommended) + +### Initial Setup + +```bash +# Clone the repository +git clone +cd bevy-tasks + +# Build the project +cargo build + +# Run tests +cargo test + +# Run the CLI +cargo run -p bevy-tasks-cli -- --help +``` + +## Project Structure + +``` +bevy-tasks/ +├── Cargo.toml # Workspace manifest +├── crates/ +│ ├── bevy-tasks-core/ # Core library +│ │ ├── src/ +│ │ │ ├── lib.rs # Library entry point +│ │ │ ├── models.rs # Data models (Task, TaskList, etc.) +│ │ │ ├── config.rs # Configuration (AppConfig, WorkspaceConfig) +│ │ │ ├── storage.rs # Storage trait and filesystem implementation +│ │ │ ├── repository.rs # Repository pattern (TaskRepository) +│ │ │ └── error.rs # Error types +│ │ └── Cargo.toml +│ ├── bevy-tasks-cli/ # CLI application +│ │ ├── src/ +│ │ │ ├── main.rs # CLI entry point and command parsing +│ │ │ ├── output.rs # Output formatting utilities +│ │ │ └── commands/ +│ │ │ ├── mod.rs # Commands module +│ │ │ ├── init.rs # Initialize workspace +│ │ │ ├── workspace.rs # Workspace management +│ │ │ ├── list.rs # List management +│ │ │ ├── task.rs # Task operations +│ │ │ └── group.rs # Grouping commands +│ │ └── Cargo.toml +│ └── bevy-tasks-gui/ # GUI application (Phase 3+) +│ ├── src/ +│ │ └── main.rs # Placeholder +│ └── Cargo.toml +└── docs/ + ├── API.md # API documentation + └── DEVELOPMENT.md # This file +``` + +## Development Workflow + +### Running Tests + +```bash +# Run all tests +cargo test + +# Run tests for a specific crate +cargo test -p bevy-tasks-core + +# Run a specific test +cargo test -p bevy-tasks-core test_create_and_list_tasks + +# Run tests with output +cargo test -- --nocapture +``` + +### Building + +```bash +# Debug build +cargo build + +# Release build (optimized) +cargo build --release + +# Build specific crate +cargo build -p bevy-tasks-cli +``` + +### Running the CLI in Development + +```bash +# Run with cargo (recommended for development) +cargo run -p bevy-tasks-cli -- init ~/test-tasks --name test + +# Run the compiled binary +./target/debug/bevy-tasks init ~/test-tasks --name test +``` + +## Code Style + +### Formatting + +We use rustfmt for code formatting: + +```bash +# Format all code +cargo fmt + +# Check formatting without modifying files +cargo fmt -- --check +``` + +### Linting + +We use clippy for linting: + +```bash +# Run clippy +cargo clippy + +# Run clippy with all warnings +cargo clippy -- -W clippy::all +``` + +## Architecture Guidelines + +### Core Library (`bevy-tasks-core`) + +**Principles:** +- Pure Rust, no CLI dependencies +- Clear separation between models, storage, and repository +- Comprehensive error handling +- Well-tested (aim for >80% coverage) + +**Adding a new feature:** + +1. Start with the data model in `models.rs` +2. Update storage layer in `storage.rs` if needed +3. Add repository methods in `repository.rs` +4. Write tests +5. Update API documentation + +### CLI (`bevy-tasks-cli`) + +**Principles:** +- Thin layer over core library +- Clear command structure using clap +- User-friendly output with colored text +- Consistent error messages + +**Adding a new command:** + +1. Define command in `main.rs` using clap +2. Create command handler in `commands/` directory +3. Use `get_repository()` helper to access the core +4. Format output using `output.rs` helpers +5. Update README with usage examples + +## Testing Strategy + +### Unit Tests + +Located in the same file as the code they test: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_something() { + // Test code + } +} +``` + +### Integration Tests + +Located in `tests/` directories within each crate: + +```rust +// crates/bevy-tasks-core/tests/integration_test.rs +use bevy_tasks_core::*; + +#[test] +fn test_full_workflow() { + // Test complete workflows +} +``` + +### Test Data + +Use `tempfile` crate for temporary directories: + +```rust +use tempfile::TempDir; + +#[test] +fn test_with_temp_dir() { + let temp_dir = TempDir::new().unwrap(); + let repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap(); + // ... test code +} +``` + +## Common Tasks + +### Adding a New Field to Task + +1. Update `Task` struct in `models.rs` +2. Update `TaskFrontmatter` in `storage.rs` +3. Update markdown parsing/writing in `storage.rs` +4. Add migration logic if needed +5. Update tests +6. Update documentation + +### Adding a New CLI Command + +1. Add command to `Commands` enum in `main.rs` +2. Add match arm in `main()` function +3. Create command handler in `commands/` directory +4. Update README with usage example + +### Debugging Storage Issues + +Enable detailed logging: + +```rust +// In test or development code +std::env::set_var("RUST_LOG", "debug"); +``` + +Inspect the file system directly: + +```bash +# Check metadata +cat ~/test-tasks/.metadata.json | jq + +# Check list metadata +cat ~/test-tasks/My\ Tasks/.listdata.json | jq + +# Check task file +cat ~/test-tasks/My\ Tasks/Example\ task.md +``` + +## Release Process + +### Version Numbering + +We follow [Semantic Versioning](https://semver.org/): +- MAJOR: Incompatible API changes +- MINOR: New functionality, backwards compatible +- PATCH: Bug fixes, backwards compatible + +### Creating a Release + +1. Update version in all `Cargo.toml` files +2. Update `CHANGELOG.md` +3. Create git tag: `git tag v0.1.0` +4. Build release binaries: `cargo build --release` +5. Test release binaries +6. Push tag: `git push origin v0.1.0` + +## Troubleshooting + +### Cargo Build Fails + +```bash +# Clean build artifacts +cargo clean + +# Update dependencies +cargo update + +# Check for errors +cargo check +``` + +### Tests Fail + +```bash +# Run single test with output +cargo test test_name -- --nocapture + +# Check for file system issues +ls -la ~/test-tasks +``` + +### CLI Command Doesn't Work + +```bash +# Verify workspace configuration +cat ~/.config/bevy-tasks/config.json | jq + +# Check current workspace +cargo run -p bevy-tasks-cli -- workspace list + +# Initialize if needed +cargo run -p bevy-tasks-cli -- init ~/test-tasks --name test +``` + +## Contributing + +### Before Submitting a PR + +1. Run tests: `cargo test` +2. Format code: `cargo fmt` +3. Lint code: `cargo clippy` +4. Update documentation +5. Add tests for new features +6. Update CHANGELOG.md + +### Commit Messages + +Follow conventional commits: +- `feat: Add new feature` +- `fix: Fix bug` +- `docs: Update documentation` +- `test: Add tests` +- `refactor: Refactor code` + +## Resources + +- [Rust Book](https://doc.rust-lang.org/book/) +- [Cargo Book](https://doc.rust-lang.org/cargo/) +- [clap Documentation](https://docs.rs/clap/) +- [serde Documentation](https://serde.rs/) +- [PLAN.md](../PLAN.md) - Project roadmap +- [API.md](API.md) - API documentation diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index dcaf622..0000000 --- a/src/main.rs +++ /dev/null @@ -1,12 +0,0 @@ -use bevy::prelude::*; - -fn main() { - App::new() - .add_plugins(DefaultPlugins) - .add_systems(Update, hello_world_system) - .run(); -} - -fn hello_world_system() { - println!("hello world"); -} \ No newline at end of file