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
|
||||||
|
**/target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
|
||||||
19
Cargo.toml
19
Cargo.toml
|
|
@ -1,7 +1,14 @@
|
||||||
[package]
|
[workspace]
|
||||||
name = "bevy-tasks"
|
members = [
|
||||||
version = "0.1.0"
|
"crates/bevy-tasks-core",
|
||||||
edition = "2024"
|
"crates/bevy-tasks-cli",
|
||||||
|
"crates/bevy-tasks-gui",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
[dependencies]
|
[workspace.dependencies]
|
||||||
bevy = "0.16.1"
|
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