progress of some kind
This commit is contained in:
parent
db18391307
commit
1d90354fd3
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -1 +1,15 @@
|
|||
# Rust
|
||||
/target
|
||||
**/target/
|
||||
Cargo.lock
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
19
Cargo.toml
19
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"
|
||||
[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"] }
|
||||
|
|
|
|||
197
README.md
Normal file
197
README.md
Normal file
|
|
@ -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 <repository-url>
|
||||
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 <task-id>
|
||||
|
||||
# Edit a task (opens in $EDITOR)
|
||||
cargo run -p bevy-tasks-cli -- edit <task-id>
|
||||
|
||||
# Delete a task
|
||||
cargo run -p bevy-tasks-cli -- delete <task-id>
|
||||
```
|
||||
|
||||
### 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.
|
||||
17
crates/bevy-tasks-cli/Cargo.toml
Normal file
17
crates/bevy-tasks-cli/Cargo.toml
Normal file
|
|
@ -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"
|
||||
39
crates/bevy-tasks-cli/src/commands/group.rs
Normal file
39
crates/bevy-tasks-cli/src/commands/group.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use anyhow::{Context, Result};
|
||||
use crate::output;
|
||||
use crate::commands::get_repository;
|
||||
|
||||
pub fn enable(list_name: String, workspace: Option<String>) -> 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<String>) -> 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(())
|
||||
}
|
||||
40
crates/bevy-tasks-cli/src/commands/init.rs
Normal file
40
crates/bevy-tasks-cli/src/commands/init.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
116
crates/bevy-tasks-cli/src/commands/list.rs
Normal file
116
crates/bevy-tasks-cli/src/commands/list.rs
Normal file
|
|
@ -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<String>) -> 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<String>, workspace: Option<String>) -> 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 <name>'");
|
||||
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<String>) -> 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(())
|
||||
}
|
||||
42
crates/bevy-tasks-cli/src/commands/mod.rs
Normal file
42
crates/bevy-tasks-cli/src/commands/mod.rs
Normal file
|
|
@ -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<AppConfig> {
|
||||
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<String>) -> 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))
|
||||
}
|
||||
212
crates/bevy-tasks-cli/src/commands/task.rs
Normal file
212
crates/bevy-tasks-cli/src/commands/task.rs
Normal file
|
|
@ -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<String>, due_str: Option<String>, workspace: Option<String>) -> 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 <name>'");
|
||||
}
|
||||
|
||||
// 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<String>) -> 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<String>) -> 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<String>) -> 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<DateTime<Utc>> {
|
||||
// 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)")
|
||||
}
|
||||
203
crates/bevy-tasks-cli/src/commands/workspace.rs
Normal file
203
crates/bevy-tasks-cli/src/commands/workspace.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
240
crates/bevy-tasks-cli/src/main.rs
Normal file
240
crates/bevy-tasks-cli/src/main.rs
Normal file
|
|
@ -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<String>,
|
||||
/// Due date (ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)
|
||||
#[arg(short, long)]
|
||||
due: Option<String>,
|
||||
/// Workspace to use
|
||||
#[arg(short, long)]
|
||||
workspace: Option<String>,
|
||||
},
|
||||
|
||||
/// Mark a task as complete
|
||||
Complete {
|
||||
/// Task ID
|
||||
task_id: String,
|
||||
/// Workspace to use
|
||||
#[arg(short, long)]
|
||||
workspace: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a task
|
||||
Delete {
|
||||
/// Task ID
|
||||
task_id: String,
|
||||
/// Workspace to use
|
||||
#[arg(short, long)]
|
||||
workspace: Option<String>,
|
||||
},
|
||||
|
||||
/// Edit a task
|
||||
Edit {
|
||||
/// Task ID
|
||||
task_id: String,
|
||||
/// Workspace to use
|
||||
#[arg(short, long)]
|
||||
workspace: Option<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
/// Show all tasks (or tasks in a specific list)
|
||||
Show {
|
||||
/// Name of the list to show
|
||||
#[arg(short, long)]
|
||||
list: Option<String>,
|
||||
/// Workspace to use
|
||||
#[arg(short, long)]
|
||||
workspace: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a task list
|
||||
Delete {
|
||||
/// Name of the list to delete
|
||||
name: String,
|
||||
/// Workspace to use
|
||||
#[arg(short, long)]
|
||||
workspace: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
17
crates/bevy-tasks-cli/src/output.rs
Normal file
17
crates/bevy-tasks-cli/src/output.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
16
crates/bevy-tasks-core/Cargo.toml
Normal file
16
crates/bevy-tasks-core/Cargo.toml
Normal file
|
|
@ -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"
|
||||
86
crates/bevy-tasks-core/src/config.rs
Normal file
86
crates/bevy-tasks-core/src/config.rs
Normal file
|
|
@ -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<String, WorkspaceConfig>,
|
||||
pub current_workspace: Option<String>,
|
||||
}
|
||||
|
||||
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<WorkspaceConfig> {
|
||||
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<Self> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
49
crates/bevy-tasks-core/src/error.rs
Normal file
49
crates/bevy-tasks-core/src/error.rs
Normal file
|
|
@ -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<io::Error> for Error {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Error::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Error::Serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_yaml::Error> for Error {
|
||||
fn from(err: serde_yaml::Error) -> Self {
|
||||
Error::Serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
10
crates/bevy-tasks-core/src/lib.rs
Normal file
10
crates/bevy-tasks-core/src/lib.rs
Normal file
|
|
@ -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};
|
||||
121
crates/bevy-tasks-core/src/models.rs
Normal file
121
crates/bevy-tasks-core/src/models.rs
Normal file
|
|
@ -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<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
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<Utc>) -> 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<Task>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
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<Task> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
208
crates/bevy-tasks-core/src/repository.rs
Normal file
208
crates/bevy-tasks-core/src/repository.rs
Normal file
|
|
@ -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<dyn Storage>,
|
||||
}
|
||||
|
||||
impl TaskRepository {
|
||||
pub fn new(tasks_folder: PathBuf) -> Result<Self> {
|
||||
let storage = FileSystemStorage::new(tasks_folder)?;
|
||||
Ok(Self {
|
||||
storage: Box::new(storage),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn init(tasks_folder: PathBuf) -> Result<Self> {
|
||||
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<Task> {
|
||||
self.storage.write_task(list_id, &task)?;
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
pub fn get_task(&self, list_id: Uuid, task_id: Uuid) -> Result<Task> {
|
||||
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<Vec<Task>> {
|
||||
self.storage.list_tasks(list_id)
|
||||
}
|
||||
|
||||
// List operations
|
||||
pub fn create_list(&mut self, name: String) -> Result<TaskList> {
|
||||
self.storage.create_list(name)
|
||||
}
|
||||
|
||||
pub fn get_lists(&self) -> Result<Vec<TaskList>> {
|
||||
self.storage.get_lists()
|
||||
}
|
||||
|
||||
pub fn get_list(&self, list_id: Uuid) -> Result<TaskList> {
|
||||
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<Vec<Uuid>> {
|
||||
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<bool> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
461
crates/bevy-tasks-core/src/storage.rs
Normal file
461
crates/bevy-tasks-core/src/storage.rs
Normal file
|
|
@ -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<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_opened_list: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl Default for RootMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
list_order: Vec::new(),
|
||||
last_opened_list: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata stored in each list's .listdata.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListMetadata {
|
||||
pub id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub group_by_due_date: bool,
|
||||
pub task_order: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl ListMetadata {
|
||||
pub fn new(id: Uuid) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
group_by_due_date: false,
|
||||
task_order: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontmatter for task markdown files
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskFrontmatter {
|
||||
pub id: Uuid,
|
||||
pub status: TaskStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub due: Option<DateTime<Utc>>,
|
||||
pub created: DateTime<Utc>,
|
||||
pub updated: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl From<&Task> for TaskFrontmatter {
|
||||
fn from(task: &Task) -> Self {
|
||||
Self {
|
||||
id: task.id,
|
||||
status: task.status,
|
||||
due: task.due_date,
|
||||
created: task.created_at,
|
||||
updated: task.updated_at,
|
||||
parent: task.parent_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Storage {
|
||||
fn read_task(&self, list_id: Uuid, task_id: Uuid) -> Result<Task>;
|
||||
fn write_task(&mut self, list_id: Uuid, task: &Task) -> Result<()>;
|
||||
fn delete_task(&mut self, list_id: Uuid, task_id: Uuid) -> Result<()>;
|
||||
fn list_tasks(&self, list_id: Uuid) -> Result<Vec<Task>>;
|
||||
|
||||
fn create_list(&mut self, name: String) -> Result<TaskList>;
|
||||
fn get_lists(&self) -> Result<Vec<TaskList>>;
|
||||
fn delete_list(&mut self, list_id: Uuid) -> Result<()>;
|
||||
|
||||
fn read_root_metadata(&self) -> Result<RootMetadata>;
|
||||
fn write_root_metadata(&mut self, metadata: &RootMetadata) -> Result<()>;
|
||||
|
||||
fn read_list_metadata(&self, list_id: Uuid) -> Result<ListMetadata>;
|
||||
fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()>;
|
||||
}
|
||||
|
||||
pub struct FileSystemStorage {
|
||||
root_path: PathBuf,
|
||||
}
|
||||
|
||||
impl FileSystemStorage {
|
||||
pub fn new(root_path: PathBuf) -> Result<Self> {
|
||||
if !root_path.exists() {
|
||||
return Err(Error::NotFound(format!("Path does not exist: {:?}", root_path)));
|
||||
}
|
||||
Ok(Self { root_path })
|
||||
}
|
||||
|
||||
pub fn init(root_path: PathBuf) -> Result<Self> {
|
||||
fs::create_dir_all(&root_path)?;
|
||||
|
||||
let storage = Self { root_path };
|
||||
|
||||
// Create default metadata if it doesn't exist
|
||||
if !storage.metadata_path().exists() {
|
||||
storage.write_root_metadata_internal(&RootMetadata::default())?;
|
||||
}
|
||||
|
||||
Ok(storage)
|
||||
}
|
||||
|
||||
fn metadata_path(&self) -> PathBuf {
|
||||
self.root_path.join(".metadata.json")
|
||||
}
|
||||
|
||||
fn list_dir_path(&self, list_id: Uuid) -> Result<PathBuf> {
|
||||
// Find the directory with this list ID
|
||||
let 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<String> {
|
||||
let frontmatter = TaskFrontmatter::from(task);
|
||||
let yaml = serde_yaml::to_string(&frontmatter)?;
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
content.push_str(&yaml);
|
||||
content.push_str("---\n\n");
|
||||
content.push_str(&task.description);
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn read_root_metadata_internal(&self) -> Result<RootMetadata> {
|
||||
let path = self.metadata_path();
|
||||
if !path.exists() {
|
||||
return Ok(RootMetadata::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let metadata = serde_json::from_str(&content)?;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn write_root_metadata_internal(&self, metadata: &RootMetadata) -> Result<()> {
|
||||
let path = self.metadata_path();
|
||||
let content = serde_json::to_string_pretty(&metadata)?;
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage for FileSystemStorage {
|
||||
fn read_task(&self, list_id: Uuid, task_id: Uuid) -> Result<Task> {
|
||||
let list_dir = self.list_dir_path(list_id)?;
|
||||
|
||||
// Read all task files in the list directory
|
||||
let entries = fs::read_dir(&list_dir)?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let (frontmatter, description) = self.parse_markdown_with_frontmatter(&content)?;
|
||||
|
||||
if frontmatter.id == task_id {
|
||||
let title = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| Error::InvalidData("Invalid filename".to_string()))?
|
||||
.to_string();
|
||||
|
||||
return Ok(Task {
|
||||
id: frontmatter.id,
|
||||
title,
|
||||
description,
|
||||
status: frontmatter.status,
|
||||
due_date: frontmatter.due,
|
||||
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<Vec<Task>> {
|
||||
let list_dir = self.list_dir_path(list_id)?;
|
||||
let list_metadata = self.read_list_metadata(list_id)?;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
let entries = fs::read_dir(&list_dir)?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let (frontmatter, description) = self.parse_markdown_with_frontmatter(&content)?;
|
||||
|
||||
let title = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| Error::InvalidData("Invalid filename".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let task = Task {
|
||||
id: frontmatter.id,
|
||||
title,
|
||||
description,
|
||||
status: frontmatter.status,
|
||||
due_date: frontmatter.due,
|
||||
created_at: frontmatter.created,
|
||||
updated_at: frontmatter.updated,
|
||||
parent_id: frontmatter.parent,
|
||||
};
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by task_order
|
||||
let order_map: HashMap<Uuid, usize> = list_metadata.task_order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &id)| (id, i))
|
||||
.collect();
|
||||
|
||||
tasks.sort_by_key(|task| order_map.get(&task.id).copied().unwrap_or(usize::MAX));
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
fn create_list(&mut self, name: String) -> Result<TaskList> {
|
||||
let list_dir = self.list_dir_path_by_name(&name);
|
||||
|
||||
if list_dir.exists() {
|
||||
return Err(Error::InvalidData(format!("List '{}' already exists", name)));
|
||||
}
|
||||
|
||||
fs::create_dir_all(&list_dir)?;
|
||||
|
||||
let list_id = Uuid::new_v4();
|
||||
let list_metadata = ListMetadata::new(list_id);
|
||||
|
||||
let metadata_path = list_dir.join(".listdata.json");
|
||||
let content = serde_json::to_string_pretty(&list_metadata)?;
|
||||
fs::write(&metadata_path, content)?;
|
||||
|
||||
// Add to root metadata
|
||||
let mut root_metadata = self.read_root_metadata_internal()?;
|
||||
root_metadata.list_order.push(list_id);
|
||||
if root_metadata.last_opened_list.is_none() {
|
||||
root_metadata.last_opened_list = Some(list_id);
|
||||
}
|
||||
self.write_root_metadata_internal(&root_metadata)?;
|
||||
|
||||
let task_list = TaskList {
|
||||
id: list_id,
|
||||
title: name,
|
||||
tasks: Vec::new(),
|
||||
created_at: list_metadata.created_at,
|
||||
updated_at: list_metadata.updated_at,
|
||||
group_by_due_date: list_metadata.group_by_due_date,
|
||||
};
|
||||
|
||||
Ok(task_list)
|
||||
}
|
||||
|
||||
fn get_lists(&self) -> Result<Vec<TaskList>> {
|
||||
let root_metadata = self.read_root_metadata_internal()?;
|
||||
let mut lists = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(&self.root_path)?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
let listdata_path = path.join(".listdata.json");
|
||||
if listdata_path.exists() {
|
||||
let content = fs::read_to_string(&listdata_path)?;
|
||||
let list_metadata: ListMetadata = serde_json::from_str(&content)?;
|
||||
|
||||
let title = path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| Error::InvalidData("Invalid directory name".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let tasks = self.list_tasks(list_metadata.id)?;
|
||||
|
||||
let task_list = TaskList {
|
||||
id: list_metadata.id,
|
||||
title,
|
||||
tasks,
|
||||
created_at: list_metadata.created_at,
|
||||
updated_at: list_metadata.updated_at,
|
||||
group_by_due_date: list_metadata.group_by_due_date,
|
||||
};
|
||||
|
||||
lists.push(task_list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by list_order
|
||||
let order_map: HashMap<Uuid, usize> = root_metadata.list_order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &id)| (id, i))
|
||||
.collect();
|
||||
|
||||
lists.sort_by_key(|list| order_map.get(&list.id).copied().unwrap_or(usize::MAX));
|
||||
|
||||
Ok(lists)
|
||||
}
|
||||
|
||||
fn delete_list(&mut self, list_id: Uuid) -> Result<()> {
|
||||
let list_dir = self.list_dir_path(list_id)?;
|
||||
|
||||
fs::remove_dir_all(&list_dir)?;
|
||||
|
||||
// Remove from root metadata
|
||||
let mut root_metadata = self.read_root_metadata_internal()?;
|
||||
root_metadata.list_order.retain(|&id| id != list_id);
|
||||
if root_metadata.last_opened_list == Some(list_id) {
|
||||
root_metadata.last_opened_list = root_metadata.list_order.first().copied();
|
||||
}
|
||||
self.write_root_metadata_internal(&root_metadata)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_root_metadata(&self) -> Result<RootMetadata> {
|
||||
self.read_root_metadata_internal()
|
||||
}
|
||||
|
||||
fn write_root_metadata(&mut self, metadata: &RootMetadata) -> Result<()> {
|
||||
self.write_root_metadata_internal(metadata)
|
||||
}
|
||||
|
||||
fn read_list_metadata(&self, list_id: Uuid) -> Result<ListMetadata> {
|
||||
let list_dir = self.list_dir_path(list_id)?;
|
||||
let metadata_path = list_dir.join(".listdata.json");
|
||||
|
||||
if !metadata_path.exists() {
|
||||
return Err(Error::NotFound(format!("List metadata not found: {}", list_id)));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&metadata_path)?;
|
||||
let metadata = serde_json::from_str(&content)?;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()> {
|
||||
let list_dir = self.list_dir_path(metadata.id)?;
|
||||
let metadata_path = list_dir.join(".listdata.json");
|
||||
|
||||
let content = serde_json::to_string_pretty(&metadata)?;
|
||||
fs::write(&metadata_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
12
crates/bevy-tasks-gui/Cargo.toml
Normal file
12
crates/bevy-tasks-gui/Cargo.toml
Normal file
|
|
@ -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"
|
||||
7
crates/bevy-tasks-gui/src/main.rs
Normal file
7
crates/bevy-tasks-gui/src/main.rs
Normal file
|
|
@ -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");
|
||||
}
|
||||
363
docs/API.md
Normal file
363
docs/API.md
Normal file
|
|
@ -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<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
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<Task>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub group_by_due_date: bool,
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
#### AppConfig
|
||||
|
||||
Global application configuration supporting multiple workspaces.
|
||||
|
||||
```rust
|
||||
pub struct AppConfig {
|
||||
pub workspaces: HashMap<String, WorkspaceConfig>,
|
||||
pub current_workspace: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
**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<Uuid> - 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<T, Error>` 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<dyn std::error::Error>> {
|
||||
// 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<TaskRepository>`)
|
||||
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.
|
||||
335
docs/DEVELOPMENT.md
Normal file
335
docs/DEVELOPMENT.md
Normal file
|
|
@ -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 <repository-url>
|
||||
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
|
||||
12
src/main.rs
12
src/main.rs
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in a new issue