From fd3f0e4f31b0a0749934296994aa1e7a7bca77eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:45:31 +0000 Subject: [PATCH 1/2] test: record current handling of quoted @board arguments Node taskbook 0.3.0 stores a task with an empty description for tb -t "@coding Fix the bug" because the quoted argument is taken whole as a board name. These tests pin how the Rust client behaves today: the same misparse happens, the empty-description guard rejects the bare case, and one extra argument gets a board name with spaces stored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhcvUKe8trrHnccV9XVYq5 --- crates/taskbook-client/src/taskbook.rs | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 186a5e9..a8d59e1 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -1408,3 +1408,58 @@ impl Taskbook { self.save(&data) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_taskbook() -> Taskbook { + let dir = std::env::temp_dir().join(format!("tb-taskbook-test-{}", uuid::Uuid::new_v4())); + let storage = LocalStorage::new(&dir).unwrap(); + Taskbook::with_storage(Box::new(storage)) + } + + fn args(words: &[&str]) -> Vec { + words.iter().map(|w| w.to_string()).collect() + } + + // Regression coverage for the Node taskbook 0.3.0 bug where a quoted + // argument starting with `@` (`tb -t "@coding Fix the bug"`) was taken + // whole as a board name and stored a task with an empty description. + + #[test] + fn quoted_board_only_task_is_rejected() { + let tb = test_taskbook(); + assert!(tb.create_task(&args(&["@coding"])).is_err()); + assert!(tb.get_all_items().unwrap().is_empty()); + } + + #[test] + fn quoted_board_only_note_is_rejected() { + let tb = test_taskbook(); + assert!(tb.create_note(&args(&["@coding"])).is_err()); + assert!(tb.get_all_items().unwrap().is_empty()); + } + + // Current behavior: the whole quoted argument is still parsed as a board, + // leaving no description, so the task is rejected instead of created. + #[test] + fn quoted_board_with_description_is_rejected() { + let tb = test_taskbook(); + assert!(tb.create_task(&args(&["@coding Fix the bug"])).is_err()); + assert!(tb.get_all_items().unwrap().is_empty()); + } + + // Current behavior: any extra argument gets past the empty-description + // guard, and the quoted argument is stored as a board name with spaces. + #[test] + fn quoted_board_followed_by_word_stores_misparsed_board() { + let tb = test_taskbook(); + tb.create_task(&args(&["@coding Fix the bug", "now"])) + .unwrap(); + let items = tb.get_all_items().unwrap(); + let task = items.values().next().unwrap(); + assert_eq!(task.description(), "now"); + assert_eq!(task.boards(), ["coding Fix the bug"]); + } +} From f24941fbff8a89e014949010595c3b296c103c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 23:17:22 +0000 Subject: [PATCH 2/2] fix: reject whitespace-only descriptions and report them once A quoted argument starting with @ stays a single board name, which is the only CLI way to create a board containing spaces and the PowerShell workaround for @. Pin that with parser tests and fix the gaps around it: - Trim the parsed description before the empty check, so tb -t " " and tb --edit @1 " " no longer store blank descriptions. - Return a dedicated MissingDescription error instead of InvalidId(0), and skip re-printing it in main, so users no longer see "Invalid item ID: 0" after the missing-description message. - Document the quoting rules in docs/cli-reference.md. Refs taskbook-sh/taskbook#69 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhcvUKe8trrHnccV9XVYq5 --- crates/taskbook-client/src/error.rs | 3 + crates/taskbook-client/src/main.rs | 5 +- crates/taskbook-client/src/taskbook.rs | 104 ++++++++++++++++--------- crates/taskbook-common/src/board.rs | 20 +++++ docs/cli-reference.md | 8 ++ 5 files changed, 102 insertions(+), 38 deletions(-) diff --git a/crates/taskbook-client/src/error.rs b/crates/taskbook-client/src/error.rs index bcff472..c80fd88 100644 --- a/crates/taskbook-client/src/error.rs +++ b/crates/taskbook-client/src/error.rs @@ -11,6 +11,9 @@ pub enum TaskbookError { #[error("Invalid item ID: {0}")] InvalidId(u64), + #[error("No description was given as input")] + MissingDescription, + #[error("Invalid custom directory: {0}")] InvalidDirectory(String), diff --git a/crates/taskbook-client/src/main.rs b/crates/taskbook-client/src/main.rs index ae9e276..1585427 100644 --- a/crates/taskbook-client/src/main.rs +++ b/crates/taskbook-client/src/main.rs @@ -350,7 +350,10 @@ fn main() { ); if let Err(e) = result { - eprintln!("{}", e); + // Render has already reported a missing description. + if !matches!(e, error::TaskbookError::MissingDescription) { + eprintln!("{}", e); + } process::exit(1); } } diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index a8d59e1..95accbc 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -149,14 +149,6 @@ impl Taskbook { } fn get_options(&self, input: &[String]) -> Result { - if input.is_empty() { - self.render.missing_desc(); - return Err(TaskbookError::InvalidId(0)); - } - - let data = self.get_data()?; - let id = self.generate_id(&data); - let parsed = board::parse_cli_input(input).map_err(|e| match e { board::CliParseError::InvalidDueDate(raw) => { self.render.invalid_due_date(); @@ -164,9 +156,18 @@ impl Taskbook { } })?; + let description = parsed.description.trim().to_string(); + if description.is_empty() { + self.render.missing_desc(); + return Err(TaskbookError::MissingDescription); + } + + let data = self.get_data()?; + let id = self.generate_id(&data); + Ok(CreateOptions { boards: parsed.boards, - description: parsed.description, + description, id, priority: parsed.priority, tags: parsed.tags, @@ -685,11 +686,6 @@ impl Taskbook { .. } = self.get_options(desc)?; - if description.is_empty() { - self.render.missing_desc(); - return Err(TaskbookError::InvalidId(0)); - } - let note = Note::new_with_tags(id, description, boards, tags); let mut data = self.get_data()?; data.insert(id.to_string(), StorageItem::Note(note)); @@ -788,11 +784,6 @@ impl Taskbook { due_date, } = self.get_options(desc)?; - if description.is_empty() { - self.render.missing_desc(); - return Err(TaskbookError::InvalidId(0)); - } - let task = Task::new_with_tags(id, description, boards, priority, tags).with_due_date(due_date); let mut data = self.get_data()?; @@ -956,11 +947,13 @@ impl Taskbook { .filter(|x| *x != target) .cloned() .collect::>() - .join(" "); + .join(" ") + .trim() + .to_string(); if new_desc.is_empty() { self.render.missing_desc(); - return Err(TaskbookError::InvalidId(0)); + return Err(TaskbookError::MissingDescription); } if let Some(item) = data.get_mut(&id.to_string()) { @@ -1423,43 +1416,80 @@ mod tests { words.iter().map(|w| w.to_string()).collect() } - // Regression coverage for the Node taskbook 0.3.0 bug where a quoted - // argument starting with `@` (`tb -t "@coding Fix the bug"`) was taken - // whole as a board name and stored a task with an empty description. + fn only_item(tb: &Taskbook) -> StorageItem { + let items = tb.get_all_items().unwrap(); + assert_eq!(items.len(), 1); + items.into_values().next().unwrap() + } + + // Node taskbook 0.3.0 stores an empty description for + // `tb -t "@coding Fix the bug"`. A quoted `@` argument is one board name + // (the only CLI way to create a board containing spaces), so these inputs + // have no description and must be rejected rather than stored. #[test] fn quoted_board_only_task_is_rejected() { let tb = test_taskbook(); - assert!(tb.create_task(&args(&["@coding"])).is_err()); + let err = tb.create_task(&args(&["@coding"])).unwrap_err(); + assert!(matches!(err, TaskbookError::MissingDescription)); assert!(tb.get_all_items().unwrap().is_empty()); } #[test] fn quoted_board_only_note_is_rejected() { let tb = test_taskbook(); - assert!(tb.create_note(&args(&["@coding"])).is_err()); + let err = tb.create_note(&args(&["@coding"])).unwrap_err(); + assert!(matches!(err, TaskbookError::MissingDescription)); assert!(tb.get_all_items().unwrap().is_empty()); } - // Current behavior: the whole quoted argument is still parsed as a board, - // leaving no description, so the task is rejected instead of created. #[test] fn quoted_board_with_description_is_rejected() { let tb = test_taskbook(); - assert!(tb.create_task(&args(&["@coding Fix the bug"])).is_err()); + let err = tb.create_task(&args(&["@coding Fix the bug"])).unwrap_err(); + assert!(matches!(err, TaskbookError::MissingDescription)); assert!(tb.get_all_items().unwrap().is_empty()); } - // Current behavior: any extra argument gets past the empty-description - // guard, and the quoted argument is stored as a board name with spaces. #[test] - fn quoted_board_followed_by_word_stores_misparsed_board() { + fn quoted_board_argument_is_one_board_name() { let tb = test_taskbook(); - tb.create_task(&args(&["@coding Fix the bug", "now"])) + tb.create_task(&args(&["@Work Projects", "Write report"])) .unwrap(); - let items = tb.get_all_items().unwrap(); - let task = items.values().next().unwrap(); - assert_eq!(task.description(), "now"); - assert_eq!(task.boards(), ["coding Fix the bug"]); + let task = only_item(&tb); + assert_eq!(task.description(), "Write report"); + assert_eq!(task.boards(), ["Work Projects"]); + } + + #[test] + fn whitespace_only_task_is_rejected() { + let tb = test_taskbook(); + let err = tb.create_task(&args(&[" "])).unwrap_err(); + assert!(matches!(err, TaskbookError::MissingDescription)); + assert!(tb.get_all_items().unwrap().is_empty()); + } + + #[test] + fn whitespace_only_note_is_rejected() { + let tb = test_taskbook(); + let err = tb.create_note(&args(&["@coding", " "])).unwrap_err(); + assert!(matches!(err, TaskbookError::MissingDescription)); + assert!(tb.get_all_items().unwrap().is_empty()); + } + + #[test] + fn task_description_is_trimmed() { + let tb = test_taskbook(); + tb.create_task(&args(&[" Fix the bug "])).unwrap(); + assert_eq!(only_item(&tb).description(), "Fix the bug"); + } + + #[test] + fn whitespace_only_edit_is_rejected() { + let tb = test_taskbook(); + tb.create_task(&args(&["Fix the bug"])).unwrap(); + let err = tb.edit_description(&args(&["@1", " "])).unwrap_err(); + assert!(matches!(err, TaskbookError::MissingDescription)); + assert_eq!(only_item(&tb).description(), "Fix the bug"); } } diff --git a/crates/taskbook-common/src/board.rs b/crates/taskbook-common/src/board.rs index 5a0b061..1fd60a3 100644 --- a/crates/taskbook-common/src/board.rs +++ b/crates/taskbook-common/src/board.rs @@ -318,4 +318,24 @@ mod tests { fn test_display_tag() { assert_eq!(display_tag("urgent"), "+urgent"); } + + // Each argv element is classified whole: quoting keeps a board name with + // spaces together, and keeps markers inside a description literal. + #[test] + fn test_parse_cli_input_quoted_board_keeps_spaces() { + let input: Vec = vec!["@Work Projects".into(), "Write report".into()]; + let parsed = parse_cli_input(&input).unwrap(); + assert_eq!(parsed.boards, vec!["Work Projects"]); + assert_eq!(parsed.description, "Write report"); + } + + #[test] + fn test_parse_cli_input_quoted_description_keeps_markers_literal() { + let input: Vec = vec!["Reply to @alice about the +1 p:3".into()]; + let parsed = parse_cli_input(&input).unwrap(); + assert_eq!(parsed.boards, vec![DEFAULT_BOARD]); + assert!(parsed.tags.is_empty()); + assert_eq!(parsed.priority, 1); + assert_eq!(parsed.description, "Reply to @alice about the +1 p:3"); + } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 77167f6..f2a51e2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -52,6 +52,14 @@ tb --task @work "Review pull request" tb --task @coding @review "Implement feature X" # Multiple boards ``` +Each argument is classified whole by how it starts, so pass the board and the description as separate arguments. A quoted argument starting with `@` is a single board name, which is how to create a board containing spaces (and is required for `@` in PowerShell). Likewise an argument starting with `+` is a tag and one starting with `due:` is a due date, while `p:N` must be the entire argument. Markers later in a quoted description stay literal. These rules apply to notes as well. + +```bash +tb --task "@Work Projects" "Write report" # Board "Work Projects" +tb --task "@coding Fix the bug" # Rejected: whole argument is the board name, no description +tb --task "Reply to @alice about the +1" # Description kept as typed, default board +``` + **Priority**: Append `p:N` where N is 1 (normal), 2 (medium), or 3 (high): ```bash