diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/__pycache__/task.cpython-312.pyc b/__pycache__/task.cpython-312.pyc new file mode 100644 index 0000000..8f834ce Binary files /dev/null and b/__pycache__/task.cpython-312.pyc differ diff --git a/__pycache__/test_task.cpython-312-pytest-8.2.2.pyc b/__pycache__/test_task.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000..c6aedfd Binary files /dev/null and b/__pycache__/test_task.cpython-312-pytest-8.2.2.pyc differ diff --git a/commands/__pycache__/__init__.cpython-312.pyc b/commands/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..4db1284 Binary files /dev/null and b/commands/__pycache__/__init__.cpython-312.pyc differ diff --git a/commands/__pycache__/add.cpython-312.pyc b/commands/__pycache__/add.cpython-312.pyc new file mode 100644 index 0000000..997bae0 Binary files /dev/null and b/commands/__pycache__/add.cpython-312.pyc differ diff --git a/commands/__pycache__/done.cpython-312.pyc b/commands/__pycache__/done.cpython-312.pyc new file mode 100644 index 0000000..a5c6b35 Binary files /dev/null and b/commands/__pycache__/done.cpython-312.pyc differ diff --git a/commands/__pycache__/list.cpython-312.pyc b/commands/__pycache__/list.cpython-312.pyc new file mode 100644 index 0000000..d360015 Binary files /dev/null and b/commands/__pycache__/list.cpython-312.pyc differ diff --git a/commands/__pycache__/utils.cpython-312.pyc b/commands/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..368e1e0 Binary files /dev/null and b/commands/__pycache__/utils.cpython-312.pyc differ diff --git a/commands/add.py b/commands/add.py index 1b1a943..126abf7 100644 --- a/commands/add.py +++ b/commands/add.py @@ -1,37 +1,15 @@ """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 import get_tasks_file, load_tasks, save_tasks, validate_description def add_task(description): """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 = load_tasks() task_id = len(tasks) + 1 tasks.append({"id": task_id, "description": description, "done": False}) - tasks_file.write_text(json.dumps(tasks, indent=2)) + save_tasks(tasks) print(f"Added task {task_id}: {description}") diff --git a/commands/done.py b/commands/done.py index c9dfd42..47d9aec 100644 --- a/commands/done.py +++ b/commands/done.py @@ -1,36 +1,21 @@ """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 import load_tasks, save_tasks, validate_task_id def mark_done(task_id): """Mark a task as complete.""" - tasks_file = get_tasks_file() - if not tasks_file.exists(): + tasks = load_tasks() + if not tasks: print("No tasks found!") return - tasks = json.loads(tasks_file.read_text()) 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)) + save_tasks(tasks) print(f"Marked task {task_id} as done: {task['description']}") return diff --git a/commands/list.py b/commands/list.py index 714315d..5b29413 100644 --- a/commands/list.py +++ b/commands/list.py @@ -1,37 +1,26 @@ """List tasks command.""" import json -from pathlib import Path +from .utils import load_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) - tasks_file = get_tasks_file() - if not tasks_file.exists(): - return [] - return 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!") - return - - tasks = json.loads(tasks_file.read_text()) +def list_tasks(as_json=False): + """List all tasks. Returns task list if as_json is True.""" + tasks = load_tasks() if not tasks: - print("No tasks yet!") - return - - for task in tasks: - status = "✓" if task["done"] else " " - print(f"[{status}] {task['id']}. {task['description']}") + if as_json: + print(json.dumps([])) + else: + print("No tasks yet!") + return tasks + + if as_json: + print(json.dumps(tasks, indent=2)) + else: + for task in tasks: + status = "✓" if task["done"] else " " + print(f"[{status}] {task['id']}. {task['description']}") + + return tasks diff --git a/commands/utils.py b/commands/utils.py new file mode 100644 index 0000000..6c7166f --- /dev/null +++ b/commands/utils.py @@ -0,0 +1,40 @@ +"""Shared validation utilities for task CLI.""" + +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 load_tasks(): + """Load tasks from file. Returns empty list if file doesn't exist.""" + tasks_file = get_tasks_file() + if not tasks_file.exists(): + return [] + return json.loads(tasks_file.read_text()) + + +def save_tasks(tasks): + """Save tasks to file.""" + tasks_file = get_tasks_file() + tasks_file.parent.mkdir(parents=True, exist_ok=True) + tasks_file.write_text(json.dumps(tasks, indent=2)) + + +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_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 diff --git a/task.py b/task.py index 53cc8ed..3265177 100644 --- a/task.py +++ b/task.py @@ -11,9 +11,22 @@ def load_config(): - """Load configuration from file.""" + """Load configuration from file. Creates default 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\n" + "storage:\n" + " format: json\n" + " max_tasks: 1000\n" + "\n" + "display:\n" + " color: true\n" + " unicode: true\n" + ) + config_path.write_text(default_config) + print(f"Created default config at {config_path}") with open(config_path) as f: return f.read() @@ -22,25 +35,38 @@ def main(): parser = argparse.ArgumentParser(description="Simple task manager") subparsers = parser.add_subparsers(dest="command", help="Command to run") + # Shared --json flag on each subcommand + json_arg = {"flags": ["--json"], "action": "store_true", "help": "Output in JSON format"} + # 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_arg["flags"], action=json_arg["action"], help=json_arg["help"]) # List command list_parser = subparsers.add_parser("list", help="List all tasks") + list_parser.add_argument(*json_arg["flags"], action=json_arg["action"], help=json_arg["help"]) # 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_arg["flags"], action=json_arg["action"], help=json_arg["help"]) args = parser.parse_args() + use_json = args.json if args.command == "add": add_task(args.description) + if use_json: + import json + print(json.dumps({"status": "added", "description": args.description})) elif args.command == "list": - list_tasks() + list_tasks(as_json=use_json) elif args.command == "done": mark_done(args.task_id) + if use_json: + import json + print(json.dumps({"status": "done", "task_id": args.task_id})) else: parser.print_help() diff --git a/test_task.py b/test_task.py index ba98e43..7afd840 100644 --- a/test_task.py +++ b/test_task.py @@ -1,12 +1,18 @@ -"""Basic tests for task CLI.""" +"""Tests for task CLI.""" import json import pytest 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 commands.utils import validate_description, validate_task_id, get_tasks_file, load_tasks, save_tasks +from commands.add import add_task +from commands.list import list_tasks +from commands.done import mark_done +from task import load_config +# --- Validation tests (Issue #3: utils module) --- + def test_validate_description(): """Test description validation.""" assert validate_description(" test ") == "test" @@ -28,3 +34,78 @@ def test_validate_task_id(): with pytest.raises(ValueError): validate_task_id(tasks, 99) + + +# --- Config crash fix tests (Issue #2) --- + +def test_load_config_creates_default(tmp_path): + """Test that load_config creates default config when missing.""" + config_path = tmp_path / ".config" / "task-cli" / "config.yaml" + with patch.object(Path, 'home', return_value=tmp_path): + config = load_config() + assert config_path.exists() + assert "storage:" in config + assert "display:" in config + + +def test_load_config_reads_existing(tmp_path): + """Test that load_config reads existing config.""" + config_path = tmp_path / ".config" / "task-cli" / "config.yaml" + config_path.parent.mkdir(parents=True) + config_path.write_text("custom: true\n") + with patch.object(Path, 'home', return_value=tmp_path): + config = load_config() + assert "custom: true" in config + + +# --- JSON output tests (Issue #1) --- + +def test_list_tasks_json(tmp_path, capsys): + """Test JSON output for list command.""" + tasks_file = tmp_path / ".local" / "share" / "task-cli" / "tasks.json" + tasks_file.parent.mkdir(parents=True) + tasks_file.write_text(json.dumps([ + {"id": 1, "description": "Test task", "done": False}, + {"id": 2, "description": "Done task", "done": True}, + ])) + with patch('commands.utils.get_tasks_file', return_value=tasks_file): + list_tasks(as_json=True) + output = capsys.readouterr().out + data = json.loads(output) + assert len(data) == 2 + assert data[0]["description"] == "Test task" + assert data[1]["done"] is True + + +def test_list_tasks_json_empty(tmp_path, capsys): + """Test JSON output for empty task list.""" + tasks_file = tmp_path / "nonexistent" / "tasks.json" + with patch('commands.utils.get_tasks_file', return_value=tasks_file): + list_tasks(as_json=True) + output = capsys.readouterr().out + assert json.loads(output) == [] + + +def test_list_tasks_human(tmp_path, capsys): + """Test human-readable output still works.""" + tasks_file = tmp_path / ".local" / "share" / "task-cli" / "tasks.json" + tasks_file.parent.mkdir(parents=True) + tasks_file.write_text(json.dumps([ + {"id": 1, "description": "Buy groceries", "done": False}, + ])) + with patch('commands.utils.get_tasks_file', return_value=tasks_file): + list_tasks(as_json=False) + output = capsys.readouterr().out + assert "[ ] 1. Buy groceries" in output + + +# --- Integration tests (utils shared across commands) --- + +def test_save_and_load_tasks(tmp_path): + """Test save/load round-trip through shared utils.""" + tasks_file = tmp_path / "tasks.json" + with patch('commands.utils.get_tasks_file', return_value=tasks_file): + save_tasks([{"id": 1, "description": "Test", "done": False}]) + loaded = load_tasks() + assert len(loaded) == 1 + assert loaded[0]["description"] == "Test"