Implement Phase 1: Core Library & CLI MVP
Complete implementation of Phase 1 from PLAN.md: Core Library (bevy-tasks-core): - Data models: Task, TaskList, TaskStatus, AppConfig, WorkspaceConfig - Storage layer with filesystem implementation - Markdown file I/O with YAML frontmatter parsing (Obsidian-compatible) - TaskRepository providing full API for task management - Multiple workspace support - Manual task ordering and grouping by due date - Comprehensive unit tests for all components CLI Application (bevy-tasks-cli): - Complete command-line interface with clap - Workspace management: init, add, list, switch, remove, retarget, migrate - Task management: add, complete, delete, edit - List management: create, view - Group-by-due-date toggle - Colored output for better UX - Support for --workspace flag on all commands Infrastructure: - Cargo workspace with three crates (core, cli, gui placeholder) - Clean separation between backend and frontend - Platform-specific config storage - Task data stored as .md files with YAML frontmatter Documentation: - Comprehensive README with usage examples - Detailed inline documentation - Examples for all CLI commands This completes all deliverables for Phase 1 as specified in PLAN.md.
This commit is contained in:
parent
db18391307
commit
5664dead36
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"] }
|
||||||
|
|
|
||||||
278
README.md
Normal file
278
README.md
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
# Bevy Tasks
|
||||||
|
|
||||||
|
A local-first, cross-platform tasks application inspired by Google Tasks. Built with Rust for high performance and true native support across Windows, Linux, macOS, iOS, and Android.
|
||||||
|
|
||||||
|
## 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 (personal, shared, work, etc.)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Phase 1 (Current) - Core Library & CLI MVP
|
||||||
|
|
||||||
|
- ✅ Full-featured task management backend
|
||||||
|
- ✅ Multiple workspace support
|
||||||
|
- ✅ Markdown-based task storage with YAML frontmatter (Obsidian-compatible)
|
||||||
|
- ✅ Manual task ordering
|
||||||
|
- ✅ Due date support
|
||||||
|
- ✅ Task grouping by due date
|
||||||
|
- ✅ Command-line interface (CLI)
|
||||||
|
- ✅ Comprehensive test coverage
|
||||||
|
|
||||||
|
### Coming Soon
|
||||||
|
|
||||||
|
- **Phase 2**: WebDAV sync for cross-device synchronization
|
||||||
|
- **Phase 3**: Desktop GUI with egui
|
||||||
|
- **Phase 4**: iOS and Android support
|
||||||
|
- **Phase 5**: Advanced GUI features
|
||||||
|
- **Phase 6**: Mobile platform-specific features
|
||||||
|
- **Phase 7**: Advanced features, imports, and collaboration
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd bevy-tasks
|
||||||
|
|
||||||
|
# Build the CLI
|
||||||
|
cargo build --release -p bevy-tasks-cli
|
||||||
|
|
||||||
|
# The binary will be at: target/release/bevy-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Initialize Your First Workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new workspace named "personal" at ~/Documents/Tasks
|
||||||
|
bevy-tasks init ~/Documents/Tasks --name personal
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Create the tasks folder at the specified location
|
||||||
|
- Initialize the workspace with proper metadata
|
||||||
|
- Create a default list called "My Tasks"
|
||||||
|
- Set it as your current workspace
|
||||||
|
|
||||||
|
### Add Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a simple task
|
||||||
|
bevy-tasks add "Buy groceries"
|
||||||
|
|
||||||
|
# Add a task to a specific list
|
||||||
|
bevy-tasks add "Review PR #123" --list Work
|
||||||
|
|
||||||
|
# Add a task with a due date
|
||||||
|
bevy-tasks add "Team meeting" --due 2025-11-15
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all tasks in all lists
|
||||||
|
bevy-tasks list
|
||||||
|
|
||||||
|
# View tasks in a specific list
|
||||||
|
bevy-tasks list --list Work
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete and Delete Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Complete a task (use the task ID from list output)
|
||||||
|
bevy-tasks complete <task-id>
|
||||||
|
|
||||||
|
# Delete a task
|
||||||
|
bevy-tasks delete <task-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit a task in your default editor
|
||||||
|
bevy-tasks edit <task-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The edit command will open the task in your `$EDITOR` (or `nano` by default).
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
### Workspace Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a new workspace
|
||||||
|
bevy-tasks workspace add shared ~/Dropbox/TeamTasks
|
||||||
|
|
||||||
|
# List all workspaces
|
||||||
|
bevy-tasks workspace list
|
||||||
|
|
||||||
|
# Switch to a different workspace
|
||||||
|
bevy-tasks workspace switch shared
|
||||||
|
|
||||||
|
# Retarget a workspace (files already at new location)
|
||||||
|
bevy-tasks workspace retarget personal ~/new/path/to/Tasks
|
||||||
|
|
||||||
|
# Migrate workspace files to a new location
|
||||||
|
bevy-tasks workspace migrate personal ~/Dropbox/Tasks
|
||||||
|
|
||||||
|
# Remove a workspace (keeps files on disk)
|
||||||
|
bevy-tasks workspace remove shared
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new task list
|
||||||
|
bevy-tasks list create "Work"
|
||||||
|
|
||||||
|
# View all tasks
|
||||||
|
bevy-tasks list
|
||||||
|
|
||||||
|
# Enable grouping by due date
|
||||||
|
bevy-tasks group enable --list Work
|
||||||
|
|
||||||
|
# Disable grouping by due date
|
||||||
|
bevy-tasks group disable --list Work
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Specific Workspaces
|
||||||
|
|
||||||
|
Most commands support a `--workspace` flag to operate on a specific workspace without switching:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a task to a specific workspace
|
||||||
|
bevy-tasks add "Team standup" --workspace shared
|
||||||
|
|
||||||
|
# Complete a task in a specific workspace
|
||||||
|
bevy-tasks complete <task-id> --workspace shared
|
||||||
|
|
||||||
|
# View tasks from a specific workspace
|
||||||
|
bevy-tasks list --workspace shared
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Format
|
||||||
|
|
||||||
|
Tasks are stored as individual markdown 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
|
||||||
|
---
|
||||||
|
|
||||||
|
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 # Task file
|
||||||
|
│ └── Call dentist.md # Task file
|
||||||
|
└── Work/ # Another task list
|
||||||
|
├── .listdata.json
|
||||||
|
├── Review PRs.md
|
||||||
|
└── Team meeting prep.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Application configuration is stored in platform-specific locations:
|
||||||
|
|
||||||
|
- **Windows**: `%APPDATA%\bevy-tasks\config.json`
|
||||||
|
- **Linux**: `~/.config/bevy-tasks/config.json`
|
||||||
|
- **macOS**: `~/Library/Application Support/bevy-tasks/config.json`
|
||||||
|
|
||||||
|
The configuration file contains your workspace definitions and current workspace selection.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Bevy Tasks uses a clean separation between backend and frontend:
|
||||||
|
|
||||||
|
- **bevy-tasks-core**: Core library with data models, storage, and repository pattern
|
||||||
|
- **bevy-tasks-cli**: Command-line interface (current)
|
||||||
|
- **bevy-tasks-gui**: Graphical user interface (coming in Phase 3)
|
||||||
|
|
||||||
|
### Cargo Workspace Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bevy-tasks/
|
||||||
|
├── Cargo.toml # Workspace definition
|
||||||
|
├── PLAN.md # Development plan
|
||||||
|
├── README.md
|
||||||
|
├── crates/
|
||||||
|
│ ├── bevy-tasks-core/ # Core library (backend)
|
||||||
|
│ ├── bevy-tasks-cli/ # CLI frontend
|
||||||
|
│ └── bevy-tasks-gui/ # GUI frontend (Phase 3+)
|
||||||
|
└── docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test the core library
|
||||||
|
cargo test -p bevy-tasks-core
|
||||||
|
|
||||||
|
# Test all crates
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build everything
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Build just the CLI
|
||||||
|
cargo build -p bevy-tasks-cli
|
||||||
|
|
||||||
|
# Build in release mode
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the CLI directly
|
||||||
|
cargo run -p bevy-tasks-cli -- init ~/test-tasks --name test
|
||||||
|
cargo run -p bevy-tasks-cli -- add "Test task"
|
||||||
|
cargo run -p bevy-tasks-cli -- list
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 licensed under GPL v3.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit issues and pull requests.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
See [PLAN.md](PLAN.md) for the complete development roadmap and detailed feature plans.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Inspired by Google Tasks and built with modern Rust tooling for maximum performance and cross-platform support.
|
||||||
16
crates/bevy-tasks-cli/Cargo.toml
Normal file
16
crates/bevy-tasks-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[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"
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
23
crates/bevy-tasks-cli/src/commands/group.rs
Normal file
23
crates/bevy-tasks-cli/src/commands/group.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use colored::*;
|
||||||
|
|
||||||
|
use super::get_current_repo;
|
||||||
|
|
||||||
|
pub fn set_group(list_name: String, enabled: bool) -> Result<()> {
|
||||||
|
let mut repo = get_current_repo(None)?;
|
||||||
|
|
||||||
|
let list_id = repo.find_list_by_name(&list_name)?;
|
||||||
|
|
||||||
|
repo.set_group_by_due_date(list_id, enabled)?;
|
||||||
|
|
||||||
|
let action = if enabled { "Enabled" } else { "Disabled" };
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} {} group-by-due-date for list \"{}\"",
|
||||||
|
"✓".green(),
|
||||||
|
action,
|
||||||
|
list_name.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
27
crates/bevy-tasks-cli/src/commands/init.rs
Normal file
27
crates/bevy-tasks-cli/src/commands/init.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use bevy_tasks_core::{AppConfig, TaskRepository};
|
||||||
|
use colored::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn execute(path: PathBuf, name: String) -> Result<()> {
|
||||||
|
// Initialize the repository
|
||||||
|
TaskRepository::init(path.clone())?;
|
||||||
|
|
||||||
|
// Load or create app config
|
||||||
|
let mut config = AppConfig::load()?;
|
||||||
|
|
||||||
|
// Add workspace
|
||||||
|
config.add_workspace(name.clone(), path.clone())?;
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
config.save()?;
|
||||||
|
|
||||||
|
println!("{} Initialized workspace \"{}\" at {}",
|
||||||
|
"✓".green(),
|
||||||
|
name.bold(),
|
||||||
|
path.display());
|
||||||
|
println!("{} Created default list \"My Tasks\"", "✓".green());
|
||||||
|
println!("{} Set \"{}\" as current workspace", "✓".green(), name.bold());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
60
crates/bevy-tasks-cli/src/commands/list.rs
Normal file
60
crates/bevy-tasks-cli/src/commands/list.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use bevy_tasks_core::TaskStatus;
|
||||||
|
use colored::*;
|
||||||
|
|
||||||
|
use super::get_current_repo;
|
||||||
|
|
||||||
|
pub fn create(name: String, workspace: Option<String>) -> Result<()> {
|
||||||
|
let mut repo = get_current_repo(workspace)?;
|
||||||
|
|
||||||
|
repo.create_list(name.clone())?;
|
||||||
|
|
||||||
|
println!("{} Created list \"{}\"", "✓".green(), name.bold());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_all(workspace: Option<String>) -> Result<()> {
|
||||||
|
let repo = get_current_repo(workspace)?;
|
||||||
|
|
||||||
|
let lists = repo.get_lists()?;
|
||||||
|
|
||||||
|
if lists.is_empty() {
|
||||||
|
println!("No task lists found.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for list in lists {
|
||||||
|
let task_count = list.tasks.len();
|
||||||
|
let completed_count = list
|
||||||
|
.tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.status == TaskStatus::Completed)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{} ({} tasks, {} completed)",
|
||||||
|
list.title.bold(),
|
||||||
|
task_count,
|
||||||
|
completed_count
|
||||||
|
);
|
||||||
|
|
||||||
|
for task in &list.tasks {
|
||||||
|
let checkbox = if task.status == TaskStatus::Completed {
|
||||||
|
"[✓]".green()
|
||||||
|
} else {
|
||||||
|
"[ ]".normal()
|
||||||
|
};
|
||||||
|
|
||||||
|
let due_info = if let Some(due) = task.due_date {
|
||||||
|
format!(" (due: {})", due.format("%Y-%m-%d")).yellow()
|
||||||
|
} else {
|
||||||
|
"".normal()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(" {} {}{}", checkbox, task.title, due_info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
23
crates/bevy-tasks-cli/src/commands/mod.rs
Normal file
23
crates/bevy-tasks-cli/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
pub mod group;
|
||||||
|
pub mod init;
|
||||||
|
pub mod list;
|
||||||
|
pub mod task;
|
||||||
|
pub mod workspace;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use bevy_tasks_core::{AppConfig, TaskRepository};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Get the current workspace repository
|
||||||
|
pub fn get_current_repo(workspace_name: Option<String>) -> Result<TaskRepository> {
|
||||||
|
let mut config = AppConfig::load()?;
|
||||||
|
|
||||||
|
let workspace_path = if let Some(name) = workspace_name {
|
||||||
|
config.get_workspace(&name)?.path.clone()
|
||||||
|
} else {
|
||||||
|
let (_, workspace) = config.get_current_workspace()?;
|
||||||
|
workspace.path.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
TaskRepository::new(workspace_path)
|
||||||
|
}
|
||||||
155
crates/bevy-tasks-cli/src/commands/task.rs
Normal file
155
crates/bevy-tasks-cli/src/commands/task.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use bevy_tasks_core::Task;
|
||||||
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
|
use colored::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::Command;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::get_current_repo;
|
||||||
|
|
||||||
|
pub fn add(
|
||||||
|
title: String,
|
||||||
|
list_name: Option<String>,
|
||||||
|
due: Option<String>,
|
||||||
|
workspace: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut repo = get_current_repo(workspace)?;
|
||||||
|
|
||||||
|
// Get list ID
|
||||||
|
let list_id = if let Some(name) = list_name {
|
||||||
|
repo.find_list_by_name(&name)?
|
||||||
|
} else {
|
||||||
|
// Use first list
|
||||||
|
let lists = repo.get_lists()?;
|
||||||
|
if lists.is_empty() {
|
||||||
|
return Err(anyhow!("No lists found. Create a list first."));
|
||||||
|
}
|
||||||
|
lists[0].id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create task
|
||||||
|
let mut task = Task::new(title.clone());
|
||||||
|
|
||||||
|
// Parse due date if provided
|
||||||
|
if let Some(due_str) = due {
|
||||||
|
task.due_date = Some(parse_due_date(&due_str)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add task
|
||||||
|
repo.create_task(list_id, task.clone())?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} Created task \"{}\" ({})",
|
||||||
|
"✓".green(),
|
||||||
|
title.bold(),
|
||||||
|
task.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(due_date) = task.due_date {
|
||||||
|
println!(" Due: {}", due_date.format("%Y-%m-%d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn complete(task_id_str: String, workspace: Option<String>) -> Result<()> {
|
||||||
|
let mut repo = get_current_repo(workspace)?;
|
||||||
|
|
||||||
|
let task_id = Uuid::parse_str(&task_id_str)?;
|
||||||
|
let (list_id, task) = repo.find_task(task_id)?;
|
||||||
|
|
||||||
|
repo.complete_task(list_id, task_id)?;
|
||||||
|
|
||||||
|
println!("{} Completed task \"{}\"", "✓".green(), task.title.bold());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(task_id_str: String, workspace: Option<String>) -> Result<()> {
|
||||||
|
let mut repo = get_current_repo(workspace)?;
|
||||||
|
|
||||||
|
let task_id = Uuid::parse_str(&task_id_str)?;
|
||||||
|
let (list_id, task) = repo.find_task(task_id)?;
|
||||||
|
|
||||||
|
repo.delete_task(list_id, task_id)?;
|
||||||
|
|
||||||
|
println!("{} Deleted task \"{}\"", "✓".green(), task.title.bold());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit(task_id_str: String, workspace: Option<String>) -> Result<()> {
|
||||||
|
let mut repo = get_current_repo(workspace)?;
|
||||||
|
|
||||||
|
let task_id = Uuid::parse_str(&task_id_str)?;
|
||||||
|
let (list_id, task) = repo.find_task(task_id)?;
|
||||||
|
|
||||||
|
// Create temporary file with task content
|
||||||
|
let temp_file = std::env::temp_dir().join(format!("bevy-task-{}.md", task_id));
|
||||||
|
|
||||||
|
// Write current task content
|
||||||
|
let content = format!(
|
||||||
|
"# {}\n\nStatus: {:?}\nDue: {}\nCreated: {}\nUpdated: {}\n\n---\n\n{}",
|
||||||
|
task.title,
|
||||||
|
task.status,
|
||||||
|
task.due_date
|
||||||
|
.map(|d| d.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "None".to_string()),
|
||||||
|
task.created_at.to_rfc3339(),
|
||||||
|
task.updated_at.to_rfc3339(),
|
||||||
|
task.description
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::write(&temp_file, content)?;
|
||||||
|
|
||||||
|
// Get editor from environment or use default
|
||||||
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
|
||||||
|
|
||||||
|
// Open editor
|
||||||
|
let status = Command::new(&editor).arg(&temp_file).status()?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow!("Editor exited with error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read edited content
|
||||||
|
let edited_content = fs::read_to_string(&temp_file)?;
|
||||||
|
|
||||||
|
// Parse edited content (simple parsing for now)
|
||||||
|
let mut updated_task = task.clone();
|
||||||
|
updated_task.description = edited_content
|
||||||
|
.split("---")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Update task
|
||||||
|
repo.update_task(list_id, updated_task)?;
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
fs::remove_file(&temp_file)?;
|
||||||
|
|
||||||
|
println!("{} Updated task \"{}\"", "✓".green(), task.title.bold());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_due_date(date_str: &str) -> Result<DateTime<Utc>> {
|
||||||
|
// Try parsing as full datetime first
|
||||||
|
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
|
||||||
|
return Ok(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as date only (YYYY-MM-DD)
|
||||||
|
if let Ok(naive_date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||||
|
let naive_datetime = naive_date.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
return Ok(DateTime::from_naive_utc_and_offset(naive_datetime, Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!(
|
||||||
|
"Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS"
|
||||||
|
))
|
||||||
|
}
|
||||||
176
crates/bevy-tasks-cli/src/commands/workspace.rs
Normal file
176
crates/bevy-tasks-cli/src/commands/workspace.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use bevy_tasks_core::{AppConfig, TaskRepository};
|
||||||
|
use colored::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn add(name: String, path: PathBuf) -> Result<()> {
|
||||||
|
// Initialize the repository at the path
|
||||||
|
TaskRepository::init(path.clone())?;
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
let mut config = AppConfig::load()?;
|
||||||
|
|
||||||
|
// Add workspace
|
||||||
|
config.add_workspace(name.clone(), path.clone())?;
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
config.save()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} Added workspace \"{}\" at {}",
|
||||||
|
"✓".green(),
|
||||||
|
name.bold(),
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
println!("{} Created default list \"My Tasks\"", "✓".green());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list() -> Result<()> {
|
||||||
|
let config = AppConfig::load()?;
|
||||||
|
|
||||||
|
if config.workspaces.is_empty() {
|
||||||
|
println!("No workspaces configured. Run 'bevy-tasks init' to create one.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, workspace) in &config.workspaces {
|
||||||
|
let is_current = config.current_workspace.as_ref() == Some(name);
|
||||||
|
let marker = if is_current {
|
||||||
|
format!(" {} ", name.bold())
|
||||||
|
} else {
|
||||||
|
format!(" {} ", name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let suffix = if is_current {
|
||||||
|
" (current)".green()
|
||||||
|
} else {
|
||||||
|
"".normal()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}: {}{}", marker, workspace.path.display(), suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn switch(name: String) -> Result<()> {
|
||||||
|
let mut config = AppConfig::load()?;
|
||||||
|
|
||||||
|
config.switch_workspace(&name)?;
|
||||||
|
config.save()?;
|
||||||
|
|
||||||
|
println!("{} Switched to workspace \"{}\"", "✓".green(), name.bold());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(name: String) -> Result<()> {
|
||||||
|
let mut config = AppConfig::load()?;
|
||||||
|
|
||||||
|
// Confirmation
|
||||||
|
println!(
|
||||||
|
"{} This will delete workspace config (files remain on disk)",
|
||||||
|
"⚠".yellow()
|
||||||
|
);
|
||||||
|
print!("Continue? (y/n): ");
|
||||||
|
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)?;
|
||||||
|
config.save()?;
|
||||||
|
|
||||||
|
println!("{} Removed workspace \"{}\"", "✓".green(), name.bold());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retarget(name: String, new_path: PathBuf) -> Result<()> {
|
||||||
|
let mut config = AppConfig::load()?;
|
||||||
|
|
||||||
|
config.update_workspace_path(&name, new_path.clone())?;
|
||||||
|
config.save()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} Workspace \"{}\" now points to {}",
|
||||||
|
"✓".green(),
|
||||||
|
name.bold(),
|
||||||
|
new_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrate(name: String, new_path: PathBuf) -> Result<()> {
|
||||||
|
let mut config = AppConfig::load()?;
|
||||||
|
|
||||||
|
let old_path = config.get_workspace(&name)?.path.clone();
|
||||||
|
|
||||||
|
// Confirmation
|
||||||
|
println!(
|
||||||
|
"{} This will move all files from {} to {}",
|
||||||
|
"⚠".yellow(),
|
||||||
|
old_path.display(),
|
||||||
|
new_path.display()
|
||||||
|
);
|
||||||
|
print!("Continue? (y/n): ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input)?;
|
||||||
|
|
||||||
|
if input.trim().to_lowercase() != "y" {
|
||||||
|
println!("Cancelled.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count files for progress
|
||||||
|
let mut file_count = 0;
|
||||||
|
for entry in fs::read_dir(&old_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
file_count += 1;
|
||||||
|
println!(" Moving {}...", entry.file_name().to_string_lossy());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination directory
|
||||||
|
fs::create_dir_all(&new_path)?;
|
||||||
|
|
||||||
|
// Move files
|
||||||
|
for entry in fs::read_dir(&old_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let dest = new_path.join(entry.file_name());
|
||||||
|
fs::rename(entry.path(), dest)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old directory
|
||||||
|
fs::remove_dir(&old_path)?;
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
config.update_workspace_path(&name, new_path.clone())?;
|
||||||
|
config.save()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} Migrated {} files to {}",
|
||||||
|
"✓".green(),
|
||||||
|
file_count,
|
||||||
|
new_path.display()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{} Workspace \"{}\" now points to {}",
|
||||||
|
"✓".green(),
|
||||||
|
name.bold(),
|
||||||
|
new_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
173
crates/bevy-tasks-cli/src/main.rs
Normal file
173
crates/bevy-tasks-cli/src/main.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
mod commands;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[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 the tasks folder
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
/// Name of the workspace
|
||||||
|
#[arg(short, long)]
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Workspace management commands
|
||||||
|
#[command(subcommand)]
|
||||||
|
Workspace(WorkspaceCommands),
|
||||||
|
/// Create a new task list
|
||||||
|
#[command(name = "list")]
|
||||||
|
ListCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<ListCommands>,
|
||||||
|
},
|
||||||
|
/// Add a new task
|
||||||
|
Add {
|
||||||
|
/// Task title
|
||||||
|
title: String,
|
||||||
|
/// List name to add task to
|
||||||
|
#[arg(short, long)]
|
||||||
|
list: Option<String>,
|
||||||
|
/// Due date (format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)
|
||||||
|
#[arg(short, long)]
|
||||||
|
due: Option<String>,
|
||||||
|
/// Workspace name
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
/// Complete a task
|
||||||
|
Complete {
|
||||||
|
/// Task ID
|
||||||
|
task_id: String,
|
||||||
|
/// Workspace name
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
/// Delete a task
|
||||||
|
Delete {
|
||||||
|
/// Task ID
|
||||||
|
task_id: String,
|
||||||
|
/// Workspace name
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
/// Edit a task
|
||||||
|
Edit {
|
||||||
|
/// Task ID
|
||||||
|
task_id: String,
|
||||||
|
/// Workspace name
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
/// Toggle group-by-due-date for a list
|
||||||
|
Group {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: GroupCommands,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum WorkspaceCommands {
|
||||||
|
/// Add a new workspace
|
||||||
|
Add {
|
||||||
|
/// Workspace name
|
||||||
|
name: String,
|
||||||
|
/// Path to the tasks folder
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
},
|
||||||
|
/// List all workspaces
|
||||||
|
List,
|
||||||
|
/// Switch to a different workspace
|
||||||
|
Switch {
|
||||||
|
/// Workspace name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Remove a workspace (keeps files on disk)
|
||||||
|
Remove {
|
||||||
|
/// Workspace name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Update workspace path (files already at new location)
|
||||||
|
Retarget {
|
||||||
|
/// Workspace name
|
||||||
|
name: String,
|
||||||
|
/// New path
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
},
|
||||||
|
/// Migrate workspace files to a new location
|
||||||
|
Migrate {
|
||||||
|
/// Workspace name
|
||||||
|
name: String,
|
||||||
|
/// New path
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ListCommands {
|
||||||
|
/// Create a new task list
|
||||||
|
Create {
|
||||||
|
/// List name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum GroupCommands {
|
||||||
|
/// Enable group-by-due-date for a list
|
||||||
|
Enable {
|
||||||
|
/// List name
|
||||||
|
#[arg(short, long)]
|
||||||
|
list: String,
|
||||||
|
},
|
||||||
|
/// Disable group-by-due-date for a list
|
||||||
|
Disable {
|
||||||
|
/// List name
|
||||||
|
#[arg(short, long)]
|
||||||
|
list: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Init { path, name } => commands::init::execute(path, name),
|
||||||
|
Commands::Workspace(cmd) => match cmd {
|
||||||
|
WorkspaceCommands::Add { name, path } => commands::workspace::add(name, path),
|
||||||
|
WorkspaceCommands::List => commands::workspace::list(),
|
||||||
|
WorkspaceCommands::Switch { name } => commands::workspace::switch(name),
|
||||||
|
WorkspaceCommands::Remove { name } => commands::workspace::remove(name),
|
||||||
|
WorkspaceCommands::Retarget { name, path } => {
|
||||||
|
commands::workspace::retarget(name, path)
|
||||||
|
}
|
||||||
|
WorkspaceCommands::Migrate { name, path } => commands::workspace::migrate(name, path),
|
||||||
|
},
|
||||||
|
Commands::ListCmd { command } => match command {
|
||||||
|
Some(ListCommands::Create { name }) => commands::list::create(name, None),
|
||||||
|
None => commands::list::list_all(None),
|
||||||
|
},
|
||||||
|
Commands::Add {
|
||||||
|
title,
|
||||||
|
list,
|
||||||
|
due,
|
||||||
|
workspace,
|
||||||
|
} => commands::task::add(title, list, due, workspace),
|
||||||
|
Commands::Complete { task_id, workspace } => commands::task::complete(task_id, workspace),
|
||||||
|
Commands::Delete { task_id, workspace } => commands::task::delete(task_id, workspace),
|
||||||
|
Commands::Edit { task_id, workspace } => commands::task::edit(task_id, workspace),
|
||||||
|
Commands::Group { command } => match command {
|
||||||
|
GroupCommands::Enable { list } => commands::group::set_group(list, true),
|
||||||
|
GroupCommands::Disable { list } => commands::group::set_group(list, false),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/bevy-tasks-core/Cargo.toml
Normal file
18
crates/bevy-tasks-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "bevy-tasks-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde_yaml = "0.9" # YAML frontmatter
|
||||||
|
pulldown-cmark = "0.12" # Markdown parsing
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
directories = "5.0"
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = "2.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.0"
|
||||||
198
crates/bevy-tasks-core/src/config.rs
Normal file
198
crates/bevy-tasks-core/src/config.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Configuration for a single workspace
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkspaceConfig {
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application configuration supporting multiple workspaces
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub workspaces: HashMap<String, WorkspaceConfig>,
|
||||||
|
pub current_workspace: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
workspaces: HashMap::new(),
|
||||||
|
current_workspace: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Get the config file path for the current platform
|
||||||
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
|
let proj_dirs = ProjectDirs::from("com", "bevy-tasks", "bevy-tasks")
|
||||||
|
.ok_or_else(|| Error::PathError("Could not determine config directory".to_string()))?;
|
||||||
|
|
||||||
|
let config_dir = proj_dirs.config_dir();
|
||||||
|
Ok(config_dir.join("config.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load config from disk, or create default if it doesn't exist
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let config_path = Self::config_path()?;
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&config_path)?;
|
||||||
|
let config: AppConfig = serde_json::from_str(&contents)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save config to disk
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let config_path = Self::config_path()?;
|
||||||
|
|
||||||
|
// Create parent directory if it doesn't exist
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = serde_json::to_string_pretty(self)?;
|
||||||
|
fs::write(&config_path, contents)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new workspace
|
||||||
|
pub fn add_workspace(&mut self, name: String, path: PathBuf) -> Result<()> {
|
||||||
|
if self.workspaces.contains_key(&name) {
|
||||||
|
return Err(Error::WorkspaceAlreadyExists(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.workspaces.insert(name.clone(), WorkspaceConfig { path });
|
||||||
|
|
||||||
|
// Set as current if it's the first workspace
|
||||||
|
if self.current_workspace.is_none() {
|
||||||
|
self.current_workspace = Some(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a workspace
|
||||||
|
pub fn remove_workspace(&mut self, name: &str) -> Result<()> {
|
||||||
|
if !self.workspaces.contains_key(name) {
|
||||||
|
return Err(Error::WorkspaceNotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow removing current workspace
|
||||||
|
if self.current_workspace.as_deref() == Some(name) {
|
||||||
|
return Err(Error::CannotRemoveCurrentWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.workspaces.remove(name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to a different workspace
|
||||||
|
pub fn switch_workspace(&mut self, name: &str) -> Result<()> {
|
||||||
|
if !self.workspaces.contains_key(name) {
|
||||||
|
return Err(Error::WorkspaceNotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_workspace = Some(name.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current workspace config
|
||||||
|
pub fn get_current_workspace(&self) -> Result<(&str, &WorkspaceConfig)> {
|
||||||
|
let name = self
|
||||||
|
.current_workspace
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(Error::NoCurrentWorkspace)?;
|
||||||
|
|
||||||
|
let config = self
|
||||||
|
.workspaces
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| Error::WorkspaceNotFound(name.clone()))?;
|
||||||
|
|
||||||
|
Ok((name, config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a workspace by name
|
||||||
|
pub fn get_workspace(&self, name: &str) -> Result<&WorkspaceConfig> {
|
||||||
|
self.workspaces
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| Error::WorkspaceNotFound(name.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the path for a workspace
|
||||||
|
pub fn update_workspace_path(&mut self, name: &str, new_path: PathBuf) -> Result<()> {
|
||||||
|
let workspace = self
|
||||||
|
.workspaces
|
||||||
|
.get_mut(name)
|
||||||
|
.ok_or_else(|| Error::WorkspaceNotFound(name.to_string()))?;
|
||||||
|
|
||||||
|
workspace.path = new_path;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_workspace() {
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
config
|
||||||
|
.add_workspace("test".to_string(), temp_dir.path().to_path_buf())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.workspaces.len(), 1);
|
||||||
|
assert_eq!(config.current_workspace, Some("test".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_switch_workspace() {
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
let temp_dir1 = TempDir::new().unwrap();
|
||||||
|
let temp_dir2 = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
config
|
||||||
|
.add_workspace("test1".to_string(), temp_dir1.path().to_path_buf())
|
||||||
|
.unwrap();
|
||||||
|
config
|
||||||
|
.add_workspace("test2".to_string(), temp_dir2.path().to_path_buf())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
config.switch_workspace("test2").unwrap();
|
||||||
|
assert_eq!(config.current_workspace, Some("test2".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_workspace() {
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
let temp_dir1 = TempDir::new().unwrap();
|
||||||
|
let temp_dir2 = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
config
|
||||||
|
.add_workspace("test1".to_string(), temp_dir1.path().to_path_buf())
|
||||||
|
.unwrap();
|
||||||
|
config
|
||||||
|
.add_workspace("test2".to_string(), temp_dir2.path().to_path_buf())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Should fail - can't remove current workspace
|
||||||
|
assert!(config.remove_workspace("test1").is_err());
|
||||||
|
|
||||||
|
// Switch and try again
|
||||||
|
config.switch_workspace("test2").unwrap();
|
||||||
|
config.remove_workspace("test1").unwrap();
|
||||||
|
assert_eq!(config.workspaces.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
crates/bevy-tasks-core/src/error.rs
Normal file
45
crates/bevy-tasks-core/src/error.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Serialization error: {0}")]
|
||||||
|
SerdeJson(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("YAML error: {0}")]
|
||||||
|
SerdeYaml(#[from] serde_yaml::Error),
|
||||||
|
|
||||||
|
#[error("Task not found: {0}")]
|
||||||
|
TaskNotFound(uuid::Uuid),
|
||||||
|
|
||||||
|
#[error("List not found: {0}")]
|
||||||
|
ListNotFound(uuid::Uuid),
|
||||||
|
|
||||||
|
#[error("Workspace not found: {0}")]
|
||||||
|
WorkspaceNotFound(String),
|
||||||
|
|
||||||
|
#[error("Workspace already exists: {0}")]
|
||||||
|
WorkspaceAlreadyExists(String),
|
||||||
|
|
||||||
|
#[error("No current workspace set")]
|
||||||
|
NoCurrentWorkspace,
|
||||||
|
|
||||||
|
#[error("Invalid task file: {0}")]
|
||||||
|
InvalidTaskFile(String),
|
||||||
|
|
||||||
|
#[error("Invalid metadata file: {0}")]
|
||||||
|
InvalidMetadata(String),
|
||||||
|
|
||||||
|
#[error("Path error: {0}")]
|
||||||
|
PathError(String),
|
||||||
|
|
||||||
|
#[error("Cannot remove current workspace")]
|
||||||
|
CannotRemoveCurrentWorkspace,
|
||||||
|
|
||||||
|
#[error("Other error: {0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
16
crates/bevy-tasks-core/src/lib.rs
Normal file
16
crates/bevy-tasks-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//! # bevy-tasks-core
|
||||||
|
//!
|
||||||
|
//! Core library for the Bevy Tasks application.
|
||||||
|
//! Provides data models, storage, and repository for managing tasks.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod models;
|
||||||
|
pub mod repository;
|
||||||
|
pub mod storage;
|
||||||
|
|
||||||
|
pub use config::{AppConfig, WorkspaceConfig};
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
pub use models::{Task, TaskList, TaskStatus};
|
||||||
|
pub use repository::TaskRepository;
|
||||||
|
pub use storage::{FileSystemStorage, Storage};
|
||||||
150
crates/bevy-tasks-core/src/models.rs
Normal file
150
crates/bevy-tasks-core/src/models.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Task status enum
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TaskStatus {
|
||||||
|
/// Task not yet completed
|
||||||
|
Backlog,
|
||||||
|
/// Task is done
|
||||||
|
Completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single task
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
/// Create a new task with the given title
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark task as completed
|
||||||
|
pub fn complete(&mut self) {
|
||||||
|
self.status = TaskStatus::Completed;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark task as backlog (incomplete)
|
||||||
|
pub fn uncomplete(&mut self) {
|
||||||
|
self.status = TaskStatus::Backlog;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for a task list stored in .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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A task list (represented by a folder on disk)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
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 {
|
||||||
|
/// Create a new task list
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global metadata stored in .metadata.json at the workspace root
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GlobalMetadata {
|
||||||
|
pub version: u32,
|
||||||
|
pub list_order: Vec<Uuid>,
|
||||||
|
pub last_opened_list: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GlobalMetadata {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
list_order: Vec::new(),
|
||||||
|
last_opened_list: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_creation() {
|
||||||
|
let task = Task::new("Test task".to_string());
|
||||||
|
assert_eq!(task.title, "Test task");
|
||||||
|
assert_eq!(task.status, TaskStatus::Backlog);
|
||||||
|
assert_eq!(task.description, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_complete() {
|
||||||
|
let mut task = Task::new("Test task".to_string());
|
||||||
|
task.complete();
|
||||||
|
assert_eq!(task.status, TaskStatus::Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_uncomplete() {
|
||||||
|
let mut task = Task::new("Test task".to_string());
|
||||||
|
task.complete();
|
||||||
|
task.uncomplete();
|
||||||
|
assert_eq!(task.status, TaskStatus::Backlog);
|
||||||
|
}
|
||||||
|
}
|
||||||
363
crates/bevy-tasks-core/src/repository.rs
Normal file
363
crates/bevy-tasks-core/src/repository.rs
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::models::{Task, TaskList, TaskStatus};
|
||||||
|
use crate::storage::{FileSystemStorage, Storage};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Repository for managing tasks and lists
|
||||||
|
pub struct TaskRepository {
|
||||||
|
storage: Box<dyn Storage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskRepository {
|
||||||
|
/// Create a new repository with an existing tasks folder
|
||||||
|
pub fn new(tasks_folder: PathBuf) -> Result<Self> {
|
||||||
|
let storage = FileSystemStorage::new(tasks_folder)?;
|
||||||
|
Ok(Self {
|
||||||
|
storage: Box::new(storage),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize a new tasks folder and repository
|
||||||
|
pub fn init(tasks_folder: PathBuf) -> Result<Self> {
|
||||||
|
let mut storage = FileSystemStorage::new(tasks_folder)?;
|
||||||
|
storage.init()?;
|
||||||
|
|
||||||
|
// Create default list
|
||||||
|
let list_id = storage.create_list("My Tasks")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
storage: Box::new(storage),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task operations
|
||||||
|
|
||||||
|
/// Create a new task
|
||||||
|
pub fn create_task(&mut self, list_id: Uuid, mut task: Task) -> Result<Task> {
|
||||||
|
// Update task order in list metadata
|
||||||
|
let mut metadata = self.storage.read_list_metadata(list_id)?;
|
||||||
|
metadata.task_order.push(task.id);
|
||||||
|
metadata.updated_at = chrono::Utc::now();
|
||||||
|
self.storage.write_list_metadata(&metadata)?;
|
||||||
|
|
||||||
|
// Write task to storage
|
||||||
|
self.storage.write_task(list_id, &task)?;
|
||||||
|
|
||||||
|
Ok(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a task by ID
|
||||||
|
pub fn get_task(&self, list_id: Uuid, task_id: Uuid) -> Result<Task> {
|
||||||
|
self.storage.read_task(list_id, task_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing task
|
||||||
|
pub fn update_task(&mut self, list_id: Uuid, mut task: Task) -> Result<()> {
|
||||||
|
task.updated_at = chrono::Utc::now();
|
||||||
|
self.storage.write_task(list_id, &task)?;
|
||||||
|
|
||||||
|
// Update list metadata timestamp
|
||||||
|
let mut metadata = self.storage.read_list_metadata(list_id)?;
|
||||||
|
metadata.updated_at = chrono::Utc::now();
|
||||||
|
self.storage.write_list_metadata(&metadata)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a task
|
||||||
|
pub fn delete_task(&mut self, list_id: Uuid, task_id: Uuid) -> Result<()> {
|
||||||
|
self.storage.delete_task(list_id, task_id)?;
|
||||||
|
|
||||||
|
// Remove from task order
|
||||||
|
let mut metadata = self.storage.read_list_metadata(list_id)?;
|
||||||
|
metadata.task_order.retain(|&id| id != task_id);
|
||||||
|
metadata.updated_at = chrono::Utc::now();
|
||||||
|
self.storage.write_list_metadata(&metadata)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all tasks in a list, ordered according to task_order
|
||||||
|
pub fn list_tasks(&self, list_id: Uuid) -> Result<Vec<Task>> {
|
||||||
|
let tasks = self.storage.list_tasks(list_id)?;
|
||||||
|
let metadata = self.storage.read_list_metadata(list_id)?;
|
||||||
|
|
||||||
|
// Create a map for quick lookup
|
||||||
|
let task_map: std::collections::HashMap<Uuid, Task> =
|
||||||
|
tasks.into_iter().map(|t| (t.id, t)).collect();
|
||||||
|
|
||||||
|
// Order tasks according to task_order
|
||||||
|
let mut ordered_tasks = Vec::new();
|
||||||
|
for task_id in &metadata.task_order {
|
||||||
|
if let Some(task) = task_map.get(task_id) {
|
||||||
|
ordered_tasks.push(task.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any tasks not in task_order at the end
|
||||||
|
for (task_id, task) in task_map {
|
||||||
|
if !metadata.task_order.contains(&task_id) {
|
||||||
|
ordered_tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ordered_tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete a task
|
||||||
|
pub fn complete_task(&mut self, list_id: Uuid, task_id: Uuid) -> Result<()> {
|
||||||
|
let mut task = self.storage.read_task(list_id, task_id)?;
|
||||||
|
task.complete();
|
||||||
|
self.update_task(list_id, task)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a task as incomplete
|
||||||
|
pub fn uncomplete_task(&mut self, list_id: Uuid, task_id: Uuid) -> Result<()> {
|
||||||
|
let mut task = self.storage.read_task(list_id, task_id)?;
|
||||||
|
task.uncomplete();
|
||||||
|
self.update_task(list_id, task)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// List operations
|
||||||
|
|
||||||
|
/// Create a new task list
|
||||||
|
pub fn create_list(&mut self, name: String) -> Result<TaskList> {
|
||||||
|
let list_id = self.storage.create_list(&name)?;
|
||||||
|
Ok(TaskList::new(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all task lists
|
||||||
|
pub fn get_lists(&self) -> Result<Vec<TaskList>> {
|
||||||
|
let lists = self.storage.list_lists()?;
|
||||||
|
let global_metadata = self.storage.read_global_metadata()?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for (list_id, title) in lists {
|
||||||
|
let tasks = self.list_tasks(list_id)?;
|
||||||
|
let metadata = self.storage.read_list_metadata(list_id)?;
|
||||||
|
|
||||||
|
result.push(TaskList {
|
||||||
|
id: list_id,
|
||||||
|
title,
|
||||||
|
tasks,
|
||||||
|
created_at: metadata.created_at,
|
||||||
|
updated_at: metadata.updated_at,
|
||||||
|
group_by_due_date: metadata.group_by_due_date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by global list_order
|
||||||
|
result.sort_by_key(|list| {
|
||||||
|
global_metadata
|
||||||
|
.list_order
|
||||||
|
.iter()
|
||||||
|
.position(|&id| id == list.id)
|
||||||
|
.unwrap_or(usize::MAX)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific task list by ID
|
||||||
|
pub fn get_list(&self, list_id: Uuid) -> Result<TaskList> {
|
||||||
|
let lists = self.storage.list_lists()?;
|
||||||
|
let (_, title) = lists
|
||||||
|
.into_iter()
|
||||||
|
.find(|(id, _)| *id == list_id)
|
||||||
|
.ok_or(Error::ListNotFound(list_id))?;
|
||||||
|
|
||||||
|
let tasks = self.list_tasks(list_id)?;
|
||||||
|
let metadata = self.storage.read_list_metadata(list_id)?;
|
||||||
|
|
||||||
|
Ok(TaskList {
|
||||||
|
id: list_id,
|
||||||
|
title,
|
||||||
|
tasks,
|
||||||
|
created_at: metadata.created_at,
|
||||||
|
updated_at: metadata.updated_at,
|
||||||
|
group_by_due_date: metadata.group_by_due_date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a task list
|
||||||
|
pub fn delete_list(&mut self, list_id: Uuid) -> Result<()> {
|
||||||
|
self.storage.delete_list(list_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task ordering operations
|
||||||
|
|
||||||
|
/// Reorder a task within its list
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
// Remove task from current position
|
||||||
|
metadata.task_order.retain(|&id| id != task_id);
|
||||||
|
|
||||||
|
// Insert at new position
|
||||||
|
let insert_pos = new_position.min(metadata.task_order.len());
|
||||||
|
metadata.task_order.insert(insert_pos, task_id);
|
||||||
|
|
||||||
|
metadata.updated_at = chrono::Utc::now();
|
||||||
|
self.storage.write_list_metadata(&metadata)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the task order for a list
|
||||||
|
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.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouping operations
|
||||||
|
|
||||||
|
/// Set whether to group tasks by due date
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get whether tasks are grouped by due date
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a task by ID across all lists
|
||||||
|
pub fn find_task(&self, task_id: Uuid) -> Result<(Uuid, Task)> {
|
||||||
|
let lists = self.storage.list_lists()?;
|
||||||
|
|
||||||
|
for (list_id, _) in lists {
|
||||||
|
if let Ok(task) = self.storage.read_task(list_id, task_id) {
|
||||||
|
return Ok((list_id, task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::TaskNotFound(task_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a list by name
|
||||||
|
pub fn find_list_by_name(&self, name: &str) -> Result<Uuid> {
|
||||||
|
let lists = self.storage.list_lists()?;
|
||||||
|
|
||||||
|
for (list_id, list_name) in lists {
|
||||||
|
if list_name == name {
|
||||||
|
return Ok(list_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::Other(format!("List not found: {}", name)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()).unwrap();
|
||||||
|
|
||||||
|
let lists = repo.get_lists().unwrap();
|
||||||
|
assert_eq!(lists.len(), 1);
|
||||||
|
assert_eq!(lists[0].title, "My Tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_and_get_task() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
|
||||||
|
let lists = repo.get_lists().unwrap();
|
||||||
|
let list_id = lists[0].id;
|
||||||
|
|
||||||
|
let task = Task::new("Test Task".to_string());
|
||||||
|
let task_id = task.id;
|
||||||
|
repo.create_task(list_id, task).unwrap();
|
||||||
|
|
||||||
|
let retrieved_task = repo.get_task(list_id, task_id).unwrap();
|
||||||
|
assert_eq!(retrieved_task.title, "Test Task");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_complete_task() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
|
||||||
|
let lists = repo.get_lists().unwrap();
|
||||||
|
let list_id = lists[0].id;
|
||||||
|
|
||||||
|
let task = Task::new("Test Task".to_string());
|
||||||
|
let task_id = task.id;
|
||||||
|
repo.create_task(list_id, task).unwrap();
|
||||||
|
|
||||||
|
repo.complete_task(list_id, task_id).unwrap();
|
||||||
|
|
||||||
|
let task = repo.get_task(list_id, task_id).unwrap();
|
||||||
|
assert_eq!(task.status, TaskStatus::Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_task() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
|
||||||
|
let lists = repo.get_lists().unwrap();
|
||||||
|
let list_id = lists[0].id;
|
||||||
|
|
||||||
|
let task = Task::new("Test Task".to_string());
|
||||||
|
let task_id = task.id;
|
||||||
|
repo.create_task(list_id, task).unwrap();
|
||||||
|
|
||||||
|
repo.delete_task(list_id, task_id).unwrap();
|
||||||
|
|
||||||
|
assert!(repo.get_task(list_id, task_id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_list() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
|
||||||
|
repo.create_list("Work".to_string()).unwrap();
|
||||||
|
|
||||||
|
let lists = repo.get_lists().unwrap();
|
||||||
|
assert_eq!(lists.len(), 2);
|
||||||
|
assert!(lists.iter().any(|l| l.title == "Work"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_ordering() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut repo = TaskRepository::init(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
|
||||||
|
let lists = repo.get_lists().unwrap();
|
||||||
|
let list_id = lists[0].id;
|
||||||
|
|
||||||
|
let task1 = Task::new("Task 1".to_string());
|
||||||
|
let task2 = Task::new("Task 2".to_string());
|
||||||
|
let task3 = Task::new("Task 3".to_string());
|
||||||
|
|
||||||
|
let id1 = task1.id;
|
||||||
|
let id2 = task2.id;
|
||||||
|
let id3 = task3.id;
|
||||||
|
|
||||||
|
repo.create_task(list_id, task1).unwrap();
|
||||||
|
repo.create_task(list_id, task2).unwrap();
|
||||||
|
repo.create_task(list_id, task3).unwrap();
|
||||||
|
|
||||||
|
// Move task3 to position 0
|
||||||
|
repo.reorder_task(list_id, id3, 0).unwrap();
|
||||||
|
|
||||||
|
let order = repo.get_task_order(list_id).unwrap();
|
||||||
|
assert_eq!(order, vec![id3, id1, id2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
412
crates/bevy-tasks-core/src/storage.rs
Normal file
412
crates/bevy-tasks-core/src/storage.rs
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::models::{GlobalMetadata, ListMetadata, Task, TaskStatus};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const METADATA_FILE: &str = ".metadata.json";
|
||||||
|
const LIST_METADATA_FILE: &str = ".listdata.json";
|
||||||
|
|
||||||
|
/// Frontmatter data stored in YAML at the top of each task file
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct TaskFrontmatter {
|
||||||
|
id: Uuid,
|
||||||
|
status: TaskStatus,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
due: Option<DateTime<Utc>>,
|
||||||
|
created: DateTime<Utc>,
|
||||||
|
updated: DateTime<Utc>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
parent: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage trait for task persistence
|
||||||
|
pub trait Storage {
|
||||||
|
fn init(&mut self) -> Result<()>;
|
||||||
|
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: &str) -> Result<Uuid>;
|
||||||
|
fn list_lists(&self) -> Result<Vec<(Uuid, String)>>;
|
||||||
|
fn delete_list(&mut self, list_id: Uuid) -> Result<()>;
|
||||||
|
fn read_global_metadata(&self) -> Result<GlobalMetadata>;
|
||||||
|
fn write_global_metadata(&mut self, metadata: &GlobalMetadata) -> Result<()>;
|
||||||
|
fn read_list_metadata(&self, list_id: Uuid) -> Result<ListMetadata>;
|
||||||
|
fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File system based storage implementation
|
||||||
|
pub struct FileSystemStorage {
|
||||||
|
root_path: PathBuf,
|
||||||
|
list_paths: HashMap<Uuid, PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemStorage {
|
||||||
|
pub fn new(root_path: PathBuf) -> Result<Self> {
|
||||||
|
let mut storage = Self {
|
||||||
|
root_path,
|
||||||
|
list_paths: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load list paths if root exists
|
||||||
|
if storage.root_path.exists() {
|
||||||
|
storage.load_list_paths()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_list_paths(&mut self) -> Result<()> {
|
||||||
|
self.list_paths.clear();
|
||||||
|
|
||||||
|
for entry in fs::read_dir(&self.root_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
let list_metadata_path = path.join(LIST_METADATA_FILE);
|
||||||
|
if list_metadata_path.exists() {
|
||||||
|
let contents = fs::read_to_string(&list_metadata_path)?;
|
||||||
|
let metadata: ListMetadata = serde_json::from_str(&contents)?;
|
||||||
|
self.list_paths.insert(metadata.id, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_list_path(&self, list_id: Uuid) -> Result<&PathBuf> {
|
||||||
|
self.list_paths
|
||||||
|
.get(&list_id)
|
||||||
|
.ok_or(Error::ListNotFound(list_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_task_file(&self, title: String, content: &str) -> Result<Task> {
|
||||||
|
// Split frontmatter and description
|
||||||
|
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||||||
|
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return Err(Error::InvalidTaskFile(
|
||||||
|
"Missing frontmatter delimiters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse YAML frontmatter
|
||||||
|
let frontmatter: TaskFrontmatter = serde_yaml::from_str(parts[1].trim())?;
|
||||||
|
|
||||||
|
// Get description (everything after second ---)
|
||||||
|
let description = parts[2].trim().to_string();
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_task(&self, task: &Task) -> Result<String> {
|
||||||
|
let frontmatter = TaskFrontmatter {
|
||||||
|
id: task.id,
|
||||||
|
status: task.status,
|
||||||
|
due: task.due_date,
|
||||||
|
created: task.created_at,
|
||||||
|
updated: task.updated_at,
|
||||||
|
parent: task.parent_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let yaml = serde_yaml::to_string(&frontmatter)?;
|
||||||
|
|
||||||
|
Ok(format!("---\n{}---\n\n{}\n", yaml, task.description))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_filename(name: &str) -> String {
|
||||||
|
// Remove or replace characters that are invalid in filenames
|
||||||
|
name.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Storage for FileSystemStorage {
|
||||||
|
fn init(&mut self) -> Result<()> {
|
||||||
|
fs::create_dir_all(&self.root_path)?;
|
||||||
|
|
||||||
|
// Create .metadata.json if it doesn't exist
|
||||||
|
let metadata_path = self.root_path.join(METADATA_FILE);
|
||||||
|
if !metadata_path.exists() {
|
||||||
|
let metadata = GlobalMetadata::default();
|
||||||
|
self.write_global_metadata(&metadata)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_task(&self, list_id: Uuid, task_id: Uuid) -> Result<Task> {
|
||||||
|
let list_path = self.get_list_path(list_id)?;
|
||||||
|
|
||||||
|
// Try to find the task file by reading all .md files and checking their IDs
|
||||||
|
for entry in fs::read_dir(list_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
let title = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| Error::InvalidTaskFile("Invalid filename".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let task = self.parse_task_file(title, &content)?;
|
||||||
|
if task.id == task_id {
|
||||||
|
return Ok(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::TaskNotFound(task_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_task(&mut self, list_id: Uuid, task: &Task) -> Result<()> {
|
||||||
|
let list_path = self.get_list_path(list_id)?;
|
||||||
|
let filename = format!("{}.md", Self::sanitize_filename(&task.title));
|
||||||
|
let task_path = list_path.join(filename);
|
||||||
|
|
||||||
|
let content = self.serialize_task(task)?;
|
||||||
|
fs::write(&task_path, content)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_task(&mut self, list_id: Uuid, task_id: Uuid) -> Result<()> {
|
||||||
|
let list_path = self.get_list_path(list_id)?;
|
||||||
|
|
||||||
|
// Find and delete the task file
|
||||||
|
for entry in fs::read_dir(list_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
let title = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| Error::InvalidTaskFile("Invalid filename".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let task = self.parse_task_file(title, &content)?;
|
||||||
|
if task.id == task_id {
|
||||||
|
fs::remove_file(&path)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::TaskNotFound(task_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_tasks(&self, list_id: Uuid) -> Result<Vec<Task>> {
|
||||||
|
let list_path = self.get_list_path(list_id)?;
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
|
||||||
|
for entry in fs::read_dir(list_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
let title = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| Error::InvalidTaskFile("Invalid filename".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let task = self.parse_task_file(title, &content)?;
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_list(&mut self, name: &str) -> Result<Uuid> {
|
||||||
|
let list_id = Uuid::new_v4();
|
||||||
|
let list_path = self.root_path.join(Self::sanitize_filename(name));
|
||||||
|
|
||||||
|
fs::create_dir_all(&list_path)?;
|
||||||
|
|
||||||
|
// Create list metadata
|
||||||
|
let metadata = ListMetadata::new(list_id);
|
||||||
|
let metadata_path = list_path.join(LIST_METADATA_FILE);
|
||||||
|
let contents = serde_json::to_string_pretty(&metadata)?;
|
||||||
|
fs::write(&metadata_path, contents)?;
|
||||||
|
|
||||||
|
// Update internal map
|
||||||
|
self.list_paths.insert(list_id, list_path);
|
||||||
|
|
||||||
|
// Update global metadata
|
||||||
|
let mut global_metadata = self.read_global_metadata()?;
|
||||||
|
global_metadata.list_order.push(list_id);
|
||||||
|
if global_metadata.last_opened_list.is_none() {
|
||||||
|
global_metadata.last_opened_list = Some(list_id);
|
||||||
|
}
|
||||||
|
self.write_global_metadata(&global_metadata)?;
|
||||||
|
|
||||||
|
Ok(list_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_lists(&self) -> Result<Vec<(Uuid, String)>> {
|
||||||
|
let mut lists = Vec::new();
|
||||||
|
|
||||||
|
for entry in fs::read_dir(&self.root_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
let list_metadata_path = path.join(LIST_METADATA_FILE);
|
||||||
|
if list_metadata_path.exists() {
|
||||||
|
let contents = fs::read_to_string(&list_metadata_path)?;
|
||||||
|
let metadata: ListMetadata = serde_json::from_str(&contents)?;
|
||||||
|
|
||||||
|
let title = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| Error::InvalidMetadata("Invalid list folder name".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
lists.push((metadata.id, title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(lists)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_list(&mut self, list_id: Uuid) -> Result<()> {
|
||||||
|
let list_path = self.get_list_path(list_id)?.clone();
|
||||||
|
fs::remove_dir_all(&list_path)?;
|
||||||
|
|
||||||
|
self.list_paths.remove(&list_id);
|
||||||
|
|
||||||
|
// Update global metadata
|
||||||
|
let mut global_metadata = self.read_global_metadata()?;
|
||||||
|
global_metadata.list_order.retain(|&id| id != list_id);
|
||||||
|
if global_metadata.last_opened_list == Some(list_id) {
|
||||||
|
global_metadata.last_opened_list = global_metadata.list_order.first().copied();
|
||||||
|
}
|
||||||
|
self.write_global_metadata(&global_metadata)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_global_metadata(&self) -> Result<GlobalMetadata> {
|
||||||
|
let metadata_path = self.root_path.join(METADATA_FILE);
|
||||||
|
|
||||||
|
if !metadata_path.exists() {
|
||||||
|
return Ok(GlobalMetadata::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&metadata_path)?;
|
||||||
|
let metadata: GlobalMetadata = serde_json::from_str(&contents)?;
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_global_metadata(&mut self, metadata: &GlobalMetadata) -> Result<()> {
|
||||||
|
let metadata_path = self.root_path.join(METADATA_FILE);
|
||||||
|
let contents = serde_json::to_string_pretty(metadata)?;
|
||||||
|
fs::write(&metadata_path, contents)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_list_metadata(&self, list_id: Uuid) -> Result<ListMetadata> {
|
||||||
|
let list_path = self.get_list_path(list_id)?;
|
||||||
|
let metadata_path = list_path.join(LIST_METADATA_FILE);
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&metadata_path)?;
|
||||||
|
let metadata: ListMetadata = serde_json::from_str(&contents)?;
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_list_metadata(&mut self, metadata: &ListMetadata) -> Result<()> {
|
||||||
|
let list_path = self.get_list_path(metadata.id)?;
|
||||||
|
let metadata_path = list_path.join(LIST_METADATA_FILE);
|
||||||
|
|
||||||
|
let contents = serde_json::to_string_pretty(metadata)?;
|
||||||
|
fs::write(&metadata_path, contents)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_storage() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut storage = FileSystemStorage::new(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
|
||||||
|
storage.init().unwrap();
|
||||||
|
|
||||||
|
assert!(temp_dir.path().join(METADATA_FILE).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_list() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut storage = FileSystemStorage::new(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
storage.init().unwrap();
|
||||||
|
|
||||||
|
let list_id = storage.create_list("Test List").unwrap();
|
||||||
|
|
||||||
|
let lists = storage.list_lists().unwrap();
|
||||||
|
assert_eq!(lists.len(), 1);
|
||||||
|
assert_eq!(lists[0].0, list_id);
|
||||||
|
assert_eq!(lists[0].1, "Test List");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_write_and_read_task() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut storage = FileSystemStorage::new(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
storage.init().unwrap();
|
||||||
|
|
||||||
|
let list_id = storage.create_list("Test List").unwrap();
|
||||||
|
let task = Task::new("Test Task".to_string());
|
||||||
|
|
||||||
|
storage.write_task(list_id, &task).unwrap();
|
||||||
|
let read_task = storage.read_task(list_id, task.id).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_task.id, task.id);
|
||||||
|
assert_eq!(read_task.title, task.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_task() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let mut storage = FileSystemStorage::new(temp_dir.path().to_path_buf()).unwrap();
|
||||||
|
storage.init().unwrap();
|
||||||
|
|
||||||
|
let list_id = storage.create_list("Test List").unwrap();
|
||||||
|
let task = Task::new("Test Task".to_string());
|
||||||
|
|
||||||
|
storage.write_task(list_id, &task).unwrap();
|
||||||
|
storage.delete_task(list_id, task.id).unwrap();
|
||||||
|
|
||||||
|
assert!(storage.read_task(list_id, task.id).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
8
crates/bevy-tasks-gui/Cargo.toml
Normal file
8
crates/bevy-tasks-gui/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "bevy-tasks-gui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bevy-tasks-core = { path = "../bevy-tasks-core" }
|
||||||
|
# GUI dependencies will be added in Phase 3
|
||||||
1
crates/bevy-tasks-gui/src/lib.rs
Normal file
1
crates/bevy-tasks-gui/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// Placeholder for GUI implementation (Phase 3+)
|
||||||
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