progress of some kind

This commit is contained in:
Tristan Michael 2026-03-17 05:49:48 -07:00
parent db18391307
commit 1d90354fd3
24 changed files with 2818 additions and 18 deletions

14
.gitignore vendored
View file

@ -1 +1,15 @@
# Rust
/target
**/target/
Cargo.lock
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

View file

@ -1,7 +1,14 @@
[package]
name = "bevy-tasks"
version = "0.1.0"
edition = "2024"
[workspace]
members = [
"crates/bevy-tasks-core",
"crates/bevy-tasks-cli",
"crates/bevy-tasks-gui",
]
resolver = "2"
[dependencies]
bevy = "0.16.1"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.0", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
tokio = { version = "1.40", features = ["full"] }

197
README.md Normal file
View 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.

View 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"

View 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(())
}

View 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(())
}

View 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(())
}

View 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))
}

View 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)")
}

View 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(())
}

View 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(())
}

View 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);
}

View 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"

View 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")
}
}

View 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>;

View 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};

View 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
}
}
}

View 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());
}
}

View 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(())
}
}

View 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"

View 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
View 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
View 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

View file

@ -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");
}