diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e69eea7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run tests + run: cargo test diff --git a/CLAUDE.md b/CLAUDE.md index 919b04c..66078a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -rem-cli is a TUI (Terminal User Interface) TODO management tool written in Rust. The core concept is that all TODO data is stored locally on the filesystem, ensuring data privacy for sensitive information. +rem-cli is a TUI (Terminal User Interface) TODO management tool written in Rust. The core concept is that all TODO data is stored locally on the filesystem, ensuring data privacy for sensitive information. The binary name is `rem` (invoked via `rem` command). ## Build Commands ```bash cargo build # Build the project -cargo run # Run the application +cargo run # Run the application (binary: rem) cargo test # Run all tests cargo test # Run a specific test cargo clippy # Run linter @@ -20,10 +20,26 @@ cargo fmt # Format code ## Architecture - **TUI Framework**: ratatui (v0.30.0) with crossterm (v0.29.0) backend -- **Application Pattern**: Event loop with state management in `App` struct - - `main()`: Terminal setup, event loop, cleanup - - `App`: Application state and input handling - - `render()`: UI rendering logic +- **Source Files**: + - `src/main.rs`: Terminal setup/cleanup, event loop, neovim integration + - `src/app.rs`: Application state (`App` struct), input handling, mode management + - `src/render.rs`: UI rendering logic (layout, task lists, preview panel) + - `src/task.rs`: Task data model, filesystem I/O, status management + +## Data Storage + +Tasks are stored as markdown files under `~/.rem-cli/tasks/` with directory-based status management: + +``` +~/.rem-cli/tasks/ + todo/.md + doing/.md + done/.md +``` + +- Status is determined by which directory the file resides in (not by frontmatter) +- Frontmatter contains: `id`, `name`, `created_at`, `updated_at` (no `status` field) +- Status changes move the file between directories via `fs::rename` ## Key Patterns @@ -31,3 +47,16 @@ cargo fmt # Format code - Event polling with 100ms timeout - Key events are handled only on `KeyEventKind::Press` - Clean terminal restoration on exit (disable raw mode, leave alternate screen) +- Two input modes: `Normal` (navigation/actions) and `Editing` (text input for new tasks) +- Done tasks are lazy-loaded on demand (`d` key toggles) to keep startup fast +- Preview panel (right 70%) shows the selected task's markdown content, updated on cursor movement +- Neovim integration: Enter key temporarily exits TUI, opens task file in nvim, then restores TUI +- `open_file: Option` is used as a message-passing mechanism between `App` (state) and `main` (terminal control) +- `--version` / `-V` flag prints version and exits without entering TUI + +## CI/CD + +- GitHub Actions workflow (`.github/workflows/release.yml`) builds release binaries on tag push (`v*`) +- Targets: macOS (aarch64, x86_64), Linux (x86_64, aarch64) +- Release artifacts are uploaded to GitHub Releases via `softprops/action-gh-release` +- Distributed via Homebrew tap (`tttol/tap`) diff --git a/Cargo.lock b/Cargo.lock index efd09f0..5ac66d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,7 +1084,7 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rem-cli" -version = "0.2.0" +version = "0.5.0" dependencies = [ "chrono", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 84f9c5f..5151b7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rem-cli" -version = "0.2.0" +version = "0.5.0" edition = "2024" [[bin]] diff --git a/docs/PLAN.md b/docs/plan/PLAN.md similarity index 100% rename from docs/PLAN.md rename to docs/plan/PLAN.md diff --git a/docs/plan/integrationtest.md b/docs/plan/integrationtest.md new file mode 100644 index 0000000..9141298 --- /dev/null +++ b/docs/plan/integrationtest.md @@ -0,0 +1,16 @@ +# Integration Test Scenario +## 概要 +Integration Test(以下ITと呼ぶ)を実装したい。実装方針やシナリオをこのファイルで定義する。 +## シナリオ +### 1.remからタスクを追加しmdファイルが新しく生成されること +- 操作:remを起動しaを押下、任意のタスク名を入力しEnterを押下 +- 期待値:mdファイルが新しく作成されていること +### 2.remからステータスを変更するとmdファイルが特定のディレクトリに移動すること +- 操作:すでに作成されたタスクに対してnまたはNを押下する +- 期待値:mdファイルがステータスに応じたディレクトリに移動していること +## 実装方針 +- tests/ディレクトリを作成してその中にITのテストファイルを作成すること +- テストファイル名はintegration_test.rsとすること +- 各シナリオごとに関数を作成し、シナリオ名は関数名で表現すること +- ITでは原則publicな関数・APIのみコールし、private関数を直接コールすることはしない。public関数を経由してprivate関数がコールされることはOK。 + diff --git a/src/app.rs b/src/app.rs index 2379e86..a99a8dd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,6 +10,7 @@ pub enum Mode { Editing, } +/// Application state and core logic for the TUI. pub struct App { pub should_quit: bool, pub input_mode: Mode, @@ -22,6 +23,10 @@ pub struct App { } impl App { + /// Creates a new `App` instance. + /// + /// Loads TODO and DOING tasks from the filesystem. + /// DONE tasks are not loaded at startup (lazy-loaded via `toggle_done`). pub fn new() -> Self { let mut tasks = Task::load_todo().unwrap_or_default(); tasks.extend(Task::load_doing().unwrap_or_default()); @@ -42,6 +47,7 @@ impl App { } } + /// Dispatches a key event to the appropriate handler based on the current input mode. pub fn handle_key_event(&mut self, key_code: KeyCode) { match self.input_mode { Mode::Normal => match key_code { @@ -53,6 +59,7 @@ impl App { KeyCode::Char('j') | KeyCode::Down => self.select_next(), KeyCode::Char('k') | KeyCode::Up => self.select_previous(), KeyCode::Char('n') => self.forward_status(), + KeyCode::Char('N') => self.backward_status(), KeyCode::Char('d') => self.toggle_done(), KeyCode::Enter => self.open_task(), _ => {} @@ -76,6 +83,7 @@ impl App { } } + /// Moves the cursor to the next task in the list. fn select_next(&mut self) { if self.tasks.is_empty() { return; @@ -87,6 +95,7 @@ impl App { self.update_preview(); } + /// Moves the cursor to the previous task in the list. fn select_previous(&mut self) { if self.tasks.is_empty() { return; @@ -98,12 +107,17 @@ impl App { self.update_preview(); } + /// Sets the selected task's file path to `open_file` for neovim to open. + /// + /// The actual neovim invocation is handled in the main event loop (`main.rs`), + /// since terminal control must be managed there. fn open_task(&mut self) { if let Some(index) = self.selected_index { self.open_file = Some(self.tasks[index].file_path()); } } + /// Reads the selected task's markdown file and updates the preview content. pub fn update_preview(&mut self) { self.preview_content = match self.selected_index { Some(index) => fs::read_to_string(self.tasks[index].file_path()).unwrap_or_default(), @@ -111,6 +125,9 @@ impl App { }; } + /// Advances the selected task's status: TODO -> DOING -> DONE. + /// + /// Does nothing if the task is already DONE. fn forward_status(&mut self) { if let Some(index) = self.selected_index { let next_status = match self.tasks[index].status { @@ -124,6 +141,25 @@ impl App { } } + /// Reverts the selected task's status: DONE -> DOING -> TODO. + /// + /// Does nothing if the task is already TODO. + fn backward_status(&mut self) { + if let Some(index) = self.selected_index { + let next_status = match self.tasks[index].status { + TaskStatus::Todo => return, + TaskStatus::Doing => TaskStatus::Todo, + TaskStatus::Done => TaskStatus::Doing, + }; + self.tasks[index].update_status(next_status); + self.tasks = Task::sort(self.tasks.clone()); + self.update_preview(); + } + } + + /// Creates a new task from the input buffer and saves it to the filesystem. + /// + /// Clears the input buffer and returns to Normal mode after completion. fn add_task(&mut self) { if !self.input_buffer.is_empty() { let new_task = Task::new(self.input_buffer.clone()); @@ -139,6 +175,10 @@ impl App { self.update_preview(); } + /// Toggles the visibility of DONE tasks. + /// + /// When enabled, loads DONE tasks from the filesystem and appends them to the task list. + /// When disabled, removes all DONE tasks from the in-memory list. fn toggle_done(&mut self) { if self.done_loaded { self.tasks.retain(|t| t.status != TaskStatus::Done); @@ -146,7 +186,6 @@ impl App { } else { if let Ok(done_tasks) = Task::load_done() { self.tasks.extend(done_tasks); - // self.tasks.sort_by(|a, b| a.created_at.cmp(&b.created_at)); } self.done_loaded = true; } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2a1e005 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod app; +pub mod render; +pub mod task; diff --git a/src/main.rs b/src/main.rs index c6dc214..479f2bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,3 @@ -mod app; -mod render; -mod task; - use std::io; use std::process::Command; use crossterm::{ @@ -10,9 +6,14 @@ use crossterm::{ ExecutableCommand, }; use ratatui::prelude::*; - -use crate::app::App; - +use rem_cli::app::App; +use rem_cli::render; + +/// Entry point for the rem TUI application. +/// +/// Handles `--version` / `-V` flags, sets up the terminal (raw mode, alternate screen), +/// runs the event loop, and restores the terminal on exit. +/// When `app.open_file` is set, temporarily exits the TUI to open the file in neovim. fn main() -> io::Result<()> { if std::env::args().any(|a| a == "--version" || a == "-V") { println!("rem {}", env!("CARGO_PKG_VERSION")); diff --git a/src/render.rs b/src/render.rs index b4d17b3..3230eec 100644 --- a/src/render.rs +++ b/src/render.rs @@ -5,6 +5,14 @@ use ratatui::{ use crate::app::{App, Mode}; use crate::task::TaskStatus; +/// Renders the entire TUI layout. +/// +/// Layout structure: +/// - Left 30%: Task list panels (TODO, DOING, DONE) +/// - Right 70%: Preview panel showing the selected task's markdown content +/// - Bottom: Input field (Editing mode) or keybinding help (Normal mode) +/// +/// The DONE panel is minimized to a border-only row when `done_loaded` is false. pub fn render(frame: &mut Frame, app: &App) { let outer = if app.input_mode == Mode::Editing { Layout::vertical([ @@ -60,8 +68,13 @@ pub fn render(frame: &mut Frame, app: &App) { ListItem::new(t.name.as_str()) }) .collect(); + let border_style = if selected_in_group.is_some() { + Style::default().fg(Color::Green) + } else { + Style::default() + }; let list = List::new(items) - .block(Block::default().title(*title).borders(Borders::ALL)) + .block(Block::default().title(*title).borders(Borders::ALL).border_style(border_style)) .highlight_style(Style::default().bg(Color::DarkGray)); let mut state = ListState::default(); state.select(selected_in_group); @@ -80,8 +93,13 @@ pub fn render(frame: &mut Frame, app: &App) { ListItem::new(t.name.as_str()) }) .collect(); + let border_style = if selected_in_group.is_some() { + Style::default().fg(Color::Green) + } else { + Style::default() + }; let list = List::new(items) - .block(Block::default().title(" DONE ").borders(Borders::ALL)) + .block(Block::default().title(" DONE ").borders(Borders::ALL).border_style(border_style)) .highlight_style(Style::default().bg(Color::DarkGray)); let mut state = ListState::default(); state.select(selected_in_group); diff --git a/src/task.rs b/src/task.rs index 18b5fbd..9c65865 100644 --- a/src/task.rs +++ b/src/task.rs @@ -5,7 +5,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Clone, PartialEq)] +/// Represents the lifecycle status of a task. +#[derive(Clone, Debug, PartialEq)] pub enum TaskStatus { Todo, Doing, @@ -13,6 +14,7 @@ pub enum TaskStatus { } impl TaskStatus { + /// Returns the directory name corresponding to this status (e.g. `"todo"`, `"doing"`, `"done"`). fn dir_name(&self) -> &str { match self { TaskStatus::Todo => "todo", @@ -23,6 +25,9 @@ impl TaskStatus { } +/// Internal representation of the YAML frontmatter stored in each task's markdown file. +/// +/// Does not include `status`, which is determined by the directory the file resides in. #[derive(Clone, Serialize, Deserialize)] struct TaskFrontmatter { id: Uuid, @@ -31,6 +36,7 @@ struct TaskFrontmatter { updated_at: DateTime, } +/// A TODO task with metadata and lifecycle status. #[derive(Clone)] pub struct Task { pub id: Uuid, @@ -41,6 +47,7 @@ pub struct Task { } impl Task { + /// Creates a new task with the given name and TODO status. pub fn new(name: String) -> Self { let now = Utc::now(); Self { @@ -52,18 +59,22 @@ impl Task { } } + /// Returns the base directory for all task files (`~/.rem-cli/tasks/`). fn base_dir() -> PathBuf { dirs::home_dir().unwrap().join(".rem-cli/tasks") } + /// Returns the directory path for a given status (e.g. `~/.rem-cli/tasks/todo/`). fn status_dir(status: &TaskStatus) -> PathBuf { Self::base_dir().join(status.dir_name()) } + /// Returns the full file path for this task's markdown file. pub fn file_path(&self) -> PathBuf { Self::status_dir(&self.status).join(format!("{}.md", self.id)) } + /// Converts this task into a `TaskFrontmatter` for serialization. fn frontmatter(&self) -> TaskFrontmatter { TaskFrontmatter { id: self.id, @@ -73,6 +84,7 @@ impl Task { } } + /// Saves this task as a markdown file with YAML frontmatter to the appropriate status directory. pub fn save(&self) -> io::Result<()> { let path = self.file_path(); fs::create_dir_all(path.parent().unwrap())?; @@ -81,6 +93,7 @@ impl Task { fs::write(path, content) } + /// Loads a task from a markdown file, assigning the given status based on its directory. fn load(path: &PathBuf, status: TaskStatus) -> io::Result { let content = fs::read_to_string(path)?; let yaml = content @@ -99,18 +112,22 @@ impl Task { }) } + /// Loads all tasks from the `todo/` directory. pub fn load_todo() -> io::Result> { Self::load_by_status(&[TaskStatus::Todo]) } + /// Loads all tasks from the `doing/` directory. pub fn load_doing() -> io::Result> { Self::load_by_status(&[TaskStatus::Doing]) } + /// Loads all tasks from the `done/` directory. pub fn load_done() -> io::Result> { Self::load_by_status(&[TaskStatus::Done]) } + /// Loads tasks from the directories corresponding to the given statuses, sorted by `created_at`. fn load_by_status(statuses: &[TaskStatus]) -> io::Result> { let mut tasks = Vec::new(); for status in statuses { @@ -131,6 +148,7 @@ impl Task { Ok(tasks) } + /// Changes this task's status and moves the file to the corresponding directory. pub fn update_status(&mut self, new_status: TaskStatus) { let old_path = self.file_path(); self.status = new_status; @@ -140,6 +158,7 @@ impl Task { let _ = self.save(); } + /// Sorts tasks by status group (TODO, DOING, DONE) and by `created_at` within each group. pub fn sort(tasks: Vec) -> Vec { let mut todos = Self::filter_by_status(&tasks, TaskStatus::Todo); let mut doings = Self::filter_by_status(&tasks, TaskStatus::Doing); @@ -150,6 +169,7 @@ impl Task { [todos, doings, dones].concat() } + /// Filters tasks by the given status, returning cloned copies. fn filter_by_status(tasks: &[Task], status: TaskStatus) -> Vec { tasks.iter() .filter(|t| t.status == status) @@ -157,3 +177,113 @@ impl Task { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::Duration; + + #[test] + fn file_path_contains_status_dir_and_uuid() { + // GIVEN: a task with TODO status + let task = Task::new("path test".to_string()); + + // WHEN: file_path is called + let path = task.file_path(); + + // THEN: the path contains /todo/ and ends with .md + assert!(path.to_str().unwrap().contains("/todo/")); + assert!(path.to_str().unwrap().ends_with(&format!("{}.md", task.id))); + } + + #[test] + fn frontmatter_excludes_status() { + // GIVEN: a task + let task = Task::new("frontmatter test".to_string()); + + // WHEN: frontmatter is serialized to YAML + let fm = task.frontmatter(); + let yaml = serde_yaml::to_string(&fm).unwrap(); + + // THEN: it contains id, name, timestamps but not status + assert_eq!(fm.id, task.id); + assert_eq!(fm.name, "frontmatter test"); + assert!(!yaml.contains("status")); + } + + #[test] + fn sort_groups_by_status_and_orders_by_created_at() { + // GIVEN: tasks with mixed statuses created in different order + let mut task_doing = Task::new("doing".to_string()); + task_doing.status = TaskStatus::Doing; + thread::sleep(Duration::from_millis(10)); + let task_todo = Task::new("todo".to_string()); + thread::sleep(Duration::from_millis(10)); + let mut task_done = Task::new("done".to_string()); + task_done.status = TaskStatus::Done; + + // WHEN: sort is called + let sorted = Task::sort(vec![task_done, task_doing, task_todo]); + + // THEN: tasks are grouped by status (TODO, DOING, DONE) + assert_eq!(sorted[0].status, TaskStatus::Todo); + assert_eq!(sorted[1].status, TaskStatus::Doing); + assert_eq!(sorted[2].status, TaskStatus::Done); + } + + #[test] + fn filter_by_status_returns_matching_tasks() { + // GIVEN: tasks with mixed statuses + let todo = Task::new("todo".to_string()); + let mut doing = Task::new("doing".to_string()); + doing.status = TaskStatus::Doing; + let tasks = vec![todo, doing]; + + // WHEN: filter_by_status is called with Todo + let filtered = Task::filter_by_status(&tasks, TaskStatus::Todo); + + // THEN: only TODO tasks are returned + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "todo"); + } + + #[test] + fn save_and_load_roundtrip() { + // GIVEN: a task saved to disk + let task = Task::new("roundtrip test".to_string()); + task.save().unwrap(); + + // WHEN: Task::load is called with the file path + let loaded = Task::load(&task.file_path(), TaskStatus::Todo).unwrap(); + + // THEN: the loaded task matches the original + assert_eq!(loaded.id, task.id); + assert_eq!(loaded.name, "roundtrip test"); + assert_eq!(loaded.status, TaskStatus::Todo); + + let _ = fs::remove_file(task.file_path()); + } + + #[test] + fn update_status_moves_file_between_directories() { + // GIVEN: a saved task with TODO status + let mut task = Task::new("status move test".to_string()); + task.save().unwrap(); + let old_path = task.file_path(); + assert!(old_path.exists()); + + // WHEN: update_status is called with Doing + thread::sleep(Duration::from_millis(10)); + let before_update = task.updated_at; + task.update_status(TaskStatus::Doing); + + // THEN: the file is moved to doing/ directory and updated_at is refreshed + assert!(!old_path.exists()); + assert!(task.file_path().exists()); + assert!(task.file_path().to_str().unwrap().contains("/doing/")); + assert!(task.updated_at > before_update); + + let _ = fs::remove_file(task.file_path()); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..ec7bd8b --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,120 @@ +use crossterm::event::KeyCode; +use rem_cli::app::App; +use rem_cli::task::TaskStatus; +use std::fs; + +/// Scenario 1: Adding a task via rem creates a new md file. +/// +/// Simulates pressing 'a', typing a task name, and pressing Enter. +/// Verifies that a corresponding md file is created in the todo/ directory. +#[test] +fn adding_task_creates_md_file() { + // GIVEN: an App instance in Normal mode + let mut app = App::new(); + let initial_task_count = app.tasks.len(); + + // WHEN: press 'a' to enter Editing mode, type a task name, and press Enter + app.handle_key_event(KeyCode::Char('a')); + for c in "integration test task".chars() { + app.handle_key_event(KeyCode::Char(c)); + } + app.handle_key_event(KeyCode::Enter); + + // THEN: a new task is added and its md file exists in the todo/ directory + assert_eq!(app.tasks.len(), initial_task_count + 1); + let new_task = app.tasks.iter() + .find(|t| t.name == "integration test task") + .expect("task should exist in the list"); + assert_eq!(new_task.status, TaskStatus::Todo); + let file_path = new_task.file_path(); + assert!(file_path.exists(), "md file should be created"); + assert!(file_path.to_str().unwrap().contains("/todo/"), "md file should be in todo/ directory"); + + // Cleanup + let _ = fs::remove_file(file_path); +} + +/// Scenario 2: Pressing 'n' moves the md file to the next status directory. +/// +/// Creates a task, then presses 'n' to forward status from TODO to DOING. +/// Verifies the file is moved from todo/ to doing/. +#[test] +fn forward_status_moves_md_file_to_next_directory() { + // GIVEN: an App with a newly added task in TODO status + let mut app = App::new(); + app.handle_key_event(KeyCode::Char('a')); + for c in "forward status test".chars() { + app.handle_key_event(KeyCode::Char(c)); + } + app.handle_key_event(KeyCode::Enter); + let task = app.tasks.iter() + .find(|t| t.name == "forward status test") + .expect("task should exist"); + let todo_path = task.file_path(); + assert!(todo_path.exists()); + + // WHEN: navigate to the task and press 'n' to forward status (TODO -> DOING) + let task_index = app.tasks.iter() + .position(|t| t.name == "forward status test") + .unwrap(); + app.selected_index = Some(task_index); + app.handle_key_event(KeyCode::Char('n')); + + // THEN: the file is moved from todo/ to doing/ + assert!(!todo_path.exists(), "file should no longer exist in todo/"); + let task = app.tasks.iter() + .find(|t| t.name == "forward status test") + .expect("task should still exist in the list"); + assert_eq!(task.status, TaskStatus::Doing); + let doing_path = task.file_path(); + assert!(doing_path.exists(), "file should exist in doing/"); + assert!(doing_path.to_str().unwrap().contains("/doing/")); + + // Cleanup + let _ = fs::remove_file(doing_path); +} + +/// Scenario 2 (reverse): Pressing 'N' moves the md file to the previous status directory. +/// +/// Creates a task in DOING status, then presses 'N' to backward status from DOING to TODO. +/// Verifies the file is moved from doing/ to todo/. +#[test] +fn backward_status_moves_md_file_to_previous_directory() { + // GIVEN: an App with a task forwarded to DOING status + let mut app = App::new(); + app.handle_key_event(KeyCode::Char('a')); + for c in "backward status test".chars() { + app.handle_key_event(KeyCode::Char(c)); + } + app.handle_key_event(KeyCode::Enter); + let task_index = app.tasks.iter() + .position(|t| t.name == "backward status test") + .unwrap(); + app.selected_index = Some(task_index); + app.handle_key_event(KeyCode::Char('n')); // TODO -> DOING + let task = app.tasks.iter() + .find(|t| t.name == "backward status test") + .expect("task should exist"); + let doing_path = task.file_path(); + assert!(doing_path.exists()); + + // WHEN: press 'N' to backward status (DOING -> TODO) + let task_index = app.tasks.iter() + .position(|t| t.name == "backward status test") + .unwrap(); + app.selected_index = Some(task_index); + app.handle_key_event(KeyCode::Char('N')); + + // THEN: the file is moved from doing/ to todo/ + assert!(!doing_path.exists(), "file should no longer exist in doing/"); + let task = app.tasks.iter() + .find(|t| t.name == "backward status test") + .expect("task should still exist in the list"); + assert_eq!(task.status, TaskStatus::Todo); + let todo_path = task.file_path(); + assert!(todo_path.exists(), "file should exist in todo/"); + assert!(todo_path.to_str().unwrap().contains("/todo/")); + + // Cleanup + let _ = fs::remove_file(todo_path); +}