todo_write — mutating the list

todo_write is the workhorse: five mutation actions under one function. All of them follow the same shape — take the current TodoList, apply the action, and return the full new list plus any nags:

pub fn todo_write(list: &TodoList, action: &TodoWriteAction)
    -> Result<TodoWriteOutput, PlanError>

An Err means the action was rejected and nothing changed. A success means the action was applied — but read the nags, because some problems are tolerated rather than rejected (see nags are advice, errors are rejections).

The Plan wrapper method Plan::todo_write(&mut self, action) applies the returned list to its internal state automatically.


Add — append a task

TodoWriteAction::Add {
    group: "Database".into(),
    description: "Write migrations".into(),
    depends_on: Some(vec!["task-1".into()]),   // optional
}
  • The task gets the next sequential id (task-1, task-2, … across all groups) and starts Pending.

  • If a group with that title exists, the task is appended to it; otherwise a new group is created (with tests_verified: false).

  • depends_on is stored as given, but problems with it only nag:

    Dependency problem Nag
    depends_on contains the task’s own id "Dependency 'x' is a self-reference."
    depends_on references a task that does not exist "Dependency 'x' does not exist in the task list."

    The task is added either way — dependencies are declarative metadata, and the module trusts the caller to fix stale ones.

Errors: an empty or whitespace-only description is rejected ("Description must be non-empty text.").


Start — mark a task in-progress

TodoWriteAction::Start { id: "task-1".into() }

Sets the task’s status to InProgress. Two rules matter here:

  • Only one task at a time. If any other task is already InProgress, Start is rejected with "Only one task at a time can be InProgress. Complete or cancel 'x' first before starting 'y'." — this holds across groups, so you cannot have two parallel in-flight tasks.
  • Unmet dependencies nag but do not block. If a dependency is not yet Completed (or no longer exists), the task still starts, with a nag like "'task-2' depends on 'task-1' which is still Pending." Starting a task early is your call; the module just tells you.

Errors: a nonexistent id is rejected ("Task 'x' does not exist. Use List to see available tasks.").


Remove — delete a task

TodoWriteAction::Remove { id: "task-1".into() }

Deletes the task wherever it lives. If other tasks still list it in their depends_on, you get a nag — "'task-1' was removed but is still referenced as a dependency by 'task-2'." — and the stale references remain. Clean them up with todo_edit afterwards.

Errors: a nonexistent id is rejected.


Clean — sweep terminal tasks

TodoWriteAction::Clean { keep_pending: true }

Removes finished work so the list reflects only what is left. The keep_pending flag decides how aggressive the sweep is:

keep_pending Removed Kept
true Completed, Cancelled Pending, InProgress
false Completed, Cancelled, and Pending only InProgress

After removing items, groups that became empty are dropped too. Clean never errors; it reports what it did through nags:

  • "Removed N completed or cancelled tasks." (when N > 0)
  • "Removed M empty group(s)." (when M > 0, pluralized)

VerifyGroup — record that tests passed

TodoWriteAction::VerifyGroup { group: "Database".into() }

Sets the group’s tests_verified flag to true — the model’s statement that tests were run for this group (see the verification contract). The flag persists: adding more tasks to the group later does not un-verify it.

  • If the group still has Pending or InProgress tasks, the verification is still recorded, but you get the nag "Group 'Database' still has pending or in-progress tasks."
  • Errors: a nonexistent group is rejected ("Group 'x' does not exist.").

Example

use cosh_tools::plan::{TodoList, TodoWriteAction, todo_write};

let mut list = TodoList::default();

// Add two tasks; the second depends on the first.
list = todo_write(&list, &TodoWriteAction::Add {
    group: "Database".into(),
    description: "Design schema".into(),
    depends_on: None,
})?.list;
let out = todo_write(&list, &TodoWriteAction::Add {
    group: "Database".into(),
    description: "Write migrations".into(),
    depends_on: Some(vec!["task-1".into()]),
})?;
list = out.list;

// Start the first task.
list = todo_write(&list, &TodoWriteAction::Start { id: "task-1".into() })?.list;

// A second Start is rejected: only one task can be in progress.
assert!(todo_write(&list, &TodoWriteAction::Start { id: "task-2".into() }).is_err());

Note the free-function pattern: each call takes the list and you keep the returned list. The Plan wrapper does this bookkeeping for you.