From 749697e91063e8d58e38115823d7287b2a80cc14 Mon Sep 17 00:00:00 2001 From: SiyaoZheng Date: Tue, 17 Mar 2026 15:47:21 +0800 Subject: [PATCH] Handle missing config, add shared validation utils, and JSON output --- README.md | 5 +++++ commands/add.py | 37 ++++++++++-------------------- commands/done.py | 36 ++++++++++++++--------------- commands/list.py | 34 +++++++++------------------- commands/validation.py | 42 ++++++++++++++++++++++++++++++++++ task.py | 15 ++++++++----- test_task.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 148 insertions(+), 72 deletions(-) create mode 100644 commands/validation.py diff --git a/README.md b/README.md index 91da9a7..883bcaa 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,17 @@ python task.py --help ```bash # Add a task python task.py add "Buy groceries" +python task.py add "Buy groceries" --json # List tasks python task.py list +python task.py list --json # Complete a task python task.py done 1 + +# Mark done and output JSON +python task.py done 1 --json ``` ## Testing diff --git a/commands/add.py b/commands/add.py index 1b1a943..3a980ab 100644 --- a/commands/add.py +++ b/commands/add.py @@ -1,37 +1,24 @@ """Add task command.""" import json -from pathlib import Path +from .validation import get_tasks_file, validate_description, read_tasks, write_tasks -def get_tasks_file(): - """Get path to tasks file.""" - return Path.home() / ".local" / "share" / "task-cli" / "tasks.json" - - -def validate_description(description): - """Validate task description.""" - # NOTE: Validation logic scattered here - should be in utils (refactor bounty) - if not description: - raise ValueError("Description cannot be empty") - if len(description) > 200: - raise ValueError("Description too long (max 200 chars)") - return description.strip() - - -def add_task(description): +def add_task(description, json_output=False): """Add a new task.""" description = validate_description(description) tasks_file = get_tasks_file() - tasks_file.parent.mkdir(parents=True, exist_ok=True) - - tasks = [] - if tasks_file.exists(): - tasks = json.loads(tasks_file.read_text()) + tasks = read_tasks(tasks_file) task_id = len(tasks) + 1 - tasks.append({"id": task_id, "description": description, "done": False}) + task = {"id": task_id, "description": description, "done": False} + tasks.append(task) + + write_tasks(tasks, tasks_file) - tasks_file.write_text(json.dumps(tasks, indent=2)) - print(f"Added task {task_id}: {description}") + response = {"status": "ok", "task": task} + if json_output: + print(json.dumps(response)) + else: + print(f"Added task {task_id}: {description}") diff --git a/commands/done.py b/commands/done.py index c9dfd42..17817b7 100644 --- a/commands/done.py +++ b/commands/done.py @@ -1,37 +1,35 @@ """Mark task done command.""" import json -from pathlib import Path +from .validation import get_tasks_file, read_tasks, validate_task_id, write_tasks -def get_tasks_file(): - """Get path to tasks file.""" - return Path.home() / ".local" / "share" / "task-cli" / "tasks.json" - - -def validate_task_id(tasks, task_id): - """Validate task ID exists.""" - # NOTE: Validation logic scattered here - should be in utils (refactor bounty) - if task_id < 1 or task_id > len(tasks): - raise ValueError(f"Invalid task ID: {task_id}") - return task_id - - -def mark_done(task_id): +def mark_done(task_id, json_output=False): """Mark a task as complete.""" tasks_file = get_tasks_file() if not tasks_file.exists(): - print("No tasks found!") + response = {"status": "error", "message": "No tasks found!"} + if json_output: + print(json.dumps(response)) + else: + print(response["message"]) return - tasks = json.loads(tasks_file.read_text()) + tasks = read_tasks(tasks_file) task_id = validate_task_id(tasks, task_id) for task in tasks: if task["id"] == task_id: task["done"] = True - tasks_file.write_text(json.dumps(tasks, indent=2)) - print(f"Marked task {task_id} as done: {task['description']}") + write_tasks(tasks, tasks_file) + response = { + "status": "ok", + "task": {"id": task["id"], "description": task["description"]}, + } + if json_output: + print(json.dumps(response)) + else: + print(f"Marked task {task_id} as done: {task['description']}") return print(f"Task {task_id} not found") diff --git a/commands/list.py b/commands/list.py index 714315d..822295a 100644 --- a/commands/list.py +++ b/commands/list.py @@ -1,35 +1,23 @@ """List tasks command.""" import json -from pathlib import Path +from .validation import get_tasks_file, read_tasks -def get_tasks_file(): - """Get path to tasks file.""" - return Path.home() / ".local" / "share" / "task-cli" / "tasks.json" - - -def validate_task_file(): - """Validate tasks file exists.""" - # NOTE: Validation logic scattered here - should be in utils (refactor bounty) +def list_tasks(json_output=False): + """List all tasks.""" tasks_file = get_tasks_file() - if not tasks_file.exists(): - return [] - return tasks_file - + tasks = read_tasks(tasks_file) -def list_tasks(): - """List all tasks.""" - # NOTE: No --json flag support yet (feature bounty) - tasks_file = validate_task_file() - if not tasks_file: - print("No tasks yet!") + if not tasks: + if json_output: + print(json.dumps([])) + else: + print("No tasks yet!") return - tasks = json.loads(tasks_file.read_text()) - - if not tasks: - print("No tasks yet!") + if json_output: + print(json.dumps(tasks)) return for task in tasks: diff --git a/commands/validation.py b/commands/validation.py new file mode 100644 index 0000000..4e2cc57 --- /dev/null +++ b/commands/validation.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path + + +def get_tasks_file(): + """Return the default task data file path.""" + return Path.home() / ".local" / "share" / "task-cli" / "tasks.json" + + +def read_tasks(tasks_file=None): + """Load tasks from disk and return a list.""" + file_path = tasks_file or get_tasks_file() + if not file_path.exists(): + return [] + + with file_path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def write_tasks(tasks, tasks_file=None): + """Persist all tasks to disk.""" + file_path = tasks_file or get_tasks_file() + file_path.parent.mkdir(parents=True, exist_ok=True) + with file_path.open("w", encoding="utf-8") as f: + json.dump(tasks, f, indent=2) + + +def validate_description(description): + """Validate and normalize task description text.""" + if not description: + raise ValueError("Description cannot be empty") + stripped = description.strip() + if len(stripped) > 200: + raise ValueError("Description too long (max 200 chars)") + return stripped + + +def validate_task_id(tasks, task_id): + """Validate task ID exists in current tasks list.""" + if task_id < 1 or task_id > len(tasks): + raise ValueError(f"Invalid task ID: {task_id}") + return task_id diff --git a/task.py b/task.py index 53cc8ed..bf2668b 100644 --- a/task.py +++ b/task.py @@ -13,8 +13,9 @@ def load_config(): """Load configuration from file.""" config_path = Path.home() / ".config" / "task-cli" / "config.yaml" - # NOTE: This will crash if config doesn't exist - known bug for bounty testing - with open(config_path) as f: + if not config_path.exists(): + return "" + with config_path.open(encoding="utf-8") as f: return f.read() @@ -25,22 +26,26 @@ def main(): # Add command add_parser = subparsers.add_parser("add", help="Add a new task") add_parser.add_argument("description", help="Task description") + add_parser.add_argument("--json", action="store_true", help="Output JSON") # List command list_parser = subparsers.add_parser("list", help="List all tasks") + list_parser.add_argument("--json", action="store_true", help="Output JSON") # Done command done_parser = subparsers.add_parser("done", help="Mark task as complete") done_parser.add_argument("task_id", type=int, help="Task ID to mark done") + done_parser.add_argument("--json", action="store_true", help="Output JSON") args = parser.parse_args() + load_config() if args.command == "add": - add_task(args.description) + add_task(args.description, json_output=args.json) elif args.command == "list": - list_tasks() + list_tasks(json_output=args.json) elif args.command == "done": - mark_done(args.task_id) + mark_done(args.task_id, json_output=args.json) else: parser.print_help() diff --git a/test_task.py b/test_task.py index ba98e43..28e8134 100644 --- a/test_task.py +++ b/test_task.py @@ -3,8 +3,12 @@ import json import pytest from pathlib import Path +from io import StringIO +from contextlib import redirect_stdout from commands.add import add_task, validate_description from commands.done import validate_task_id +from commands.validation import validate_description as validation_validate_description +from task import load_config def test_validate_description(): @@ -18,6 +22,13 @@ def test_validate_description(): validate_description("x" * 201) +def test_validate_description_reuses_shared_validation(monkeypatch): + """Ensure command validation and shared validation stay aligned.""" + assert validate_description(" shared validation ") == validation_validate_description( + " shared validation " + ) + + def test_validate_task_id(): """Test task ID validation.""" tasks = [{"id": 1}, {"id": 2}] @@ -28,3 +39,43 @@ def test_validate_task_id(): with pytest.raises(ValueError): validate_task_id(tasks, 99) + + +def test_load_config_missing_is_safe(monkeypatch, tmp_path): + """Load config should not crash if config file is missing.""" + monkeypatch.setenv("HOME", str(tmp_path)) + assert load_config() == "" + + +def test_list_tasks_outputs_json(monkeypatch, tmp_path): + """list command should emit valid JSON.""" + monkeypatch.setenv("HOME", str(tmp_path)) + + with StringIO() as buffer, redirect_stdout(buffer): + from commands import list as list_cmd + + list_cmd.list_tasks(json_output=True) + payload = json.loads(buffer.getvalue()) + + assert payload == [] + + +def test_add_and_done_json_output(monkeypatch, tmp_path): + """add and done should return valid JSON.""" + monkeypatch.setenv("HOME", str(tmp_path)) + from commands import add as add_cmd + from commands import done as done_cmd + + with StringIO() as buffer, redirect_stdout(buffer): + add_cmd.add_task("test task", json_output=True) + added = json.loads(buffer.getvalue()) + + assert added["status"] == "ok" + assert added["task"]["description"] == "test task" + + with StringIO() as buffer, redirect_stdout(buffer): + done_cmd.mark_done(1, json_output=True) + done_payload = json.loads(buffer.getvalue()) + + assert done_payload["status"] == "ok" + assert done_payload["task"]["id"] == 1