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 186a5e9..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()) { @@ -1408,3 +1401,95 @@ 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() + } + + 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(); + 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(); + let err = tb.create_note(&args(&["@coding"])).unwrap_err(); + assert!(matches!(err, TaskbookError::MissingDescription)); + assert!(tb.get_all_items().unwrap().is_empty()); + } + + #[test] + fn quoted_board_with_description_is_rejected() { + let tb = test_taskbook(); + 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()); + } + + #[test] + fn quoted_board_argument_is_one_board_name() { + let tb = test_taskbook(); + tb.create_task(&args(&["@Work Projects", "Write report"])) + .unwrap(); + 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