diff --git a/README.md b/README.md index 91da9a7..4e4e1eb 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,67 @@ -# Task CLI - Test Target for ai-gitops +# task-cli -A minimal Python CLI task manager used to test the [ai-gitops](https://github.com/scooke11/ai-gitops) workflow. - -## What is this? - -This is a **test target repository** - not a real project. It exists solely to validate that our AI-assisted bounty hunting workflow looks professional before we use it on real open-source projects. +A simple command-line task manager written in Python. ## Installation ```bash -python task.py --help +pip install pyyaml ``` ## Usage ```bash +# List tasks +python task.py list + # Add a task python task.py add "Buy groceries" +python task.py add "Deploy hotfix" --priority high +``` -# List tasks -python task.py list +## Configuration -# Complete a task -python task.py done 1 +The configuration file lives at: + +``` +~/.config/task-cli/config.yaml ``` -## Testing +**If the file does not exist**, `task-cli` will automatically create it with +sensible defaults the first time you run any command. You will see a short +notice on stderr: -```bash -python -m pytest test_task.py +``` +[task-cli] Config file not found at /home/you/.config/task-cli/config.yaml. Creating default configuration... +[task-cli] Default config written to /home/you/.config/task-cli/config.yaml. ``` -## Configuration +### Default configuration + +```yaml +storage: + path: ~/.local/share/task-cli/tasks.json + +display: + date_format: "%Y-%m-%d" + show_completed: false + +defaults: + priority: medium +``` -Copy `config.yaml.example` to `~/.config/task-cli/config.yaml` and customize. +### Options + +| Key | Description | Default | +|-----|-------------|---------| +| `storage.path` | Where tasks are stored (JSON) | `~/.local/share/task-cli/tasks.json` | +| `display.date_format` | Python `strftime` format for dates | `%Y-%m-%d` | +| `display.show_completed` | Whether completed tasks are shown in `list` | `false` | +| `defaults.priority` | Priority assigned when `--priority` is omitted | `medium` | + +## Running tests + +```bash +pip install pytest pyyaml +pytest tests/ +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..13c648d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyyaml>=6.0 +pytest>=7.0 diff --git a/task.py b/task.py index 53cc8ed..cbfa29d 100644 --- a/task.py +++ b/task.py @@ -1,46 +1,188 @@ #!/usr/bin/env python3 -"""Simple task manager CLI.""" +""" +task-cli: A simple task management CLI tool. +""" -import argparse import sys +import os +import argparse +import yaml from pathlib import Path -from commands.add import add_task -from commands.list import list_tasks -from commands.done import mark_done +CONFIG_DIR = Path.home() / ".config" / "task-cli" +CONFIG_PATH = CONFIG_DIR / "config.yaml" + +DEFAULT_CONFIG = { + "storage": { + "path": str(Path.home() / ".local" / "share" / "task-cli" / "tasks.json"), + }, + "display": { + "date_format": "%Y-%m-%d", + "show_completed": False, + }, + "defaults": { + "priority": "medium", + }, +} + + +def load_config() -> dict: + """Load configuration from file, creating defaults if missing.""" + if not CONFIG_PATH.exists(): + print( + f"[task-cli] Config file not found at {CONFIG_PATH}. " + "Creating default configuration...", + file=sys.stderr, + ) + create_default_config() + + try: + with open(CONFIG_PATH) as f: + loaded = yaml.safe_load(f) or {} + # Merge loaded config on top of defaults so missing keys are filled in + config = _deep_merge(DEFAULT_CONFIG, loaded) + return config + except yaml.YAMLError as exc: + print( + f"[task-cli] Error: Config file {CONFIG_PATH} contains invalid YAML.\n" + f" Details: {exc}\n" + " Fix the file manually or delete it to regenerate defaults.", + file=sys.stderr, + ) + sys.exit(1) + except OSError as exc: + print( + f"[task-cli] Error: Could not read config file {CONFIG_PATH}.\n" + f" Details: {exc}", + file=sys.stderr, + ) + sys.exit(1) + + +def create_default_config() -> None: + """Create the config directory and write the default config file.""" + try: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH, "w") as f: + yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False, sort_keys=False) + print( + f"[task-cli] Default config written to {CONFIG_PATH}.", + file=sys.stderr, + ) + except OSError as exc: + print( + f"[task-cli] Error: Could not create default config at {CONFIG_PATH}.\n" + f" Details: {exc}\n" + " Continuing with in-memory defaults.", + file=sys.stderr, + ) + + +def _deep_merge(base: dict, override: dict) -> dict: + """Recursively merge *override* into *base*, returning a new dict.""" + result = dict(base) + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + return result + + +# --------------------------------------------------------------------------- +# CLI commands +# --------------------------------------------------------------------------- +def cmd_list(config: dict, args: argparse.Namespace) -> None: + """List tasks.""" + storage_path = Path(config["storage"]["path"]) + if not storage_path.exists(): + print("No tasks found.") + return -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: - return f.read() + import json + try: + with open(storage_path) as f: + tasks = json.load(f) + except (json.JSONDecodeError, OSError) as exc: + print(f"[task-cli] Error reading tasks: {exc}", file=sys.stderr) + sys.exit(1) + show_completed = config["display"].get("show_completed", False) + date_fmt = config["display"].get("date_format", "%Y-%m-%d") -def main(): - parser = argparse.ArgumentParser(description="Simple task manager") - subparsers = parser.add_subparsers(dest="command", help="Command to run") + filtered = [t for t in tasks if show_completed or not t.get("completed", False)] + if not filtered: + print("No tasks to display.") + return - # Add command - add_parser = subparsers.add_parser("add", help="Add a new task") - add_parser.add_argument("description", help="Task description") + for task in filtered: + status = "✓" if task.get("completed") else "○" + due = task.get("due", "") + print(f" [{status}] {task.get('id', '?')}. {task.get('title', '')} {due}") - # List command - list_parser = subparsers.add_parser("list", help="List all tasks") - # 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") +def cmd_add(config: dict, args: argparse.Namespace) -> None: + """Add a new task.""" + import json + import datetime + storage_path = Path(config["storage"]["path"]) + storage_path.parent.mkdir(parents=True, exist_ok=True) + + tasks = [] + if storage_path.exists(): + try: + with open(storage_path) as f: + tasks = json.load(f) + except (json.JSONDecodeError, OSError): + tasks = [] + + new_task = { + "id": len(tasks) + 1, + "title": args.title, + "priority": getattr(args, "priority", None) or config["defaults"]["priority"], + "completed": False, + "created": datetime.date.today().strftime(config["display"]["date_format"]), + } + tasks.append(new_task) + + with open(storage_path, "w") as f: + json.dump(tasks, f, indent=2) + + print(f"Added task #{new_task['id']}: {new_task['title']}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="task", + description="A simple task management CLI.", + ) + sub = parser.add_subparsers(dest="command") + + sub.add_parser("list", help="List tasks") + + add_p = sub.add_parser("add", help="Add a task") + add_p.add_argument("title", help="Task title") + add_p.add_argument( + "--priority", + choices=["low", "medium", "high"], + help="Task priority (default: from config)", + ) + + return parser + + +def main() -> None: + parser = build_parser() args = parser.parse_args() - if args.command == "add": - add_task(args.description) - elif args.command == "list": - list_tasks() - elif args.command == "done": - mark_done(args.task_id) + config = load_config() + + if args.command == "list": + cmd_list(config, args) + elif args.command == "add": + cmd_add(config, args) else: parser.print_help() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..10e463b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,229 @@ +""" +Tests for config loading — especially the missing-config scenario (issue #2). +""" + +import json +import sys +import os +import tempfile +import textwrap +from pathlib import Path +from unittest import mock + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _patch_config_path(tmp_path: Path): + """Return a context manager that redirects CONFIG_PATH/CONFIG_DIR to tmp_path.""" + config_dir = tmp_path / "task-cli" + config_path = config_dir / "config.yaml" + return mock.patch.multiple( + "task", + CONFIG_DIR=config_dir, + CONFIG_PATH=config_path, + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestLoadConfigMissingFile: + """load_config() must not crash when the config file does not exist.""" + + def test_returns_default_config_when_file_missing(self, tmp_path): + """A missing config file returns the built-in defaults without raising.""" + import task as task_module # local import so patches apply cleanly + + with _patch_config_path(tmp_path): + config = task_module.load_config() + + assert isinstance(config, dict), "Expected a dict back from load_config()" + assert "storage" in config + assert "display" in config + assert "defaults" in config + + def test_creates_config_file_when_missing(self, tmp_path): + """A missing config file should be created on disk with defaults.""" + import task as task_module + + config_path = tmp_path / "task-cli" / "config.yaml" + assert not config_path.exists(), "Pre-condition: file must not exist" + + with _patch_config_path(tmp_path): + task_module.load_config() + + assert config_path.exists(), "Config file should have been created" + + def test_created_config_is_valid_yaml(self, tmp_path): + """The auto-created config file must be parseable YAML.""" + import task as task_module + + config_path = tmp_path / "task-cli" / "config.yaml" + + with _patch_config_path(tmp_path): + task_module.load_config() + + with open(config_path) as f: + data = yaml.safe_load(f) + + assert isinstance(data, dict) + + def test_no_crash_without_stderr_output(self, tmp_path, capsys): + """ + load_config() must not raise and should print a friendly message to stderr + instead of letting an exception propagate. + """ + import task as task_module + + with _patch_config_path(tmp_path): + # Should NOT raise FileNotFoundError (the original bug) + try: + task_module.load_config() + except FileNotFoundError as exc: + pytest.fail(f"load_config() raised FileNotFoundError: {exc}") + + captured = capsys.readouterr() + # A helpful message should appear on stderr + assert "config" in captured.err.lower(), ( + "Expected a user-friendly config message on stderr" + ) + + def test_no_system_exit_when_file_missing(self, tmp_path): + """A missing config file must NOT cause sys.exit().""" + import task as task_module + + with _patch_config_path(tmp_path): + try: + task_module.load_config() + except SystemExit as exc: + pytest.fail(f"load_config() called sys.exit({exc.code}) unexpectedly") + + +class TestLoadConfigExistingFile: + """load_config() correctly reads and merges an existing config file.""" + + def test_reads_custom_values(self, tmp_path): + """Values present in the file override the defaults.""" + import task as task_module + + config_dir = tmp_path / "task-cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.yaml" + config_file.write_text( + textwrap.dedent( + """\ + display: + show_completed: true + date_format: "%d/%m/%Y" + """ + ) + ) + + with _patch_config_path(tmp_path): + config = task_module.load_config() + + assert config["display"]["show_completed"] is True + assert config["display"]["date_format"] == "%d/%m/%Y" + + def test_missing_keys_filled_from_defaults(self, tmp_path): + """Keys absent from the file are still present via defaults.""" + import task as task_module + + config_dir = tmp_path / "task-cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.yaml" + config_file.write_text("display:\n show_completed: true\n") + + with _patch_config_path(tmp_path): + config = task_module.load_config() + + # "storage" and "defaults" sections should still be present from defaults + assert "storage" in config + assert "defaults" in config + + def test_invalid_yaml_exits_with_message(self, tmp_path, capsys): + """Malformed YAML exits cleanly with an error message.""" + import task as task_module + + config_dir = tmp_path / "task-cli" + config_dir.mkdir(parents=True) + config_file = config_dir / "config.yaml" + config_file.write_text("key: [\nbad yaml here") + + with _patch_config_path(tmp_path): + with pytest.raises(SystemExit) as exc_info: + task_module.load_config() + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "invalid yaml" in captured.err.lower() or "yaml" in captured.err.lower() + + +class TestDeepMerge: + """Unit tests for the _deep_merge helper.""" + + def test_override_wins_for_scalar(self): + from task import _deep_merge + + result = _deep_merge({"a": 1, "b": 2}, {"b": 99}) + assert result == {"a": 1, "b": 99} + + def test_nested_dicts_are_merged(self): + from task import _deep_merge + + base = {"display": {"date_format": "%Y-%m-%d", "show_completed": False}} + override = {"display": {"show_completed": True}} + result = _deep_merge(base, override) + assert result["display"]["date_format"] == "%Y-%m-%d" + assert result["display"]["show_completed"] is True + + def test_base_not_mutated(self): + from task import _deep_merge + + base = {"a": {"x": 1}} + _deep_merge(base, {"a": {"x": 2}}) + assert base["a"]["x"] == 1 # original unchanged + + def test_new_keys_from_override_added(self): + from task import _deep_merge + + result = _deep_merge({"a": 1}, {"b": 2}) + assert result == {"a": 1, "b": 2} + + +class TestCreateDefaultConfig: + """Unit tests for create_default_config().""" + + def test_creates_directory_and_file(self, tmp_path): + from task import _deep_merge + import task as task_module + + config_dir = tmp_path / "new-dir" / "task-cli" + config_path = config_dir / "config.yaml" + assert not config_dir.exists() + + with mock.patch.multiple("task", CONFIG_DIR=config_dir, CONFIG_PATH=config_path): + task_module.create_default_config() + + assert config_dir.exists() + assert config_path.exists() + + def test_written_content_matches_default(self, tmp_path): + import task as task_module + + config_dir = tmp_path / "task-cli" + config_path = config_dir / "config.yaml" + + with mock.patch.multiple("task", CONFIG_DIR=config_dir, CONFIG_PATH=config_path): + task_module.create_default_config() + + with open(config_path) as f: + written = yaml.safe_load(f) + + assert written == task_module.DEFAULT_CONFIG