From 35f1eae16d316766ebfb861afccf82c4787ffe71 Mon Sep 17 00:00:00 2001 From: tommy Date: Fri, 27 Mar 2026 12:22:09 +0800 Subject: [PATCH 1/2] refactor: extract validation to utils module (closes #3) --- .gitignore | 2 ++ commands/add.py | 18 ++---------------- commands/done.py | 16 ++-------------- commands/list.py | 20 +++----------------- utils/__init__.py | 0 utils/paths.py | 8 ++++++++ utils/validation.py | 24 ++++++++++++++++++++++++ 7 files changed, 41 insertions(+), 47 deletions(-) create mode 100644 .gitignore create mode 100644 utils/__init__.py create mode 100644 utils/paths.py create mode 100644 utils/validation.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/commands/add.py b/commands/add.py index 1b1a943..dba12e4 100644 --- a/commands/add.py +++ b/commands/add.py @@ -1,22 +1,8 @@ """Add task command.""" import json -from pathlib import Path - - -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() +from utils.paths import get_tasks_file +from utils.validation import validate_description def add_task(description): diff --git a/commands/done.py b/commands/done.py index c9dfd42..995f058 100644 --- a/commands/done.py +++ b/commands/done.py @@ -1,20 +1,8 @@ """Mark task done command.""" import json -from pathlib import Path - - -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 +from utils.paths import get_tasks_file +from utils.validation import validate_task_id def mark_done(task_id): diff --git a/commands/list.py b/commands/list.py index 714315d..a4b0b87 100644 --- a/commands/list.py +++ b/commands/list.py @@ -1,27 +1,13 @@ """List tasks command.""" import json -from pathlib import Path - - -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) - tasks_file = get_tasks_file() - if not tasks_file.exists(): - return [] - return tasks_file +from utils.paths import get_tasks_file +from utils.validation import validate_task_file def list_tasks(): """List all tasks.""" - # NOTE: No --json flag support yet (feature bounty) - tasks_file = validate_task_file() + tasks_file = validate_task_file(get_tasks_file()) if not tasks_file: print("No tasks yet!") return diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/paths.py b/utils/paths.py new file mode 100644 index 0000000..4ce0eef --- /dev/null +++ b/utils/paths.py @@ -0,0 +1,8 @@ +"""Shared path helpers.""" + +from pathlib import Path + + +def get_tasks_file(): + """Get path to tasks file.""" + return Path.home() / ".local" / "share" / "task-cli" / "tasks.json" diff --git a/utils/validation.py b/utils/validation.py new file mode 100644 index 0000000..80854ef --- /dev/null +++ b/utils/validation.py @@ -0,0 +1,24 @@ +"""Shared validation helpers.""" + + +def validate_description(description): + """Validate task description.""" + 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 validate_task_file(tasks_file): + """Validate tasks file exists.""" + if not tasks_file.exists(): + return [] + return tasks_file + + +def validate_task_id(tasks, task_id): + """Validate task ID exists.""" + if task_id < 1 or task_id > len(tasks): + raise ValueError(f"Invalid task ID: {task_id}") + return task_id From 7ec0012a28449b45e3301a399605b1db188c998b Mon Sep 17 00:00:00 2001 From: tommy Date: Fri, 27 Mar 2026 13:24:32 +0800 Subject: [PATCH 2/2] feat: add --json output flag to all commands (closes #1) --- commands/add.py | 11 ++++++--- commands/done.py | 17 +++++++++---- commands/list.py | 21 +++++++++++----- task.py | 19 +++++++++++---- test_task.py | 62 +++++++++++++++++++++++++++++++++++++++++------- 5 files changed, 104 insertions(+), 26 deletions(-) diff --git a/commands/add.py b/commands/add.py index dba12e4..4c25471 100644 --- a/commands/add.py +++ b/commands/add.py @@ -5,7 +5,7 @@ from utils.validation import validate_description -def add_task(description): +def add_task(description, output_json=False): """Add a new task.""" description = validate_description(description) @@ -17,7 +17,12 @@ def add_task(description): tasks = json.loads(tasks_file.read_text()) 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) tasks_file.write_text(json.dumps(tasks, indent=2)) - print(f"Added task {task_id}: {description}") + + if output_json: + print(json.dumps(task)) + else: + print(f"Added task {task_id}: {description}") diff --git a/commands/done.py b/commands/done.py index 995f058..6370a04 100644 --- a/commands/done.py +++ b/commands/done.py @@ -5,11 +5,14 @@ from utils.validation import validate_task_id -def mark_done(task_id): +def mark_done(task_id, output_json=False): """Mark a task as complete.""" tasks_file = get_tasks_file() if not tasks_file.exists(): - print("No tasks found!") + if output_json: + print(json.dumps({"error": "No tasks found"})) + else: + print("No tasks found!") return tasks = json.loads(tasks_file.read_text()) @@ -19,7 +22,13 @@ def mark_done(task_id): 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']}") + if output_json: + print(json.dumps(task)) + else: + print(f"Marked task {task_id} as done: {task['description']}") return - print(f"Task {task_id} not found") + if output_json: + print(json.dumps({"error": f"Task {task_id} not found"})) + else: + print(f"Task {task_id} not found") diff --git a/commands/list.py b/commands/list.py index a4b0b87..ab819f6 100644 --- a/commands/list.py +++ b/commands/list.py @@ -5,19 +5,28 @@ from utils.validation import validate_task_file -def list_tasks(): +def list_tasks(output_json=False): """List all tasks.""" tasks_file = validate_task_file(get_tasks_file()) if not tasks_file: - print("No tasks yet!") + if output_json: + print("[]") + else: + print("No tasks yet!") return tasks = json.loads(tasks_file.read_text()) if not tasks: - print("No tasks yet!") + if output_json: + print("[]") + else: + print("No tasks yet!") return - for task in tasks: - status = "✓" if task["done"] else " " - print(f"[{status}] {task['id']}. {task['description']}") + if output_json: + print(json.dumps(tasks)) + else: + for task in tasks: + status = "✓" if task["done"] else " " + print(f"[{status}] {task['id']}. {task['description']}") diff --git a/task.py b/task.py index 53cc8ed..a89e886 100644 --- a/task.py +++ b/task.py @@ -11,36 +11,45 @@ def load_config(): - """Load configuration from file.""" + """Load configuration from file. Creates default config if missing.""" config_path = Path.home() / ".config" / "task-cli" / "config.yaml" - # NOTE: This will crash if config doesn't exist - known bug for bounty testing + if not config_path.exists(): + config_path.parent.mkdir(parents=True, exist_ok=True) + default_config = "# task-cli configuration\nversion: 1\n" + config_path.write_text(default_config) + return default_config with open(config_path) as f: return f.read() def main(): parser = argparse.ArgumentParser(description="Simple task manager") + parser.add_argument("--json", action="store_true", help="Output in JSON format") subparsers = parser.add_subparsers(dest="command", help="Command to run") # 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 in JSON format") # List command list_parser = subparsers.add_parser("list", help="List all tasks") + list_parser.add_argument("--json", action="store_true", help="Output in JSON format") # 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 in JSON format") args = parser.parse_args() + use_json = getattr(args, "json", False) if args.command == "add": - add_task(args.description) + add_task(args.description, output_json=use_json) elif args.command == "list": - list_tasks() + list_tasks(output_json=use_json) elif args.command == "done": - mark_done(args.task_id) + mark_done(args.task_id, output_json=use_json) else: parser.print_help() diff --git a/test_task.py b/test_task.py index ba98e43..4611efc 100644 --- a/test_task.py +++ b/test_task.py @@ -2,29 +2,75 @@ import json import pytest +import tempfile from pathlib import Path -from commands.add import add_task, validate_description -from commands.done import validate_task_id +from unittest.mock import patch + +from utils.validation import validate_description, validate_task_id +from task import load_config +from commands.add import add_task +from commands.list import list_tasks +from commands.done import mark_done def test_validate_description(): - """Test description validation.""" assert validate_description(" test ") == "test" - with pytest.raises(ValueError): validate_description("") - with pytest.raises(ValueError): validate_description("x" * 201) def test_validate_task_id(): - """Test task ID validation.""" tasks = [{"id": 1}, {"id": 2}] assert validate_task_id(tasks, 1) == 1 - with pytest.raises(ValueError): validate_task_id(tasks, 0) - with pytest.raises(ValueError): validate_task_id(tasks, 99) + + +def test_load_config_missing_file(tmp_path): + with patch("task.Path.home", return_value=tmp_path): + result = load_config() + assert "version: 1" in result + assert (tmp_path / ".config" / "task-cli" / "config.yaml").exists() + + +def test_load_config_existing_file(tmp_path): + config_dir = tmp_path / ".config" / "task-cli" + config_dir.mkdir(parents=True) + (config_dir / "config.yaml").write_text("version: 2\n") + with patch("task.Path.home", return_value=tmp_path): + result = load_config() + assert "version: 2" in result + + +def test_add_task_json_output(tmp_path, capsys): + with patch("utils.paths.Path.home", return_value=tmp_path): + add_task("test task", output_json=True) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["description"] == "test task" + assert data["done"] == False + assert "id" in data + + +def test_list_tasks_json_output(tmp_path, capsys): + with patch("utils.paths.Path.home", return_value=tmp_path): + add_task("task one") + add_task("task two") + list_tasks(output_json=True) + captured = capsys.readouterr() + # last line is the JSON from list_tasks + lines = [l for l in captured.out.strip().split("\n") if l.startswith("[")] + data = json.loads(lines[-1]) + assert len(data) == 2 + assert data[0]["description"] == "task one" + + +def test_list_tasks_empty_json(tmp_path, capsys): + with patch("utils.paths.Path.home", return_value=tmp_path): + list_tasks(output_json=True) + captured = capsys.readouterr() + assert captured.out.strip() == "[]"