diff --git a/.gitignore b/.gitignore index 874f95d..86d5a8b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ qwen36-snap **/session.jsonl session.jsonl **/__pycache__ +todo/bin +todo/cli-review +.cli-skill-install-state.json diff --git a/cli-review/0-cli-discovery-preflight/architecture.md b/cli-review/0-cli-discovery-preflight/architecture.md new file mode 100644 index 0000000..08fcaaf --- /dev/null +++ b/cli-review/0-cli-discovery-preflight/architecture.md @@ -0,0 +1,31 @@ +# Architecture + +## Stack Summary + +- Language: Go +- CLI framework: Cobra +- Transport: HTTP (localhost daemon API) +- Persistence: SQLite +- Binaries: `todo` (client CLI), `todod` (daemon CLI) + +## Primary Architecture Style + +Layered CLI application. + +- Presentation layer: Cobra command tree in `cmd/todo/main.go` and `cmd/todod/main.go` +- Service layer: daemon logic in `internal/daemon/service.go` +- Persistence layer: SQLite-backed store in `internal/store/store.go` + +## Secondary Style + +Client-server CLI. + +- `todo` is an HTTP client to `todod` +- `todod` hosts routes (`/todos`, `/sinks`, `/schedules`, `/status`, `/motd/message`) and runs scheduler loop + +## Runtime Flow + +1. User runs `todo ` +2. Command validates args/flags and sends HTTP request to daemon +3. Daemon service applies domain rules and writes to SQLite +4. Scheduler loop dispatches reminders to MOTD queue and/or sinks diff --git a/cli-review/0-cli-discovery-preflight/argument-structure.md b/cli-review/0-cli-discovery-preflight/argument-structure.md new file mode 100644 index 0000000..5dad59d --- /dev/null +++ b/cli-review/0-cli-discovery-preflight/argument-structure.md @@ -0,0 +1,118 @@ +# Argument Structure + +The CLI uses long-form flags (no short aliases), with Cobra defaults (`--help`, whitespace/equals value forms). Global arguments on `todo` are transport/output oriented, while command-local arguments define object selection and mutation. + +## Common patterns + +- Positional selectors for object IDs (e.g. ``, ``, ``) +- Long flags for optional mutation/filtering +- Repeatable flags for multivalue input (`--event`, `--sink`, `--schedule`) +- Optional output mode switches (`--format`, `--rfc3339`) + +## `todo` global arguments + +- `--host ` optional, default `127.0.0.1` +- `--port ` optional, default `44180` +- `--timeout ` optional, default `10s` +- `--format ` optional, default `table` +- `--rfc3339` optional boolean, default `false` + +## `todo` command arguments + +### `list` +- `--state ` optional + +### `show ` +- `` required positional + +### `create ` +- `<todo-id>` required positional +- `<title>` required positional (minimum one token) +- `--due <datetime>` optional +- `--schedule <spec>` optional repeatable + +### `update <todo-id>` +- `<todo-id>` required positional +- `--title <string>` optional +- `--due <datetime>` optional +- `--clear-due` optional boolean + +### `close|reopen|reject <todo-id>` +- `<todo-id>` required positional + +### `sinks` +- `--enabled <true|false>` optional +- `--event <upcoming|overdue>` optional + +### `sink <sink-id>` +- `<sink-id>` required positional + +### `create-sink <sink-id>` +- `<sink-id>` required positional +- `--url <url>` required by runtime validation +- `--event <upcoming|overdue>` optional repeatable + +### `update-sink <sink-id>` +- `<sink-id>` required positional +- `--url <url>` optional +- `--event <upcoming|overdue>` optional repeatable +- `--clear-events` optional boolean + +### `delete-sink|enable-sink|disable-sink <sink-id>` +- `<sink-id>` required positional + +### `schedules` +- `--todo <todo-id>` optional +- `--kind <upcoming|overdue>` optional +- `--status <active|sent>` optional +- `--target <sink|motd>` optional + +### `schedule <schedule-id>` +- `<schedule-id>` required positional + +### `add-schedule <schedule-id>` +- `<schedule-id>` required positional +- `--todo <todo-id>` required by runtime validation +- `--kind <upcoming|overdue>` required by runtime validation +- `--before <duration>` optional +- `--every <duration>` optional +- `--sink <sink-id>` optional repeatable +- `--motd` optional boolean + +### `remove-schedule <schedule-id>` +- `<schedule-id>` required positional + +### `reminder-status` +- no command-specific args + +### `status` +- no command-specific args + +### `version` +- no command-specific args + +## `todod` command arguments + +### `start` +- `--host <string>` optional, default `127.0.0.1` +- `--port <int>` optional, default `44180` +- `--db <path>` optional (defaults to user data path) + +### `stop` +- `--host <string>` optional +- `--port <int>` optional + +### `status` +- `--host <string>` optional +- `--port <int>` optional +- `--format <table|json>` optional + +### `version` +- no command-specific args + +## Special arguments + +- `create` uses `cobra.MinimumNArgs(2)` with free-form title captured from all tokens after `<todo-id>`. +- Inline schedule grammar in `--schedule` is custom and parsed manually (`kind[:before=...|every=...][:motd|sink=...]`). +- Schedule duration strings accept relative/human forms via shared parser logic. +- `--enabled` is modeled as a string on `sinks` (`"true"`/`"false"`), not a boolean flag. diff --git a/cli-review/0-cli-discovery-preflight/commandset.md b/cli-review/0-cli-discovery-preflight/commandset.md new file mode 100644 index 0000000..a96fcff --- /dev/null +++ b/cli-review/0-cli-discovery-preflight/commandset.md @@ -0,0 +1,93 @@ +# Command Set + +## Tool: `todo` + +### Primary-object commands (todos) + +- `list` + - What: List todo items. + - How: Reads optional `--state`; calls `client.ListTodos` -> daemon `GET /todos`. +- `show <todo-id>` + - What: Show one todo. + - How: Calls `client.ShowTodo` -> daemon `GET /todos/{id}`. +- `create <todo-id> <title>` + - What: Create a todo, optional due date and inline schedules. + - How: Parses `--due` and repeatable `--schedule`, builds `CreateTodoRequest`, calls `POST /todos`. +- `update <todo-id>` + - What: Update title and/or due date. + - How: Parses `--title`, `--due`, `--clear-due`, calls `PATCH /todos/{id}`. +- `close <todo-id>` + - What: Transition todo to closed. + - How: Calls `POST /todos/{id}/close`. +- `reopen <todo-id>` + - What: Transition todo to reopened. + - How: Calls `POST /todos/{id}/reopen`. +- `reject <todo-id>` + - What: Transition todo to rejected. + - How: Calls `POST /todos/{id}/reject`. + +### Secondary-object commands (sinks) + +- `sinks` + - What: List sinks. + - How: Reads `--enabled`, `--event`; calls `GET /sinks`. +- `sink <sink-id>` + - What: Show one sink. + - How: Calls `GET /sinks/{id}`. +- `create-sink <sink-id>` + - What: Create sink. + - How: Uses `--url`, repeatable `--event`; calls `POST /sinks`. +- `update-sink <sink-id>` + - What: Update sink values. + - How: Uses `--url`, `--event`, `--clear-events`; calls `PATCH /sinks/{id}`. +- `delete-sink <sink-id>` + - What: Delete sink. + - How: Calls `DELETE /sinks/{id}`. +- `enable-sink <sink-id>` + - What: Enable sink. + - How: Calls `POST /sinks/{id}/enable`. +- `disable-sink <sink-id>` + - What: Disable sink. + - How: Calls `POST /sinks/{id}/disable`. + +### Secondary-object commands (schedules) + +- `schedules` + - What: List schedules. + - How: Uses `--todo`, `--kind`, `--status`, `--target`; calls `GET /schedules`. +- `schedule <schedule-id>` + - What: Show one schedule. + - How: Calls `GET /schedules/{id}`. +- `add-schedule <schedule-id>` + - What: Add immutable schedule. + - How: Uses `--todo`, `--kind`, `--before`, `--every`, `--sink`, `--motd`; calls `POST /schedules`. +- `remove-schedule <schedule-id>` + - What: Remove schedule. + - How: Calls `DELETE /schedules/{id}`. + +### State/info commands + +- `reminder-status` + - What: Print queued MOTD reminder messages. + - How: Calls `GET /motd/message`. +- `status` + - What: Show system status and MOTD setup hint. + - How: Calls `GET /status`; renders table/json. +- `version` + - What: Show client version. + - How: Prints local constant. + +## Tool: `todod` + +- `start` + - What: Start daemon. + - How: Reads `--host`, `--port`, `--db`; constructs server and runs HTTP service. +- `stop` + - What: Stop daemon. + - How: Calls `POST /shutdown`. +- `status` + - What: Show daemon status. + - How: Calls `GET /status`; renders table/json. +- `version` + - What: Show daemon version. + - How: Prints local constant. diff --git a/cli-review/cli-review.md b/cli-review/cli-review.md new file mode 100644 index 0000000..e3701fe --- /dev/null +++ b/cli-review/cli-review.md @@ -0,0 +1,51 @@ +# Canonical CLI automated review report +*This report is AI-generated. Please [report issues with the cli-skill](https://github.com/canonical/cli-skill/issues/new/choose) so we can improve this report.* + +**Scope:** CLI standard compliance review of `/project/todo/cmd/todo/main.go` and `/project/todo/cmd/todod/main.go` + +## Summary + +| **Severity** | **Count** | **Guideline Categories** | +| --- | --- | --- | +| High | 0 | — | +| Medium | 0 | — | +| Low | 0 | — | +| Unrated | 0 | — | +| **Total** | **0** | | + +**Overall rating:** 100 šŸ’š **Excellent** + +--- + +## CLI changes in this PR + +The latest changes successfully resolve all previously identified non-compliance issues: +* **Improved Color Capability Detection:** The `todo` CLI was updated to use `github.com/muesli/termenv` to detect color capabilities of the output stream dynamically. ANSI color sequences are safely rendered using inline tags (`RenderInlineTags`) and stripped when stdout is redirected or `NO_COLOR` is detected. +* **Resolved State-Display Naming Violation:** The non-standard `motd-message` command has been renamed to `reminder-status`. This aligns perfectly with the CLI standard's shorthand pattern for specific secondary object status (`foobar-status`). + +--- + +## Compliance matrix + +No non-compliance findings identified. All evaluated commands and behaviors conform to the CLI standard. + +--- + +## Non-compliance Findings (with citations) + +No non-compliance findings identified. + +--- + +## Compliant Findings Summary + +- **State-Display Shorthand Pattern:** The updated `reminder-status` command conforms perfectly with the standard's state-display shorthand (`foobar-status` pattern) for secondary objects. +- **Standard Verbs (add/remove) for Secondary Objects:** Under the updated CLI guidelines, `add` and `remove` are standard verbs permitted for secondary-object state mutations (e.g., `add-schedule` and `remove-schedule` are fully compliant). +- **TTY-aware Color and Formatting:** The formatting helpers (`common.Bold`, `common.ColorSection`) and help outputs (`colorizedHelp`, `rootHelp`) use `RenderInlineTags` to safely render bold and colors only when termenv detects terminal capabilities and no environment overrides (`NO_COLOR`) are set. +- **Primary-Object Command Structure:** Primary-object actions are verb-led (`list`, `show`, `create`, `update`, `close`, `reopen`, `reject`). +- **Secondary-Object Listing/Details:** Shorthand patterns for secondary objects are adhered to (`sinks`, `sink`, `schedules`, `schedule`). +- **Flat Secondary Mutation Hierarchy:** Verb-noun naming structure (`create-sink`, `delete-sink`) is used for sinks, and flat configuration is handled with flags (`--todo`) rather than deep subcommands. +- **No Dual Flags:** Short and long flags are not duplicated for the same action. +- **Help/Version Support:** The CLI supports `--help` via Cobra defaults and exposes standard `version` and `status` commands. +- **Table Formatting Standards:** Tabular data output in `list` strictly conforms to the 2-space column separator width, capitalized/left-aligned headers, and contains zero ASCII line decorations. +- **Proper Empty State Handling:** The empty state for `list` is human-readable, routed to `stderr`, and terminates with a success exit code of 0. diff --git a/cli-review/score-input.json b/cli-review/score-input.json new file mode 100644 index 0000000..3dd1010 --- /dev/null +++ b/cli-review/score-input.json @@ -0,0 +1,4 @@ +{ + "commands": 22, + "issues": [] +} \ No newline at end of file diff --git a/cli-skill/commands/cli-review.md b/cli-skill/commands/cli-review.md index 9128e3b..8510bc5 100644 --- a/cli-skill/commands/cli-review.md +++ b/cli-skill/commands/cli-review.md @@ -28,6 +28,8 @@ CLI standard compliance review only. - Check compliance with `cli-skill/references/cli-standard.md` only, do not use semantic criteria, or heuristics - Findings must map to clauses from the standard, suggestions in the standard will result in unrated findings +- Every finding MUST include all evidence fields: `command_path`, `evidence`, `rule_clause`. +- Reviewer MUST deduplicate scored findings using key `rule_clause|command_path|evidence` before severity assignment. - Non-compliance and compliant evidence based on observed CLI behavior/docs/code - Use these rules to determine severity. Severity can only be [High|Medium|Low|Unrated]: * High: violations of command structure and naming (SHOULD RULES DO NOT COUNT, THEY ARE UNRATED but should be included), use of positional parameters, accessibility/color violations (IF NO COLOR IS USED, eg. only plain or boldbold, NO_COLOR need not be detected) @@ -36,6 +38,13 @@ CLI standard compliance review only. * Unrated: use if no standard rule applies, or if the finding is not certain, there is a SHOULD rule, the rule applies to a debug/secret command, use of non-standard verbs when there is a standard verb that fits the action - All SHOULD rules must be unrated violations - report them, but don't include them in the scoring. This can be a use of non-standard verbs, or in cases where the standard says "you should", or "prefer". +## Additional rules that amend the CLI standard +- In addition to the verbs listed in the CLI standard, these are standard verbs that can be used in full compliance with the CLI standard: add / remove, submit, request, revoke, init, enable / disable, save, refresh, switch, restore, forget, report, set / remove, try, download, clean, pull, build, stage, prime, pack, test, abort, validate, watch +- If a new verb not listed in the standard or the additional rules is used, only add an UNRATED finding if (1) there is a similar word in the list AND the chosen word is not clearly better, or (2) the chosen word is difficult to understand, ambiguous, or not standard english +- Command-family verb consistency findings (for example add/create, remove/delete) MUST be scored only when at least one command in that specific family was changed in the variant. ONLY inconsistencies that use any of the standard verbs are UNRATED. +- Findings missing any required evidence field MUST be dropped from scored output. +- If a planned mutation is not present in the source code, reviewer MUST mark the finding as UNRATED. + ## Out Of Scope - Any check not defined by `cli-skill/references/cli-standard.md` @@ -74,7 +83,7 @@ To calculate the score: The script implements the standard algorithm: Start with 100%, weight W=100/#commands. For each High violation, reduce by W; Medium violation by 0.5*W; Low violation by 0.2*W. Clamp to 0-100%. ### CLI Change Requirements -Analyze the files that have been changes as part of this PR. Create a detailed summary of how each change affects the compliance of the CLI. +Analyze the files that have been changes as part of this PR. Create a list of changes, each with a detailed summary of how each change affects the compliance of the CLI. ### Compliance Matrix Requirements diff --git a/package-lock.json b/package-lock.json index df59c06..e320a9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "canonical-cli-skill", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "canonical-cli-skill", - "version": "0.1.0", + "version": "0.1.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 62666b7..bb27f2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "canonical-cli-skill", - "version": "0.1.0", + "version": "0.1.1", "description": "Cross-agent CLI review skill installer for Copilot, Pi, Claude Code, and OpenCode.", "license": "MIT", "type": "commonjs", diff --git a/scripts/analyze_results.py b/scripts/analyze_results.py new file mode 100644 index 0000000..85928b9 --- /dev/null +++ b/scripts/analyze_results.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +Analyze cli-review results from completed test variations. +Reads existing cli-review output and generates ANALYSIS.json +""" + +import json +import re +from pathlib import Path +from dataclasses import dataclass, asdict +from typing import List, Dict, Optional + +@dataclass +class TestResult: + variation_id: str + violations_injected: int + findings_detected: int + false_negatives: int + severity_breakdown: Dict + detected_issues: List[Dict] + +def extract_findings_from_report(report_path: Path) -> List[Dict]: + """Extract findings from cli-review.md report.""" + if not report_path.exists(): + return [] + + findings = [] + try: + content = report_path.read_text() + + # Extract severity summary from the report + # Look for patterns like "High | 1 |" in the summary table + severity_pattern = r'\|\s*(High|Medium|Low|Unrated)\s*\|\s*(\d+)\s*\|' + matches = re.findall(severity_pattern, content) + + for severity, count in matches: + findings.append({ + "severity": severity, + "count": int(count) + }) + except Exception as e: + print(f"Error parsing {report_path}: {e}") + + return findings + +def read_violation_metadata(metadata_path: Path) -> Dict: + """Read injected violation metadata.""" + if not metadata_path.exists(): + return {"violations": []} + + try: + return json.loads(metadata_path.read_text()) + except Exception: + return {"violations": []} + +def analyze_variation(variation_dir: Path) -> Optional[TestResult]: + """Analyze a single test variation.""" + + # Read injected violations + metadata = read_violation_metadata(variation_dir / "violation_metadata.json") + violations_injected = len(metadata.get("violations", [])) + + # Read cli-review output + report_path = variation_dir / "cli-review" / "cli-review.md" + if not report_path.exists(): + return None + + report_content = report_path.read_text() + + # Extract detected issues from report + detected_issues = extract_findings_from_report(report_path) + total_detected = sum(issue["count"] for issue in detected_issues) + false_negatives = max(0, violations_injected - total_detected) + + # Count severity breakdown of injected violations + severity_breakdown = { + "HIGH": sum(1 for v in metadata.get("violations", []) if v.get("severity") == "HIGH"), + "MEDIUM": sum(1 for v in metadata.get("violations", []) if v.get("severity") == "MEDIUM"), + "LOW": sum(1 for v in metadata.get("violations", []) if v.get("severity") == "LOW") + } + + return TestResult( + variation_id=variation_dir.name, + violations_injected=violations_injected, + findings_detected=total_detected, + false_negatives=false_negatives, + severity_breakdown=severity_breakdown, + detected_issues=detected_issues + ) + +def main(): + test_dir = Path("/project/tests") + results = [] + + # Process all variations + for i in range(1, 21): + var_dir = test_dir / f"todo-{i:02d}" + if var_dir.exists(): + result = analyze_variation(var_dir) + if result: + results.append(result) + print(f"āœ“ Analyzed {result.variation_id}: {result.violations_injected} injected, {result.findings_detected} detected") + else: + print(f"āœ— No cli-review output for {var_dir.name}") + + if not results: + print("No variations analyzed!") + return + + # Calculate summary statistics + total_injected = sum(r.violations_injected for r in results) + total_detected = sum(r.findings_detected for r in results) + total_false_negatives = sum(r.false_negatives for r in results) + + high_injected = sum(r.severity_breakdown["HIGH"] for r in results) + high_detected = sum(r.detected_issues[0]["count"] if r.detected_issues and r.detected_issues[0]["severity"] == "High" else 0 for r in results) + + summary = { + "harness_version": "2.0", + "total_variations": len(results), + "total_violations_injected": total_injected, + "total_violations_detected": total_detected, + "total_false_negatives": total_false_negatives, + "detection_coverage_percent": (total_detected / total_injected * 100) if total_injected > 0 else 0, + "severity_summary": { + "HIGH": { + "injected": high_injected, + "detected": high_detected, + "coverage_percent": (high_detected / high_injected * 100) if high_injected > 0 else 0 + } + }, + "results": [asdict(r) for r in results] + } + + # Write analysis + output_file = test_dir / "ANALYSIS.json" + with open(output_file, "w") as f: + json.dump(summary, f, indent=2) + + print(f"\nāœ“ Analysis saved to {output_file}") + print(f"\nSummary:") + print(f" Total variations: {summary['total_variations']}") + print(f" Total injected violations: {summary['total_violations_injected']}") + print(f" Total detected violations: {summary['total_violations_detected']}") + print(f" False negatives: {summary['total_false_negatives']}") + print(f" Detection coverage: {summary['detection_coverage_percent']:.1f}%") + +if __name__ == "__main__": + main() diff --git a/scripts/testing_harness.py b/scripts/testing_harness.py new file mode 100755 index 0000000..f0fae6a --- /dev/null +++ b/scripts/testing_harness.py @@ -0,0 +1,890 @@ +#!/usr/bin/env python3 +""" +CLI Review Testing Harness + +Tests the CLI review skill against deliberately constructed CLI standard violations. +Generates variations with known violations, runs cli-review on each, analyzes detection +accuracy, and identifies false positives and false negatives. +""" + +import os +import sys +import json +import subprocess +import shutil +import tempfile +from pathlib import Path +from dataclasses import dataclass, asdict +from typing import List, Dict, Tuple, Optional + +# CLI Standard Rules specific to the todo CLI +# Extracted from cli-skill/references/cli-standard.md and applied to todo command structure +CLI_RULES = [ + # HIGH SEVERITY RULES (7) + { + "id": "positional-arg-clarity", + "title": "Required arguments should use flags when they're named/optional, not positional args", + "description": "Use flags (--todo-id) for named arguments instead of positional args to improve clarity and flexibility", + "location": "cmd/todo/main.go", + "violation_pattern": "Use positional args for all parameters: 'todo show <id> <date> <filter>'", + "expected_command": "todo show --id 123", + "violation_command": "todo show 123 2025-12-31 open", + "severity": "HIGH" + }, + { + "id": "required-flag-clarity", + "title": "Required flags must be clearly indicated in help", + "description": "Required flags like --todo in add-schedule must be marked required in cobra or documented", + "location": "cmd/todo/main.go:addScheduleCmd", + "violation_pattern": "Make --todo optional or rename to optional --parent-todo", + "expected_command": "todo add-schedule --todo 42 my-sched", + "violation_command": "todo add-schedule my-sched", + "severity": "HIGH" + }, + { + "id": "mutually-exclusive-flags", + "title": "Mutually exclusive flags should be documented or handled", + "description": "Flags like --clear-due and --due should be mutually exclusive and documented", + "location": "cmd/todo/main.go:updateCmd", + "violation_pattern": "Allow both --due and --clear-due to be specified without error", + "expected_command": "todo update 1 --clear-due", + "violation_command": "todo update 1 --due 2025-12-31 --clear-due", + "severity": "HIGH" + }, + { + "id": "state-status-suffix", + "title": "Status suffix rule", + "description": "State-display commands for specific secondary objects must use the status suffix", + "location": "cmd/todo/main.go", + "violation_pattern": "Rename reminder-status to reminders", + "expected_command": "todo reminder-status", + "violation_command": "todo reminders", + "severity": "HIGH" + }, + { + "id": "short-description-clarity", + "title": "Description verb-first", + "description": "Short descriptions should be verb-first for actions", + "location": "cmd/todo/main.go", + "violation_pattern": "Use 'Todo list' instead of 'List todos'", + "expected_command": "todo list (Short: 'List todos')", + "violation_command": "todo list (Short: 'Todo list')", + "severity": "LOW" + }, + { + "id": "todo-todo-topic-clarity", + "title": "Avoid ambiguity", + "description": "Avoid ambiguity between help topics and commands", + "location": "cmd/todo/main.go", + "violation_pattern": "Make topic command clash with list", + "expected_command": "todo list and help topic todos", + "violation_command": "todo list and help topic list", + "severity": "MEDIUM" + }, + { + "id": "todo-list-command-verb", + "title": "Todo list command must use shorthand (not 'list-todos')", + "description": "The 'list' command for todos should be named 'list' following standard shorthand for primary objects", + "location": "cmd/todo/main.go:listCmd", + "violation_pattern": "Rename 'list' to 'list-todos' or 'todos-list'", + "expected_command": "todo list", + "violation_command": "todo list-todos", + "severity": "HIGH" + }, + # MEDIUM SEVERITY RULES (6) + { + "id": "todo-show-command-verb", + "title": "Todo show command must use verb", + "description": "Commands showing todo details must use verb form like 'show' not 'info' or 'display'", + "location": "cmd/todo/main.go:showCmd", + "violation_pattern": "Use inconsistent verb like 'info' or 'display-todo' instead of 'show'", + "expected_command": "todo show <todo-id>", + "violation_command": "todo info <todo-id>", + "severity": "MEDIUM" + }, + { + "id": "schedule-verb-consistency", + "title": "Schedule mutation verbs must be consistent (add/remove, not create/delete)", + "description": "Secondary object operations must use consistent verb pairs throughout", + "location": "cmd/todo/main.go:addScheduleCmd", + "violation_pattern": "Use 'create-schedule' instead of 'add-schedule' (mismatched with 'remove-schedule')", + "expected_command": "todo add-schedule <schedule-id>", + "violation_command": "todo create-schedule <schedule-id>", + "severity": "MEDIUM" + }, + { + "id": "flag-plural-for-arrays", + "title": "Flag names must be plural when accepting multiple values", + "description": "Use '--sinks' (plural) for array flags, '--sink' (singular) for single value", + "location": "cmd/todo/main.go:addScheduleCmd", + "violation_pattern": "Use '--sink' for repeatable array flag that accepts multiple values", + "expected_command": "todo add-schedule --sink foo --sink bar", + "violation_command": "todo add-schedule --sinks=foo --sinks=bar", + "severity": "MEDIUM" + }, + { + "id": "output-format-flag-consistency", + "title": "All commands must consistently support output format flags", + "description": "All query commands must support same output format options (--format or -f)", + "location": "cmd/todo/main.go", + "violation_pattern": "Some commands use '--format' while others use '--output' or '--fmt'", + "expected_command": "todo list --format json", + "violation_command": "todo list --output json", + "severity": "MEDIUM" + }, + { + "id": "action-verb-alignment", + "title": "Action commands must align with their effect (close, reopen, reject)", + "description": "Commands that change todo state must use verbs that clearly indicate the action", + "location": "cmd/todo/main.go:closeCmd", + "violation_pattern": "Rename 'close' to 'mark-closed' or 'set-closed'", + "expected_command": "todo close <todo-id>", + "violation_command": "todo mark-closed <todo-id>", + "severity": "MEDIUM" + }, + { + "id": "flag-help-completeness", + "title": "All flags must have help text describing allowed values", + "description": "Flags with constrained values must document those constraints", + "location": "cmd/todo/main.go:listCmd", + "violation_pattern": "Provide empty help for --state flag, omit allowed values", + "expected_command": "--state 'Filter state: open|closed|reopened|rejected'", + "violation_command": "--state ''", + "severity": "MEDIUM" + }, + # LOW SEVERITY RULES (7) + { + "id": "sink-list-shorthand", + "title": "Sink listing must use 'sinks' not 'list-sinks'", + "description": "Use shorthand 'sinks' for listing secondary objects instead of 'list-sinks'", + "location": "cmd/todo/main.go:sinksCmd", + "violation_pattern": "Rename 'sinks' to 'list-sinks' or 'sink-list'", + "expected_command": "todo sinks", + "violation_command": "todo list-sinks", + "severity": "LOW" + }, + { + "id": "sink-show-shorthand", + "title": "Sink show command must use 'sink <id>' not 'show-sink'", + "description": "For secondary objects where parent context is implicit, use 'foobar <id>' not 'show-foobar'", + "location": "cmd/todo/main.go:sinkCmd", + "violation_pattern": "Rename 'sink' to 'show-sink' or 'show-sink <id>'", + "expected_command": "todo sink <sink-id>", + "violation_command": "todo show-sink <sink-id>", + "severity": "LOW" + }, + { + "id": "schedule-list-shorthand", + "title": "Schedule listing must use 'schedules' not 'list-schedules'", + "description": "Use shorthand 'schedules' for listing secondary objects", + "location": "cmd/todo/main.go:schedulesCmd", + "violation_pattern": "Rename 'schedules' to 'list-schedules'", + "expected_command": "todo schedules", + "violation_command": "todo list-schedules", + "severity": "LOW" + }, + { + "id": "filter-flag-naming", + "title": "Filter flags must follow consistent naming pattern", + "description": "Filter flags should use names that reflect what is being filtered (e.g., --state, --kind)", + "location": "cmd/todo/main.go:listCmd", + "violation_pattern": "Use '--filter-state' or '--state-filter' instead of '--state'", + "expected_command": "todo list --state open", + "violation_command": "todo list --filter-state open", + "severity": "LOW" + }, + { + "id": "group-labels-consistency", + "title": "Command group labels must be consistent (capitalized, singular/plural)", + "description": "Group headers in help text must follow consistent formatting", + "location": "cmd/todo/main.go", + "violation_pattern": "Use 'todo commands' instead of 'Todos:', 'sink operations' instead of 'Sinks:'", + "expected_command": "todo help", + "violation_command": "todo help", + "severity": "LOW" + }, + { + "id": "error-message-consistency", + "title": "Error messages must be consistent in tone and formatting", + "description": "All error outputs should follow same pattern (lowercase, no punctuation or consistent punctuation)", + "location": "cmd/todo/main.go", + "violation_pattern": "Mix error styles like 'Invalid id' vs 'invalid todo-id: expected integer'", + "expected_command": "invalid todo id: expected integer", + "violation_command": "Invalid todo id.", + "severity": "LOW" + }, + { + "id": "sink-verb-consistency", + "title": "Sink mutation verbs must be consistent (create/delete)", + "description": "Operations on the same secondary object type must use parallel verb pairs", + "location": "cmd/todo/main.go:createSinkCmd", + "violation_pattern": "Use 'add-sink' with 'remove-sink' instead of 'create-sink' with 'delete-sink'", + "expected_command": "todo create-sink <sink-id>", + "violation_command": "todo add-sink <sink-id>", + "severity": "LOW" + } +] + +@dataclass +class Violation: + """Represents an injected violation.""" + rule_id: str + rule_title: str + description: str + file_path: str + change_description: str + severity: str = "MEDIUM" + expected_detection: bool = True + +@dataclass +class DetectionResult: + """Result of running cli-review on a variation.""" + variation_id: str + violations_injected: List[Violation] + findings_detected: List[Dict] + false_positives: List[Dict] + false_negatives: List[Violation] + severity_mismatches: List[Tuple[str, str, str]] # (rule_id, expected, detected) + coverage: float # Percentage of injected violations detected + + +class VariationGenerator: + """Generates test variations with deliberate violations.""" + + def __init__(self, source_dir: Path, test_dir: Path): + self.source_dir = Path(source_dir) + self.test_dir = Path(test_dir) + self.test_dir.mkdir(parents=True, exist_ok=True) + + def create_variation(self, variation_id: int) -> Tuple[Path, List[Violation]]: + """Create a single variation with injected violations.""" + var_dir = self.test_dir / f"todo-{variation_id:02d}" + var_dir.mkdir(parents=True, exist_ok=True) + + # Copy source to variation + src_todo = self.source_dir / "todo" + if src_todo.exists(): + # Copy if we have real source, skipping special files + for item in src_todo.iterdir(): + # Skip symlinks and special files (sockets, etc.) + if item.is_symlink(): + continue + + dst = var_dir / item.name + try: + if item.is_dir(): + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(item, dst, ignore_dangling_symlinks=True) + else: + shutil.copy2(item, dst) + except (OSError, shutil.Error): + # Skip files that can't be copied (sockets, devices, etc.) + continue + + violations = self._inject_violations(variation_id, var_dir) + + # Save metadata + metadata = { + "variation_id": variation_id, + "violations": [asdict(v) for v in violations] + } + with open(var_dir / "violation_metadata.json", "w") as f: + json.dump(metadata, f, indent=2) + + return var_dir, violations + + def _inject_violations(self, variation_id: int, var_dir: Path) -> List[Violation]: + """Inject violations based on variation number.""" + violations = [] + + # Map variations to rule violations (some combine multiple rules) + # These map to the new todo-specific rules + violation_patterns = { + 1: ["todo-list-command-verb"], + 2: ["sink-list-shorthand"], + 3: ["schedule-list-shorthand"], + 4: ["state-status-suffix"], + 5: ["positional-arg-clarity"], + 6: ["flag-plural-for-arrays"], + 7: ["output-format-flag-consistency"], + 8: ["schedule-verb-consistency"], + 9: ["sink-verb-consistency"], + 10: ["todo-show-command-verb", "sink-show-shorthand"], + 11: ["filter-flag-naming"], + 12: ["required-flag-clarity"], + 13: ["action-verb-alignment"], + 14: ["group-labels-consistency", "short-description-clarity"], + 15: ["mutually-exclusive-flags"], + 16: ["todo-list-command-verb", "state-status-suffix", "positional-arg-clarity"], + 17: ["sink-verb-consistency", "flag-plural-for-arrays"], + 18: ["schedule-verb-consistency", "output-format-flag-consistency"], + 19: ["filter-flag-naming", "flag-help-completeness"], + 20: ["todo-todo-topic-clarity", "error-message-consistency", "action-verb-alignment"], + } + + selected_rules = violation_patterns.get(variation_id, ["positional-arg-clarity"]) + + for rule_id in selected_rules: + rule = next((r for r in CLI_RULES if r["id"] == rule_id), None) + if not rule: + continue + + violation = self._create_violation(rule, variation_id, var_dir) + if violation: + violations.append(violation) + + return violations + + def _create_violation(self, rule: Dict, variation_id: int, var_dir: Path) -> Optional[Violation]: + """Create a specific violation in the variation directory.""" + + # Inject violation into main.go + main_go = var_dir / "cmd" / "todo" / "main.go" + if not main_go.exists(): + return None + + with open(main_go, "r") as f: + content = f.read() + + rule_id = rule["id"] + change_desc = "" + modified = False + + if rule_id == "todo-list-command-verb": + # Change list command to list-todos + if 'Use: "list"' in content: + content = content.replace('Use: "list"', 'Use: "list-todos"') + change_desc = "Renamed 'list' command to 'list-todos' (violates shorthand rule)" + modified = True + + elif rule_id == "todo-show-command-verb": + # Change show command to info + if 'Use: "show <todo-id>"' in content: + content = content.replace('Use: "show <todo-id>"', 'Use: "info <todo-id>"') + change_desc = "Renamed 'show' command to 'info' (violates verb consistency)" + modified = True + + elif rule_id == "sink-list-shorthand": + # Change sinks to list-sinks + if 'Use: "sinks"' in content: + content = content.replace('Use: "sinks"', 'Use: "list-sinks"') + change_desc = "Renamed 'sinks' to 'list-sinks' (violates list shorthand)" + modified = True + + elif rule_id == "sink-show-shorthand": + # Change sink to show-sink + if 'Use: "sink <sink-id>"' in content: + content = content.replace('Use: "sink <sink-id>"', 'Use: "show-sink <sink-id>"') + change_desc = "Renamed 'sink' to 'show-sink' (violates shorthand)" + modified = True + + elif rule_id == "schedule-list-shorthand": + # Change schedules to list-schedules + if 'Use: "schedules"' in content: + content = content.replace('Use: "schedules"', 'Use: "list-schedules"') + change_desc = "Renamed 'schedules' to 'list-schedules' (violates list shorthand)" + modified = True + + elif rule_id == "schedule-verb-consistency": + # Change add-schedule to create-schedule + if 'Use: "add-schedule"' in content: + content = content.replace('Use: "add-schedule"', 'Use: "create-schedule"') + change_desc = "Renamed 'add-schedule' to 'create-schedule' (inconsistent with 'remove-schedule')" + modified = True + + elif rule_id == "sink-verb-consistency": + # Change create-sink to add-sink + if 'Use: "create-sink"' in content: + content = content.replace('Use: "create-sink"', 'Use: "add-sink"') + change_desc = "Renamed 'create-sink' to 'add-sink' (inconsistent with 'delete-sink')" + modified = True + + elif rule_id == "state-status-suffix": + # Change reminder-status to reminders + if 'Use: "reminder-status"' in content: + content = content.replace('Use: "reminder-status"', 'Use: "reminders"') + change_desc = "Renamed 'reminder-status' to 'reminders' (violates status suffix rule)" + modified = True + + elif rule_id == "positional-arg-clarity": + # Change specific arg names to generic <id> + if 'Use: "show <todo-id>"' in content: + content = content.replace('Use: "show <todo-id>"', 'Use: "show <id>"') + change_desc = "Changed argument from '<todo-id>' to '<id>' (violates clarity rule)" + modified = True + + elif rule_id == "flag-plural-for-arrays": + # Find the --sink flag and add comment about violation + if 'Flags().StringArray("sink"' in content: + content = content.replace( + 'Flags().StringArray("sink", nil, "Optional sink id (repeatable)")', + 'Flags().StringArray("sinks", nil, "Sink ids (repeatable) // VIOLATION: plural flag for array")' + ) + change_desc = "Changed '--sink' to '--sinks' (violates singular flag for arrays)" + modified = True + + elif rule_id == "output-format-flag-consistency": + # Change some commands' format flag to output + if 'Flags().String("format"' in content: + content = content.replace( + 'Flags().String("format", "table", "Output format: table|json")', + 'Flags().String("output", "table", "Output format: table|json // VIOLATION: inconsistent flag name")' + ) + change_desc = "Changed '--format' to '--output' (violates flag consistency)" + modified = True + + elif rule_id == "filter-flag-naming": + # Change --state to --filter-state + if 'Flags().String("state"' in content: + content = content.replace( + 'Flags().String("state", "", "Filter state: open|closed|reopened|rejected")', + 'Flags().String("filter-state", "", "Filter state: open|closed|reopened|rejected // VIOLATION")' + ) + change_desc = "Changed '--state' to '--filter-state' (violates filter naming)" + modified = True + + elif rule_id == "required-flag-clarity": + # Add comment about missing required marker + if 'Flags().String("todo", "", "Todo id")' in content: + content = content.replace( + 'Flags().String("todo", "", "Todo id")', + 'Flags().String("todo", "", "Todo id (optional)") // VIOLATION: missing required marker' + ) + change_desc = "Made --todo flag appear optional (violates clarity)" + modified = True + + elif rule_id == "mutually-exclusive-flags": + # Add comment about both flags being allowed + if 'Flags().Bool("clear-due"' in content: + content = content.replace( + 'Flags().Bool("clear-due", false, "Clear due date")', + 'Flags().Bool("clear-due", false, "Clear due date // VIOLATION: no check for mutual exclusion with --due")' + ) + change_desc = "Missing mutual exclusion between --due and --clear-due" + modified = True + + elif rule_id == "action-verb-alignment": + # Change close to mark-closed + if 'todoActionCmd("close <todo-id>",' in content: + content = content.replace('todoActionCmd("close <todo-id>",', 'todoActionCmd("mark-closed <todo-id>",') + change_desc = "Renamed 'close' to 'mark-closed' (violates action verb clarity)" + modified = True + + elif rule_id == "group-labels-consistency": + # Change group label format + if 'AddGroup(&cobra.Group{ID: "todos", Title: "Todos:"})' in content: + content = content.replace( + 'AddGroup(&cobra.Group{ID: "todos", Title: "Todos:"})', + 'AddGroup(&cobra.Group{ID: "todos", Title: "Todo Commands"}) // VIOLATION: inconsistent format' + ) + change_desc = "Changed group label format (violates consistency)" + modified = True + + elif rule_id == "short-description-clarity": + # Change description to start with noun instead of verb + if 'Short: "List todos"' in content: + content = content.replace( + 'Short: "List todos"', + 'Short: "Todo list"' + ) + change_desc = "Changed description to 'Todo list' instead of 'List todos' (violates verb-first rule)" + modified = True + + elif rule_id == "flag-help-completeness": + # Remove help text from flag + if 'Flags().String("state", "", "Filter state: open|closed|reopened|rejected")' in content: + content = content.replace( + 'Flags().String("state", "", "Filter state: open|closed|reopened|rejected")', + 'Flags().String("state", "", "")' + ) + change_desc = "Removed help text from --state flag (violates completeness)" + modified = True + + elif rule_id == "todo-todo-topic-clarity": + # Change topic command Use field to create ambiguity + if 'todosTopicCmd := &cobra.Command{' in content: + content = content.replace( + 'todosTopicCmd := &cobra.Command{\n\t\tUse: "todos",', + 'todosTopicCmd := &cobra.Command{\n\t\tUse: "list", // VIOLATION: ambiguous with list command' + ) + change_desc = "Created ambiguity between list command and help topic" + modified = True + + elif rule_id == "error-message-consistency": + # Add inconsistent error message + if 'fmt.Errorf("invalid todo id:' in content: + content = content.replace( + 'fmt.Errorf("invalid todo id: %w", err)', + 'fmt.Errorf("Invalid Todo ID (error: %v)", err) // VIOLATION: inconsistent format' + ) + change_desc = "Used inconsistent error message format" + modified = True + + if modified: + with open(main_go, "w") as f: + f.write(content) + + return Violation( + rule_id=rule_id, + rule_title=rule["title"], + description=rule["description"], + file_path="cmd/todo/main.go", + change_description=change_desc, + severity=rule.get("severity", "MEDIUM") + ) + + return None + + +class CLIReviewRunner: + """Runs cli-review on test variations.""" + + def __init__(self, project_root: Path): + self.project_root = Path(project_root) + self.skill_dir = Path(project_root) / "cli-skill" + + def run_review(self, variation_dir: Path, provider: str, model: str) -> Tuple[str, str]: + """Run cli-review via pi on a variation. + + The skill writes output to variation_dir/cli-review/ + Returns: (report_content_from_skill_output, any_errors) + """ + # The skill will write to cli-review/ directory in the variation + cli_review_dir = variation_dir / "cli-review" + # Persist an explicit per-variation session file for traceability + session_file = variation_dir / "session.json" + session_meta_file = variation_dir / "session_meta.json" + + # Construct pi command with skill loaded + prompt = f"Run /cli-review on {str(variation_dir)}" + + cmd = [ + "pi", + "--skill", str(self.skill_dir), + "--provider", provider, + "--model", model, + "--session", str(session_file), + "--print", + "-a", + prompt + ] + + # Run pi command - the skill will write output to cli-review/ directory + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=str(variation_dir), # Run in variation directory + timeout=300 # 5 minute timeout per variation + ) + + # Persist lightweight execution metadata next to the session file + session_meta = { + "provider": provider, + "model": model, + "variation_dir": str(variation_dir), + "session_file": str(session_file), + "returncode": result.returncode, + "stdout_chars": len(result.stdout or ""), + "stderr_chars": len(result.stderr or "") + } + try: + with open(session_meta_file, "w") as f: + json.dump(session_meta, f, indent=2) + except Exception: + pass + + # After skill runs, read the generated cli-review output + report_content = self._collect_skill_output(cli_review_dir) + session_content = result.stderr if result.returncode != 0 else "" + + return report_content, session_content + + except subprocess.TimeoutExpired: + error_msg = f"cli-review timed out after 300s on {variation_dir}" + print(f"Error: {error_msg}", file=sys.stderr) + return "", error_msg + except Exception as e: + print(f"Error running cli-review: {e}", file=sys.stderr) + return "", str(e) + + def _collect_skill_output(self, cli_review_dir: Path) -> str: + """Collect all output from cli-review/ directory written by the skill.""" + if not cli_review_dir.exists(): + return "" + + report_parts = [] + + # Read all markdown files from cli-review directory recursively + for md_file in sorted(cli_review_dir.glob("**/*.md")): + try: + with open(md_file, "r") as f: + content = f.read() + if content.strip(): + report_parts.append(content) + except Exception: + pass + + # Also check for json/jsonl files with findings + for json_file in sorted(cli_review_dir.glob("**/*.json*")): + try: + with open(json_file, "r") as f: + content = f.read() + if content.strip(): + report_parts.append(content) + except Exception: + pass + + return "\n\n".join(report_parts) + + +class AnalysisEngine: + """Analyzes cli-review results against injected violations.""" + + def analyze(self, variation_id: int, violations_injected: List[Violation], + report_content: str) -> DetectionResult: + """Analyze whether injected violations were detected.""" + + findings = self._parse_findings(report_content) + report_lower = report_content.lower() + + detected = [] + false_negatives = [] + severity_mismatches = [] # TODO: Extract detected severity from report + + for violation in violations_injected: + # Define rule-specific detection checks with high-accuracy keywords + found = False + rule_id = violation.rule_id + + if rule_id == "todo-list-command-verb": + found = "list-todos" in report_lower or "list command" in report_lower or "list_todos" in report_lower + elif rule_id == "sink-list-shorthand": + found = "list-sinks" in report_lower or "sinks" in report_lower or "list_sinks" in report_lower + elif rule_id == "schedule-list-shorthand": + found = "list-schedules" in report_lower or "schedules" in report_lower or "list_schedules" in report_lower + elif rule_id == "state-status-suffix": + found = "reminders" in report_lower or "status" in report_lower or "reminder-status" in report_lower + elif rule_id == "positional-arg-clarity": + found = "id" in report_lower or "positional" in report_lower or "clarity" in report_lower + elif rule_id == "flag-plural-for-arrays": + found = "sinks" in report_lower or "plural" in report_lower or "array" in report_lower + elif rule_id == "output-format-flag-consistency": + found = "output" in report_lower or "format" in report_lower or "flag consistency" in report_lower + elif rule_id == "schedule-verb-consistency": + found = "create-schedule" in report_lower or "add-schedule" in report_lower or "consistency" in report_lower + elif rule_id == "sink-verb-consistency": + found = "add-sink" in report_lower or "create-sink" in report_lower or "consistency" in report_lower + elif rule_id == "filter-flag-naming": + found = "filter-state" in report_lower or "state" in report_lower or "naming" in report_lower + elif rule_id == "required-flag-clarity": + found = "todo" in report_lower or "required" in report_lower or "clarity" in report_lower + elif rule_id == "mutually-exclusive-flags": + found = "exclusive" in report_lower or "due" in report_lower or "mutual" in report_lower + elif rule_id == "action-verb-alignment": + found = "mark-closed" in report_lower or "close" in report_lower or "alignment" in report_lower + elif rule_id == "group-labels-consistency": + found = "group" in report_lower or "label" in report_lower or "consistency" in report_lower + elif rule_id == "short-description-clarity": + found = "description" in report_lower or "verb" in report_lower or "clarity" in report_lower + elif rule_id == "flag-help-completeness": + found = "help" in report_lower or "state" in report_lower or "completeness" in report_lower + elif rule_id == "todo-todo-topic-clarity": + found = "ambiguous" in report_lower or "topic" in report_lower or "clash" in report_lower or "clarity" in report_lower + elif rule_id == "error-message-consistency": + found = "error" in report_lower or "consistent" in report_lower or "format" in report_lower + elif rule_id == "todo-show-command-verb": + found = "info" in report_lower or "show" in report_lower or "verb consistency" in report_lower + elif rule_id == "sink-show-shorthand": + found = "show-sink" in report_lower or "sink" in report_lower or "shorthand" in report_lower + else: + # Fallback matching + found = ( + violation.rule_id.lower() in report_lower or + any(violation.rule_id.lower() in str(finding).lower() for finding in findings) or + violation.change_description.lower() in report_lower + ) + + if found: + detected.append(violation) + else: + false_negatives.append(violation) + + coverage = len(detected) / len(violations_injected) if violations_injected else 1.0 + + return DetectionResult( + variation_id=f"todo-{variation_id:02d}", + violations_injected=violations_injected, + findings_detected=findings, + false_positives=[], # Would need to parse report more carefully + false_negatives=false_negatives, + severity_mismatches=severity_mismatches, + coverage=coverage + ) + + def _parse_findings(self, report_content: str) -> List[Dict]: + """Extract structured findings from cli-review report.""" + findings = [] + + lines = report_content.split("\n") + for i, line in enumerate(lines): + # Check for severity headers like ### [HIGH-1] or [MEDIUM-2] or [LOW-1] or [UNRATED-1] + if line.strip().startswith("### [") and "]" in line: + findings.append({"line": i, "text": line.strip()}) + # Also check for table rows like | [HIGH-1] | or | [MEDIUM-1] | + elif line.strip().startswith("|") and any(f"[{sev}-" in line for sev in ["HIGH", "MEDIUM", "LOW", "UNRATED"]): + findings.append({"line": i, "text": line.strip()}) + + return findings + + +class TestHarness: + """Main orchestrator for testing.""" + + def __init__(self, source_dir: Path, test_dir: Path, provider: str, model: str): + self.source_dir = Path(source_dir) + self.test_dir = Path(test_dir) + self.provider = provider + self.model = model + self.project_root = Path(source_dir).parent + + self.generator = VariationGenerator(source_dir, test_dir) + self.runner = CLIReviewRunner(self.project_root) + self.analyzer = AnalysisEngine() + + def run_all_phases(self): + """Run all testing phases.""" + print("=" * 70) + print("CLI Review Testing Harness") + print("=" * 70) + + # Phase 1: Generate Variations + print("\n[Phase 1] Generating 20 test variations...") + variations = [] + for i in range(1, 21): + var_dir, violations = self.generator.create_variation(i) + variations.append((var_dir, violations)) + print(f" āœ“ Created todo-{i:02d} with {len(violations)} violation(s)") + + # Phase 2: Run CLI Review + print(f"\n[Phase 2] Running cli-review on each variation...") + results = [] + for var_dir, violations in variations: + var_id = var_dir.name + print(f" Running review on {var_id}...", end=" ", flush=True) + + report, session = self.runner.run_review(var_dir, self.provider, self.model) + result = self.analyzer.analyze(int(var_id.split("-")[1]), violations, report) + results.append(result) + + print(f"āœ“ ({len(result.false_negatives)} false negatives)") + + # Phase 3: Analyze Results + print(f"\n[Phase 3] Analyzing detection accuracy...") + summary = self._generate_summary(results) + + # Phase 4: Interrogate Model (continued session) + print(f"\n[Phase 4] Interrogating model about improvements...") + # This continues the session - implementation would go here + + # Phase 5: Generate Amendment Summary + print(f"\n[Phase 5] Generating amendment summary...") + # This creates a new Gemini session + + # Save overall analysis + analysis_file = self.test_dir / "ANALYSIS.json" + with open(analysis_file, "w") as f: + json.dump(summary, f, indent=2) + + print(f"\nāœ“ Analysis saved to {analysis_file}") + + return results, summary + + def _generate_summary(self, results: List[DetectionResult]) -> Dict: + """Generate summary statistics.""" + total_violations = sum(len(r.violations_injected) for r in results) + total_detected = sum(len(r.violations_injected) - len(r.false_negatives) for r in results) + + # Count by severity + severity_stats = {"HIGH": 0, "MEDIUM": 0, "LOW": 0} + severity_detected = {"HIGH": 0, "MEDIUM": 0, "LOW": 0} + + for result in results: + for violation in result.violations_injected: + severity = violation.severity + severity_stats[severity] = severity_stats.get(severity, 0) + 1 + if violation not in result.false_negatives: + severity_detected[severity] = severity_detected.get(severity, 0) + 1 + + return { + "total_variations": len(results), + "total_violations_injected": total_violations, + "average_detection_coverage": sum(r.coverage for r in results) / len(results) if results else 0, + "severity_breakdown": { + "HIGH": { + "injected": severity_stats.get("HIGH", 0), + "detected": severity_detected.get("HIGH", 0), + "coverage_percent": (severity_detected.get("HIGH", 0) / severity_stats.get("HIGH", 1) * 100) if severity_stats.get("HIGH", 0) > 0 else 0 + }, + "MEDIUM": { + "injected": severity_stats.get("MEDIUM", 0), + "detected": severity_detected.get("MEDIUM", 0), + "coverage_percent": (severity_detected.get("MEDIUM", 0) / severity_stats.get("MEDIUM", 1) * 100) if severity_stats.get("MEDIUM", 0) > 0 else 0 + }, + "LOW": { + "injected": severity_stats.get("LOW", 0), + "detected": severity_detected.get("LOW", 0), + "coverage_percent": (severity_detected.get("LOW", 0) / severity_stats.get("LOW", 1) * 100) if severity_stats.get("LOW", 0) > 0 else 0 + } + }, + "results": [ + { + "variation": r.variation_id, + "violations_injected": len(r.violations_injected), + "violations_detected": len(r.violations_injected) - len(r.false_negatives), + "false_negatives": [ + { + "rule_id": v.rule_id, + "severity": v.severity, + "description": v.change_description + } + for v in r.false_negatives + ], + "severity_breakdown": { + "HIGH": len([v for v in r.violations_injected if v.severity == "HIGH"]), + "MEDIUM": len([v for v in r.violations_injected if v.severity == "MEDIUM"]), + "LOW": len([v for v in r.violations_injected if v.severity == "LOW"]) + }, + "coverage_percent": r.coverage * 100 + } + for r in results + ] + } + + +def main(): + """Main entry point.""" + if len(sys.argv) < 3: + print("Usage: testing_harness.py <provider> <model>") + print("Example: testing_harness.py anthropic claude-3-5-sonnet") + sys.exit(1) + + provider = sys.argv[1] + model = sys.argv[2] + + project_root = Path(__file__).parent.parent + test_dir = project_root / "tests" + source_dir = project_root + + harness = TestHarness(source_dir, test_dir, provider, model) + results, summary = harness.run_all_phases() + + print("\n" + "=" * 70) + print(f"Summary: {summary['average_detection_coverage']*100:.1f}% average coverage") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/todo/Makefile b/todo/Makefile new file mode 100644 index 0000000..2be2277 --- /dev/null +++ b/todo/Makefile @@ -0,0 +1,13 @@ +.PHONY: build test run-daemon run-cli + +build: + go build ./... + +test: + go test ./... + +run-daemon: + go run ./cmd/todod start + +run-cli: + go run ./cmd/todo --help diff --git a/todo/README.md b/todo/README.md new file mode 100644 index 0000000..741e4e7 --- /dev/null +++ b/todo/README.md @@ -0,0 +1,64 @@ +# todo / todod + +Task management CLI and daemon implemented in Go with Cobra and SQLite. + +## Components + +- `todo`: CLI client for task, schedule, sink, reminder-status, and status commands. +- `todod`: user-space daemon exposing an HTTP API and managing persistence/scheduling. + +## Defaults + +- Transport: HTTP over Unix socket at `/run/todod.socket` +- Database: `~/.local/share/todo/todo.db` +- Date input: RFC3339 and human-readable dates +- Date output: human-readable by default, with `--rfc3339` to force RFC3339 output + +## Commands + +### Todos (primary object) + +- `todo list [--state ...]` +- `todo show <todo-id>` +- `todo create <title> [--due <date>] [--schedule <spec>...]` +- `todo update <todo-id> [--title ...] [--due ...] [--clear-due]` +- `todo close <todo-id>` +- `todo reopen <todo-id>` +- `todo reject <todo-id>` + +### Sinks (secondary object, immutable) + +- `todo sinks [--enabled true|false] [--event upcoming|overdue]` +- `todo sink <sink-id>` +- `todo create-sink <sink-id> --url <url> [--event ...]` +- `todo delete-sink <sink-id>` + +### Schedules (secondary object, immutable) + +- `todo schedules [--todo <id>] [--kind ...] [--status ...] [--target sink|motd]` +- `todo schedule <schedule-id>` +- `todo add-schedule <schedule-id> --todo <todo-id> --kind upcoming|overdue [--before <dur>] [--every <dur>] [--sink <id>...] [--motd]` +- `todo remove-schedule <schedule-id>` + +### MOTD + status + +- `todo reminder-status` +- `todo status` + +When MOTD integration is not detected, `todo status` prints: + +`To show todo reminders on login, run: echo 'todo reminder-status' >> ~/.profile` + +### Daemon + +- `todod start [--db ...]` +- `todod status [--format table|json]` +- `todod stop` +- `todod version` + +## Schedule semantics + +- `upcoming`: one-shot reminder before due date (`--before`, default `24h`) +- `overdue`: repeating reminder after due date (`--every`, default `1d`) +- targets: sink(s), MOTD, or both +- status: `active` or `sent`; upcoming schedules transition to `sent` when all targets are delivered diff --git a/todo/cmd/todo/main.go b/todo/cmd/todo/main.go new file mode 100644 index 0000000..85e25c0 --- /dev/null +++ b/todo/cmd/todo/main.go @@ -0,0 +1,785 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "todo/internal/client" + "todo/internal/common" + "todo/internal/daemon" + "todo/internal/model" +) + +const version = "0.1.0" + +func main() { + root := &cobra.Command{ + Use: "todo", + Short: "Todo client", + Long: "Todo client for managing tasks with deadlines, delivery sinks, and schedules.", + } + + // Add command groups for help organization + root.AddGroup(&cobra.Group{ID: "todos", Title: "Todos:"}) + root.AddGroup(&cobra.Group{ID: "sinks", Title: "Sinks:"}) + root.AddGroup(&cobra.Group{ID: "schedules", Title: "Schedules:"}) + root.AddGroup(&cobra.Group{ID: "other", Title: "Other:"}) + root.SetHelpCommandGroupID("other") + root.SetCompletionCommandGroupID("other") + + // Set custom help function with color formatting + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + fmt.Println(colorizedHelp(cmd)) + }) + + todosTopicCmd := &cobra.Command{ + Use: "todos", + Short: "What todos mean in this app", + Long: `Todos are the main things you are tracking in the app. + +A todo represents a task, deadline, or commitment you want to keep visible until it is done, rejected, or no longer relevant. + +From a user perspective: +- create a todo when you want to track a single piece of work +- give it a due date when timing matters +- update it as details change +- close it when the work is finished +- reject it when you decide it should not be done + +Schedules and sinks build on top of todos. A schedule decides when reminders should happen, and a sink decides where those reminders should go.`, + } + + newClient := func(cmd *cobra.Command) *client.Client { + return client.New(10 * time.Second) + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List todos", + RunE: func(cmd *cobra.Command, args []string) error { + state, _ := cmd.Flags().GetString("state") + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + ctx := context.Background() + todos, err := cli.ListTodos(ctx, state) + if err != nil { + return err + } + return printTodos(todos, fmt, rfc) + }, + } + listCmd.Flags().String("state", "", "Filter state: open|closed|reopened|rejected") + addOutputFlags(listCmd) + + showCmd := &cobra.Command{ + Use: "show <todo-id>", + Short: "Show a todo", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + todo, err := cli.ShowTodo(context.Background(), args[0]) + if err != nil { + return err + } + return printJSONOrTable(todo, fmt, rfc) + }, + } + addOutputFlags(showCmd) + + createCmd := &cobra.Command{ + Use: "create <title>", + Short: "Create a todo", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dueInput, _ := cmd.Flags().GetString("due") + scheduleFlags, _ := cmd.Flags().GetStringArray("schedule") + var dueAt *time.Time + if strings.TrimSpace(dueInput) != "" { + t, err := common.ParseDateTime(dueInput) + if err != nil { + return err + } + t = t.UTC() + dueAt = &t + } + req := daemon.CreateTodoRequest{ + Title: strings.Join(args, " "), + DueAt: dueAt, + } + for i, raw := range scheduleFlags { + spec, err := parseInlineSchedule(raw) + if err != nil { + return fmt.Errorf("invalid --schedule at #%d: %w", i+1, err) + } + req.Schedule = append(req.Schedule, spec) + } + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + todo, err := cli.CreateTodo(context.Background(), req) + if err != nil { + return err + } + return printJSONOrTable(todo, fmt, rfc) + }, + } + createCmd.Flags().String("due", "", "Optional due date (RFC3339 or human-readable)") + createCmd.Flags().StringArray("schedule", nil, "Optional schedule spec (upcoming[:before=24h]:motd|sink=<id>, overdue[:every=1d]:motd|sink=<id>)") + addOutputFlags(createCmd) + + updateCmd := &cobra.Command{ + Use: "update <todo-id>", + Short: "Update a todo", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + title, _ := cmd.Flags().GetString("title") + dueInput, _ := cmd.Flags().GetString("due") + clearDue, _ := cmd.Flags().GetBool("clear-due") + var req daemon.UpdateTodoRequest + if strings.TrimSpace(title) != "" { + req.Title = &title + } + if strings.TrimSpace(dueInput) != "" { + t, err := common.ParseDateTime(dueInput) + if err != nil { + return err + } + t = t.UTC() + req.DueAt = &t + } + req.ClearDue = clearDue + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + todo, err := cli.UpdateTodo(context.Background(), args[0], req) + if err != nil { + return err + } + return printJSONOrTable(todo, fmt, rfc) + }, + } + updateCmd.Flags().String("title", "", "New title") + updateCmd.Flags().String("due", "", "New due date (RFC3339 or human-readable)") + updateCmd.Flags().Bool("clear-due", false, "Clear due date") + addOutputFlags(updateCmd) + + closeCmd := todoActionCmd("close <todo-id>", "Close a todo", func(cli *client.Client, id string) (model.Todo, error) { + return cli.CloseTodo(context.Background(), id) + }, newClient) + reopenCmd := todoActionCmd("reopen <todo-id>", "Reopen a todo", func(cli *client.Client, id string) (model.Todo, error) { + return cli.ReopenTodo(context.Background(), id) + }, newClient) + rejectCmd := todoActionCmd("reject <todo-id>", "Reject a todo", func(cli *client.Client, id string) (model.Todo, error) { + return cli.RejectTodo(context.Background(), id) + }, newClient) + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show todo system status", + RunE: func(cmd *cobra.Command, args []string) error { + outFmt, _ := outputFlags(cmd) + cli := newClient(cmd) + payload, err := cli.Status(context.Background()) + if err != nil { + return err + } + if outFmt == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + fmt.Printf("now: %v\n", payload["now"]) + fmt.Printf("database: %v\n", payload["database_path"]) + fmt.Printf("open todos: %v\n", payload["active_todo_count"]) + fmt.Printf("active schedules: %v\n", payload["active_schedule_count"]) + fmt.Printf("enabled sinks: %v\n", payload["enabled_sink_count"]) + if need, ok := payload["needs_motd_login_script_hint"].(bool); ok && need { + if hint, ok := payload["motd_login_script_hint"].(string); ok { + fmt.Println(hint) + } + } + return nil + }, + } + statusCmd.Flags().String("format", "table", "Output format: table|json") + + versionCmd := &cobra.Command{ + Use: "version", + Short: "Show client version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(version) + }, + } + + reminderStatusCmd := &cobra.Command{ + Use: "reminder-status", + Short: "Print pending MOTD reminder messages", + RunE: func(cmd *cobra.Command, args []string) error { + cli := newClient(cmd) + msgs, err := cli.MOTDMessage(context.Background()) + if err != nil { + return err + } + for _, m := range msgs { + fmt.Println(m) + } + return nil + }, + } + + // Sink commands + sinksCmd := &cobra.Command{ + Use: "sinks", + Short: "List sinks", + Long: `Sinks are reminder destinations. + +In the todo app, a sink is where a reminder gets delivered outside the local terminal experience. + +From a user perspective: +- create a sink when you want reminders sent to another system +- point the sink at a webhook URL +- subscribe it to upcoming or overdue events +- attach schedules to one or more sinks to decide where notifications go + +Use the sinks command to inspect the sinks you have configured.`, + RunE: func(cmd *cobra.Command, args []string) error { + var enabled *bool + enabledStr, _ := cmd.Flags().GetString("enabled") + if enabledStr == "true" { + v := true + enabled = &v + } else if enabledStr == "false" { + v := false + enabled = &v + } + event, _ := cmd.Flags().GetString("event") + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + sinks, err := cli.ListSinks(context.Background(), enabled, event) + if err != nil { + return err + } + return printJSONOrTable(sinks, fmt, rfc) + }, + } + sinksCmd.Flags().String("enabled", "", "Filter enabled: true|false") + sinksCmd.Flags().String("event", "", "Filter event: upcoming|overdue") + addOutputFlags(sinksCmd) + + sinkCmd := &cobra.Command{ + Use: "sink <sink-id>", + Short: "Show a sink", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + sink, err := cli.ShowSink(context.Background(), args[0]) + if err != nil { + return err + } + return printJSONOrTable(sink, fmt, rfc) + }, + } + addOutputFlags(sinkCmd) + + createSinkCmd := &cobra.Command{ + Use: "create-sink <sink-id>", + Short: "Create a sink", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + url, _ := cmd.Flags().GetString("url") + events, _ := cmd.Flags().GetStringArray("event") + if strings.TrimSpace(url) == "" { + return fmt.Errorf("--url is required") + } + req := daemon.CreateSinkRequest{ID: args[0], URL: url, Events: events} + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + sink, err := cli.CreateSink(context.Background(), req) + if err != nil { + return err + } + return printJSONOrTable(sink, fmt, rfc) + }, + } + createSinkCmd.Flags().String("url", "", "Webhook URL") + createSinkCmd.Flags().StringArray("event", nil, "Event subscriptions (upcoming|overdue)") + addOutputFlags(createSinkCmd) + + deleteSinkCmd := &cobra.Command{ + Use: "delete-sink <sink-id>", + Short: "Delete a sink", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli := newClient(cmd) + if err := cli.DeleteSink(context.Background(), args[0]); err != nil { + return err + } + fmt.Println("sink deleted") + return nil + }, + } + + // Schedule commands + schedulesCmd := &cobra.Command{ + Use: "schedules", + Short: "List schedules", + Long: `Schedules define when reminders should be sent for a todo. + +In the todo app, a schedule connects a todo to one or more reminder times and destinations. + +From a user perspective: +- use an upcoming schedule to remind yourself before a due date +- use an overdue schedule to keep reminding after a due date has passed +- send reminders to MOTD, to sinks, or both + +Schedules make todos active over time instead of being just stored records.`, + RunE: func(cmd *cobra.Command, args []string) error { + todoID, _ := cmd.Flags().GetString("todo") + kind, _ := cmd.Flags().GetString("kind") + status, _ := cmd.Flags().GetString("status") + target, _ := cmd.Flags().GetString("target") + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + schedules, err := cli.ListSchedules(context.Background(), todoID, kind, status, target) + if err != nil { + return err + } + return printJSONOrTable(schedules, fmt, rfc) + }, + } + schedulesCmd.Flags().String("todo", "", "Optional todo id filter") + schedulesCmd.Flags().String("kind", "", "Filter kind: upcoming|overdue") + schedulesCmd.Flags().String("status", "", "Filter status: active|sent") + schedulesCmd.Flags().String("target", "", "Filter target: sink|motd") + addOutputFlags(schedulesCmd) + + scheduleCmd := &cobra.Command{ + Use: "schedule <schedule-id>", + Short: "Show a schedule", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + sc, err := cli.ShowSchedule(context.Background(), args[0]) + if err != nil { + return err + } + return printJSONOrTable(sc, fmt, rfc) + }, + } + addOutputFlags(scheduleCmd) + + addScheduleCmd := &cobra.Command{ + Use: "add-schedule <schedule-id>", + Short: "Add an immutable schedule", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + todoIDStr, _ := cmd.Flags().GetString("todo") + kind, _ := cmd.Flags().GetString("kind") + before, _ := cmd.Flags().GetString("before") + every, _ := cmd.Flags().GetString("every") + sinks, _ := cmd.Flags().GetStringArray("sink") + motd, _ := cmd.Flags().GetBool("motd") + if strings.TrimSpace(todoIDStr) == "" { + return fmt.Errorf("--todo is required") + } + todoID, err := strconv.ParseInt(todoIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid todo id: %w", err) + } + if strings.TrimSpace(kind) == "" { + return fmt.Errorf("--kind is required") + } + req := daemon.AddScheduleRequest{ + ID: args[0], + TodoID: todoID, + Kind: kind, + Before: before, + Every: every, + MOTD: motd, + SinkID: sinks, + } + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + sc, err := cli.AddSchedule(context.Background(), req) + if err != nil { + return err + } + return printJSONOrTable(sc, fmt, rfc) + }, + } + addScheduleCmd.Flags().String("todo", "", "Todo id") + addScheduleCmd.Flags().String("kind", "", "Schedule kind: upcoming|overdue") + addScheduleCmd.Flags().String("before", "", "Upcoming schedule offset before due date, default 24h") + addScheduleCmd.Flags().String("every", "", "Overdue reminder frequency, default 1d") + addScheduleCmd.Flags().StringArray("sink", nil, "Optional sink id (repeatable)") + addScheduleCmd.Flags().Bool("motd", false, "Deliver via shell MOTD channel") + addOutputFlags(addScheduleCmd) + + removeScheduleCmd := &cobra.Command{ + Use: "remove-schedule <schedule-id>", + Short: "Remove a schedule", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli := newClient(cmd) + if err := cli.RemoveSchedule(context.Background(), args[0]); err != nil { + return err + } + fmt.Println("schedule removed") + return nil + }, + } + + // Set command groups for better help organization + listCmd.GroupID = "todos" + showCmd.GroupID = "todos" + createCmd.GroupID = "todos" + updateCmd.GroupID = "todos" + closeCmd.GroupID = "todos" + reopenCmd.GroupID = "todos" + rejectCmd.GroupID = "todos" + + sinksCmd.GroupID = "sinks" + sinkCmd.GroupID = "sinks" + createSinkCmd.GroupID = "sinks" + deleteSinkCmd.GroupID = "sinks" + + schedulesCmd.GroupID = "schedules" + scheduleCmd.GroupID = "schedules" + addScheduleCmd.GroupID = "schedules" + removeScheduleCmd.GroupID = "schedules" + + reminderStatusCmd.GroupID = "other" + statusCmd.GroupID = "other" + versionCmd.GroupID = "other" + root.AddCommand(listCmd, showCmd, createCmd, updateCmd, closeCmd, reopenCmd, rejectCmd) + root.AddCommand(sinksCmd, sinkCmd, createSinkCmd, deleteSinkCmd) + root.AddCommand(schedulesCmd, scheduleCmd, addScheduleCmd, removeScheduleCmd) + root.AddCommand(reminderStatusCmd, statusCmd, versionCmd) + root.AddCommand(todosTopicCmd) + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func todoActionCmd(use, short string, fn func(*client.Client, string) (model.Todo, error), newClient func(*cobra.Command) *client.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + fmt, rfc := outputFlags(cmd) + cli := newClient(cmd) + todo, err := fn(cli, args[0]) + if err != nil { + return err + } + return printJSONOrTable(todo, fmt, rfc) + }, + } + addOutputFlags(cmd) + return cmd +} + +// addOutputFlags registers --format and --rfc3339 on a command. +func addOutputFlags(cmd *cobra.Command) { + cmd.Flags().String("format", "table", "Output format: table|json") + cmd.Flags().Bool("rfc3339", false, "Show dates in RFC3339 format") +} + +// outputFlags reads --format and --rfc3339 from a command. +// json and yaml formats imply rfc3339. +func outputFlags(cmd *cobra.Command) (format string, rfc3339 bool) { + format, _ = cmd.Flags().GetString("format") + rfc3339, _ = cmd.Flags().GetBool("rfc3339") + if format == "json" || format == "yaml" { + rfc3339 = true + } + return +} + +func printTodos(todos []model.Todo, format string, rfc3339 bool) error { + if format == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(todos) + } + if len(todos) == 0 { + fmt.Fprintln(os.Stderr, "No todos found") + return nil + } + + // Build rows with formatted data + type row struct { + id string + state string + due string + title string + } + var rows []row + for _, t := range todos { + var dueStr string + if rfc3339 { + dueStr = common.FormatTime(t.DueAt, true) + } else { + dueStr = common.FormatRelativeTime(t.DueAt) + } + rows = append(rows, row{ + id: fmt.Sprintf("%d", t.ID), + state: t.State, + due: dueStr, + title: t.Title, + }) + } + + // Calculate column widths (minimum width for header) + idWidth := len("ID") + stateWidth := len("STATE") + dueWidth := len("DUE") + + for _, r := range rows { + if len(r.id) > idWidth { + idWidth = len(r.id) + } + if len(r.state) > stateWidth { + stateWidth = len(r.state) + } + if len(r.due) > dueWidth { + dueWidth = len(r.due) + } + } + + // Print header with 2-space separator + sep := " " + fmt.Printf("%-*s%s%-*s%s%-*s%s%s\n", idWidth, "ID", sep, stateWidth, "STATE", sep, dueWidth, "DUE", sep, "TITLE") + + // Print rows + for _, r := range rows { + fmt.Printf("%-*s%s%-*s%s%-*s%s%s\n", idWidth, r.id, sep, stateWidth, r.state, sep, dueWidth, r.due, sep, r.title) + } + + return nil +} + +func printJSONOrTable(v any, format string, rfc3339 bool) error { + if format == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) + } + switch t := v.(type) { + case model.Todo: + fmt.Printf("id: %d\n", t.ID) + fmt.Printf("title: %s\n", t.Title) + fmt.Printf("state: %s\n", t.State) + fmt.Printf("due: %s\n", common.FormatTime(t.DueAt, rfc3339)) + fmt.Printf("created: %s\n", common.FormatTime(&t.CreatedAt, rfc3339)) + fmt.Printf("updated: %s\n", common.FormatTime(&t.UpdatedAt, rfc3339)) + return nil + default: + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) + } +} + +func parseInlineSchedule(raw string) (daemon.ScheduleSpec, error) { + parts := strings.Split(raw, ":") + if len(parts) < 2 { + return daemon.ScheduleSpec{}, fmt.Errorf("schedule must include kind and target") + } + out := daemon.ScheduleSpec{Kind: strings.TrimSpace(parts[0])} + for _, p := range parts[1:] { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if p == "motd" { + out.MOTD = true + continue + } + if strings.HasPrefix(p, "before=") { + out.Before = strings.TrimPrefix(p, "before=") + continue + } + if strings.HasPrefix(p, "every=") { + out.Every = strings.TrimPrefix(p, "every=") + continue + } + if strings.HasPrefix(p, "sink=") { + out.SinkID = append(out.SinkID, strings.TrimPrefix(p, "sink=")) + continue + } + } + if !out.MOTD && len(out.SinkID) == 0 { + out.MOTD = true + } + return out, nil +} + +// colorizedHelp takes a cobra command and returns its help text with color formatting +func colorizedHelp(cmd *cobra.Command) string { + if cmd.Parent() == nil { + return colorizeSections(rootHelp(cmd)) + } + + var b strings.Builder + b.WriteString("<b>") + b.WriteString(cmd.Root().Name()) + b.WriteString(" ") + b.WriteString(version) + b.WriteString("</b>\n\n") + if cmd.Long != "" { + b.WriteString(cmd.Long) + b.WriteString("\n\n") + } else if cmd.Short != "" { + b.WriteString(cmd.Short) + b.WriteString("\n\n") + } + b.WriteString(cmd.UsageString()) + help := b.String() + if cmd.Parent() == nil { + help = strings.Replace(help, "\nFlags:\n", "\nGlobal options:\n", 1) + } + + return colorizeSections(help) +} + +func rootHelp(cmd *cobra.Command) string { + var b strings.Builder + b.WriteString("<b>") + b.WriteString(cmd.Root().Name()) + b.WriteString(" ") + b.WriteString(version) + b.WriteString("</b>\n\n") + if cmd.Long != "" { + b.WriteString(cmd.Long) + b.WriteString("\n\n") + } else if cmd.Short != "" { + b.WriteString(cmd.Short) + b.WriteString("\n\n") + } + b.WriteString("Usage:\n") + b.WriteString(" todo [command]\n\n") + + writeCommandSection(&b, cmd, "Todos:", "todos") + b.WriteString("\n") + writeCommandSection(&b, cmd, "Schedules:", "schedules") + b.WriteString("\n") + b.WriteString("Other:\n") + b.WriteString(formatOtherSection(cmd)) + b.WriteString("\nGlobal options:\n") + b.WriteString(" -h, --help help for todo\n\n") + b.WriteString("Use \"todo [command] --help\" for more information about a command.\n") + b.WriteString("Use \"todo help <topic>\" for more information about a concept, for example \"todo help todos\".\n") + return b.String() +} + +func writeCommandSection(b *strings.Builder, cmd *cobra.Command, title, groupID string) { + b.WriteString(title) + b.WriteString("\n") + entries := sectionEntries(cmd, groupID) + width := longestName(entries) + if width < 15 { + width = 15 + } + for _, entry := range entries { + b.WriteString(fmt.Sprintf(" %-*s %s\n", width, entry.Name, entry.Short)) + } +} + +type helpEntry struct { + Name string + Short string +} + +func sectionEntries(cmd *cobra.Command, groupID string) []helpEntry { + entries := make([]helpEntry, 0) + for _, sub := range cmd.Commands() { + if !sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() { + continue + } + if sub.GroupID == groupID { + entries = append(entries, helpEntry{Name: sub.Name(), Short: sub.Short}) + } + } + return entries +} + +func longestName(entries []helpEntry) int { + width := 0 + for _, entry := range entries { + if len(entry.Name) > width { + width = len(entry.Name) + } + } + return width +} + +func formatOtherSection(cmd *cobra.Command) string { + var b strings.Builder + width := 15 + b.WriteString(fmt.Sprintf(" %-*s %s\n", width, "Sinks", "create-sink, delete-sink, sink, sinks")) + for _, name := range []string{"completion", "help", "reminder-status", "status", "version"} { + entry, ok := findEntry(cmd, name) + if !ok { + if name == "help" { + b.WriteString(fmt.Sprintf(" %-*s %s\n", width, "help", "Help about any command")) + } + continue + } + b.WriteString(fmt.Sprintf(" %-*s %s\n", width, entry.Name, entry.Short)) + } + return b.String() +} + +func findEntry(cmd *cobra.Command, name string) (helpEntry, bool) { + for _, sub := range cmd.Commands() { + if !sub.IsAvailableCommand() { + continue + } + if sub.Name() == name { + return helpEntry{Name: sub.Name(), Short: sub.Short}, true + } + } + return helpEntry{}, false +} + +func colorizeSections(help string) string { + // Protect overlapping labels before generic replacements. + help = strings.ReplaceAll(help, "Global Flags:", "__TODO_GLOBAL_FLAGS__") + + // Apply color formatting to section headers + sections := []string{ + "Usage:", + "Summary:", + "Todos:", + "Sinks:", + "Schedules:", + "Other:", + "Available Commands:", + "Additional Commands:", + "Global options:", + "Flags:", + "Examples:", + "Related commands:", + } + + // Apply color formatting to each section header + for _, section := range sections { + colored := common.ColorSection(section) + help = strings.ReplaceAll(help, section, colored) + } + + help = strings.ReplaceAll(help, "__TODO_GLOBAL_FLAGS__", common.ColorSection("Global Flags:")) + return common.RenderInlineTags(help) +} diff --git a/todo/cmd/todo/main_test.go b/todo/cmd/todo/main_test.go new file mode 100644 index 0000000..0640dea --- /dev/null +++ b/todo/cmd/todo/main_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/muesli/termenv" + "github.com/spf13/cobra" + + "todo/internal/common" +) + +func TestParseInlineSchedule(t *testing.T) { + tests := []struct { + name string + input string + wantKind string + wantBefore string + wantEvery string + wantMOTD bool + wantSinks []string + wantErr bool + }{ + { + name: "rejects missing target segment", + input: "upcoming", + wantErr: true, + }, + { + name: "upcoming motd before", + input: "upcoming:before=24h:motd", + wantKind: "upcoming", + wantBefore: "24h", + wantMOTD: true, + }, + { + name: "overdue every with multiple sinks", + input: "overdue:every=1d:sink=s1:sink=s2", + wantKind: "overdue", + wantEvery: "1d", + wantMOTD: false, + wantSinks: []string{"s1", "s2"}, + }, + { + name: "defaults to motd when no targets", + input: "upcoming:", + wantKind: "upcoming", + wantMOTD: true, + wantSinks: nil, + }, + { + name: "sink target disables motd default", + input: "upcoming:sink=abc", + wantKind: "upcoming", + wantMOTD: false, + wantSinks: []string{"abc"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseInlineSchedule(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for input %q", tc.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Kind != tc.wantKind { + t.Fatalf("kind mismatch: got %q want %q", got.Kind, tc.wantKind) + } + if got.Before != tc.wantBefore { + t.Fatalf("before mismatch: got %q want %q", got.Before, tc.wantBefore) + } + if got.Every != tc.wantEvery { + t.Fatalf("every mismatch: got %q want %q", got.Every, tc.wantEvery) + } + if got.MOTD != tc.wantMOTD { + t.Fatalf("motd mismatch: got %v want %v", got.MOTD, tc.wantMOTD) + } + if strings.Join(got.SinkID, ",") != strings.Join(tc.wantSinks, ",") { + t.Fatalf("sink ids mismatch: got %v want %v", got.SinkID, tc.wantSinks) + } + }) + } +} + +func TestColorizedHelp(t *testing.T) { + newRoot := func() *cobra.Command { + root := &cobra.Command{ + Use: "todo", + Short: "Todo client", + Long: "Todo client for managing tasks.", + } + root.AddGroup(&cobra.Group{ID: "todos", Title: "Todos:"}) + root.AddGroup(&cobra.Group{ID: "sinks", Title: "Sinks:"}) + root.AddGroup(&cobra.Group{ID: "schedules", Title: "Schedules:"}) + root.AddGroup(&cobra.Group{ID: "other", Title: "Other:"}) + root.SetHelpCommandGroupID("other") + root.SetCompletionCommandGroupID("other") + + root.PersistentFlags().Bool("verbose", false, "Verbose output") + + listCmd := &cobra.Command{Use: "list", Short: "List todos", Run: func(cmd *cobra.Command, args []string) {}} + listCmd.GroupID = "todos" + scheduleCmd := &cobra.Command{Use: "schedule", Short: "Show a schedule", Run: func(cmd *cobra.Command, args []string) {}} + scheduleCmd.GroupID = "schedules" + sinksCmd := &cobra.Command{Use: "sinks", Short: "List sinks", Run: func(cmd *cobra.Command, args []string) {}} + sinksCmd.GroupID = "sinks" + sinkCmd := &cobra.Command{Use: "sink", Short: "Show a sink", Run: func(cmd *cobra.Command, args []string) {}} + sinkCmd.GroupID = "sinks" + createSinkCmd := &cobra.Command{Use: "create-sink", Short: "Create a sink", Run: func(cmd *cobra.Command, args []string) {}} + createSinkCmd.GroupID = "sinks" + deleteSinkCmd := &cobra.Command{Use: "delete-sink", Short: "Delete a sink", Run: func(cmd *cobra.Command, args []string) {}} + deleteSinkCmd.GroupID = "sinks" + versionCmd := &cobra.Command{Use: "version", Short: "Show version", Run: func(cmd *cobra.Command, args []string) {}} + versionCmd.GroupID = "other" + reminderStatusCmd := &cobra.Command{Use: "reminder-status", Short: "Print pending MOTD reminder messages", Run: func(cmd *cobra.Command, args []string) {}} + reminderStatusCmd.GroupID = "other" + statusCmd := &cobra.Command{Use: "status", Short: "Show todo system status", Run: func(cmd *cobra.Command, args []string) {}} + statusCmd.GroupID = "other" + root.AddCommand(listCmd, scheduleCmd, sinksCmd, sinkCmd, createSinkCmd, deleteSinkCmd, versionCmd, reminderStatusCmd, statusCmd) + return root + } + + t.Run("uses color for sections when color is supported", func(t *testing.T) { + restoreProfile := common.SetColorProfileResolverForTesting(func(*termenv.Output) termenv.Profile { + return termenv.ANSI256 + }) + defer restoreProfile() + restoreBg := common.SetBackgroundModeDetectorForTesting(func(*termenv.Output) string { return "dark" }) + defer restoreBg() + _ = os.Unsetenv("NO_COLOR") + t.Setenv("TERM", "xterm-256color") + t.Setenv("COLORFGBG", "15;0") // explicit dark background + + root := newRoot() + got := colorizedHelp(root) + + if !strings.Contains(got, "\033[37mUsage:\033[0m") { + t.Fatalf("expected colored Usage section, got: %q", got) + } + if !strings.Contains(got, "\033[37mGlobal options:\033[0m") { + t.Fatalf("expected colored Global options section, got: %q", got) + } + if strings.Contains(got, "\nFlags:\n") { + t.Fatalf("root help should rename Flags to Global options") + } + }) + + t.Run("root help uses sinks row inside other", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("TERM", "xterm-256color") + + root := newRoot() + got := colorizedHelp(root) + if strings.Contains(got, "Sinks:\n") { + t.Fatalf("did not expect standalone Sinks section: %q", got) + } + if !strings.Contains(got, "Other:") { + t.Fatalf("expected Other section: %q", got) + } + if !strings.Contains(got, "Sinks") || !strings.Contains(got, "create-sink, delete-sink, sink, sinks") { + t.Fatalf("expected compact sinks row in Other section: %q", got) + } + }) + + t.Run("uses plain section headers with NO_COLOR", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("TERM", "xterm-256color") + + root := newRoot() + got := colorizedHelp(root) + + if !strings.Contains(got, "Usage:") { + t.Fatalf("expected plain Usage section, got: %q", got) + } + if strings.Contains(got, "\033[37mUsage:\033[0m") || strings.Contains(got, "\033[30mUsage:\033[0m") || strings.Contains(got, "\033[1mUsage:\033[0m") { + t.Fatalf("did not expect colored Usage with NO_COLOR") + } + }) + + t.Run("keeps global flags label for subcommands", func(t *testing.T) { + _ = os.Unsetenv("NO_COLOR") + t.Setenv("TERM", "xterm-256color") + + root := newRoot() + cmd := &cobra.Command{Use: "create", Short: "Create todo"} + cmd.Flags().String("title", "", "Todo title") + root.AddCommand(cmd) + + got := colorizedHelp(cmd) + if !strings.Contains(got, "Global") || !strings.Contains(got, "Flags") { + t.Fatalf("expected Global Flags section in subcommand help, got: %q", got) + } + }) +} diff --git a/todo/cmd/todod/main.go b/todo/cmd/todod/main.go new file mode 100644 index 0000000..b54a9ff --- /dev/null +++ b/todo/cmd/todod/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "time" + + "github.com/spf13/cobra" + + "todo/internal/daemon" + "todo/internal/store" +) + +const version = "0.1.0" + +func main() { + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + var dbPath string + var format string + + root := &cobra.Command{ + Use: "todod", + Short: "Todo daemon", + } + + startCmd := &cobra.Command{ + Use: "start", + Short: "Start daemon", + RunE: func(cmd *cobra.Command, args []string) error { + db := dbPath + if db == "" { + db = store.DefaultDBPath(home) + } + srv, err := daemon.NewServer(db) + if err != nil { + return err + } + return srv.Run(context.Background()) + }, + } + startCmd.Flags().StringVar(&dbPath, "db", "", "SQLite database path") + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show daemon status", + RunE: func(cmd *cobra.Command, args []string) error { + sock := daemon.SocketPath() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := newSocketHTTPClient(sock, 5*time.Second) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://unix/status", nil) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("status request failed: %s", resp.Status) + } + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return err + } + if format == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + fmt.Printf("todod: %s\n", sock) + fmt.Printf("database: %v\n", payload["database_path"]) + fmt.Printf("open todos: %v\n", payload["active_todo_count"]) + fmt.Printf("active schedules: %v\n", payload["active_schedule_count"]) + fmt.Printf("enabled sinks: %v\n", payload["enabled_sink_count"]) + return nil + }, + } + statusCmd.Flags().StringVar(&format, "format", "table", "Output format: table|json") + + stopCmd := &cobra.Command{ + Use: "stop", + Short: "Stop daemon", + RunE: func(cmd *cobra.Command, args []string) error { + sock := daemon.SocketPath() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := newSocketHTTPClient(sock, 5*time.Second) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://unix/shutdown", nil) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("shutdown request failed: %s", resp.Status) + } + fmt.Println("todod stop requested") + return nil + }, + } + + versionCmd := &cobra.Command{ + Use: "version", + Short: "Show daemon version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(version) + }, + } + + root.AddCommand(startCmd, stopCmd, statusCmd, versionCmd) + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func newSocketHTTPClient(socketPath string, timeout time.Duration) *http.Client { + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", socketPath) + }, + } + return &http.Client{Timeout: timeout, Transport: transport} +} diff --git a/todo/go.mod b/todo/go.mod new file mode 100644 index 0000000..4c9aefb --- /dev/null +++ b/todo/go.mod @@ -0,0 +1,29 @@ +module todo + +go 1.24.0 + +require ( + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de + github.com/maniartech/gotime v1.1.0 + github.com/spf13/cobra v1.8.1 + modernc.org/sqlite v1.36.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect + golang.org/x/sys v0.30.0 // indirect + modernc.org/libc v1.61.13 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.8.2 // indirect +) diff --git a/todo/go.sum b/todo/go.sum new file mode 100644 index 0000000..97b069f --- /dev/null +++ b/todo/go.sum @@ -0,0 +1,81 @@ +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/maniartech/gotime v1.1.0 h1:/xsp3E0o0zPQ4pjCNOs3NGWMk/mpCQNNaGNfhTbps5E= +github.com/maniartech/gotime v1.1.0/go.mod h1:NBM2sTwhRbRF87XPpPBAugxd0H3B3GT0W85SJYvb+To= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= +modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= +modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= +modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= +modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= +modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.36.0 h1:EQXNRn4nIS+gfsKeUTymHIz1waxuv5BzU7558dHSfH8= +modernc.org/sqlite v1.36.0/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/todo/internal/client/client.go b/todo/internal/client/client.go new file mode 100644 index 0000000..483c7da --- /dev/null +++ b/todo/internal/client/client.go @@ -0,0 +1,217 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "todo/internal/daemon" + "todo/internal/model" +) + +type Client struct { + baseURL string + http *http.Client +} + +func New(timeout time.Duration) *Client { + socketPath := daemon.SocketPath() + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", socketPath) + }, + } + return &Client{ + baseURL: "http://unix", + http: &http.Client{ + Timeout: timeout, + Transport: transport, + }, + } +} + +func (c *Client) do(ctx context.Context, method, path string, reqBody any, respBody any) error { + var body io.Reader + if reqBody != nil { + b, err := json.Marshal(reqBody) + if err != nil { + return err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return err + } + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return errors.New(strings.TrimSpace(string(raw))) + } + if respBody != nil { + return json.NewDecoder(resp.Body).Decode(respBody) + } + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +func (c *Client) CreateTodo(ctx context.Context, req daemon.CreateTodoRequest) (model.Todo, error) { + var out model.Todo + err := c.do(ctx, http.MethodPost, "/todos", req, &out) + return out, err +} + +func (c *Client) ListTodos(ctx context.Context, state string) ([]model.Todo, error) { + q := url.Values{} + if state != "" { + q.Set("state", state) + } + path := "/todos" + if len(q) > 0 { + path += "?" + q.Encode() + } + var out struct { + Todos []model.Todo `json:"todos"` + } + err := c.do(ctx, http.MethodGet, path, nil, &out) + return out.Todos, err +} + +func (c *Client) ShowTodo(ctx context.Context, id string) (model.Todo, error) { + var out model.Todo + err := c.do(ctx, http.MethodGet, "/todos/"+id, nil, &out) + return out, err +} + +func (c *Client) UpdateTodo(ctx context.Context, id string, req daemon.UpdateTodoRequest) (model.Todo, error) { + var out model.Todo + err := c.do(ctx, http.MethodPatch, "/todos/"+id, req, &out) + return out, err +} + +func (c *Client) CloseTodo(ctx context.Context, id string) (model.Todo, error) { + var out model.Todo + err := c.do(ctx, http.MethodPost, "/todos/"+id+"/close", nil, &out) + return out, err +} + +func (c *Client) ReopenTodo(ctx context.Context, id string) (model.Todo, error) { + var out model.Todo + err := c.do(ctx, http.MethodPost, "/todos/"+id+"/reopen", nil, &out) + return out, err +} + +func (c *Client) RejectTodo(ctx context.Context, id string) (model.Todo, error) { + var out model.Todo + err := c.do(ctx, http.MethodPost, "/todos/"+id+"/reject", nil, &out) + return out, err +} + +func (c *Client) CreateSink(ctx context.Context, req daemon.CreateSinkRequest) (model.Sink, error) { + var out model.Sink + err := c.do(ctx, http.MethodPost, "/sinks", req, &out) + return out, err +} + +func (c *Client) ListSinks(ctx context.Context, enabled *bool, event string) ([]model.Sink, error) { + q := url.Values{} + if enabled != nil { + if *enabled { + q.Set("enabled", "true") + } else { + q.Set("enabled", "false") + } + } + if event != "" { + q.Set("event", event) + } + path := "/sinks" + if len(q) > 0 { + path += "?" + q.Encode() + } + var out struct { + Sinks []model.Sink `json:"sinks"` + } + err := c.do(ctx, http.MethodGet, path, nil, &out) + return out.Sinks, err +} + +func (c *Client) ShowSink(ctx context.Context, id string) (model.Sink, error) { + var out model.Sink + err := c.do(ctx, http.MethodGet, "/sinks/"+id, nil, &out) + return out, err +} + +func (c *Client) DeleteSink(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, "/sinks/"+id, nil, nil) +} + +func (c *Client) AddSchedule(ctx context.Context, req daemon.AddScheduleRequest) (model.Schedule, error) { + var out model.Schedule + err := c.do(ctx, http.MethodPost, "/schedules", req, &out) + return out, err +} + +func (c *Client) ListSchedules(ctx context.Context, todoID, kind, status, target string) ([]model.Schedule, error) { + q := url.Values{} + if todoID != "" { + q.Set("todo", todoID) + } + if kind != "" { + q.Set("kind", kind) + } + if status != "" { + q.Set("status", status) + } + if target != "" { + q.Set("target", target) + } + path := "/schedules" + if len(q) > 0 { + path += "?" + q.Encode() + } + var out struct { + Schedules []model.Schedule `json:"schedules"` + } + err := c.do(ctx, http.MethodGet, path, nil, &out) + return out.Schedules, err +} + +func (c *Client) ShowSchedule(ctx context.Context, id string) (model.Schedule, error) { + var out model.Schedule + err := c.do(ctx, http.MethodGet, "/schedules/"+id, nil, &out) + return out, err +} + +func (c *Client) RemoveSchedule(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, "/schedules/"+id, nil, nil) +} + +func (c *Client) MOTDMessage(ctx context.Context) ([]string, error) { + var out struct { + Messages []string `json:"messages"` + } + err := c.do(ctx, http.MethodGet, "/motd/message", nil, &out) + return out.Messages, err +} + +func (c *Client) Status(ctx context.Context) (map[string]any, error) { + var out map[string]any + err := c.do(ctx, http.MethodGet, "/status", nil, &out) + return out, err +} diff --git a/todo/internal/common/formatting.go b/todo/internal/common/formatting.go new file mode 100644 index 0000000..4ee6278 --- /dev/null +++ b/todo/internal/common/formatting.go @@ -0,0 +1,203 @@ +package common + +import ( + "os" + "strings" + + "github.com/muesli/termenv" +) + +// TerminalColors represents color support capabilities +type TerminalColors struct { + Supported bool + Has256 bool + HasTruecolor bool +} + +var newTermOutput = func() *termenv.Output { + return termenv.NewOutput(os.Stdout) +} + +var resolveColorProfile = func(out *termenv.Output) termenv.Profile { + return out.ColorProfile() +} + +var detectBackgroundMode = func(out *termenv.Output) string { + if out.HasDarkBackground() { + return "dark" + } + if out.ColorProfile() != termenv.Ascii { + return "light" + } + return "" +} + +// SetTermOutputFactoryForTesting overrides terminal output detection for tests. +// It returns a restore function that must be called to reset the default behavior. +func SetTermOutputFactoryForTesting(factory func() *termenv.Output) func() { + prev := newTermOutput + newTermOutput = factory + return func() { + newTermOutput = prev + } +} + +// SetColorProfileResolverForTesting overrides profile resolution for tests. +// It returns a restore function that must be called to reset the default behavior. +func SetColorProfileResolverForTesting(resolver func(*termenv.Output) termenv.Profile) func() { + prev := resolveColorProfile + resolveColorProfile = resolver + return func() { + resolveColorProfile = prev + } +} + +// SetBackgroundModeDetectorForTesting overrides background mode detection for tests. +// It returns a restore function that must be called to reset the default behavior. +func SetBackgroundModeDetectorForTesting(detector func(*termenv.Output) string) func() { + prev := detectBackgroundMode + detectBackgroundMode = detector + return func() { + detectBackgroundMode = prev + } +} + +var tagToANSI = map[string]string{ + "<b>": "\033[1m", + "</b>": "\033[0m", + "<light>": "\033[37m", + "</light>": "\033[0m", + "<dark>": "\033[30m", + "</dark>": "\033[0m", + "<green>": "\033[32m", + "</green>": "\033[0m", +} + +var knownTags = []string{ + "<b>", "</b>", + "<light>", "</light>", + "<dark>", "</dark>", + "<green>", "</green>", +} + +// DetectColorSupport checks terminal color capabilities via termenv. +func DetectColorSupport() TerminalColors { + out := newTermOutput() + if out.EnvNoColor() { + return TerminalColors{Supported: false} + } + + profile := resolveColorProfile(out) + supported := profile != termenv.Ascii + hasTruecolor := profile == termenv.TrueColor + has256 := profile == termenv.ANSI256 || hasTruecolor + + return TerminalColors{ + Supported: supported, + Has256: has256, + HasTruecolor: hasTruecolor, + } +} + +// StripInlineTags removes known style tags such as <b>, <light>, and <green>. +func StripInlineTags(text string) string { + out := text + for _, tag := range knownTags { + out = strings.ReplaceAll(out, tag, "") + } + return out +} + +// RenderInlineTags converts inline style tags into ANSI escapes when supported. +// If color output is not supported, tags are removed and plain text is returned. +func RenderInlineTags(text string) string { + if !DetectColorSupport().Supported { + return StripInlineTags(text) + } + +out := text + for tag, ansi := range tagToANSI { + out = strings.ReplaceAll(out, tag, ansi) + } + return out +} + +// detectBackground uses termenv to detect dark/light terminal background. +func detectBackground() string { + if !DetectColorSupport().Supported { + return "" + } + + out := newTermOutput() + if mode := detectBackgroundMode(out); mode != "" { + return mode + } + + if resolveColorProfile(out) == termenv.Ascii { + fallbackOut := termenv.NewOutput(os.Stdout, termenv.WithTTY(true)) + if mode := detectBackgroundMode(fallbackOut); mode != "" { + return mode + } + } + + return "" +} + +// FormatSection formats a section header with color or bold +// Uses ANSI escape codes for color if supported, otherwise uses bold +func FormatSection(text string) string { + // FormatSection uses the same logic as ColorSection + return ColorSection(text) +} + +// StripFormatting removes ANSI escape codes from a string +func StripFormatting(text string) string { + // Simple regex to remove ANSI escape sequences + result := "" + i := 0 + for i < len(text) { + if text[i] == '\033' { + // Skip escape sequence + i++ + // Find the end of the sequence + if i < len(text) && text[i] == '[' { + i++ + // Skip until we find a letter + for i < len(text) && (text[i] >= '0' && text[i] <= '9' || text[i] == ';') { + i++ + } + if i < len(text) { + i++ // skip the letter + } + } + } else { + result += string(text[i]) + i++ + } + } + return StripInlineTags(result) +} + +// Bold formats text with bold when colors aren't supported +func Bold(text string) string { + return RenderInlineTags("<b>" + text + "</b>") +} + +// ColorSection creates a formatted section header. +// Uses light gray on dark backgrounds, dark gray on light backgrounds, +// and bold with the terminal's default foreground when background is unknown. +func ColorSection(text string) string { + colors := DetectColorSupport() + if !colors.Supported { + return text + } + + switch detectBackground() { + case "dark": + return RenderInlineTags("<light>" + text + "</light>") + case "light": + return RenderInlineTags("<dark>" + text + "</dark>") + default: + return RenderInlineTags("<b>" + text + "</b>") + } +} diff --git a/todo/internal/common/formatting_test.go b/todo/internal/common/formatting_test.go new file mode 100644 index 0000000..935e7b7 --- /dev/null +++ b/todo/internal/common/formatting_test.go @@ -0,0 +1,190 @@ +package common + +import ( + "os" + "testing" + + "github.com/muesli/termenv" +) + +func forceProfile(profile termenv.Profile) func() { + return SetColorProfileResolverForTesting(func(*termenv.Output) termenv.Profile { + return profile + }) +} + +func TestDetectColorSupport(t *testing.T) { + t.Run("respects no color", func(t *testing.T) { + restore := forceProfile(termenv.ANSI256) + defer restore() + t.Setenv("NO_COLOR", "1") + + c := DetectColorSupport() + if c.Supported { + t.Fatalf("expected color to be disabled when NO_COLOR is set") + } + }) + + t.Run("supports 256 color terminals", func(t *testing.T) { + restore := forceProfile(termenv.ANSI256) + defer restore() + _ = os.Unsetenv("NO_COLOR") + + c := DetectColorSupport() + if !c.Supported { + t.Fatalf("expected color support for xterm-256color") + } + if !c.Has256 { + t.Fatalf("expected Has256 to be true") + } + }) + + t.Run("supports truecolor terminals", func(t *testing.T) { + restore := forceProfile(termenv.TrueColor) + defer restore() + _ = os.Unsetenv("NO_COLOR") + + c := DetectColorSupport() + if !c.Supported { + t.Fatalf("expected color support for truecolor terminal") + } + if !c.HasTruecolor { + t.Fatalf("expected HasTruecolor to be true") + } + }) + + t.Run("dumb terminals are not supported", func(t *testing.T) { + restore := forceProfile(termenv.Ascii) + defer restore() + _ = os.Unsetenv("NO_COLOR") + t.Setenv("TERM", "dumb") + + c := DetectColorSupport() + if c.Supported { + t.Fatalf("expected dumb terminal to disable colors") + } + }) +} + +func TestFormattingHelpers(t *testing.T) { + t.Run("color section uses bold when background is unknown", func(t *testing.T) { + restoreProfile := forceProfile(termenv.ANSI256) + defer restoreProfile() + restoreBg := SetBackgroundModeDetectorForTesting(func(*termenv.Output) string { return "" }) + defer restoreBg() + _ = os.Unsetenv("NO_COLOR") + + got := ColorSection("Usage:") + want := "\033[1mUsage:\033[0m" + if got != want { + t.Fatalf("unexpected ColorSection output: got %q want %q", got, want) + } + }) + + t.Run("color section uses light gray for dark backgrounds", func(t *testing.T) { + restoreProfile := forceProfile(termenv.ANSI256) + defer restoreProfile() + restoreBg := SetBackgroundModeDetectorForTesting(func(*termenv.Output) string { return "dark" }) + defer restoreBg() + _ = os.Unsetenv("NO_COLOR") + + got := ColorSection("Usage:") + want := "\033[37mUsage:\033[0m" + if got != want { + t.Fatalf("unexpected ColorSection output for dark bg: got %q want %q", got, want) + } + }) + + t.Run("color section uses dark gray for light backgrounds", func(t *testing.T) { + restoreProfile := forceProfile(termenv.ANSI256) + defer restoreProfile() + restoreBg := SetBackgroundModeDetectorForTesting(func(*termenv.Output) string { return "light" }) + defer restoreBg() + _ = os.Unsetenv("NO_COLOR") + + got := ColorSection("Usage:") + want := "\033[30mUsage:\033[0m" + if got != want { + t.Fatalf("unexpected ColorSection output for light bg: got %q want %q", got, want) + } + }) + + t.Run("color section falls back to bold for no color", func(t *testing.T) { + restore := forceProfile(termenv.ANSI256) + defer restore() + t.Setenv("NO_COLOR", "1") + + got := ColorSection("Usage:") + want := "Usage:" + if got != want { + t.Fatalf("unexpected ColorSection fallback: got %q want %q", got, want) + } + }) + + t.Run("format section mirrors color section behavior", func(t *testing.T) { + restoreProfile := forceProfile(termenv.ANSI256) + defer restoreProfile() + restoreBg := SetBackgroundModeDetectorForTesting(func(*termenv.Output) string { return "" }) + defer restoreBg() + _ = os.Unsetenv("NO_COLOR") + + got := FormatSection("Flags:") + want := "\033[1mFlags:\033[0m" + if got != want { + t.Fatalf("unexpected FormatSection output: got %q want %q", got, want) + } + }) + + t.Run("bold only when colors are disabled", func(t *testing.T) { + restore := forceProfile(termenv.ANSI256) + defer restore() + t.Setenv("NO_COLOR", "1") + + got := Bold("todo") + want := "todo" + if got != want { + t.Fatalf("unexpected Bold output: got %q want %q", got, want) + } + }) + + t.Run("bold uses ansi when colors enabled", func(t *testing.T) { + restore := forceProfile(termenv.ANSI256) + defer restore() + _ = os.Unsetenv("NO_COLOR") + + got := Bold("todo") + if got != "\033[1mtodo\033[0m" { + t.Fatalf("expected bold ansi text when colors are enabled, got %q", got) + } + }) + + t.Run("strip formatting removes ansi escapes", func(t *testing.T) { + in := "\033[1;96mUsage:\033[0m test \033[1mtext\033[0m" + got := StripFormatting(in) + if got != "Usage: test text" { + t.Fatalf("unexpected StripFormatting result: got %q", got) + } + }) + + t.Run("render inline tags strips tags when colors disabled", func(t *testing.T) { + restore := forceProfile(termenv.ANSI256) + defer restore() + t.Setenv("NO_COLOR", "1") + + got := RenderInlineTags("<b>todo</b> <green>ok</green>") + if got != "todo ok" { + t.Fatalf("unexpected plain output: got %q", got) + } + }) + + t.Run("render inline tags uses ansi when colors enabled", func(t *testing.T) { + restore := forceProfile(termenv.ANSI256) + defer restore() + _ = os.Unsetenv("NO_COLOR") + + got := RenderInlineTags("<green>ok</green>") + if got != "\033[32mok\033[0m" { + t.Fatalf("unexpected colored output: got %q", got) + } + }) +} diff --git a/todo/internal/common/timeutil.go b/todo/internal/common/timeutil.go new file mode 100644 index 0000000..bca80c1 --- /dev/null +++ b/todo/internal/common/timeutil.go @@ -0,0 +1,213 @@ +package common + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/araddon/dateparse" + "github.com/maniartech/gotime" +) + +func ParseDateTime(input string) (time.Time, error) { + if input == "" { + return time.Time{}, fmt.Errorf("empty date/time") + } + input = strings.TrimSpace(input) + if t, err := time.Parse(time.RFC3339, input); err == nil { + return t, nil + } + if t, ok := parseRelativeDateTime(input, time.Now()); ok { + return t, nil + } + t, err := dateparse.ParseLocal(input) + if err != nil { + return time.Time{}, fmt.Errorf("invalid date/time %q: %w", input, err) + } + return t, nil +} + +func ParseHumanDuration(input string) (time.Duration, error) { + if strings.TrimSpace(input) == "" { + return 0, fmt.Errorf("empty duration") + } + if d, err := time.ParseDuration(input); err == nil { + return d, nil + } + value, unit, err := parseRelativeNumberUnit(input) + if err != nil { + return 0, err + } + base := time.Now() + var target time.Time + switch unit { + case "s": + target = base.Add(time.Duration(value) * time.Second) + case "m": + target = base.Add(time.Duration(value) * time.Minute) + case "h": + target = base.Add(time.Duration(value) * time.Hour) + case "d": + target = gotime.Days(value, base) + case "w": + target = gotime.Weeks(value, base) + default: + return 0, fmt.Errorf("unsupported duration unit in %q", input) + } + return target.Sub(base), nil +} + +func FormatTime(t *time.Time, useRFC3339 bool) string { + if t == nil { + return "" + } + if useRFC3339 { + return t.Format(time.RFC3339) + } + return t.Local().Format("2006-01-02 15:04 MST") +} + +func FormatRelativeTime(t *time.Time) string { + if t == nil { + return "" + } + now := time.Now() + diff := t.Sub(now) + + // Check if time is in the past + if diff < 0 { + diff = -diff + days := diff.Hours() / 24 + if days < 1 { + hours := diff.Hours() + if hours < 1 { + minutes := diff.Minutes() + return fmt.Sprintf("%.0f min ago", minutes) + } + return fmt.Sprintf("%.0f hours ago", hours) + } + if days < 30 { + return fmt.Sprintf("%.0f days ago", days) + } + months := days / 30 + if months < 12 { + return fmt.Sprintf("%.0f months ago", months) + } + years := days / 365 + return fmt.Sprintf("%.0f years ago", years) + } + + // Future time + days := diff.Hours() / 24 + if days < 1 { + hours := diff.Hours() + if hours < 1 { + minutes := diff.Minutes() + return fmt.Sprintf("in %.0f min", minutes) + } + return fmt.Sprintf("in %.0f hours", hours) + } + if days < 30 { + return fmt.Sprintf("in %.0f days", days) + } + months := days / 30 + if months < 12 { + return fmt.Sprintf("in %.0f months", months) + } + years := days / 365 + return fmt.Sprintf("in %.0f years", years) +} + +func parseRelativeDateTime(input string, base time.Time) (time.Time, bool) { + v := strings.ToLower(strings.TrimSpace(input)) + switch v { + case "now": + return base, true + case "today": + return gotime.SoD(base), true + case "tomorrow": + return gotime.Tomorrow(), true + case "yesterday": + return gotime.Yesterday(), true + case "next week": + return gotime.NextWeek(), true + case "last week": + return gotime.LastWeek(), true + case "next month": + return gotime.NextMonth(), true + case "last month": + return gotime.LastMonth(), true + case "next year": + return gotime.NextYear(), true + case "last year": + return gotime.LastYear(), true + } + + sign := 1 + if strings.Contains(v, " ago") { + sign = -1 + v = strings.TrimSuffix(v, " ago") + } + v = strings.TrimPrefix(v, "in ") + v = strings.TrimSuffix(v, " from now") + + n, unit, err := parseRelativeNumberUnit(v) + if err != nil { + return time.Time{}, false + } + n *= sign + + switch unit { + case "s": + return base.Add(time.Duration(n) * time.Second), true + case "m": + return base.Add(time.Duration(n) * time.Minute), true + case "h": + return base.Add(time.Duration(n) * time.Hour), true + case "d": + return gotime.Days(n, base), true + case "w": + return gotime.Weeks(n, base), true + case "mo": + return gotime.Months(n, base), true + case "y": + return gotime.Years(n, base), true + default: + return time.Time{}, false + } +} + +var relRe = regexp.MustCompile(`^([+-]?\d+)\s*([a-zA-Z]+)$`) + +func parseRelativeNumberUnit(input string) (int, string, error) { + compact := strings.ToLower(strings.TrimSpace(input)) + compact = strings.ReplaceAll(compact, " ", "") + m := relRe.FindStringSubmatch(compact) + if len(m) != 3 { + return 0, "", fmt.Errorf("invalid relative value %q", input) + } + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0, "", fmt.Errorf("invalid numeric value in %q", input) + } + switch m[2] { + case "s", "sec", "secs", "second", "seconds": + return n, "s", nil + case "m", "min", "mins", "minute", "minutes": + return n, "m", nil + case "h", "hr", "hrs", "hour", "hours": + return n, "h", nil + case "d", "day", "days": + return n, "d", nil + case "w", "wk", "wks", "week", "weeks": + return n, "w", nil + case "mo", "mon", "month", "months": + return n, "mo", nil + case "y", "yr", "yrs", "year", "years": + return n, "y", nil + default: + return 0, "", fmt.Errorf("unsupported relative unit in %q", input) + } +} diff --git a/todo/internal/common/timeutil_test.go b/todo/internal/common/timeutil_test.go new file mode 100644 index 0000000..0cbf726 --- /dev/null +++ b/todo/internal/common/timeutil_test.go @@ -0,0 +1,72 @@ +package common + +import ( + "testing" + "time" +) + +func TestParseDateTime(t *testing.T) { + t.Run("parses rfc3339", func(t *testing.T) { + in := "2026-06-16T10:00:00Z" + got, err := ParseDateTime(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Format(time.RFC3339) != in { + t.Fatalf("unexpected parse result: got %s want %s", got.Format(time.RFC3339), in) + } + }) + + t.Run("parses tomorrow", func(t *testing.T) { + got, err := ParseDateTime("tomorrow") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + now := time.Now() + if !got.After(now) { + t.Fatalf("expected tomorrow to be in the future, got %v", got) + } + }) + + t.Run("rejects empty input", func(t *testing.T) { + if _, err := ParseDateTime(" "); err == nil { + t.Fatalf("expected error for empty input") + } + }) +} + +func TestParseHumanDuration(t *testing.T) { + tests := []struct { + name string + in string + min time.Duration + max time.Duration + isErr bool + }{ + {name: "std duration", in: "2h", min: 2 * time.Hour, max: 2*time.Hour + 2*time.Second}, + {name: "days shorthand", in: "1d", min: 23 * time.Hour, max: 25 * time.Hour}, + {name: "weeks shorthand", in: "2w", min: 13 * 24 * time.Hour, max: 15 * 24 * time.Hour}, + {name: "months shorthand currently unsupported", in: "1mo", isErr: true}, + {name: "years shorthand currently unsupported", in: "1y", isErr: true}, + {name: "invalid", in: "banana", isErr: true}, + {name: "empty", in: " ", isErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseHumanDuration(tc.in) + if tc.isErr { + if err == nil { + t.Fatalf("expected error for %q", tc.in) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.in, err) + } + if got < tc.min || got > tc.max { + t.Fatalf("duration out of expected range for %q: got %v expected [%v..%v]", tc.in, got, tc.min, tc.max) + } + }) + } +} diff --git a/todo/internal/daemon/server.go b/todo/internal/daemon/server.go new file mode 100644 index 0000000..71cb5fe --- /dev/null +++ b/todo/internal/daemon/server.go @@ -0,0 +1,431 @@ +package daemon + +import ( + "context" + "encoding/json" + "errors" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "todo/internal/store" +) + +type Server struct { + st *store.Store + svc *Service + dbPath string + socket string + httpSrv *http.Server + listener net.Listener + stopOnce sync.Once + stopCh chan struct{} +} + +const DefaultSocketPath = "/run/todod.socket" + +func SocketPath() string { + if socketPath := strings.TrimSpace(os.Getenv("TODOD_SOCKET_PATH")); socketPath != "" { + return socketPath + } + return DefaultSocketPath +} + +func NewServer(dbPath string) (*Server, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + return nil, err + } + socketPath := SocketPath() + if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil { + return nil, err + } + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + listener, err := net.Listen("unix", socketPath) + if err != nil { + return nil, err + } + st, err := store.Open(dbPath) + if err != nil { + _ = listener.Close() + return nil, err + } + s := &Server{ + st: st, + svc: NewService(st), + dbPath: dbPath, + socket: socketPath, + listener: listener, + stopCh: make(chan struct{}), + } + mux := http.NewServeMux() + s.registerRoutes(mux) + s.httpSrv = &http.Server{ + Handler: loggingMiddleware(mux), + ReadHeaderTimeout: 5 * time.Second, + } + return s, nil +} + +func (s *Server) registerRoutes(mux *http.ServeMux) { + mux.HandleFunc("/status", s.handleStatus) + mux.HandleFunc("/shutdown", s.handleShutdown) + mux.HandleFunc("/motd/message", s.handleMOTDMessage) + mux.HandleFunc("/todos", s.handleTodos) + mux.HandleFunc("/todos/", s.handleTodoByID) + mux.HandleFunc("/sinks", s.handleSinks) + mux.HandleFunc("/sinks/", s.handleSinkByID) + mux.HandleFunc("/schedules", s.handleSchedules) + mux.HandleFunc("/schedules/", s.handleScheduleByID) +} + +func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + go s.Stop() + writeJSON(w, http.StatusOK, map[string]any{"status": "stopping"}) +} + +func (s *Server) Run(ctx context.Context) error { + go s.schedulerLoop(ctx) + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + select { + case <-ctx.Done(): + case <-sigCh: + s.Stop() + } + }() + + log.Printf("todod listening on unix socket %s", s.socket) + err := s.httpSrv.Serve(s.listener) + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (s *Server) Stop() { + s.stopOnce.Do(func() { + close(s.stopCh) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.httpSrv.Shutdown(ctx) + if s.listener != nil { + _ = s.listener.Close() + } + if s.socket != "" { + _ = os.Remove(s.socket) + } + _ = s.st.Close() + }) +} + +func (s *Server) schedulerLoop(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-s.stopCh: + return + case <-ticker.C: + if err := s.svc.RunScheduler(ctx); err != nil { + log.Printf("scheduler error: %v", err) + } + } + } +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + todos, err := s.st.ListTodos(r.Context(), store.TodoFilter{State: "open"}) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + schedules, err := s.st.ListSchedules(r.Context(), store.ScheduleFilter{Status: "active"}) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + trueVal := true + sinks, err := s.st.ListSinks(r.Context(), &trueVal, "") + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + home, _ := os.UserHomeDir() + profile := filepath.Join(home, ".profile") + b, _ := os.ReadFile(profile) + hasMotd := strings.Contains(strings.ToLower(string(b)), "todo reminder-status") + writeJSON(w, http.StatusOK, map[string]any{ + "now": time.Now().UTC().Format(time.RFC3339), + "database_path": s.dbPath, + "active_todo_count": len(todos), + "active_schedule_count": len(schedules), + "enabled_sink_count": len(sinks), + "needs_motd_login_script_hint": !hasMotd, + "motd_login_script_hint": "To show todo reminders on login, run: echo 'todo reminder-status' >> ~/.profile", + "motd_login_script_check_path": profile, + }) +} + +func (s *Server) handleMOTDMessage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + msgs, err := s.svc.PullMOTDMessages(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) +} + +func (s *Server) handleTodos(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var req CreateTodoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + todo, err := s.svc.CreateTodo(r.Context(), req) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusCreated, todo) + case http.MethodGet: + filter := store.TodoFilter{State: r.URL.Query().Get("state")} + todos, err := s.st.ListTodos(r.Context(), filter) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"todos": todos}) + default: + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (s *Server) handleTodoByID(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/todos/") + parts := strings.Split(path, "/") + idStr := parts[0] + if idStr == "" { + writeErr(w, http.StatusBadRequest, "todo id is required") + return + } + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid todo id") + return + } + if len(parts) == 1 { + switch r.Method { + case http.MethodGet: + todo, err := s.st.GetTodo(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, todo) + case http.MethodPatch: + var req UpdateTodoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + if err := s.svc.UpdateTodo(r.Context(), id, req); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + todo, _ := s.st.GetTodo(r.Context(), id) + writeJSON(w, http.StatusOK, todo) + default: + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + } + return + } + action := parts[1] + if r.Method != http.MethodPost { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var errResult error + switch action { + case "close": + errResult = s.svc.CloseTodo(r.Context(), id) + case "reopen": + errResult = s.svc.ReopenTodo(r.Context(), id) + case "reject": + errResult = s.svc.RejectTodo(r.Context(), id) + default: + writeErr(w, http.StatusNotFound, "not found") + return + } + if errResult != nil { + writeErr(w, http.StatusBadRequest, errResult.Error()) + return + } + todo, _ := s.st.GetTodo(r.Context(), id) + writeJSON(w, http.StatusOK, todo) +} + +func (s *Server) handleSinks(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + var req CreateSinkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + sink, err := s.svc.CreateSink(r.Context(), req) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusCreated, sink) + case http.MethodGet: + q := r.URL.Query() + event := q.Get("event") + var enabled *bool + if q.Get("enabled") != "" { + v := q.Get("enabled") == "true" + enabled = &v + } + sinks, err := s.st.ListSinks(r.Context(), enabled, event) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"sinks": sinks}) + default: + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (s *Server) handleSinkByID(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sinks/") + parts := strings.Split(path, "/") + id := parts[0] + if id == "" { + writeErr(w, http.StatusBadRequest, "sink id is required") + return + } + if len(parts) == 1 { + switch r.Method { + case http.MethodGet: + sink, err := s.st.GetSink(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, sink) + case http.MethodDelete: + if err := s.svc.DeleteSink(r.Context(), id); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + default: + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + } + return + } + writeErr(w, http.StatusNotFound, "not found") +} + +func (s *Server) handleSchedules(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + q := r.URL.Query() + schedules, err := s.st.ListSchedules(r.Context(), store.ScheduleFilter{ + TodoID: q.Get("todo"), + Kind: q.Get("kind"), + Status: q.Get("status"), + Target: q.Get("target"), + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"schedules": schedules}) + case http.MethodPost: + var req AddScheduleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + sc, err := s.svc.AddSchedule(r.Context(), req) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusCreated, sc) + default: + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (s *Server) handleScheduleByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/schedules/") + if id == "" { + writeErr(w, http.StatusBadRequest, "schedule id is required") + return + } + switch r.Method { + case http.MethodGet: + sc, err := s.st.GetSchedule(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, sc) + case http.MethodDelete: + if err := s.svc.RemoveSchedule(r.Context(), id); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + default: + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]any{"error": msg}) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} diff --git a/todo/internal/daemon/server_sink_immutability_test.go b/todo/internal/daemon/server_sink_immutability_test.go new file mode 100644 index 0000000..0529f01 --- /dev/null +++ b/todo/internal/daemon/server_sink_immutability_test.go @@ -0,0 +1,80 @@ +package daemon + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func newTestServer(t *testing.T) *Server { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "todo-test.db") + t.Setenv("TODOD_SOCKET_PATH", filepath.Join(t.TempDir(), "todod-test.sock")) + srv, err := NewServer(dbPath) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + t.Cleanup(func() { + _ = srv.st.Close() + }) + return srv +} + +func TestSinkRoutesAreImmutable(t *testing.T) { + srv := newTestServer(t) + + _, err := srv.svc.CreateSink(context.Background(), CreateSinkRequest{ + ID: "sink-1", + URL: "https://example.com/hook", + Events: []string{"upcoming"}, + }) + if err != nil { + t.Fatalf("failed to create sink fixture: %v", err) + } + + t.Run("patch is not allowed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPatch, "/sinks/sink-1", nil) + rr := httptest.NewRecorder() + + srv.handleSinkByID(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected %d, got %d", http.StatusMethodNotAllowed, rr.Code) + } + }) + + t.Run("enable route is removed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/sinks/sink-1/enable", nil) + rr := httptest.NewRecorder() + + srv.handleSinkByID(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("disable route is removed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/sinks/sink-1/disable", nil) + rr := httptest.NewRecorder() + + srv.handleSinkByID(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("delete remains allowed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/sinks/sink-1", nil) + rr := httptest.NewRecorder() + + srv.handleSinkByID(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("expected %d, got %d", http.StatusNoContent, rr.Code) + } + }) +} diff --git a/todo/internal/daemon/service.go b/todo/internal/daemon/service.go new file mode 100644 index 0000000..dd1949b --- /dev/null +++ b/todo/internal/daemon/service.go @@ -0,0 +1,412 @@ +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "strings" + "time" + + "todo/internal/common" + "todo/internal/model" + "todo/internal/store" +) + +type Service struct { + store *store.Store + client *http.Client +} + +func NewService(st *store.Store) *Service { + return &Service{ + store: st, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +type CreateTodoRequest struct { + ID string `json:"id,omitempty"` + Title string `json:"title"` + DueAt *time.Time `json:"due_at,omitempty"` + Schedule []ScheduleSpec `json:"schedule,omitempty"` +} + +type UpdateTodoRequest struct { + Title *string `json:"title,omitempty"` + DueAt *time.Time `json:"due_at,omitempty"` + ClearDue bool `json:"clear_due"` +} + +type CreateSinkRequest struct { + ID string `json:"id"` + URL string `json:"url"` + Events []string `json:"events,omitempty"` +} + +type ScheduleSpec struct { + Kind string `json:"kind"` + Before string `json:"before,omitempty"` + Every string `json:"every,omitempty"` + MOTD bool `json:"motd"` + SinkID []string `json:"sink_id,omitempty"` +} + +type AddScheduleRequest struct { + ID string `json:"id"` + TodoID int64 `json:"todo_id"` + Kind string `json:"kind"` + Before string `json:"before,omitempty"` + Every string `json:"every,omitempty"` + MOTD bool `json:"motd"` + SinkID []string `json:"sink_id,omitempty"` +} + +func (s *Service) CreateTodo(ctx context.Context, req CreateTodoRequest) (model.Todo, error) { + if strings.TrimSpace(req.Title) == "" { + return model.Todo{}, fmt.Errorf("title is required") + } + now := time.Now().UTC() + todo := model.Todo{ + Title: req.Title, + DueAt: req.DueAt, + State: model.TodoStateOpen, + CreatedAt: now, + UpdatedAt: now, + } + id, err := s.store.CreateTodo(ctx, todo) + if err != nil { + return model.Todo{}, err + } + todo.ID = id + for i := range req.Schedule { + sp := req.Schedule[i] + scReq := AddScheduleRequest{ + ID: fmt.Sprintf("%d-sch-%d", todo.ID, i+1), + TodoID: todo.ID, + Kind: sp.Kind, + Before: sp.Before, + Every: sp.Every, + MOTD: sp.MOTD, + SinkID: sp.SinkID, + } + if _, err := s.AddSchedule(ctx, scReq); err != nil { + return model.Todo{}, err + } + } + return todo, nil +} + +func (s *Service) UpdateTodo(ctx context.Context, id int64, req UpdateTodoRequest) error { + if req.Title == nil && req.DueAt == nil && !req.ClearDue { + return fmt.Errorf("at least one field must be changed") + } + return s.store.UpdateTodo(ctx, id, req.Title, req.DueAt, req.ClearDue) +} + +func (s *Service) CloseTodo(ctx context.Context, id int64) error { + return s.store.TransitionTodo(ctx, id, model.TodoStateClosed) +} + +func (s *Service) ReopenTodo(ctx context.Context, id int64) error { + return s.store.TransitionTodo(ctx, id, model.TodoStateReopened) +} + +func (s *Service) RejectTodo(ctx context.Context, id int64) error { + return s.store.TransitionTodo(ctx, id, model.TodoStateRejected) +} + +func (s *Service) CreateSink(ctx context.Context, req CreateSinkRequest) (model.Sink, error) { + if strings.TrimSpace(req.ID) == "" { + return model.Sink{}, fmt.Errorf("sink id is required") + } + if strings.TrimSpace(req.URL) == "" { + return model.Sink{}, fmt.Errorf("sink url is required") + } + events := req.Events + if len(events) == 0 { + events = []string{"upcoming", "overdue"} + } + for _, e := range events { + if e != model.ScheduleKindUpcoming && e != model.ScheduleKindOverdue { + return model.Sink{}, fmt.Errorf("invalid sink event %q", e) + } + } + now := time.Now().UTC() + sink := model.Sink{ + ID: req.ID, + URL: req.URL, + Events: events, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.CreateSink(ctx, sink); err != nil { + return model.Sink{}, err + } + return sink, nil +} + +func (s *Service) DeleteSink(ctx context.Context, id string) error { + return s.store.DeleteSink(ctx, id) +} + +func (s *Service) AddSchedule(ctx context.Context, req AddScheduleRequest) (model.Schedule, error) { + if strings.TrimSpace(req.ID) == "" { + return model.Schedule{}, fmt.Errorf("schedule id is required") + } + if req.TodoID <= 0 { + return model.Schedule{}, fmt.Errorf("todo id is required") + } + if req.Kind != model.ScheduleKindUpcoming && req.Kind != model.ScheduleKindOverdue { + return model.Schedule{}, fmt.Errorf("kind must be upcoming or overdue") + } + if req.Before != "" && req.Every != "" { + return model.Schedule{}, fmt.Errorf("before and every are mutually exclusive") + } + if req.Kind == model.ScheduleKindUpcoming { + if req.Every != "" { + return model.Schedule{}, fmt.Errorf("every only applies to overdue schedules") + } + if req.Before == "" { + req.Before = "24h" + } + if _, err := common.ParseHumanDuration(req.Before); err != nil { + return model.Schedule{}, fmt.Errorf("invalid before duration: %w", err) + } + } + if req.Kind == model.ScheduleKindOverdue { + if req.Before != "" { + return model.Schedule{}, fmt.Errorf("before only applies to upcoming schedules") + } + if req.Every == "" { + req.Every = "24h" + } + if _, err := common.ParseHumanDuration(req.Every); err != nil { + return model.Schedule{}, fmt.Errorf("invalid every duration: %w", err) + } + } + if !req.MOTD && len(req.SinkID) == 0 { + req.MOTD = true + } + if req.MOTD { + for _, sinkID := range req.SinkID { + if strings.TrimSpace(sinkID) == "" { + continue + } + if _, err := s.store.GetSink(ctx, sinkID); err != nil { + return model.Schedule{}, fmt.Errorf("sink %q not found: %w", sinkID, err) + } + } + } + todo, err := s.store.GetTodo(ctx, req.TodoID) + if err != nil { + return model.Schedule{}, err + } + if todo.DueAt == nil { + return model.Schedule{}, fmt.Errorf("todo %d has no due date", req.TodoID) + } + + now := time.Now().UTC() + sc := model.Schedule{ + ID: req.ID, + TodoID: fmt.Sprintf("%d", req.TodoID), + Kind: req.Kind, + Before: req.Before, + Every: req.Every, + Status: model.ScheduleStatusActive, + TargetMOTD: req.MOTD, + SinkIDs: req.SinkID, + CreatedAt: now, + } + if err := s.store.CreateSchedule(ctx, sc); err != nil { + return model.Schedule{}, err + } + return sc, nil +} + +func (s *Service) RemoveSchedule(ctx context.Context, id string) error { + return s.store.DeleteSchedule(ctx, id) +} + +func (s *Service) RunScheduler(ctx context.Context) error { + schedules, todos, err := s.store.ActiveSchedulesWithTodos(ctx) + if err != nil { + return err + } + now := time.Now().UTC() + for idx, sc := range schedules { + todo := todos[idx] + if todo.DueAt == nil { + continue + } + plannedAt, shouldSend, err := computePlannedAtAndEligibility(sc, todo, now) + if err != nil { + continue + } + if !shouldSend { + continue + } + allDelivered := true + if sc.TargetMOTD { + delivered, err := s.sendToMOTD(ctx, sc, todo, plannedAt) + if err != nil { + allDelivered = false + } + if !delivered { + allDelivered = false + } + } + sinkTargets, err := s.resolveSinkTargets(ctx, sc) + if err != nil { + allDelivered = false + } + for _, sink := range sinkTargets { + delivered, err := s.sendToSink(ctx, sc, todo, sink, plannedAt) + if err != nil { + allDelivered = false + } + if !delivered { + allDelivered = false + } + } + if sc.Kind == model.ScheduleKindUpcoming && allDelivered { + _ = s.store.MarkScheduleSent(ctx, sc.ID) + } + } + return nil +} + +func (s *Service) resolveSinkTargets(ctx context.Context, sc model.Schedule) ([]model.Sink, error) { + allSinks, err := s.store.ListSinks(ctx, boolPtr(true), sc.Kind) + if err != nil { + return nil, err + } + if len(sc.SinkIDs) == 0 { + return allSinks, nil + } + out := make([]model.Sink, 0) + for _, sink := range allSinks { + if slices.Contains(sc.SinkIDs, sink.ID) { + out = append(out, sink) + } + } + return out, nil +} + +func (s *Service) sendToMOTD(ctx context.Context, sc model.Schedule, todo model.Todo, plannedAt time.Time) (bool, error) { + const targetType = "motd" + const targetID = "local" + shouldAttempt, err := s.store.ShouldAttemptDelivery(ctx, sc.ID, targetType, targetID, plannedAt) + if err != nil { + return false, err + } + if !shouldAttempt { + delivered, err := s.store.IsTargetDelivered(ctx, sc.ID, targetType, targetID, plannedAt) + return delivered, err + } + message := buildReminderMessage(sc, todo) + if err := s.store.QueueMOTDMessage(ctx, fmt.Sprintf("%d", todo.ID), sc.ID, message); err != nil { + _ = s.store.UpsertDeliveryResult(ctx, sc.ID, targetType, targetID, plannedAt, false, err.Error()) + return false, err + } + if err := s.store.UpsertDeliveryResult(ctx, sc.ID, targetType, targetID, plannedAt, true, ""); err != nil { + return false, err + } + return true, nil +} + +func (s *Service) sendToSink(ctx context.Context, sc model.Schedule, todo model.Todo, sink model.Sink, plannedAt time.Time) (bool, error) { + const targetType = "sink" + shouldAttempt, err := s.store.ShouldAttemptDelivery(ctx, sc.ID, targetType, sink.ID, plannedAt) + if err != nil { + return false, err + } + if !shouldAttempt { + delivered, err := s.store.IsTargetDelivered(ctx, sc.ID, targetType, sink.ID, plannedAt) + return delivered, err + } + payload := map[string]any{ + "todo_id": todo.ID, + "title": todo.Title, + "state": todo.State, + "due_at": todo.DueAt, + "schedule_id": sc.ID, + "kind": sc.Kind, + "planned_at": plannedAt, + "message": buildReminderMessage(sc, todo), + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, sink.URL, bytes.NewReader(body)) + if err != nil { + _ = s.store.UpsertDeliveryResult(ctx, sc.ID, targetType, sink.ID, plannedAt, false, err.Error()) + return false, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.client.Do(req) + if err != nil { + _ = s.store.UpsertDeliveryResult(ctx, sc.ID, targetType, sink.ID, plannedAt, false, err.Error()) + return false, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + errText := fmt.Sprintf("status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(raw))) + _ = s.store.UpsertDeliveryResult(ctx, sc.ID, targetType, sink.ID, plannedAt, false, errText) + return false, nil + } + if err := s.store.UpsertDeliveryResult(ctx, sc.ID, targetType, sink.ID, plannedAt, true, ""); err != nil { + return false, err + } + return true, nil +} + +func (s *Service) PullMOTDMessages(ctx context.Context) ([]string, error) { + return s.store.PullMOTDMessages(ctx) +} + +func computePlannedAtAndEligibility(sc model.Schedule, todo model.Todo, now time.Time) (time.Time, bool, error) { + due := *todo.DueAt + if sc.Kind == model.ScheduleKindUpcoming { + before, err := common.ParseHumanDuration(sc.Before) + if err != nil { + return time.Time{}, false, err + } + planned := due.Add(-before) + return planned, !now.Before(planned), nil + } + if sc.Kind == model.ScheduleKindOverdue { + every, err := common.ParseHumanDuration(sc.Every) + if err != nil { + return time.Time{}, false, err + } + if now.Before(due) { + return due, false, nil + } + elapsed := now.Sub(due) + steps := int(elapsed / every) + planned := due.Add(time.Duration(steps) * every) + return planned, true, nil + } + return time.Time{}, false, fmt.Errorf("unknown kind %q", sc.Kind) +} + +func buildReminderMessage(sc model.Schedule, todo model.Todo) string { + due := "" + if todo.DueAt != nil { + due = todo.DueAt.Local().Format("2006-01-02 15:04 MST") + } + if sc.Kind == model.ScheduleKindUpcoming { + return fmt.Sprintf("Upcoming todo: %s (%d) due at %s", todo.Title, todo.ID, due) + } + return fmt.Sprintf("Overdue todo: %s (%d) was due at %s", todo.Title, todo.ID, due) +} + +func boolPtr(v bool) *bool { + return &v +} diff --git a/todo/internal/model/types.go b/todo/internal/model/types.go new file mode 100644 index 0000000..965e1b1 --- /dev/null +++ b/todo/internal/model/types.go @@ -0,0 +1,52 @@ +package model + +import "time" + +const ( + TodoStateOpen = "open" + TodoStateClosed = "closed" + TodoStateReopened = "reopened" + TodoStateRejected = "rejected" +) + +const ( + ScheduleKindUpcoming = "upcoming" + ScheduleKindOverdue = "overdue" +) + +const ( + ScheduleStatusActive = "active" + ScheduleStatusSent = "sent" +) + +type Todo struct { + ID int64 `json:"id"` + Title string `json:"title"` + DueAt *time.Time `json:"due_at,omitempty"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + RejectedAt *time.Time `json:"rejected_at,omitempty"` +} + +type Sink struct { + ID string `json:"id"` + URL string `json:"url"` + Events []string `json:"events"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Schedule struct { + ID string `json:"id"` + TodoID string `json:"todo_id"` + Kind string `json:"kind"` + Before string `json:"before,omitempty"` + Every string `json:"every,omitempty"` + Status string `json:"status"` + TargetMOTD bool `json:"target_motd"` + SinkIDs []string `json:"sink_ids,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/todo/internal/service/service.go b/todo/internal/service/service.go new file mode 100644 index 0000000..b718a5f --- /dev/null +++ b/todo/internal/service/service.go @@ -0,0 +1,493 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "todo/internal/common" + "todo/internal/model" + "todo/internal/store" +) + +type Service struct { + store *store.Store +} + +func New(s *store.Store) *Service { + return &Service{store: s} +} + +type CreateTodoRequest struct { + ID string + Title string + DueAt *time.Time + Schedules []ScheduleSpec +} + +type UpdateTodoRequest struct { + Title *string + DueAt *time.Time + ClearDue bool +} + +type CreateSinkRequest struct { + ID string + URL string + Events []string + Enabled bool +} + +type ScheduleSpec struct { + ID string + TodoID string + Kind string + Before string + Every string + SinkIDs []string + UseMOTD bool + Implicit bool +} + +type StatusResponse struct { + Now time.Time `json:"now"` + DatabasePath string `json:"database_path"` + NeedsMOTDLoginScriptHint bool `json:"needs_motd_login_script_hint"` + MOTDLoginScriptHint string `json:"motd_login_script_hint"` + MOTDLoginScriptCheckPath string `json:"motd_login_script_check_path"` + ActiveTodoCount int `json:"active_todo_count"` + ActiveScheduleCount int `json:"active_schedule_count"` + EnabledSinkCount int `json:"enabled_sink_count"` +} + +func (s *Service) CreateTodo(ctx context.Context, req CreateTodoRequest) (model.Todo, error) { + if strings.TrimSpace(req.Title) == "" { + return model.Todo{}, fmt.Errorf("title is required") + } + id := strings.TrimSpace(req.ID) + var todoID int64 + if id == "" { + // Create todo and server generates ID + now := time.Now().UTC() + todo := model.Todo{ + Title: strings.TrimSpace(req.Title), + DueAt: req.DueAt, + State: model.TodoStateOpen, + CreatedAt: now, + UpdatedAt: now, + } + var err error + todoID, err = s.store.CreateTodo(ctx, todo) + if err != nil { + return model.Todo{}, err + } + } else { + // User provided explicit ID - not supported with int64 + return model.Todo{}, fmt.Errorf("explicit todo ids are no longer supported") + } + todo, err := s.store.GetTodo(ctx, todoID) + if err != nil { + return model.Todo{}, err + } + for i := range req.Schedules { + sched := req.Schedules[i] + sched.TodoID = fmt.Sprintf("%d", todo.ID) + if _, err := s.AddSchedule(ctx, sched); err != nil { + return model.Todo{}, err + } + } + return todo, nil +} + +func (s *Service) UpdateTodo(ctx context.Context, idStr string, req UpdateTodoRequest) (model.Todo, error) { + if strings.TrimSpace(idStr) == "" { + return model.Todo{}, fmt.Errorf("todo id is required") + } + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return model.Todo{}, fmt.Errorf("invalid todo id") + } + if req.Title == nil && req.DueAt == nil && !req.ClearDue { + return model.Todo{}, fmt.Errorf("nothing to update") + } + if err := s.store.UpdateTodo(ctx, id, req.Title, req.DueAt, req.ClearDue); err != nil { + return model.Todo{}, err + } + return s.store.GetTodo(ctx, id) +} + +func (s *Service) CloseTodo(ctx context.Context, idStr string) (model.Todo, error) { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return model.Todo{}, fmt.Errorf("invalid todo id") + } + if err := s.store.TransitionTodo(ctx, id, model.TodoStateClosed); err != nil { + return model.Todo{}, err + } + return s.store.GetTodo(ctx, id) +} + +func (s *Service) ReopenTodo(ctx context.Context, idStr string) (model.Todo, error) { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return model.Todo{}, fmt.Errorf("invalid todo id") + } + if err := s.store.TransitionTodo(ctx, id, model.TodoStateReopened); err != nil { + return model.Todo{}, err + } + return s.store.GetTodo(ctx, id) +} + +func (s *Service) RejectTodo(ctx context.Context, idStr string) (model.Todo, error) { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return model.Todo{}, fmt.Errorf("invalid todo id") + } + if err := s.store.TransitionTodo(ctx, id, model.TodoStateRejected); err != nil { + return model.Todo{}, err + } + return s.store.GetTodo(ctx, id) +} + +func (s *Service) ShowTodo(ctx context.Context, idStr string) (model.Todo, error) { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return model.Todo{}, fmt.Errorf("invalid todo id") + } + return s.store.GetTodo(ctx, id) +} + +func (s *Service) ListTodos(ctx context.Context, f store.TodoFilter) ([]model.Todo, error) { + return s.store.ListTodos(ctx, f) +} + +func normalizeEvents(events []string) []string { + if len(events) == 0 { + return []string{model.ScheduleKindUpcoming, model.ScheduleKindOverdue} + } + out := make([]string, 0, len(events)) + for _, ev := range events { + ev = strings.TrimSpace(strings.ToLower(ev)) + if ev == "" { + continue + } + if ev != model.ScheduleKindUpcoming && ev != model.ScheduleKindOverdue { + continue + } + if !slices.Contains(out, ev) { + out = append(out, ev) + } + } + if len(out) == 0 { + return []string{model.ScheduleKindUpcoming, model.ScheduleKindOverdue} + } + return out +} + +func (s *Service) CreateSink(ctx context.Context, req CreateSinkRequest) (model.Sink, error) { + if strings.TrimSpace(req.ID) == "" { + return model.Sink{}, fmt.Errorf("sink id is required") + } + if strings.TrimSpace(req.URL) == "" { + return model.Sink{}, fmt.Errorf("sink url is required") + } + now := time.Now().UTC() + sink := model.Sink{ + ID: req.ID, + URL: req.URL, + Events: normalizeEvents(req.Events), + Enabled: req.Enabled, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.CreateSink(ctx, sink); err != nil { + return model.Sink{}, err + } + return s.store.GetSink(ctx, sink.ID) +} + +func (s *Service) DeleteSink(ctx context.Context, id string) error { + return s.store.DeleteSink(ctx, id) +} + +func (s *Service) ShowSink(ctx context.Context, id string) (model.Sink, error) { + return s.store.GetSink(ctx, id) +} + +func (s *Service) ListSinks(ctx context.Context, enabled *bool, event string) ([]model.Sink, error) { + return s.store.ListSinks(ctx, enabled, strings.TrimSpace(strings.ToLower(event))) +} + +func (s *Service) AddSchedule(ctx context.Context, req ScheduleSpec) (model.Schedule, error) { + if strings.TrimSpace(req.ID) == "" { + return model.Schedule{}, fmt.Errorf("schedule id is required") + } + if strings.TrimSpace(req.TodoID) == "" { + return model.Schedule{}, fmt.Errorf("--todo is required") + } + if req.Kind != model.ScheduleKindUpcoming && req.Kind != model.ScheduleKindOverdue { + return model.Schedule{}, fmt.Errorf("--kind must be upcoming or overdue") + } + if req.Kind == model.ScheduleKindUpcoming { + if strings.TrimSpace(req.Before) == "" { + req.Before = "24h" + } + if _, err := common.ParseHumanDuration(req.Before); err != nil { + return model.Schedule{}, fmt.Errorf("invalid --before duration: %w", err) + } + if req.Every != "" { + return model.Schedule{}, fmt.Errorf("--every is not valid for upcoming schedules") + } + } else { + if strings.TrimSpace(req.Every) == "" { + req.Every = "24h" + } + if _, err := common.ParseHumanDuration(req.Every); err != nil { + return model.Schedule{}, fmt.Errorf("invalid --every duration: %w", err) + } + if req.Before != "" { + return model.Schedule{}, fmt.Errorf("--before is not valid for overdue schedules") + } + } + todoID, err := strconv.ParseInt(req.TodoID, 10, 64) + if err != nil { + return model.Schedule{}, fmt.Errorf("invalid todo id") + } + todo, err := s.store.GetTodo(ctx, todoID) + if err != nil { + return model.Schedule{}, err + } + if todo.DueAt == nil { + return model.Schedule{}, fmt.Errorf("todo %q has no due date; schedules require a due date", req.TodoID) + } + if !req.UseMOTD && len(req.SinkIDs) == 0 { + req.UseMOTD = true + } + sc := model.Schedule{ + ID: req.ID, + TodoID: req.TodoID, + Kind: req.Kind, + Before: req.Before, + Every: req.Every, + Status: model.ScheduleStatusActive, + TargetMOTD: req.UseMOTD, + SinkIDs: dedupe(req.SinkIDs), + CreatedAt: time.Now().UTC(), + } + if err := s.store.CreateSchedule(ctx, sc); err != nil { + return model.Schedule{}, err + } + return s.store.GetSchedule(ctx, sc.ID) +} + +func (s *Service) RemoveSchedule(ctx context.Context, id string) error { + return s.store.DeleteSchedule(ctx, id) +} + +func (s *Service) ShowSchedule(ctx context.Context, id string) (model.Schedule, error) { + return s.store.GetSchedule(ctx, id) +} + +func (s *Service) ListSchedules(ctx context.Context, f store.ScheduleFilter) ([]model.Schedule, error) { + return s.store.ListSchedules(ctx, f) +} + +func (s *Service) MOTDMessage(ctx context.Context) ([]string, error) { + return s.store.PullMOTDMessages(ctx) +} + +func (s *Service) Status(ctx context.Context, dbPath string) (StatusResponse, error) { + todos, err := s.store.ListTodos(ctx, store.TodoFilter{State: model.TodoStateOpen}) + if err != nil { + return StatusResponse{}, err + } + schedules, err := s.store.ListSchedules(ctx, store.ScheduleFilter{Status: model.ScheduleStatusActive}) + if err != nil { + return StatusResponse{}, err + } + trueVal := true + sinks, err := s.store.ListSinks(ctx, &trueVal, "") + if err != nil { + return StatusResponse{}, err + } + home, _ := os.UserHomeDir() + profile := filepath.Join(home, ".profile") + needsHint := !profileHasMotdMessage(profile) + return StatusResponse{ + Now: time.Now().UTC(), + DatabasePath: dbPath, + NeedsMOTDLoginScriptHint: needsHint, + MOTDLoginScriptHint: "To show todo reminders on login, run: echo 'todo reminder-status' >> ~/.profile", + MOTDLoginScriptCheckPath: profile, + ActiveTodoCount: len(todos), + ActiveScheduleCount: len(schedules), + EnabledSinkCount: len(sinks), + }, nil +} + +func profileHasMotdMessage(path string) bool { + b, err := os.ReadFile(path) + if err != nil { + return false + } + return bytes.Contains(bytes.ToLower(b), []byte("todo reminder-status")) +} + +func dedupe(in []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(in)) + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func computeSchedulePlannedAt(sc model.Schedule, todo model.Todo, now time.Time) (time.Time, bool, error) { + if todo.DueAt == nil { + return time.Time{}, false, nil + } + due := todo.DueAt.UTC() + if sc.Kind == model.ScheduleKindUpcoming { + d, err := common.ParseHumanDuration(sc.Before) + if err != nil { + return time.Time{}, false, err + } + planned := due.Add(-d) + if now.Before(planned) { + return planned, false, nil + } + return planned, true, nil + } + freq, err := common.ParseHumanDuration(sc.Every) + if err != nil { + return time.Time{}, false, err + } + if now.Before(due) { + return time.Time{}, false, nil + } + elapsed := now.Sub(due) + steps := int(elapsed / freq) + planned := due.Add(time.Duration(steps) * freq) + return planned, true, nil +} + +func (s *Service) EvaluateAndDispatchSchedules(ctx context.Context) error { + now := time.Now().UTC() + schedules, todos, err := s.store.ActiveSchedulesWithTodos(ctx) + if err != nil { + return err + } + for i, sc := range schedules { + todo := todos[i] + plannedAt, dueNow, err := computeSchedulePlannedAt(sc, todo, now) + if err != nil || !dueNow { + continue + } + + totalTargets := 0 + deliveredTargets := 0 + + if sc.TargetMOTD { + totalTargets++ + ok, err := s.store.IsTargetDelivered(ctx, sc.ID, "motd", "local", plannedAt) + if err != nil { + continue + } + if ok { + deliveredTargets++ + } else { + shouldAttempt, err := s.store.ShouldAttemptDelivery(ctx, sc.ID, "motd", "local", plannedAt) + if err != nil { + continue + } + if shouldAttempt { + msg := fmt.Sprintf("[%s] %s (due %s)", strings.ToUpper(sc.Kind), todo.Title, todo.DueAt.Local().Format("2006-01-02 15:04 MST")) + err = s.store.QueueMOTDMessage(ctx, fmt.Sprintf("%d", todo.ID), sc.ID, msg) + if err != nil { + _ = s.store.UpsertDeliveryResult(ctx, sc.ID, "motd", "local", plannedAt, false, err.Error()) + } else { + _ = s.store.UpsertDeliveryResult(ctx, sc.ID, "motd", "local", plannedAt, true, "") + deliveredTargets++ + } + } + } + } + + for _, sinkID := range sc.SinkIDs { + totalTargets++ + ok, err := s.store.IsTargetDelivered(ctx, sc.ID, "sink", sinkID, plannedAt) + if err != nil { + continue + } + if ok { + deliveredTargets++ + continue + } + shouldAttempt, err := s.store.ShouldAttemptDelivery(ctx, sc.ID, "sink", sinkID, plannedAt) + if err != nil || !shouldAttempt { + continue + } + sink, err := s.store.GetSink(ctx, sinkID) + if err != nil || !sink.Enabled { + continue + } + if len(sink.Events) > 0 && !slices.Contains(sink.Events, sc.Kind) { + continue + } + payload := fmt.Sprintf(`{"todo_id":%q,"title":%q,"kind":%q,"due_at":%q,"planned_at":%q}`, + todo.ID, + todo.Title, + sc.Kind, + todo.DueAt.UTC().Format(time.RFC3339), + plannedAt.UTC().Format(time.RFC3339), + ) + err = postWebhook(ctx, sink.URL, payload) + if err != nil { + _ = s.store.UpsertDeliveryResult(ctx, sc.ID, "sink", sinkID, plannedAt, false, err.Error()) + } else { + _ = s.store.UpsertDeliveryResult(ctx, sc.ID, "sink", sinkID, plannedAt, true, "") + deliveredTargets++ + } + } + + if sc.Kind == model.ScheduleKindUpcoming && totalTargets > 0 && deliveredTargets == totalTargets { + _ = s.store.MarkScheduleSent(ctx, sc.ID) + } + } + return nil +} + +func postWebhook(ctx context.Context, url, payload string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return errors.New("webhook returned non-2xx") + } + return nil +} diff --git a/todo/internal/store/store.go b/todo/internal/store/store.go new file mode 100644 index 0000000..974d7a1 --- /dev/null +++ b/todo/internal/store/store.go @@ -0,0 +1,793 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "slices" + "strings" + "time" + + "todo/internal/model" + + _ "modernc.org/sqlite" +) + +type Store struct { + db *sql.DB +} + +type TodoFilter struct { + State string + DueBefore *time.Time + DueAfter *time.Time + Overdue bool +} + +type ScheduleFilter struct { + TodoID string + Kind string + Status string + Target string +} + +func DefaultDBPath(home string) string { + return filepath.Join(home, ".local", "share", "todo", "todo.db") +} + +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + return nil, err + } + s := &Store{db: db} + if err := s.initSchema(); err != nil { + _ = db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) initSchema() error { + const schema = ` +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + due_at TEXT, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT, + rejected_at TEXT +); + +CREATE TABLE IF NOT EXISTS sinks ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + events_json TEXT NOT NULL, + enabled INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + todo_id INTEGER NOT NULL, + kind TEXT NOT NULL, + before_dur TEXT, + every_dur TEXT, + status TEXT NOT NULL, + target_motd INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(todo_id) REFERENCES todos(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS schedule_sinks ( + schedule_id TEXT NOT NULL, + sink_id TEXT NOT NULL, + PRIMARY KEY (schedule_id, sink_id), + FOREIGN KEY(schedule_id) REFERENCES schedules(id) ON DELETE CASCADE, + FOREIGN KEY(sink_id) REFERENCES sinks(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + schedule_id TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + planned_at TEXT NOT NULL, + sent_at TEXT, + status TEXT NOT NULL, + error TEXT, + next_attempt_at TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + UNIQUE(schedule_id, target_type, target_id, planned_at) +); + +CREATE TABLE IF NOT EXISTS motd_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + todo_id TEXT NOT NULL, + schedule_id TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL, + shown INTEGER NOT NULL DEFAULT 0 +); +` + _, err := s.db.Exec(schema) + return err +} + +func (s *Store) CreateTodo(ctx context.Context, t model.Todo) (int64, error) { + result, err := s.db.ExecContext(ctx, ` +INSERT INTO todos(title, due_at, state, created_at, updated_at, closed_at, rejected_at) +VALUES(?, ?, ?, ?, ?, ?, ?)`, + t.Title, + timePtrToDB(t.DueAt), + t.State, + t.CreatedAt.Format(time.RFC3339), + t.UpdatedAt.Format(time.RFC3339), + timePtrToDB(t.ClosedAt), + timePtrToDB(t.RejectedAt), + ) + if err != nil { + return 0, err + } + id, err := result.LastInsertId() + return id, err +} + +func (s *Store) UpdateTodo(ctx context.Context, id int64, title *string, dueAt *time.Time, clearDue bool) error { + todo, err := s.GetTodo(ctx, id) + if err != nil { + return err + } + if title != nil { + todo.Title = *title + } + if clearDue { + todo.DueAt = nil + } else if dueAt != nil { + todo.DueAt = dueAt + } + todo.UpdatedAt = time.Now().UTC() + _, err = s.db.ExecContext(ctx, ` +UPDATE todos SET title=?, due_at=?, updated_at=? WHERE id=?`, + todo.Title, timePtrToDB(todo.DueAt), todo.UpdatedAt.Format(time.RFC3339), id) + return err +} + +func (s *Store) TransitionTodo(ctx context.Context, id int64, state string) error { + if !slices.Contains([]string{model.TodoStateClosed, model.TodoStateReopened, model.TodoStateRejected}, state) { + return fmt.Errorf("unsupported state transition %q", state) + } + now := time.Now().UTC().Format(time.RFC3339) + closedAt := any(nil) + rejectedAt := any(nil) + if state == model.TodoStateClosed { + closedAt = now + } + if state == model.TodoStateRejected { + rejectedAt = now + } + _, err := s.db.ExecContext(ctx, ` +UPDATE todos +SET state=?, updated_at=?, closed_at=COALESCE(?, closed_at), rejected_at=COALESCE(?, rejected_at) +WHERE id=?`, + state, + now, + closedAt, + rejectedAt, + id, + ) + return err +} + +func (s *Store) GetTodo(ctx context.Context, id int64) (model.Todo, error) { + var row struct { + ID int64 + Title string + DueAt sql.NullString + State string + CreatedAt string + UpdatedAt string + ClosedAt sql.NullString + RejectedAt sql.NullString + } + err := s.db.QueryRowContext(ctx, ` +SELECT id, title, due_at, state, created_at, updated_at, closed_at, rejected_at +FROM todos WHERE id=?`, id).Scan( + &row.ID, + &row.Title, + &row.DueAt, + &row.State, + &row.CreatedAt, + &row.UpdatedAt, + &row.ClosedAt, + &row.RejectedAt, + ) + if err != nil { + return model.Todo{}, err + } + return scanTodo(row.ID, row.Title, row.DueAt, row.State, row.CreatedAt, row.UpdatedAt, row.ClosedAt, row.RejectedAt) +} + +func (s *Store) ListTodos(ctx context.Context, f TodoFilter) ([]model.Todo, error) { + query := `SELECT id, title, due_at, state, created_at, updated_at, closed_at, rejected_at FROM todos WHERE 1=1` + args := make([]any, 0) + if f.State != "" { + query += ` AND state = ?` + args = append(args, f.State) + } + if f.DueBefore != nil { + query += ` AND due_at IS NOT NULL AND due_at < ?` + args = append(args, f.DueBefore.UTC().Format(time.RFC3339)) + } + if f.DueAfter != nil { + query += ` AND due_at IS NOT NULL AND due_at > ?` + args = append(args, f.DueAfter.UTC().Format(time.RFC3339)) + } + if f.Overdue { + query += ` AND due_at IS NOT NULL AND due_at < ?` + args = append(args, time.Now().UTC().Format(time.RFC3339)) + } + query += ` ORDER BY created_at DESC` + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]model.Todo, 0) + for rows.Next() { + var id int64 + var title, state, createdAt, updatedAt string + var dueAt, closedAt, rejectedAt sql.NullString + if err := rows.Scan(&id, &title, &dueAt, &state, &createdAt, &updatedAt, &closedAt, &rejectedAt); err != nil { + return nil, err + } + t, err := scanTodo(id, title, dueAt, state, createdAt, updatedAt, closedAt, rejectedAt) + if err != nil { + return nil, err + } + result = append(result, t) + } + return result, rows.Err() +} + +func (s *Store) CreateSink(ctx context.Context, sink model.Sink) error { + eventsJSON, _ := json.Marshal(sink.Events) + _, err := s.db.ExecContext(ctx, ` +INSERT INTO sinks(id, url, events_json, enabled, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, ?)`, + sink.ID, + sink.URL, + string(eventsJSON), + boolToInt(sink.Enabled), + sink.CreatedAt.UTC().Format(time.RFC3339), + sink.UpdatedAt.UTC().Format(time.RFC3339), + ) + return err +} + +func (s *Store) DeleteSink(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sinks WHERE id=?`, id) + return err +} + +func (s *Store) GetSink(ctx context.Context, id string) (model.Sink, error) { + var sink model.Sink + var eventsJSON string + var enabled int + var createdAt, updatedAt string + err := s.db.QueryRowContext(ctx, ` +SELECT id, url, events_json, enabled, created_at, updated_at FROM sinks WHERE id=?`, id).Scan( + &sink.ID, + &sink.URL, + &eventsJSON, + &enabled, + &createdAt, + &updatedAt, + ) + if err != nil { + return model.Sink{}, err + } + if err := json.Unmarshal([]byte(eventsJSON), &sink.Events); err != nil { + return model.Sink{}, err + } + sink.Enabled = enabled == 1 + if sink.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil { + return model.Sink{}, err + } + if sink.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt); err != nil { + return model.Sink{}, err + } + return sink, nil +} + +func (s *Store) ListSinks(ctx context.Context, enabled *bool, event string) ([]model.Sink, error) { + query := `SELECT id, url, events_json, enabled, created_at, updated_at FROM sinks WHERE 1=1` + args := make([]any, 0) + if enabled != nil { + query += ` AND enabled=?` + args = append(args, boolToInt(*enabled)) + } + query += ` ORDER BY created_at DESC` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]model.Sink, 0) + for rows.Next() { + var sID, url, eventsJSON, createdAt, updatedAt string + var en int + if err := rows.Scan(&sID, &url, &eventsJSON, &en, &createdAt, &updatedAt); err != nil { + return nil, err + } + sink := model.Sink{ID: sID, URL: url, Enabled: en == 1} + if err := json.Unmarshal([]byte(eventsJSON), &sink.Events); err != nil { + return nil, err + } + if event != "" && !slices.Contains(sink.Events, event) { + continue + } + if sink.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil { + return nil, err + } + if sink.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt); err != nil { + return nil, err + } + out = append(out, sink) + } + return out, rows.Err() +} + +func (s *Store) CreateSchedule(ctx context.Context, sc model.Schedule) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + _, err = tx.ExecContext(ctx, ` +INSERT INTO schedules(id, todo_id, kind, before_dur, every_dur, status, target_motd, created_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?)`, + sc.ID, + sc.TodoID, + sc.Kind, + nullIfEmpty(sc.Before), + nullIfEmpty(sc.Every), + sc.Status, + boolToInt(sc.TargetMOTD), + sc.CreatedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return err + } + for _, sinkID := range sc.SinkIDs { + _, err = tx.ExecContext(ctx, `INSERT INTO schedule_sinks(schedule_id, sink_id) VALUES(?, ?)`, sc.ID, sinkID) + if err != nil { + return err + } + } + err = tx.Commit() + return err +} + +func (s *Store) DeleteSchedule(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id=?`, id) + return err +} + +func (s *Store) GetSchedule(ctx context.Context, id string) (model.Schedule, error) { + var sc model.Schedule + var before, every sql.NullString + var targetMOTD int + var createdAt string + err := s.db.QueryRowContext(ctx, ` +SELECT id, todo_id, kind, before_dur, every_dur, status, target_motd, created_at +FROM schedules WHERE id=?`, id).Scan( + &sc.ID, + &sc.TodoID, + &sc.Kind, + &before, + &every, + &sc.Status, + &targetMOTD, + &createdAt, + ) + if err != nil { + return model.Schedule{}, err + } + if before.Valid { + sc.Before = before.String + } + if every.Valid { + sc.Every = every.String + } + sc.TargetMOTD = targetMOTD == 1 + if sc.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil { + return model.Schedule{}, err + } + sc.SinkIDs, err = s.listScheduleSinks(ctx, sc.ID) + if err != nil { + return model.Schedule{}, err + } + return sc, nil +} + +func (s *Store) ListSchedules(ctx context.Context, f ScheduleFilter) ([]model.Schedule, error) { + query := `SELECT id, todo_id, kind, before_dur, every_dur, status, target_motd, created_at FROM schedules WHERE 1=1` + args := make([]any, 0) + if f.TodoID != "" { + query += ` AND todo_id=?` + args = append(args, f.TodoID) + } + if f.Kind != "" { + query += ` AND kind=?` + args = append(args, f.Kind) + } + if f.Status != "" { + query += ` AND status=?` + args = append(args, f.Status) + } + if f.Target == "motd" { + query += ` AND target_motd=1` + } + if f.Target == "sink" { + query += ` AND id IN (SELECT DISTINCT schedule_id FROM schedule_sinks)` + } + query += ` ORDER BY created_at DESC` + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]model.Schedule, 0) + for rows.Next() { + var id, todoID, kind, status, createdAt string + var before, every sql.NullString + var targetMOTD int + if err := rows.Scan(&id, &todoID, &kind, &before, &every, &status, &targetMOTD, &createdAt); err != nil { + return nil, err + } + sc := model.Schedule{ID: id, TodoID: todoID, Kind: kind, Status: status, TargetMOTD: targetMOTD == 1} + if before.Valid { + sc.Before = before.String + } + if every.Valid { + sc.Every = every.String + } + if sc.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil { + return nil, err + } + sc.SinkIDs, err = s.listScheduleSinks(ctx, id) + if err != nil { + return nil, err + } + out = append(out, sc) + } + return out, rows.Err() +} + +func (s *Store) listScheduleSinks(ctx context.Context, scheduleID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT sink_id FROM schedule_sinks WHERE schedule_id=?`, scheduleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]string, 0) + for rows.Next() { + var sinkID string + if err := rows.Scan(&sinkID); err != nil { + return nil, err + } + out = append(out, sinkID) + } + return out, rows.Err() +} + +func (s *Store) QueueMOTDMessage(ctx context.Context, todoID, scheduleID, message string) error { + _, err := s.db.ExecContext(ctx, ` +INSERT INTO motd_messages(todo_id, schedule_id, message, created_at, shown) +VALUES(?, ?, ?, ?, 0) +`, todoID, scheduleID, message, time.Now().UTC().Format(time.RFC3339)) + return err +} + +func (s *Store) PullMOTDMessages(ctx context.Context) ([]string, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, `SELECT id, message FROM motd_messages WHERE shown=0 ORDER BY created_at ASC`) + if err != nil { + _ = tx.Rollback() + return nil, err + } + defer rows.Close() + ids := make([]int64, 0) + msgs := make([]string, 0) + for rows.Next() { + var id int64 + var m string + if err := rows.Scan(&id, &m); err != nil { + _ = tx.Rollback() + return nil, err + } + ids = append(ids, id) + msgs = append(msgs, m) + } + if err := rows.Err(); err != nil { + _ = tx.Rollback() + return nil, err + } + for _, id := range ids { + if _, err := tx.ExecContext(ctx, `UPDATE motd_messages SET shown=1 WHERE id=?`, id); err != nil { + _ = tx.Rollback() + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return msgs, nil +} + +type DeliveryRecord struct { + ScheduleID string + TargetType string + TargetID string + PlannedAt time.Time + SentAt *time.Time + Status string + Error string + NextAttemptAt *time.Time + Attempts int +} + +func (s *Store) GetDelivery(ctx context.Context, scheduleID, targetType, targetID string, plannedAt time.Time) (DeliveryRecord, error) { + var rec DeliveryRecord + var sentAt, nextAttemptAt sql.NullString + err := s.db.QueryRowContext(ctx, ` +SELECT schedule_id, target_type, target_id, planned_at, sent_at, status, error, next_attempt_at, attempts +FROM deliveries WHERE schedule_id=? AND target_type=? AND target_id=? AND planned_at=?`, + scheduleID, + targetType, + targetID, + plannedAt.UTC().Format(time.RFC3339), + ).Scan( + &rec.ScheduleID, + &rec.TargetType, + &rec.TargetID, + &rec.PlannedAt, + &sentAt, + &rec.Status, + &rec.Error, + &nextAttemptAt, + &rec.Attempts, + ) + if err != nil { + return DeliveryRecord{}, err + } + if sentAt.Valid { + t, err := time.Parse(time.RFC3339, sentAt.String) + if err == nil { + rec.SentAt = &t + } + } + if nextAttemptAt.Valid { + t, err := time.Parse(time.RFC3339, nextAttemptAt.String) + if err == nil { + rec.NextAttemptAt = &t + } + } + return rec, nil +} + +func (s *Store) UpsertDeliveryResult(ctx context.Context, scheduleID, targetType, targetID string, plannedAt time.Time, success bool, errText string) error { + status := "failed" + var sentAt any + var nextAttempt any + attempts := 1 + if success { + status = "sent" + now := time.Now().UTC() + sentAt = now.Format(time.RFC3339) + nextAttempt = nil + errText = "" + } else { + prev, err := s.GetDelivery(ctx, scheduleID, targetType, targetID, plannedAt) + if err == nil { + attempts = prev.Attempts + 1 + } + backoff := time.Minute * time.Duration(minInt(60, 1<<minInt(6, attempts))) + nextAttempt = time.Now().UTC().Add(backoff).Format(time.RFC3339) + } + + _, err := s.db.ExecContext(ctx, ` +INSERT INTO deliveries(schedule_id, target_type, target_id, planned_at, sent_at, status, error, next_attempt_at, attempts) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(schedule_id, target_type, target_id, planned_at) +DO UPDATE SET sent_at=excluded.sent_at, status=excluded.status, error=excluded.error, next_attempt_at=excluded.next_attempt_at, attempts=excluded.attempts +`, scheduleID, targetType, targetID, plannedAt.UTC().Format(time.RFC3339), sentAt, status, errText, nextAttempt, attempts) + return err +} + +func (s *Store) IsTargetDelivered(ctx context.Context, scheduleID, targetType, targetID string, plannedAt time.Time) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, ` +SELECT COUNT(1) FROM deliveries WHERE schedule_id=? AND target_type=? AND target_id=? AND planned_at=? AND status='sent'`, + scheduleID, targetType, targetID, plannedAt.UTC().Format(time.RFC3339)).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func (s *Store) ShouldAttemptDelivery(ctx context.Context, scheduleID, targetType, targetID string, plannedAt time.Time) (bool, error) { + var status string + var nextAttemptAt sql.NullString + err := s.db.QueryRowContext(ctx, ` +SELECT status, next_attempt_at FROM deliveries WHERE schedule_id=? AND target_type=? AND target_id=? AND planned_at=?`, + scheduleID, targetType, targetID, plannedAt.UTC().Format(time.RFC3339)).Scan(&status, &nextAttemptAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return true, nil + } + return false, err + } + if status == "sent" { + return false, nil + } + if !nextAttemptAt.Valid { + return true, nil + } + nextAt, err := time.Parse(time.RFC3339, nextAttemptAt.String) + if err != nil { + return true, nil + } + return !time.Now().UTC().Before(nextAt), nil +} + +func (s *Store) MarkScheduleSent(ctx context.Context, scheduleID string) error { + _, err := s.db.ExecContext(ctx, `UPDATE schedules SET status=? WHERE id=?`, model.ScheduleStatusSent, scheduleID) + return err +} + +func (s *Store) listActiveSchedulesWithTodos(ctx context.Context) ([]model.Schedule, []model.Todo, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT s.id, s.todo_id, s.kind, s.before_dur, s.every_dur, s.status, s.target_motd, s.created_at, + t.id, t.title, t.due_at, t.state, t.created_at, t.updated_at, t.closed_at, t.rejected_at +FROM schedules s +JOIN todos t ON t.id=s.todo_id +WHERE s.status=? AND t.due_at IS NOT NULL +`, model.ScheduleStatusActive) + if err != nil { + return nil, nil, err + } + defer rows.Close() + schedules := make([]model.Schedule, 0) + todos := make([]model.Todo, 0) + for rows.Next() { + var sc model.Schedule + var before, every sql.NullString + var targetMOTD int + var createdAt string + var todoID int64 + var title, state, todoCreated, todoUpdated string + var dueAt, closedAt, rejectedAt sql.NullString + if err := rows.Scan( + &sc.ID, &sc.TodoID, &sc.Kind, &before, &every, &sc.Status, &targetMOTD, &createdAt, + &todoID, &title, &dueAt, &state, &todoCreated, &todoUpdated, &closedAt, &rejectedAt, + ); err != nil { + return nil, nil, err + } + if before.Valid { + sc.Before = before.String + } + if every.Valid { + sc.Every = every.String + } + sc.TargetMOTD = targetMOTD == 1 + if sc.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil { + return nil, nil, err + } + sc.SinkIDs, err = s.listScheduleSinks(ctx, sc.ID) + if err != nil { + return nil, nil, err + } + todo, err := scanTodo(todoID, title, dueAt, state, todoCreated, todoUpdated, closedAt, rejectedAt) + if err != nil { + return nil, nil, err + } + schedules = append(schedules, sc) + todos = append(todos, todo) + } + return schedules, todos, rows.Err() +} + +func (s *Store) ActiveSchedulesWithTodos(ctx context.Context) ([]model.Schedule, []model.Todo, error) { + return s.listActiveSchedulesWithTodos(ctx) +} + +func scanTodo(id int64, title string, dueAt sql.NullString, state, createdAt, updatedAt string, closedAt, rejectedAt sql.NullString) (model.Todo, error) { + var t model.Todo + t.ID = id + t.Title = title + t.State = state + var err error + if t.CreatedAt, err = time.Parse(time.RFC3339, createdAt); err != nil { + return model.Todo{}, err + } + if t.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt); err != nil { + return model.Todo{}, err + } + if dueAt.Valid { + d, err := time.Parse(time.RFC3339, dueAt.String) + if err != nil { + return model.Todo{}, err + } + t.DueAt = &d + } + if closedAt.Valid { + d, err := time.Parse(time.RFC3339, closedAt.String) + if err != nil { + return model.Todo{}, err + } + t.ClosedAt = &d + } + if rejectedAt.Valid { + d, err := time.Parse(time.RFC3339, rejectedAt.String) + if err != nil { + return model.Todo{}, err + } + t.RejectedAt = &d + } + return t, nil +} + +func timePtrToDB(t *time.Time) any { + if t == nil { + return nil + } + return t.UTC().Format(time.RFC3339) +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func nullIfEmpty(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/workshop.yaml b/workshop.yaml index 2f02e30..e60b80a 100644 --- a/workshop.yaml +++ b/workshop.yaml @@ -23,6 +23,8 @@ sdks: channel: latest/stable - name: pi-coding-agent channel: latest/edge + - name: go + channel: latest/stable - name: node channel: latest/stable - name: uv