Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/taskbook-client/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
5 changes: 4 additions & 1 deletion crates/taskbook-client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
127 changes: 106 additions & 21 deletions crates/taskbook-client/src/taskbook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,25 @@ impl Taskbook {
}

fn get_options(&self, input: &[String]) -> Result<CreateOptions> {
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();
TaskbookError::General(format!("invalid due date: {raw}"))
}
})?;

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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -956,11 +947,13 @@ impl Taskbook {
.filter(|x| *x != target)
.cloned()
.collect::<Vec<_>>()
.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()) {
Expand Down Expand Up @@ -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<String> {
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");
}
}
20 changes: 20 additions & 0 deletions crates/taskbook-common/src/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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<String> = 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");
}
}
8 changes: 8 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down