diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..9257e14 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,34 @@ +# https://golangci-lint.run/usage/linters/ +# The goal here is to: +# (a) reduce bugs +# (b) make code more readable to make code reviews faster +# (c) speed up development by catching some things that would otherwise be caught by the compiler +# (d) prefer automatically fixing code +version: "2" +linters: + default: all + enable: + - wsl_v5 + disable: + - wsl + - depguard + - noinlineerr + - exhaustruct + - testpackage + - varnamelen + settings: + funlen: + lines: 100 + gocognit: + min-complexity: 50 + cyclop: + max-complexity: 15 + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 + tagliatelle: + case: + rules: + json: snake + yaml: snake \ No newline at end of file diff --git a/README.md b/README.md index a35fd42..876b2c8 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ The tool assembles context into a structured prompt with the following component - **Task-Specific Prompts**: Use different prompts for different tasks (e.g., `feature`, `bugfix`). - **Rule-Based Context**: Define reusable context snippets (rules) that can be included or excluded. - **Skills System**: Progressive disclosure of specialized capabilities via skill directories. +- **Namespaces**: Isolate multiple teams' assets under `.agents/namespaces//` while sharing a global layer. - **Frontmatter Filtering**: Select rules based on metadata using frontmatter selectors (matches top-level YAML fields only). - **Bootstrap Scripts**: Run scripts to fetch or generate context dynamically. - **Parameter Substitution**: Inject values into your task prompts. @@ -66,14 +67,14 @@ This tool is compatible with configuration files from various AI coding agents a ### Primary Supported Agents (with dedicated `-a` flag) -- **[GitHub Copilot](https://github.com/features/copilot)**: `.github/copilot-instructions.md`, `.github/agents` (`-a copilot`) -- **[Anthropic Claude](https://claude.ai/)**: `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` (`-a claude`) -- **[Cursor](https://cursor.sh/)**: `.cursor/rules`, `.cursorrules` (`-a cursor`) -- **[Google Gemini](https://gemini.google.com/)**: `GEMINI.md`, `.gemini/styleguide.md` (`-a gemini`) -- **[Augment](https://augmentcode.com/)**: `.augment/rules`, `.augment/guidelines.md` (`-a augment`) -- **[Windsurf](https://codeium.com/windsurf)**: `.windsurf/rules`, `.windsurfrules` (`-a windsurf`) -- **[OpenCode.ai](https://opencode.ai/)**: `.opencode/agent`, `.opencode/command`, `.opencode/rules` (`-a opencode`) -- **[Codex](https://codex.ai/)**: `AGENTS.md`, `.codex/AGENTS.md` (`-a codex`) +- **[GitHub Copilot](https://github.com/features/copilot)**: `.github/copilot-instructions.md`, `.github/agents/` (`-a copilot`) +- **[Anthropic Claude](https://claude.ai/)**: `CLAUDE.md`, `CLAUDE.local.md`, `.claude/` (directory) (`-a claude`) +- **[Cursor](https://cursor.sh/)**: `.cursor/rules/`, `.cursorrules` (`-a cursor`) +- **[Google Gemini](https://gemini.google.com/)**: `GEMINI.md`, `.gemini/styleguide.md`, `.gemini/` (directory) (`-a gemini`) +- **[Augment](https://augmentcode.com/)**: `.augment/rules/`, `.augment/guidelines.md` (`-a augment`) +- **[Windsurf](https://codeium.com/windsurf)**: `.windsurf/rules/`, `.windsurfrules` (`-a windsurf`) +- **[OpenCode.ai](https://opencode.ai/)**: `.opencode/agent/`, `.opencode/rules/` (rules); `.opencode/command/` (commands) (`-a opencode`) +- **[Codex](https://codex.ai/)**: `AGENTS.md`, `.codex/` (directory) (`-a codex`) ### Additional Compatible Agents @@ -158,13 +159,15 @@ Options: Go Getter URL to a manifest file containing search paths (one per line). Every line is included as-is. -p value Parameter to substitute in the prompt. Can be specified multiple times as key=value. - -r Resume mode: skip outputting rules and select task with 'resume: true' in frontmatter. + -r Resume mode: set 'resume=true' selector to filter tasks by their frontmatter resume field. Does not skip rules; use --skip-bootstrap to skip rule discovery. -s value Include rules with matching frontmatter. Can be specified multiple times as key=value. Note: Only matches top-level YAML fields in frontmatter. -a string Target agent to use. Required when using -w to write rules to the agent's user rules path. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex. -w Write rules to agent's config file and output only task to stdout. Requires agent (via task or -a flag). + --skip-bootstrap + Skip discovering rules, skills, and running bootstrap scripts. ``` ### Examples @@ -325,6 +328,57 @@ coding-context \ /implement-feature ``` +## Namespaces + +Namespaces let multiple teams share a single `.agents/` directory without conflicts. Select a namespace by prefixing the task name with `namespace/`: + +```bash +# Global task (existing behaviour) +coding-context fix-bug + +# Namespaced task — activates the "myteam" namespace +coding-context myteam/fix-bug +``` + +**Directory structure:** + +``` +.agents/ +├── tasks/ # Global tasks +├── rules/ # Global rules (always included) +├── commands/ # Global commands +├── skills/ # Global skills +└── namespaces/ # Namespace root (new) + ├── myteam/ + │ ├── tasks/ # Tasks accessed as "myteam/" + │ ├── rules/ # Namespace rules (included first) + │ ├── commands/ # Override global commands + │ └── skills/ + └── otherteam/ + └── ... +``` + +**Resolution rules:** +- **Tasks**: Namespace directory first, falls back to global if not found +- **Rules**: Namespace rules included first, then all global rules (both always included) +- **Commands**: Namespace command wins over global command with the same name +- **Skills**: Namespace and global skills both discovered; namespace listed first + +**Scoping rules to a namespace** — add `namespace: myteam` to a rule's frontmatter: + +```markdown +--- +namespace: myteam +--- +# myteam Internal Standards + +Only included for myteam/* tasks. +``` + +Rules with no `namespace` field are always included regardless of namespace. + +For a complete guide, see [How to Use Namespaces](https://kitproj.github.io/coding-context-cli/how-to/use-namespaces). + ## File Formats ### Task Files @@ -438,23 +492,23 @@ The `expand` field works in: ### Resume Mode -Resume mode is designed for continuing work on a task where you've already established context. When using the `-r` flag: +Resume mode is designed for continuing work on a task where you've already established context. The `-r` flag adds a `resume=true` selector, which filters tasks to those with `resume: true` in their frontmatter. -1. **Rules are skipped**: All rule files are excluded from output, saving tokens and reducing context size -2. **Resume-specific task prompts are selected**: Automatically adds `-s resume=true` selector to find task files with `resume: true` in their frontmatter +**What the `-r` flag does:** +- Adds `-s resume=true` selector to find task files with `resume: true` in their frontmatter -This is particularly useful in agentic workflows where an AI agent has already been primed with rules and is continuing work from a previous session. - -**The `-r` flag is shorthand for:** -- Adding `-s resume=true` selector -- Skipping all rules output +**What the `-r` flag does NOT do:** +- It does **not** skip rules or bootstrap scripts. To skip those, use `--skip-bootstrap`. **Example usage:** ```bash -# Initial task invocation (includes all rules, uses task with resume: false) +# Initial task invocation (includes all rules and bootstrap) coding-context -s resume=false fix-bug | ai-agent -# Resume the task (skips rules, uses task with resume: true) +# Resume the task with rules skipped (uses resume task, skips rules and bootstrap) +coding-context -r --skip-bootstrap fix-bug | ai-agent + +# Resume the task keeping rules (just selects the resume variant of the task) coding-context -r fix-bug | ai-agent ``` @@ -482,7 +536,7 @@ Continue working on the bug fix. Review your previous work and complete remaining tasks. ``` -With this approach, you can have multiple task prompts for the same task name, differentiated by the `resume` frontmatter field. Use `-s resume=false` to select the initial task (with rules), or `-r` to select the resume task (without rules). +With this approach, you can have multiple task prompts for the same task name, differentiated by the `resume` frontmatter field. Use `-s resume=false` to select the initial task, or `-r` to select the resume task. Combine with `--skip-bootstrap` to also skip rules. ### Rule Files diff --git a/docs/how-to/create-tasks.md b/docs/how-to/create-tasks.md index d979d43..0c58083 100644 --- a/docs/how-to/create-tasks.md +++ b/docs/how-to/create-tasks.md @@ -186,6 +186,24 @@ coding-context implement-feature The frontmatter (with `selectors`, `languages`, etc.) is parsed and used to filter rules and control behavior, but it does not appear in the final output sent to the AI agent. +## Namespaced Tasks + +If your project uses namespaces (multiple teams sharing `.agents/`), create team-specific tasks under `.agents/namespaces//tasks/`: + +``` +.agents/namespaces/myteam/tasks/build.md +``` + +Invoke with the `team/task` format: + +```bash +coding-context myteam/build +``` + +If the task file doesn't exist in the namespace directory, the tool automatically falls back to `.agents/tasks/build.md`. + +See [How to Use Namespaces](./use-namespaces) for a full guide including rules, commands, and skills. + ## Best Practices 1. **Use descriptive task names**: Make them clear and specific diff --git a/docs/how-to/index.md b/docs/how-to/index.md index aca8483..d113c46 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -17,6 +17,7 @@ These guides are problem-oriented and help you achieve specific goals. - [Create Rule Files](./create-rules) - Provide reusable context - [Create Skills](./create-skills) - Organize specialized capabilities with progressive disclosure - [Use Frontmatter Selectors](./use-selectors) - Filter rules and tasks +- [Use Namespaces](./use-namespaces) - Isolate team assets in a shared repository - [Use Remote Directories](./use-remote-directories) - Load rules from Git, HTTP, or S3 - [Use with AI Agents](./use-with-ai-agents) - Integrate with various AI tools - [Integrate with GitHub Actions](./github-actions) - Automate with CI/CD diff --git a/docs/how-to/use-namespaces.md b/docs/how-to/use-namespaces.md new file mode 100644 index 0000000..299fa40 --- /dev/null +++ b/docs/how-to/use-namespaces.md @@ -0,0 +1,227 @@ +--- +layout: default +title: Use Namespaces +parent: How-to Guides +nav_order: 4 +--- + +# How to Use Namespaces + +Namespaces let multiple teams share a single `.agents/` directory without conflicts. Each team's tasks, rules, commands, and skills live under their own subdirectory and take precedence over global assets, while still inheriting the shared global layer. + +## When to Use Namespaces + +Use namespaces when: +- Multiple teams share a repository and each needs distinct rules, bootstraps, or tasks +- You want to prevent one team's selectors or context from leaking into another's +- A team needs to override a global command or skill with their own version + +If you're a single team, namespaces are not required — the global `.agents/` layout works as-is. + +## Directory Structure + +``` +.agents/ +├── tasks/ # Global tasks (no namespace) +│ └── fix-bug.md +├── rules/ # Global rules (always included) +│ └── coding-standards.md +├── commands/ # Global commands +│ └── deploy.md +├── skills/ # Global skills +│ └── data-analysis/ +│ └── SKILL.md +└── namespaces/ # Namespace root + ├── myteam/ + │ ├── tasks/ # Tasks accessed as "myteam/" + │ │ └── build.md + │ ├── rules/ # Namespace-specific rules (included first) + │ │ └── team-rules.md + │ ├── commands/ # Can override global commands + │ │ └── deploy.md # Shadows the global deploy command + │ └── skills/ + │ └── special-tool/ + │ └── SKILL.md + └── otherteam/ + ├── tasks/ + ├── rules/ + ├── commands/ + └── skills/ +``` + +Only the generic `.agents/` structure supports namespacing. Agent-specific paths (`.cursor/`, `.claude/`, `.github/`, etc.) are not namespaced. + +## Task Name Format + +Select a namespace by using `namespace/taskname` as the task argument: + +| Task argument | Namespace | Base task name | +|---|---|---| +| `fix-bug` | _(none — global)_ | `fix-bug` | +| `myteam/fix-bug` | `myteam` | `fix-bug` | + +Only a single level of namespacing is supported. `myteam/fix-bug` is valid; `myteam/subteam/fix-bug` is an error. + +```bash +# Global task (existing behaviour, unchanged) +coding-context fix-bug + +# Namespaced task +coding-context myteam/fix-bug + +# Namespaced task with parameters +coding-context -p issue=BUG-123 myteam/fix-bug +``` + +## Resolution Rules + +| Asset | No namespace | With namespace | +|---|---|---| +| **Task** | `.agents/tasks/.md` | `.agents/namespaces//tasks/.md` first; falls back to global | +| **Rules** | `.agents/rules/` only | Namespace rules **first**, then **all** global rules (both always included) | +| **Commands** | `.agents/commands/` | Namespace commands searched first; first match wins | +| **Skills** | `.agents/skills/` | Both namespace and global skills discovered; namespace skills listed first | + +## Quick Start + +### 1. Create the namespace directory structure + +```bash +mkdir -p .agents/namespaces/myteam/{tasks,rules,commands,skills} +``` + +### 2. Create a namespaced task + +```markdown +# .agents/namespaces/myteam/tasks/build.md + +Build the myteam service using our internal pipeline. +``` + +### 3. Create a namespace-specific rule (optional) + +```markdown +# .agents/namespaces/myteam/rules/team-standards.md +--- +name: myteam-standards +--- + +# myteam Coding Standards + +Always prefix internal service calls with `svc.`. +``` + +### 4. Run the namespaced task + +```bash +coding-context myteam/build +``` + +The assembled context will include: +1. Rules from `.agents/namespaces/myteam/rules/` (namespace rules, first) +2. Rules from `.agents/rules/` (global rules, always included) +3. Skills from both namespace and global directories +4. The task from `.agents/namespaces/myteam/tasks/build.md` + +## Falling Back to Global Tasks + +If a task doesn't exist in the namespace directory, the tool falls back to the global task directory automatically: + +``` +.agents/tasks/common-task.md # global task +.agents/namespaces/myteam/tasks/ # no common-task.md here + +$ coding-context myteam/common-task # resolves to .agents/tasks/common-task.md +``` + +This allows namespaces to selectively override tasks without having to duplicate every task. + +## Overriding Global Commands + +A namespace command with the same name as a global command takes precedence: + +``` +.agents/commands/deploy.md # global deploy +.agents/namespaces/myteam/commands/deploy.md # myteam deploy (overrides global) + +# When running any myteam/* task, /deploy expands the myteam version +``` + +## Scoping Rules to a Namespace + +Global rules are always included for namespaced tasks. If a global rule should only apply to a specific namespace, add `namespace: ` to its frontmatter using the existing selector system: + +```markdown +--- +# .agents/rules/myteam-only-rule.md +namespace: myteam +--- + +# myteam Internal Requirements + +Only apply this rule for myteam tasks. +``` + +This rule will be included when running `myteam/*` tasks and excluded for all other namespaces or global tasks, because the `namespace` selector is automatically set based on the task name. + +Rules with **no** `namespace` frontmatter field are always included regardless of namespace. + +## The `namespace` Selector + +When a namespaced task is run, the tool automatically injects `namespace=` into the selector set. This means: + +- Rules with `namespace: myteam` in frontmatter are included only for `myteam/*` tasks +- Rules with no `namespace` field are always included (the existing behaviour) +- For non-namespaced tasks (e.g., `fix-bug`), the selector is set to `namespace=""`, so rules with any explicit `namespace:` value are excluded + +You can observe this with the lint command: + +```bash +# No namespace selector errors +coding-context lint myteam/build +``` + +## Team Isolation Example + +Two teams sharing a repository with no overlap: + +``` +.agents/ +├── rules/ +│ └── company-wide.md # Always included for everyone +└── namespaces/ + ├── backend/ + │ ├── tasks/ + │ │ └── deploy-service.md + │ └── rules/ + │ └── go-standards.md # Only included for backend/* tasks + └── frontend/ + ├── tasks/ + │ └── deploy-app.md + └── rules/ + └── ts-standards.md # Only included for frontend/* tasks +``` + +```bash +# Backend team runs their tasks — gets company-wide.md + go-standards.md +coding-context backend/deploy-service + +# Frontend team runs their tasks — gets company-wide.md + ts-standards.md +coding-context frontend/deploy-app +``` + +If backend wanted to restrict their rule further to _only_ backend tasks (in case the file path isn't already enough), they could add `namespace: backend` to the frontmatter. + +## Error Cases + +| Input | Error | +|---|---| +| `myteam/subteam/build` | Only one level of namespacing is supported | +| `/build` | Namespace must not be empty | +| `myteam/` | Task base name must not be empty | + +## See Also + +- [Create Task Files](./create-tasks) - General task file documentation +- [Use Frontmatter Selectors](./use-selectors) - How selectors work, including the `namespace` selector +- [Search Paths Reference](../reference/search-paths) - Complete path resolution reference diff --git a/docs/how-to/use-selectors.md b/docs/how-to/use-selectors.md index 24d2e1d..eed71c1 100644 --- a/docs/how-to/use-selectors.md +++ b/docs/how-to/use-selectors.md @@ -111,6 +111,33 @@ coding-context -s source=jira fix-bug coding-context -s source=github code-review ``` +## Namespace Selector (Automatic) + +When you run a namespaced task (e.g., `myteam/fix-bug`), the tool automatically adds `namespace=myteam` to the active selectors. You don't need to pass `-s namespace=myteam` manually. + +This means you can scope any rule to a specific namespace by adding `namespace:` to its frontmatter: + +```markdown +--- +namespace: myteam +--- + +# myteam Internal Standards + +Only included when running myteam/* tasks. +``` + +For non-namespaced tasks (e.g., `fix-bug`), the namespace selector is set to the empty string, so rules that declare a specific `namespace:` value are automatically excluded. + +Rules with **no** `namespace` field are always included regardless of the task's namespace. + +```bash +# namespace=myteam is auto-injected — no -s flag needed +coding-context myteam/fix-bug +``` + +See [How to Use Namespaces](./use-namespaces) for a complete guide. + ## Resume Mode The `-r` flag sets the resume selector to "true", which can be used to filter tasks by their frontmatter `resume` field: diff --git a/docs/index.md b/docs/index.md index f293b68..9784de2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,7 @@ Practical guides to solve specific problems: - [Create Rule Files](./how-to/create-rules) - Provide reusable context - [Create Skills](./how-to/create-skills) - Organize specialized capabilities - [Use Frontmatter Selectors](./how-to/use-selectors) - Filter rules and tasks +- [Use Namespaces](./how-to/use-namespaces) - Isolate team assets in a shared repository - [Use Remote Directories](./how-to/use-remote-directories) - Load rules from Git, HTTP, or S3 - [Use with AI Agents](./how-to/use-with-ai-agents) - Integrate with various AI tools - [Integrate with GitHub Actions](./how-to/github-actions) - Automate with CI/CD @@ -72,6 +73,7 @@ Conceptual guides to deepen your understanding: - **Task-Specific Prompts**: Different prompts for different tasks - **Rule-Based Context**: Reusable context snippets - **Skills System**: Progressive disclosure of specialized capabilities +- **Namespaces**: Isolate multiple teams' assets in a shared `.agents/` directory - **Frontmatter Filtering**: Select rules based on metadata - **Bootstrap Scripts**: Fetch or generate context dynamically - **Parameter Substitution**: Inject runtime values diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4c53b3a..18ebfd1 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -344,6 +344,10 @@ Skills metadata (when present) is output as XML after rules and before the task ```markdown # Rule content here... +# Skills + +You have access to the following skills. Skills are specialized capabilities that provide domain expertise, workflows, and procedural knowledge. When a task matches a skill's description, you can load the full skill content by reading the SKILL.md file at the location provided. + data-analysis diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 54d223a..611888c 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -1251,7 +1251,7 @@ If multiple task files have the same filename (without `.md` extension) in diffe ### Rules Without Frontmatter -Rules without frontmatter are always included (unless resume mode is active). +Rules without frontmatter are always included (unless filtered out by selectors or `--skip-bootstrap`). ```markdown # General Standards @@ -1263,15 +1263,18 @@ This rule is included in every context assembly. ### Resume Mode Special Handling -The `-r` flag: -1. Skips all rule file output -2. Adds implicit `-s resume=true` selector +The `-r` flag adds an implicit `-s resume=true` selector to filter tasks by their `resume` frontmatter field. It does **not** skip rule discovery or bootstrap scripts. + +To skip rules and bootstrap, use `--skip-bootstrap`. **Equivalent commands:** ```bash -# These are NOT exactly equivalent: -coding-context -r fix-bug # Skips rules -coding-context -s resume=true fix-bug # Includes rules +# These ARE equivalent (both include rules): +coding-context -r fix-bug +coding-context -s resume=true fix-bug + +# To skip rules, use --skip-bootstrap (independent of -r): +coding-context -r --skip-bootstrap fix-bug ``` ## Validation diff --git a/docs/reference/search-paths.md b/docs/reference/search-paths.md index e44b2a0..b5f4785 100644 --- a/docs/reference/search-paths.md +++ b/docs/reference/search-paths.md @@ -23,6 +23,8 @@ Within each directory, task files are searched in the following locations: **Note:** Task files are matched by filename (without `.md` extension), not by `task_name` in frontmatter. +**Namespaced tasks:** Use `namespace/taskname` syntax (e.g., `myteam/fix-bug`) to search `.agents/namespaces/myteam/tasks/` first, falling back to `.agents/tasks/`. See [Namespace Search Paths](#namespace-search-paths) below. + ### Command File Search Paths (for slash commands) Command files are referenced via slash commands inside task content. Within each directory, command files are searched in: @@ -123,6 +125,61 @@ coding-context plan-feature → Uses ~/.agents/tasks/plan-feature.md (from **Note:** The working directory and home directory are automatically added to search paths, so tasks in those locations are found automatically. +## Namespace Search Paths + +When a task name contains a `/` (e.g., `myteam/fix-bug`), the part before the slash is treated as a namespace. Namespace paths are always searched **before** their global equivalents so that namespace assets take precedence. + +### Namespace Directory Root + +``` +.agents/namespaces// +├── tasks/ +├── rules/ +├── commands/ +└── skills/ +``` + +### Namespace Path Resolution Order + +| Asset | Search order | +|---|---| +| **Tasks** | `.agents/namespaces//tasks/` → `.agents/tasks/` | +| **Rules** | `.agents/namespaces//rules/` then **all** global rule paths (both layers always included) | +| **Commands** | `.agents/namespaces//commands/` → global command paths (first match wins) | +| **Skills** | `.agents/namespaces//skills/` + all global skill paths (all included; namespace listed first) | + +**Non-namespaced tasks** (e.g., `fix-bug`) use only the standard global paths — no namespace layer is consulted. + +Only the generic `.agents/` structure supports namespacing. Agent-specific paths (`.cursor/`, `.claude/`, `.github/`, etc.) are unaffected. + +### Namespace Example + +``` +.agents/ +├── tasks/ +│ └── fix-bug.md # global task +├── rules/ +│ └── company-standards.md # global rule, always included +└── namespaces/ + └── myteam/ + ├── tasks/ + │ └── build.md # accessed via "myteam/build" + ├── rules/ + │ └── team-rules.md # included first, before global rules + └── commands/ + └── deploy.md # overrides any global "deploy" command + +Commands: +coding-context myteam/build + Tasks searched: .agents/namespaces/myteam/tasks/ then .agents/tasks/ + Rules included: .agents/namespaces/myteam/rules/ + .agents/rules/ (both) + Commands: .agents/namespaces/myteam/commands/ then .agents/commands/ +``` + +See [How to Use Namespaces](../how-to/use-namespaces) for a full guide. + +--- + ## Rule File Search Paths Rule files are discovered from directories specified via the `-d` flag (plus automatically-added working directory and home directory). Within each directory, the CLI searches for all standard file patterns listed below. @@ -135,57 +192,50 @@ Rule files are discovered from directories specified via the `-d` flag (plus aut ### Rule File Locations Within Each Directory -**Agent-specific directories:** +**Agent-specific directories (all `.md`/`.mdc` files within these are indexed):** ``` .agents/rules/ .cursor/rules/ .augment/rules/ .windsurf/rules/ .opencode/agent/ +.opencode/rules/ .github/agents/ +.claude/ +.codex/ +.gemini/ ``` **Specific files:** ``` CLAUDE.local.md -.github/copilot-instructions.md -.gemini/styleguide.md -.augment/guidelines.md -``` - -**Standard files:** -``` -AGENTS.md CLAUDE.md GEMINI.md +AGENTS.md .cursorrules .windsurfrules +.github/copilot-instructions.md +.gemini/styleguide.md +.augment/guidelines.md ``` -**User-specific locations (only in home directory):** -``` -.agents/rules/ -.claude/CLAUDE.md -.codex/AGENTS.md -.gemini/GEMINI.md -.opencode/rules/ -``` +**Note:** All paths are searched in every search-path directory (working dir, home dir, and any `-d` directories). There is no distinction between project-level and user-level paths — the same set of sub-paths is checked in each root directory. ## Supported AI Agent Formats The CLI automatically discovers rules from configuration files for these AI coding agents: -| Agent | File Locations | +| Agent | Rule Locations | |-------|----------------| -| **Anthropic Claude** | `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` | -| **Codex** | `AGENTS.md`, `.codex/AGENTS.md` | -| **Cursor** | `.cursor/rules/`, `.cursorrules`, `.cursor/commands/` (commands, not tasks) | +| **Anthropic Claude** | `CLAUDE.md`, `CLAUDE.local.md`, `.claude/` (all `.md`/`.mdc` files in directory) | +| **Codex** | `AGENTS.md`, `.codex/` (all `.md`/`.mdc` files in directory) | +| **Cursor** | `.cursor/rules/`, `.cursorrules` | | **Augment** | `.augment/rules/`, `.augment/guidelines.md` | | **Windsurf** | `.windsurf/rules/`, `.windsurfrules` | -| **OpenCode.ai** | `.opencode/agent/`, `.opencode/rules/`, `.opencode/command/` (commands, not tasks) | +| **OpenCode.ai** | `.opencode/agent/`, `.opencode/rules/` (rules); `.opencode/command/` (commands) | | **GitHub Copilot** | `.github/copilot-instructions.md`, `.github/agents/` | -| **Google Gemini** | `GEMINI.md`, `.gemini/styleguide.md` | -| **Generic** | `AGENTS.md`, `.agents/rules/`, `.agents/tasks/` (tasks), `.agents/commands/` (commands) | +| **Google Gemini** | `GEMINI.md`, `.gemini/styleguide.md`, `.gemini/` (all `.md`/`.mdc` files in directory) | +| **Generic** | `.agents/rules/` (rules), `.agents/tasks/` (tasks), `.agents/commands/` (commands) | ## Discovery Behavior diff --git a/examples/PRESENTATION.md b/examples/PRESENTATION.md index 4c80e57..2e4133a 100644 --- a/examples/PRESENTATION.md +++ b/examples/PRESENTATION.md @@ -76,7 +76,7 @@ Focus on these slides: coding-context fix-bug | llm -m claude-3-5-sonnet-20241022 # With parameters -coding-context -p issue=BUG-123 -s languages=go fix-bug +coding-context -p issue=BUG-123 -s language=go fix-bug ``` ### Scenario 2: Technical Deep Dive (15 minutes) @@ -91,10 +91,10 @@ Cover these sections: **Demo commands:** ```bash # Show rule discovery -coding-context -s languages=go fix-bug | head -50 +coding-context -s language=go fix-bug | head -50 # Show with selectors -coding-context -s languages=go -s stage=implementation implement-feature +coding-context -s language=go -s stage=implementation implement-feature # Show remote rules coding-context -d git::https://github.com/company/rules.git fix-bug diff --git a/examples/agents/skills/README.md b/examples/agents/skills/README.md index 0b8749d..8a0750b 100644 --- a/examples/agents/skills/README.md +++ b/examples/agents/skills/README.md @@ -53,7 +53,7 @@ description: Extract text and tables from PDF files, fill forms, merge documents - `license`: License name or reference - `compatibility`: Environment requirements - `metadata`: Additional key-value pairs -- `allowed-tools`: Pre-approved tools (experimental) +- `allowed_tools`: Pre-approved tools (experimental) ## Progressive Disclosure diff --git a/examples/agents/tasks/example-with-standard-fields.md b/examples/agents/tasks/example-with-standard-fields.md index 0ec2ed9..5c63b56 100644 --- a/examples/agents/tasks/example-with-standard-fields.md +++ b/examples/agents/tasks/example-with-standard-fields.md @@ -4,12 +4,12 @@ name: Example Task with Standard Frontmatter Fields description: This task demonstrates all standard frontmatter fields supported by the coding-context CLI task_name: example-with-standard-fields agent: cursor -language: go model: anthropic.claude-sonnet-4-20250514-v1-0 single_shot: false timeout: 10m selectors: stage: implementation + language: go --- # Example Task with Standard Frontmatter Fields @@ -31,7 +31,6 @@ These fields are metadata only and do not affect task matching or filtering. These fields automatically filter rules when present in task frontmatter: - **agent**: `cursor` - Only includes rules with `agent: cursor` (or no agent field) -- **language**: `go` - Only includes rules with `language: go` (or no language field) ## Standard Fields (Metadata Only) @@ -43,16 +42,17 @@ These fields are stored in frontmatter and passed through to output, but do NOT ## Custom Selectors -Additional filtering criteria beyond the standard fields: +Additional filtering criteria specified in the `selectors:` map: - **selectors.stage**: `implementation` - Only includes rules with `stage: implementation` +- **selectors.language**: `go` - Only includes rules with `language: go` ## How Filtering Works When this task runs, rules are included if they match ALL of the following: 1. `agent: cursor` OR no agent field -2. `language: go` OR no language field -3. `stage: implementation` OR no stage field +2. `stage: implementation` OR no stage field +3. `language: go` OR no language field 4. `task_name: example-with-standard-fields` OR no task_name field Rules without any selectors are always included (generic rules). @@ -60,7 +60,7 @@ Rules without any selectors are always included (generic rules). ## Usage ```bash -coding-context-cli example-with-standard-fields +coding-context example-with-standard-fields ``` The output will include: diff --git a/examples/remote-rules-example.md b/examples/remote-rules-example.md index a5f937b..71a0da2 100644 --- a/examples/remote-rules-example.md +++ b/examples/remote-rules-example.md @@ -68,7 +68,7 @@ coding-context-cli \ ## Supported Protocols -The `-r` flag uses HashiCorp's go-getter library, which supports many protocols: +The `-d` flag uses HashiCorp's go-getter library, which supports many protocols: ### Git Repositories diff --git a/examples/workflows/agentic-bugfix.yml b/examples/workflows/agentic-bugfix.yml index b0053d8..13bc7cf 100644 --- a/examples/workflows/agentic-bugfix.yml +++ b/examples/workflows/agentic-bugfix.yml @@ -1,7 +1,7 @@ # Example: Agentic Bug Fix Workflow # # This workflow demonstrates how an AI agent can autonomously fix bugs -# using context assembled by coding-context-cli. +# using context assembled by coding-context. name: Agentic Bug Fix @@ -24,10 +24,14 @@ jobs: uses: actions/checkout@v4 - name: Install Coding Context CLI + env: + GH_TOKEN: ${{ github.token }} run: | - curl -fsL -o /usr/local/bin/coding-context-cli \ - https://github.com/kitproj/coding-context-cli/releases/latest/download/coding-context-cli_linux_amd64 - chmod +x /usr/local/bin/coding-context-cli + gh release download \ + --repo kitproj/coding-context-cli \ + --pattern 'coding-context_*_linux_amd64' \ + --output /usr/local/bin/coding-context + chmod +x /usr/local/bin/coding-context - name: Determine Bug Severity id: severity @@ -46,7 +50,7 @@ jobs: - name: Assemble Bug Fix Context run: | # Assemble context with issue-specific information - coding-context-cli \ + coding-context \ -s task=fix-bug \ -s severity=${{ steps.severity.outputs.severity }} \ -p issue_number=${{ github.event.issue.number }} \ diff --git a/examples/workflows/agentic-code-review.yml b/examples/workflows/agentic-code-review.yml index 8811417..a31e465 100644 --- a/examples/workflows/agentic-code-review.yml +++ b/examples/workflows/agentic-code-review.yml @@ -1,6 +1,6 @@ # Example: Agentic Code Review Workflow # -# This workflow demonstrates how to use coding-context-cli to prepare +# This workflow demonstrates how to use coding-context to prepare # context for an AI agent that performs automated code reviews. name: Agentic Code Review @@ -23,16 +23,20 @@ jobs: fetch-depth: 0 # Full history for better context - name: Install Coding Context CLI + env: + GH_TOKEN: ${{ github.token }} run: | - curl -fsL -o /usr/local/bin/coding-context-cli \ - https://github.com/kitproj/coding-context-cli/releases/latest/download/coding-context-cli_linux_amd64 - chmod +x /usr/local/bin/coding-context-cli + gh release download \ + --repo kitproj/coding-context-cli \ + --pattern 'coding-context_*_linux_amd64' \ + --output /usr/local/bin/coding-context + chmod +x /usr/local/bin/coding-context - name: Assemble Review Context id: context run: | # Assemble context with PR-specific parameters - coding-context-cli \ + coding-context \ -s task=code-review \ -s language=${{ github.event.repository.language }} \ -p pr_number=${{ github.event.pull_request.number }} \ diff --git a/examples/workflows/agentic-feature-development.yml b/examples/workflows/agentic-feature-development.yml index 2878e1a..2beb942 100644 --- a/examples/workflows/agentic-feature-development.yml +++ b/examples/workflows/agentic-feature-development.yml @@ -38,14 +38,18 @@ jobs: uses: actions/checkout@v4 - name: Install Coding Context CLI + env: + GH_TOKEN: ${{ github.token }} run: | - curl -fsL -o /usr/local/bin/coding-context-cli \ - https://github.com/kitproj/coding-context-cli/releases/latest/download/coding-context-cli_linux_amd64 - chmod +x /usr/local/bin/coding-context-cli + gh release download \ + --repo kitproj/coding-context-cli \ + --pattern 'coding-context_*_linux_amd64' \ + --output /usr/local/bin/coding-context + chmod +x /usr/local/bin/coding-context - name: Assemble Planning Context run: | - coding-context-cli \ + coding-context \ -s stage=planning \ -s priority=${{ inputs.priority }} \ -p feature_name="${{ inputs.feature_name }}" \ @@ -84,10 +88,14 @@ jobs: name: feature-plan - name: Install Coding Context CLI + env: + GH_TOKEN: ${{ github.token }} run: | - curl -fsL -o /usr/local/bin/coding-context-cli \ - https://github.com/kitproj/coding-context-cli/releases/latest/download/coding-context-cli_linux_amd64 - chmod +x /usr/local/bin/coding-context-cli + gh release download \ + --repo kitproj/coding-context-cli \ + --pattern 'coding-context_*_linux_amd64' \ + --output /usr/local/bin/coding-context + chmod +x /usr/local/bin/coding-context - name: Create Feature Branch id: branch @@ -101,7 +109,7 @@ jobs: # Include the plan in the context PLAN_CONTENT=$(cat feature-plan.md) - coding-context-cli \ + coding-context \ -s stage=implementation \ -s priority=${{ inputs.priority }} \ -p feature_name="${{ inputs.feature_name }}" \ @@ -143,14 +151,18 @@ jobs: ref: ${{ needs.implement.outputs.branch_name }} - name: Install Coding Context CLI + env: + GH_TOKEN: ${{ github.token }} run: | - curl -fsL -o /usr/local/bin/coding-context-cli \ - https://github.com/kitproj/coding-context-cli/releases/latest/download/coding-context-cli_linux_amd64 - chmod +x /usr/local/bin/coding-context-cli + gh release download \ + --repo kitproj/coding-context-cli \ + --pattern 'coding-context_*_linux_amd64' \ + --output /usr/local/bin/coding-context + chmod +x /usr/local/bin/coding-context - name: Assemble Testing Context run: | - coding-context-cli \ + coding-context \ -s stage=testing \ -s priority=${{ inputs.priority }} \ -p feature_name="${{ inputs.feature_name }}" \ @@ -193,13 +205,13 @@ jobs: - name: Install Coding Context CLI run: | - curl -fsL -o /usr/local/bin/coding-context-cli \ + curl -fsL -o /usr/local/bin/coding-context \ https://github.com/kitproj/coding-context-cli/releases/latest/download/coding-context-cli_linux_amd64 - chmod +x /usr/local/bin/coding-context-cli + chmod +x /usr/local/bin/coding-context - name: Assemble Documentation Context run: | - coding-context-cli \ + coding-context \ -s stage=documentation \ -p feature_name="${{ inputs.feature_name }}" \ -p feature_description="${{ inputs.feature_description }}" \ diff --git a/go.mod b/go.mod index a273078..962534e 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.24.5 require ( github.com/alecthomas/participle/v2 v2.1.4 + github.com/goccy/go-yaml v1.18.0 github.com/hashicorp/go-getter/v2 v2.2.3 github.com/stretchr/testify v1.10.0 - gopkg.in/yaml.v3 v3.0.1 + github.com/yuin/goldmark v1.7.12 + github.com/yuin/goldmark-meta v1.1.0 ) require ( @@ -22,4 +24,6 @@ require ( github.com/mitchellh/go-testing-interface v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/ulikunitz/xz v0.5.8 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index af935d2..cdb1d79 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1U github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= @@ -36,7 +38,13 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ulikunitz/xz v0.5.8 h1:ERv8V6GKqVi23rgu5cj9pVfVzJbOqAY2Ntl88O6c2nQ= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= +github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= +github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integration_test.go b/integration_test.go index daec99a..36391a6 100644 --- a/integration_test.go +++ b/integration_test.go @@ -1,7 +1,9 @@ +// Package main provides the coding-context CLI and its integration tests. package main import ( "bytes" + "context" "fmt" "os" "os/exec" @@ -10,23 +12,25 @@ import ( "testing" ) -// testDirs holds the directory structure for a test +// testDirs holds the directory structure for a test. type testDirs struct { tmpDir string rulesDir string tasksDir string } -// setupTestDirs creates the standard directory structure for tests +// setupTestDirs creates the standard directory structure for tests. func setupTestDirs(t *testing.T) testDirs { + t.Helper() tmpDir := t.TempDir() rulesDir := filepath.Join(tmpDir, ".agents", "rules") tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(rulesDir, 0o755); err != nil { + if err := os.MkdirAll(rulesDir, 0o750); err != nil { t.Fatalf("failed to create rules dir: %v", err) } - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } @@ -40,29 +44,65 @@ func setupTestDirs(t *testing.T) testDirs { // runTool executes the program using "go run ." with the given arguments // It fatally fails the test if the command returns an error. func runTool(t *testing.T, args ...string) string { + t.Helper() + output, err := runToolWithError(args...) if err != nil { t.Fatalf("failed to run tool: %v\n%s", err, output) } - return string(output) + + return output +} + +// runToolWithEnv executes the program with additional env vars (e.g. HOME for isolation). +// Each override is "KEY=value"; existing env vars are preserved. +func runToolWithEnv(t *testing.T, envOverrides []string, args ...string) string { + t.Helper() + + output, err := runToolWithErrorAndEnv(envOverrides, args...) + if err != nil { + t.Fatalf("failed to run tool: %v\n%s", err, output) + } + + return output } // runToolWithError executes the program using "go run ." with the given arguments // and returns both output and error (for tests that expect errors). func runToolWithError(args ...string) (string, error) { + return runToolWithErrorAndEnv(nil, args...) +} + +func runToolWithErrorAndEnv(envOverrides []string, args ...string) (string, error) { // Get the current working directory to use as the source path for go run wd, err := os.Getwd() if err != nil { - return "", err + return "", fmt.Errorf("failed to get working directory: %w", err) + } + + absWd, err := filepath.Abs(filepath.Clean(wd)) + if err != nil { + return "", fmt.Errorf("failed to resolve working directory: %w", err) } - cmd := exec.Command("go", append([]string{"run", wd}, args...)...) + + // #nosec G204 -- integration test runs "go run" with controlled args + cmd := exec.CommandContext(context.Background(), "go", append([]string{"run", absWd}, args...)...) + if len(envOverrides) > 0 { + cmd.Env = append(os.Environ(), envOverrides...) + } + output, err := cmd.CombinedOutput() + return string(output), err } -// createStandardTask creates a standard task file with the given task name -func createStandardTask(t *testing.T, tasksDir, taskName string) { +// createStandardTask creates a standard task file for integration tests. +func createStandardTask(t *testing.T, tasksDir string) { + t.Helper() + + taskName := "test-task" taskFile := filepath.Join(tasksDir, taskName+".md") + taskContent := `--- task_name: ` + taskName + ` --- @@ -70,36 +110,43 @@ task_name: ` + taskName + ` Please help with this task. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } } func TestBootstrapFromFile(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file ruleFile := filepath.Join(dirs.rulesDir, "setup.md") + ruleContent := `--- --- # Development Setup This is a setup guide. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a bootstrap file for the rule (setup.md -> setup-bootstrap) bootstrapFile := filepath.Join(dirs.rulesDir, "setup-bootstrap") + bootstrapContent := `#!/bin/bash echo "Running bootstrap" ` - if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o755); err != nil { + if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o600); err != nil { t.Fatalf("failed to write bootstrap file: %v", err) } + // #nosec G302 -- bootstrap scripts must be executable to run + if err := os.Chmod(bootstrapFile, 0o755); err != nil { + t.Fatalf("failed to chmod bootstrap file: %v", err) + } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program output := runTool(t, "-C", dirs.tmpDir, "test-task") @@ -111,9 +158,11 @@ echo "Running bootstrap" if bootstrapIdx == -1 { t.Errorf("bootstrap output not found in stdout") } + if setupIdx == -1 { t.Errorf("rule content not found in stdout") } + if bootstrapIdx != -1 && setupIdx != -1 && bootstrapIdx > setupIdx { t.Errorf("bootstrap output should appear before rule content") } @@ -125,21 +174,23 @@ echo "Running bootstrap" } func TestBootstrapFileNotRequired(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file WITHOUT a bootstrap ruleFile := filepath.Join(dirs.rulesDir, "info.md") + ruleContent := `--- --- # Project Info General information about the project. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program - should succeed without a bootstrap file output := runTool(t, "-C", dirs.tmpDir, "test-task") @@ -156,10 +207,12 @@ General information about the project. } func TestBootstrapFromFrontmatter(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file with bootstrap in frontmatter ruleFile := filepath.Join(dirs.rulesDir, "setup.md") + ruleContent := `--- bootstrap: | #!/bin/sh @@ -169,11 +222,11 @@ bootstrap: | This is a setup guide with frontmatter bootstrap. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program output := runTool(t, "-C", dirs.tmpDir, "test-task") @@ -190,10 +243,12 @@ This is a setup guide with frontmatter bootstrap. } func TestBootstrapFrontmatterPreferredOverFile(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file with bootstrap in frontmatter ruleFile := filepath.Join(dirs.rulesDir, "setup.md") + ruleContent := `--- bootstrap: | #!/bin/sh @@ -203,20 +258,25 @@ bootstrap: | Testing that frontmatter bootstrap is preferred. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Also create a file-based bootstrap (should be ignored) bootstrapFile := filepath.Join(dirs.rulesDir, "setup-bootstrap") + bootstrapContent := `#!/bin/bash echo "Using file bootstrap" ` - if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o755); err != nil { + if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o600); err != nil { t.Fatalf("failed to write bootstrap file: %v", err) } + // #nosec G302 -- bootstrap scripts must be executable to run + if err := os.Chmod(bootstrapFile, 0o755); err != nil { + t.Fatalf("failed to chmod bootstrap file: %v", err) + } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program output := runTool(t, "-C", dirs.tmpDir, "test-task") @@ -228,49 +288,62 @@ echo "Using file bootstrap" } func TestMultipleBootstrapFiles(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create first rule file with bootstrap ruleFile1 := filepath.Join(dirs.rulesDir, "setup.md") + ruleContent1 := `--- --- # Setup -Setup instructions. +Bootstrap instructions. ` - if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0o644); err != nil { + if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0o600); err != nil { t.Fatalf("failed to write rule file 1: %v", err) } bootstrapFile1 := filepath.Join(dirs.rulesDir, "setup-bootstrap") + bootstrapContent1 := `#!/bin/bash echo "Running setup bootstrap" ` - if err := os.WriteFile(bootstrapFile1, []byte(bootstrapContent1), 0o755); err != nil { + if err := os.WriteFile(bootstrapFile1, []byte(bootstrapContent1), 0o600); err != nil { t.Fatalf("failed to write bootstrap file 1: %v", err) } + // #nosec G302 -- bootstrap scripts must be executable to run + if err := os.Chmod(bootstrapFile1, 0o755); err != nil { + t.Fatalf("failed to chmod bootstrap file 1: %v", err) + } // Create second rule file with bootstrap ruleFile2 := filepath.Join(dirs.rulesDir, "deploy.md") + ruleContent2 := `--- --- # Deploy Deployment instructions. ` - if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0o644); err != nil { + if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0o600); err != nil { t.Fatalf("failed to write rule file 2: %v", err) } bootstrapFile2 := filepath.Join(dirs.rulesDir, "deploy-bootstrap") + bootstrapContent2 := `#!/bin/bash echo "Running deploy bootstrap" ` - if err := os.WriteFile(bootstrapFile2, []byte(bootstrapContent2), 0o755); err != nil { + if err := os.WriteFile(bootstrapFile2, []byte(bootstrapContent2), 0o600); err != nil { t.Fatalf("failed to write bootstrap file 2: %v", err) } + // #nosec G302 -- bootstrap scripts must be executable to run + if err := os.Chmod(bootstrapFile2, 0o755); err != nil { + t.Fatalf("failed to chmod bootstrap file 2: %v", err) + } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program output := runTool(t, "-C", dirs.tmpDir, "test-task") @@ -279,6 +352,7 @@ echo "Running deploy bootstrap" if !strings.Contains(output, "Running setup bootstrap") { t.Errorf("setup bootstrap output not found in stdout") } + if !strings.Contains(output, "Running deploy bootstrap") { t.Errorf("deploy bootstrap output not found in stdout") } @@ -287,16 +361,19 @@ echo "Running deploy bootstrap" if !strings.Contains(output, "# Setup") { t.Errorf("setup rule content not found in stdout") } + if !strings.Contains(output, "# Deploy") { t.Errorf("deploy rule content not found in stdout") } } func TestSelectorFiltering(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create rule files with different Selectors ruleFile1 := filepath.Join(dirs.rulesDir, "python.md") + ruleContent1 := `--- language: python --- @@ -304,11 +381,12 @@ language: python Python specific guidelines. ` - if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0o644); err != nil { + if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0o600); err != nil { t.Fatalf("failed to write python rule file: %v", err) } ruleFile2 := filepath.Join(dirs.rulesDir, "golang.md") + ruleContent2 := `--- language: go --- @@ -316,11 +394,11 @@ language: go Go specific guidelines. ` - if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0o644); err != nil { + if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0o600); err != nil { t.Fatalf("failed to write go rule file: %v", err) } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program with selector filtering for Python output := runTool(t, "-C", dirs.tmpDir, "-s", "language=python", "test-task") @@ -329,21 +407,24 @@ Go specific guidelines. if !strings.Contains(output, "# Python Guidelines") { t.Errorf("Python guidelines not found in stdout") } + if strings.Contains(output, "# Go Guidelines") { t.Errorf("Go guidelines should not be in stdout when filtering for Python") } } func TestTemplateExpansionWithOsExpand(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } // Create a task file with template variables taskFile := filepath.Join(tasksDir, "test-task.md") + taskContent := `--- task_name: test-task --- @@ -351,7 +432,7 @@ task_name: test-task Please work on ${component} and fix ${issue}. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -365,21 +446,23 @@ Please work on ${component} and fix ${issue}. } func TestExpanderIntegration(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } // Create a test file for path expansion dataFile := filepath.Join(tmpDir, "data.txt") - if err := os.WriteFile(dataFile, []byte("file content"), 0o644); err != nil { + if err := os.WriteFile(dataFile, []byte("file content"), 0o600); err != nil { t.Fatalf("failed to write data file: %v", err) } // Create a task file with all three expansion types taskFile := filepath.Join(tasksDir, "test-expander.md") + taskContent := fmt.Sprintf(`--- task_name: test-expander --- @@ -390,7 +473,7 @@ Command: !`+"`echo hello`"+` Path: @%s Combined: ${component} !`+"`echo world`"+` `, dataFile) - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -419,21 +502,23 @@ Combined: ${component} !`+"`echo world`"+` } func TestExpanderSecurityIntegration(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } // Create a file that contains expansion syntax (should not be re-expanded) dataFile := filepath.Join(tmpDir, "injection.txt") - if err := os.WriteFile(dataFile, []byte("${injected} and !`echo hacked`"), 0o644); err != nil { + if err := os.WriteFile(dataFile, []byte("${injected} and !`echo hacked`"), 0o600); err != nil { t.Fatalf("failed to write data file: %v", err) } // Create a task file that tests security (no re-expansion) taskFile := filepath.Join(tasksDir, "test-security.md") + taskContent := fmt.Sprintf(`--- task_name: test-security --- @@ -443,7 +528,7 @@ File content: @%s Param with command: ${evil} Command with param: !`+"`echo '${secret}'`"+` `, dataFile) - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -453,13 +538,16 @@ Command with param: !`+"`echo '${secret}'`"+` // Split output into lines to separate stderr logs from stdout prompt // Since task frontmatter is no longer printed, we identify stdout by filtering out stderr log lines lines := strings.Split(output, "\n") + var promptLines []string + for _, line := range lines { // Stderr log lines start with "time=", stdout lines don't if !strings.HasPrefix(line, "time=") { promptLines = append(promptLines, line) } } + promptOutput := strings.Join(promptLines, "\n") // Check that file content with expansion syntax is NOT re-expanded @@ -490,21 +578,23 @@ Command with param: !`+"`echo '${secret}'`"+` } func TestMdcFileSupport(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a .mdc rule file ruleFile := filepath.Join(dirs.rulesDir, "custom.mdc") + ruleContent := `--- --- # Custom Rules This is a .mdc file. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write .mdc rule file: %v", err) } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program output := runTool(t, "-C", dirs.tmpDir, "test-task") @@ -516,30 +606,37 @@ This is a .mdc file. } func TestMdcFileWithBootstrap(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a .mdc rule file ruleFile := filepath.Join(dirs.rulesDir, "custom.mdc") + ruleContent := `--- --- # Custom Rules This is a .mdc file with bootstrap. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write .mdc rule file: %v", err) } // Create a bootstrap file for the .mdc file (custom.mdc -> custom-bootstrap) bootstrapFile := filepath.Join(dirs.rulesDir, "custom-bootstrap") + bootstrapContent := `#!/bin/bash echo "Running custom bootstrap" ` - if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o755); err != nil { + if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o600); err != nil { t.Fatalf("failed to write bootstrap file: %v", err) } + // #nosec G302 -- bootstrap scripts must be executable to run + if err := os.Chmod(bootstrapFile, 0o755); err != nil { + t.Fatalf("failed to chmod bootstrap file: %v", err) + } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program output := runTool(t, "-C", dirs.tmpDir, "test-task") @@ -548,23 +645,26 @@ echo "Running custom bootstrap" if !strings.Contains(output, "Running custom bootstrap") { t.Errorf("custom bootstrap output not found in stdout") } + if !strings.Contains(output, "# Custom Rules") { t.Errorf(".mdc file content not found in stdout") } } func TestBootstrapWithoutExecutePermission(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file ruleFile := filepath.Join(dirs.rulesDir, "setup.md") + ruleContent := `--- --- # Development Setup This is a setup guide. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } @@ -572,10 +672,11 @@ This is a setup guide. // This simulates a bootstrap file that was checked out from git on Windows // or otherwise doesn't have the executable bit set bootstrapFile := filepath.Join(dirs.rulesDir, "setup-bootstrap") + bootstrapContent := `#!/bin/bash echo "Bootstrap executed successfully" ` - if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o644); err != nil { + if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o600); err != nil { t.Fatalf("failed to write bootstrap file: %v", err) } @@ -584,11 +685,12 @@ echo "Bootstrap executed successfully" if err != nil { t.Fatalf("failed to stat bootstrap file: %v", err) } + if fileInfo.Mode()&0o111 != 0 { t.Fatalf("bootstrap file should not be executable initially, but has mode: %v", fileInfo.Mode()) } - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run the program - this should chmod +x the bootstrap file before running it output := runTool(t, "-C", dirs.tmpDir, "test-task") @@ -613,35 +715,40 @@ echo "Bootstrap executed successfully" if err != nil { t.Fatalf("failed to stat bootstrap file after run: %v", err) } + if fileInfo.Mode()&0o111 == 0 { t.Errorf("bootstrap file should be executable after run, but has mode: %v", fileInfo.Mode()) } } func TestOpenCodeRulesSupport(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() openCodeAgentDir := filepath.Join(tmpDir, ".opencode", "agent") tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(openCodeAgentDir, 0o755); err != nil { + if err := os.MkdirAll(openCodeAgentDir, 0o750); err != nil { t.Fatalf("failed to create opencode agent dir: %v", err) } - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } // Create an agent rule file in .opencode/agent agentFile := filepath.Join(openCodeAgentDir, "docs.md") + agentContent := `# Documentation Agent This agent helps with documentation. ` - if err := os.WriteFile(agentFile, []byte(agentContent), 0o644); err != nil { + if err := os.WriteFile(agentFile, []byte(agentContent), 0o600); err != nil { t.Fatalf("failed to write agent file: %v", err) } // Create a task file taskFile := filepath.Join(tasksDir, "test-opencode.md") + taskContent := `--- task_name: test-opencode --- @@ -649,7 +756,7 @@ task_name: test-opencode This is a test task. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -668,16 +775,18 @@ This is a test task. } func TestOpenCodeCommandTaskSupport(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() // Tasks must be in .agents/tasks directory tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } // Create a task file in the correct location taskFile := filepath.Join(tasksDir, "fix-bug.md") + taskContent := `--- task_name: fix-bug --- @@ -685,7 +794,7 @@ task_name: fix-bug This is a task for fixing bugs. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -696,28 +805,31 @@ This is a task for fixing bugs. if !strings.Contains(output, "# Fix Bug Task") { t.Errorf("task content not found in stdout") } + if !strings.Contains(output, "This is a task for fixing bugs.") { t.Errorf("task description not found in stdout") } } func TestTaskSelectionByFrontmatter(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } // Create a task file - task name is based on filename now taskFile := filepath.Join(tasksDir, "my-special-task.md") + taskContent := `--- --- # My Special Task This task name is based on the filename. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -731,15 +843,17 @@ This task name is based on the filename. } func TestTaskWithoutTaskNameUsesFilename(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } // Create a file WITHOUT task_name in frontmatter - should use filename taskFile := filepath.Join(tasksDir, "my-task.md") + taskContent := `--- description: A task without task_name --- @@ -747,7 +861,7 @@ description: A task without task_name This file uses the filename as task_name. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write file: %v", err) } @@ -758,21 +872,24 @@ This file uses the filename as task_name. if !strings.Contains(output, "# My Task") { t.Errorf("task content not found in stdout") } + if !strings.Contains(output, "This file uses the filename as task_name.") { t.Errorf("task description not found in stdout") } } func TestTaskSelectionWithSelectors(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } // Create two task files with different filenames but same base name and different environments taskFile1 := filepath.Join(tasksDir, "deploy-staging.md") + taskContent1 := `--- environment: staging --- @@ -780,11 +897,12 @@ environment: staging Deploy to the staging environment. ` - if err := os.WriteFile(taskFile1, []byte(taskContent1), 0o644); err != nil { + if err := os.WriteFile(taskFile1, []byte(taskContent1), 0o600); err != nil { t.Fatalf("failed to write staging task file: %v", err) } taskFile2 := filepath.Join(tasksDir, "deploy-production.md") + taskContent2 := `--- environment: production --- @@ -792,7 +910,7 @@ environment: production Deploy to the production environment. ` - if err := os.WriteFile(taskFile2, []byte(taskContent2), 0o644); err != nil { + if err := os.WriteFile(taskFile2, []byte(taskContent2), 0o600); err != nil { t.Fatalf("failed to write production task file: %v", err) } @@ -803,6 +921,7 @@ Deploy to the production environment. if !strings.Contains(output, "# Deploy to Staging") { t.Errorf("staging task content not found in stdout") } + if strings.Contains(output, "# Deploy to Production") { t.Errorf("production task content should not be in stdout when selecting staging") } @@ -814,49 +933,60 @@ Deploy to the production environment. if !strings.Contains(output, "# Deploy to Production") { t.Errorf("production task content not found in stdout") } + if strings.Contains(output, "# Deploy to Staging") { t.Errorf("staging task content should not be in stdout when selecting production") } } +//nolint:cyclop,funlen func TestResumeMode(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file that should be included in normal mode ruleFile := filepath.Join(dirs.rulesDir, "coding-standards.md") + ruleContent := `--- --- # Coding Standards These are the coding standards for the project. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a bootstrap script for the rule file to verify it doesn't run in resume mode ruleBootstrapFile := filepath.Join(dirs.rulesDir, "coding-standards-bootstrap") + ruleBootstrapContent := `#!/bin/bash echo "RULE_BOOTSTRAP_RAN" >&2 ` - if err := os.WriteFile(ruleBootstrapFile, []byte(ruleBootstrapContent), 0o755); err != nil { + if err := os.WriteFile(ruleBootstrapFile, []byte(ruleBootstrapContent), 0o600); err != nil { t.Fatalf("failed to write rule bootstrap file: %v", err) } + // #nosec G302 -- bootstrap scripts must be executable to run + if err := os.Chmod(ruleBootstrapFile, 0o755); err != nil { + t.Fatalf("failed to chmod rule bootstrap file: %v", err) + } // Create a normal task file (without resume field) normalTaskFile := filepath.Join(dirs.tasksDir, "fix-bug.md") + normalTaskContent := `--- --- # Fix Bug (Initial) This is the initial task prompt for fixing a bug. ` - if err := os.WriteFile(normalTaskFile, []byte(normalTaskContent), 0o644); err != nil { + if err := os.WriteFile(normalTaskFile, []byte(normalTaskContent), 0o600); err != nil { t.Fatalf("failed to write normal task file: %v", err) } // Create a resume task file (with resume: true) resumeTaskFile := filepath.Join(dirs.tasksDir, "fix-bug-resume.md") + resumeTaskContent := `--- resume: true --- @@ -864,7 +994,7 @@ resume: true This is the resume task prompt for continuing the bug fix. ` - if err := os.WriteFile(resumeTaskFile, []byte(resumeTaskContent), 0o644); err != nil { + if err := os.WriteFile(resumeTaskFile, []byte(resumeTaskContent), 0o600); err != nil { t.Fatalf("failed to write resume task file: %v", err) } @@ -874,13 +1004,24 @@ This is the resume task prompt for continuing the bug fix. if err != nil { t.Fatalf("failed to get working directory: %v", err) } - cmd := exec.Command("go", "run", wd, "-C", dirs.tmpDir, "-s", "resume=false", "fix-bug") + + absWd, err := filepath.Abs(filepath.Clean(wd)) + if err != nil { + t.Fatalf("failed to resolve working directory: %v", err) + } + + // #nosec G204 -- integration test runs "go run" with controlled args + cmd := exec.CommandContext(t.Context(), "go", "run", absWd, "-C", dirs.tmpDir, "-s", "resume=false", "fix-bug") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr if err := cmd.Run(); err != nil { t.Fatalf("failed to run binary in normal mode: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) } + output := stdout.String() stderrOutput := stderr.String() @@ -898,20 +1039,28 @@ This is the resume task prompt for continuing the bug fix. if !strings.Contains(output, "# Fix Bug (Initial)") { t.Errorf("normal mode: normal task content not found in stdout") } + if strings.Contains(output, "# Fix Bug (Resume)") { t.Errorf("normal mode: resume task content should not be in stdout") } // Test 2: Run with resume selector and bootstrap disabled (with -s resume=true and --skip-bootstrap) // Capture stdout and stderr separately to verify bootstrap scripts don't run - cmd = exec.Command("go", "run", wd, "-C", dirs.tmpDir, "-s", "resume=true", "--skip-bootstrap", "fix-bug-resume") + // #nosec G204 -- integration test runs "go run" with controlled args + cmd = exec.CommandContext(t.Context(), "go", "run", absWd, + "-C", dirs.tmpDir, "-s", "resume=true", "--skip-bootstrap", "fix-bug-resume") + stdout.Reset() stderr.Reset() + cmd.Stdout = &stdout + cmd.Stderr = &stderr if err = cmd.Run(); err != nil { - t.Fatalf("failed to run binary with resume selector and bootstrap disabled: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + t.Fatalf("failed to run binary with resume selector and bootstrap disabled: %v\nstdout: %s\nstderr: %s", + err, stdout.String(), stderr.String()) } + output = stdout.String() stderrOutput = stderr.String() @@ -929,19 +1078,27 @@ This is the resume task prompt for continuing the bug fix. if !strings.Contains(output, "# Fix Bug (Resume)") { t.Errorf("resume selector: resume task content not found in stdout") } + if strings.Contains(output, "# Fix Bug (Initial)") { t.Errorf("resume selector: normal task content should not be in stdout") } // Test 3: Run with -r flag (sets resume selector) and --skip-bootstrap (disables bootstrap) - cmd = exec.Command("go", "run", wd, "-C", dirs.tmpDir, "-r", "--skip-bootstrap", "fix-bug-resume") + // #nosec G204 -- integration test runs "go run" with controlled args + cmd = exec.CommandContext(t.Context(), "go", "run", absWd, + "-C", dirs.tmpDir, "-r", "--skip-bootstrap", "fix-bug-resume") + stdout.Reset() stderr.Reset() + cmd.Stdout = &stdout + cmd.Stderr = &stderr if err = cmd.Run(); err != nil { - t.Fatalf("failed to run binary with -r flag and --skip-bootstrap: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + t.Fatalf("failed to run binary with -r flag and --skip-bootstrap: %v\nstdout: %s\nstderr: %s", + err, stdout.String(), stderr.String()) } + output = stdout.String() stderrOutput = stderr.String() @@ -952,35 +1109,40 @@ This is the resume task prompt for continuing the bug fix. // With bootstrap disabled, bootstrap scripts should NOT run if strings.Contains(stderrOutput, "RULE_BOOTSTRAP_RAN") { - t.Errorf("bootstrap disabled (--skip-bootstrap): rule bootstrap script should not run (found in stderr: %s)", stderrOutput) + t.Errorf("bootstrap disabled (--skip-bootstrap): rule bootstrap script should not run (found in stderr: %s)", + stderrOutput) } // With -r flag, should use the resume task if !strings.Contains(output, "# Fix Bug (Resume)") { t.Errorf("resume selector (-r flag): resume task content not found in stdout") } + if strings.Contains(output, "# Fix Bug (Initial)") { t.Errorf("resume selector (-r flag): normal task content should not be in stdout") } } func TestRemoteRuleFromHTTP(t *testing.T) { + t.Parallel() // Create a remote directory structure to serve remoteDir := t.TempDir() + rulesDir := filepath.Join(remoteDir, ".agents", "rules") - if err := os.MkdirAll(rulesDir, 0o755); err != nil { + if err := os.MkdirAll(rulesDir, 0o750); err != nil { t.Fatalf("failed to create remote rules dir: %v", err) } // Create a remote rule file remoteRuleFile := filepath.Join(rulesDir, "remote-rule.md") + remoteRuleContent := `--- --- # Remote Rule This is a rule loaded from a remote directory. ` - if err := os.WriteFile(remoteRuleFile, []byte(remoteRuleContent), 0o644); err != nil { + if err := os.WriteFile(remoteRuleFile, []byte(remoteRuleContent), 0o600); err != nil { t.Fatalf("failed to write remote rule file: %v", err) } @@ -988,11 +1150,11 @@ This is a rule loaded from a remote directory. tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } - createStandardTask(t, tasksDir, "test-task") + createStandardTask(t, tasksDir) // Run the program with remote directory (using file:// URL) remoteURL := "file://" + remoteDir @@ -1002,6 +1164,7 @@ This is a rule loaded from a remote directory. if !strings.Contains(output, "# Remote Rule") { t.Errorf("remote rule content not found in stdout") } + if !strings.Contains(output, "This is a rule loaded from a remote directory") { t.Errorf("remote rule description not found in stdout") } @@ -1013,10 +1176,12 @@ This is a rule loaded from a remote directory. } func TestPrintTaskFrontmatter(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file ruleFile := filepath.Join(dirs.rulesDir, "test-rule.md") + ruleContent := `--- language: go --- @@ -1024,12 +1189,13 @@ language: go This is a test rule. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a task file with frontmatter taskFile := filepath.Join(dirs.tasksDir, "test-task.md") + taskContent := `--- task_name: test-task author: tester @@ -1039,20 +1205,23 @@ version: 1.0 This is a test task. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } // Test that task frontmatter is NOT printed - output := runTool(t, "-C", dirs.tmpDir, "test-task") + // Use isolated HOME so the tool does not discover rules/skills from the real home directory + output := runToolWithEnv(t, []string{"HOME=" + dirs.tmpDir}, "-C", dirs.tmpDir, "test-task") // Task frontmatter fields should NOT be in the output if strings.Contains(output, "task_name: test-task") { t.Errorf("task frontmatter field 'task_name' should not be in output") } + if strings.Contains(output, "author: tester") { t.Errorf("task frontmatter field 'author' should not be in output") } + if strings.Contains(output, "version: 1.0") { t.Errorf("task frontmatter field 'version' should not be in output") } @@ -1086,10 +1255,12 @@ This is a test task. } func TestTaskBootstrapFromFile(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a simple task file taskFile := filepath.Join(dirs.tasksDir, "test-task.md") + taskContent := `--- task_name: test-task --- @@ -1097,7 +1268,7 @@ task_name: test-task This is a test task. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -1113,10 +1284,12 @@ This is a test task. } func TestTaskBootstrapFileNotRequired(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a task file WITHOUT a bootstrap taskFile := filepath.Join(dirs.tasksDir, "no-bootstrap-task.md") + taskContent := `--- task_name: no-bootstrap-task --- @@ -1124,7 +1297,7 @@ task_name: no-bootstrap-task This task has no bootstrap script. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -1138,30 +1311,38 @@ This task has no bootstrap script. } func TestTaskBootstrapWithRuleBootstrap(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file with bootstrap ruleFile := filepath.Join(dirs.rulesDir, "setup.md") + ruleContent := `--- --- # Setup Rule Setup instructions. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } ruleBootstrapFile := filepath.Join(dirs.rulesDir, "setup-bootstrap") + ruleBootstrapContent := `#!/bin/bash echo "Running rule bootstrap" ` - if err := os.WriteFile(ruleBootstrapFile, []byte(ruleBootstrapContent), 0o755); err != nil { + if err := os.WriteFile(ruleBootstrapFile, []byte(ruleBootstrapContent), 0o600); err != nil { t.Fatalf("failed to write rule bootstrap file: %v", err) } + // #nosec G302 -- bootstrap scripts must be executable to run + if err := os.Chmod(ruleBootstrapFile, 0o755); err != nil { + t.Fatalf("failed to chmod rule bootstrap file: %v", err) + } // Create a task file (tasks no longer have bootstrap scripts) taskFile := filepath.Join(dirs.tasksDir, "deploy-task.md") + taskContent := `--- task_name: deploy-task --- @@ -1169,7 +1350,7 @@ task_name: deploy-task Deploy instructions. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -1185,6 +1366,7 @@ Deploy instructions. if !strings.Contains(output, "# Setup Rule") { t.Errorf("rule content not found in stdout") } + if !strings.Contains(output, "# Deploy Task") { t.Errorf("task content not found in stdout") } @@ -1197,26 +1379,30 @@ Deploy instructions. if ruleBootstrapIdx > ruleContentIdx { t.Errorf("rule bootstrap should run before rule content") } + if ruleContentIdx > taskContentIdx { t.Errorf("rule content should appear before task content") } } func TestManifestFile(t *testing.T) { + t.Parallel() // Create main project directory mainDir := t.TempDir() mainRulesDir := filepath.Join(mainDir, ".agents", "rules") mainTasksDir := filepath.Join(mainDir, ".agents", "tasks") - if err := os.MkdirAll(mainRulesDir, 0o755); err != nil { + if err := os.MkdirAll(mainRulesDir, 0o750); err != nil { t.Fatalf("failed to create main rules dir: %v", err) } - if err := os.MkdirAll(mainTasksDir, 0o755); err != nil { + + if err := os.MkdirAll(mainTasksDir, 0o750); err != nil { t.Fatalf("failed to create main tasks dir: %v", err) } // Create a task file in the main directory taskFile := filepath.Join(mainTasksDir, "test-task.md") + taskContent := `--- task_name: test-task --- @@ -1224,44 +1410,48 @@ task_name: test-task This is a test task. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } // Create a rule file in the main directory (should be included) mainRuleFile := filepath.Join(mainRulesDir, "main-rule.md") + mainRuleContent := `--- --- # Main Rule This rule is in the main project. ` - if err := os.WriteFile(mainRuleFile, []byte(mainRuleContent), 0o644); err != nil { + if err := os.WriteFile(mainRuleFile, []byte(mainRuleContent), 0o600); err != nil { t.Fatalf("failed to write main rule file: %v", err) } // Create a remote directory with rules remoteDir := t.TempDir() + remoteRulesDir := filepath.Join(remoteDir, ".agents", "rules") - if err := os.MkdirAll(remoteRulesDir, 0o755); err != nil { + if err := os.MkdirAll(remoteRulesDir, 0o750); err != nil { t.Fatalf("failed to create remote rules dir: %v", err) } remoteRuleFile := filepath.Join(remoteRulesDir, "remote-rule.md") + remoteRuleContent := `--- --- # Remote Rule This rule is from a remote directory. ` - if err := os.WriteFile(remoteRuleFile, []byte(remoteRuleContent), 0o644); err != nil { + if err := os.WriteFile(remoteRuleFile, []byte(remoteRuleContent), 0o600); err != nil { t.Fatalf("failed to write remote rule file: %v", err) } // Create a manifest file that references the remote directory manifestFile := filepath.Join(t.TempDir(), "manifest.txt") + manifestContent := fmt.Sprintf("file://%s\n", remoteDir) - if err := os.WriteFile(manifestFile, []byte(manifestContent), 0o644); err != nil { + if err := os.WriteFile(manifestFile, []byte(manifestContent), 0o600); err != nil { t.Fatalf("failed to write manifest file: %v", err) } @@ -1284,16 +1474,18 @@ This rule is from a remote directory. } } -// TestSingleExpansion verifies that content is expanded only once in the full flow +// TestSingleExpansion verifies that content is expanded only once in the full flow. func TestSingleExpansion(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a task that uses a parameter with expansion syntax taskFile := filepath.Join(dirs.tasksDir, "test-expand.md") + taskContent := `Task with parameter: ${param1} And a value that looks like expansion syntax but should not be expanded: ${"nested"}` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to create task file: %v", err) } @@ -1313,25 +1505,29 @@ And a value that looks like expansion syntax but should not be expanded: ${"nest } } -// TestCommandExpansionOnce verifies that command files are expanded only once +// TestCommandExpansionOnce verifies that command files are expanded only once. func TestCommandExpansionOnce(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) + commandsDir := filepath.Join(dirs.tmpDir, ".agents", "commands") - if err := os.MkdirAll(commandsDir, 0o755); err != nil { + if err := os.MkdirAll(commandsDir, 0o750); err != nil { t.Fatalf("failed to create commands dir: %v", err) } // Create a command file with a parameter commandFile := filepath.Join(commandsDir, "test-cmd.md") + commandContent := `Command param: ${cmd_param}` - if err := os.WriteFile(commandFile, []byte(commandContent), 0o644); err != nil { + if err := os.WriteFile(commandFile, []byte(commandContent), 0o600); err != nil { t.Fatalf("failed to create command file: %v", err) } // Create a task that calls the command with a param containing expansion syntax taskFile := filepath.Join(dirs.tasksDir, "test-cmd-task.md") + taskContent := `/test-cmd cmd_param="!` + "`echo injected`" + `"` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to create task file: %v", err) } @@ -1350,11 +1546,14 @@ func TestCommandExpansionOnce(t *testing.T) { } } +//nolint:funlen func TestWriteRulesOption(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file ruleFile := filepath.Join(dirs.rulesDir, "test-rule.md") + ruleContent := `--- language: go --- @@ -1362,12 +1561,13 @@ language: go This is a test rule that should be written to the user rules path. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a task file taskFile := filepath.Join(dirs.tasksDir, "test-task.md") + taskContent := `--- task_name: test-task --- @@ -1375,7 +1575,7 @@ task_name: test-task This is the task prompt. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -1387,8 +1587,17 @@ This is the task prompt. if err != nil { t.Fatalf("failed to get working directory: %v", err) } - cmd := exec.Command("go", "run", wd, "-C", dirs.tmpDir, "-a", "copilot", "-w", "test-task") + + absWd, err := filepath.Abs(filepath.Clean(wd)) + if err != nil { + t.Fatalf("failed to resolve working directory: %v", err) + } + + // #nosec G204 -- integration test runs "go run" with controlled args + cmd := exec.CommandContext(t.Context(), "go", "run", absWd, "-C", dirs.tmpDir, "-a", "copilot", "-w", "test-task") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout cmd.Stderr = &stderr // Build a clean environment that explicitly sets GOMODCACHE outside tmpDir @@ -1397,6 +1606,7 @@ This is the task prompt. if gomodcache == "" { gomodcache = filepath.Join(os.Getenv("HOME"), "go", "pkg", "mod") } + cmd.Env = append(os.Environ(), "HOME="+tmpHome, "GOMODCACHE="+gomodcache, @@ -1417,12 +1627,14 @@ This is the task prompt. if !strings.Contains(output, "# Test Task") { t.Errorf("task content not found in stdout") } + if !strings.Contains(output, "This is the task prompt.") { t.Errorf("task description not found in stdout") } // Verify that rules were written to the user rules path - expectedRulesPath := filepath.Join(tmpHome, ".github", "agents", "AGENTS.md") + expectedRulesPath := filepath.Clean(filepath.Join(tmpHome, ".github", "agents", "AGENTS.md")) + rulesFileContent, err := os.ReadFile(expectedRulesPath) if err != nil { t.Fatalf("failed to read rules file at %s: %v", expectedRulesPath, err) @@ -1432,6 +1644,7 @@ This is the task prompt. if !strings.Contains(rulesStr, "# Test Rule") { t.Errorf("rules file does not contain rule content") } + if !strings.Contains(rulesStr, "This is a test rule that should be written to the user rules path.") { t.Errorf("rules file does not contain rule description") } @@ -1443,10 +1656,11 @@ This is the task prompt. } func TestWriteRulesOptionWithoutAgent(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a simple task file without agent field - createStandardTask(t, dirs.tasksDir, "test-task") + createStandardTask(t, dirs.tasksDir) // Run with -w flag but WITHOUT -a flag and task has no agent field (should fail) output, err := runToolWithError("-C", dirs.tmpDir, "-w", "test-task") @@ -1460,11 +1674,14 @@ func TestWriteRulesOptionWithoutAgent(t *testing.T) { } } +//nolint:funlen func TestWriteRulesOptionWithResumeMode(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create a rule file ruleFile := filepath.Join(dirs.rulesDir, "test-rule.md") + ruleContent := `--- language: go --- @@ -1472,12 +1689,13 @@ language: go This is a test rule that should NOT be written in resume mode. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a resume task file taskFile := filepath.Join(dirs.tasksDir, "test-task-resume.md") + taskContent := `--- resume: true --- @@ -1485,7 +1703,7 @@ resume: true This is the task prompt for resume mode. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } @@ -1497,8 +1715,21 @@ This is the task prompt for resume mode. if err != nil { t.Fatalf("failed to get working directory: %v", err) } - cmd := exec.Command("go", "run", wd, "-C", dirs.tmpDir, "-a", "copilot", "-w", "-r", "--skip-bootstrap", "test-task-resume") + + absWd, err := filepath.Abs(filepath.Clean(wd)) + if err != nil { + t.Fatalf("failed to resolve working directory: %v", err) + } + + runArgs := []string{ + "run", absWd, "-C", dirs.tmpDir, "-a", "copilot", + "-w", "-r", "--skip-bootstrap", "test-task-resume", + } + // #nosec G204 -- integration test runs "go run" with controlled args + cmd := exec.CommandContext(t.Context(), "go", runArgs...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout cmd.Stderr = &stderr // Build a clean environment that explicitly sets GOMODCACHE outside tmpDir @@ -1507,6 +1738,7 @@ This is the task prompt for resume mode. if gomodcache == "" { gomodcache = filepath.Join(os.Getenv("HOME"), "go", "pkg", "mod") } + cmd.Env = append(os.Environ(), "HOME="+tmpHome, "GOMODCACHE="+gomodcache, @@ -1527,6 +1759,7 @@ This is the task prompt for resume mode. if !strings.Contains(output, "# Test Task Resume") { t.Errorf("task content not found in stdout") } + if !strings.Contains(output, "This is the task prompt for resume mode.") { t.Errorf("task description not found in stdout") } @@ -1534,7 +1767,8 @@ This is the task prompt for resume mode. // Verify that NO rules file was created when bootstrap is disabled expectedRulesPath := filepath.Join(tmpHome, ".github", "agents", "AGENTS.md") if _, err := os.Stat(expectedRulesPath); err == nil { - t.Errorf("rules file should NOT be created when bootstrap is disabled with -w flag, but found at %s", expectedRulesPath) + t.Errorf("rules file should NOT be created when bootstrap is disabled with -w flag, but found at %s", + expectedRulesPath) } else if !os.IsNotExist(err) { t.Fatalf("unexpected error checking for rules file: %v", err) } @@ -1548,16 +1782,18 @@ This is the task prompt for resume mode. // TestLocalDirectoryNotDeleted verifies that local directories passed via -d flag // are not deleted after the command completes. func TestLocalDirectoryNotDeleted(t *testing.T) { + t.Parallel() // Create a local directory with a rule file and a marker file localDir := t.TempDir() rulesDir := filepath.Join(localDir, ".agents", "rules") - if err := os.MkdirAll(rulesDir, 0o755); err != nil { + if err := os.MkdirAll(rulesDir, 0o750); err != nil { t.Fatalf("failed to create rules dir: %v", err) } // Create a rule file ruleFile := filepath.Join(rulesDir, "local-rule.md") + ruleContent := `--- language: go --- @@ -1565,13 +1801,13 @@ language: go This is a rule from a local directory. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a marker file to verify the directory is not deleted markerFile := filepath.Join(localDir, "marker.txt") - if err := os.WriteFile(markerFile, []byte("marker"), 0o644); err != nil { + if err := os.WriteFile(markerFile, []byte("marker"), 0o600); err != nil { t.Fatalf("failed to write marker file: %v", err) } @@ -1579,11 +1815,11 @@ This is a rule from a local directory. tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } - createStandardTask(t, tasksDir, "test-task") + createStandardTask(t, tasksDir) // Run the program with local directory using file:// URL localURL := "file://" + localDir @@ -1593,6 +1829,7 @@ This is a rule from a local directory. if !strings.Contains(output, "# Local Rule") { t.Errorf("local rule content not found in stdout") } + if !strings.Contains(output, "This is a rule from a local directory") { t.Errorf("local rule description not found in stdout") } @@ -1619,16 +1856,18 @@ This is a rule from a local directory. // TestLocalDirectoryWithoutProtocol verifies that local directories passed // without the file:// protocol are not deleted. func TestLocalDirectoryWithoutProtocol(t *testing.T) { + t.Parallel() // Create a local directory with a rule file and a marker file localDir := t.TempDir() rulesDir := filepath.Join(localDir, ".agents", "rules") - if err := os.MkdirAll(rulesDir, 0o755); err != nil { + if err := os.MkdirAll(rulesDir, 0o750); err != nil { t.Fatalf("failed to create rules dir: %v", err) } // Create a rule file ruleFile := filepath.Join(rulesDir, "local-rule.md") + ruleContent := `--- language: go --- @@ -1636,13 +1875,13 @@ language: go This is a rule from a local directory without protocol. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o600); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a marker file to verify the directory is not deleted markerFile := filepath.Join(localDir, "marker.txt") - if err := os.WriteFile(markerFile, []byte("marker"), 0o644); err != nil { + if err := os.WriteFile(markerFile, []byte("marker"), 0o600); err != nil { t.Fatalf("failed to write marker file: %v", err) } @@ -1650,11 +1889,11 @@ This is a rule from a local directory without protocol. tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { + if err := os.MkdirAll(tasksDir, 0o750); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } - createStandardTask(t, tasksDir, "test-task") + createStandardTask(t, tasksDir) // Run the program with local directory using absolute path (no protocol) output := runTool(t, "-C", tmpDir, "-d", localDir, "test-task") @@ -1663,6 +1902,7 @@ This is a rule from a local directory without protocol. if !strings.Contains(output, "# Local Rule") { t.Errorf("local rule content not found in stdout") } + if !strings.Contains(output, "This is a rule from a local directory without protocol") { t.Errorf("local rule description not found in stdout") } @@ -1689,6 +1929,8 @@ This is a rule from a local directory without protocol. // TestTaskWithEmptyContent verifies that tasks with only frontmatter // and empty or whitespace-only content are handled gracefully. func TestTaskWithEmptyContent(t *testing.T) { + t.Parallel() + tests := []struct { name string taskName string @@ -1735,12 +1977,13 @@ task_name: whitespace-task for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() dirs := setupTestDirs(t) // Create task file with empty or whitespace content // Use the task name in the filename taskFile := filepath.Join(dirs.tasksDir, tt.taskName+".md") - if err := os.WriteFile(taskFile, []byte(tt.taskContent), 0o644); err != nil { + if err := os.WriteFile(taskFile, []byte(tt.taskContent), 0o600); err != nil { t.Fatalf("failed to write task file: %v", err) } diff --git a/main.go b/main.go index b2edc8d..d229120 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "flag" "fmt" "log/slog" @@ -16,35 +17,137 @@ import ( "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) +var ( + errInvalidUsage = errors.New("invalid usage: expected one task name argument and optional user-prompt") + errWriteRulesNoAgent = errors.New("-w flag requires an agent to be specified (via task 'agent' field or -a flag)") + errNoUserRulePath = errors.New("no user rule path available for agent") + errRulesPathEscapesHome = errors.New("rules path escapes home directory") +) + +type cliConfig struct { + workDir string + resume bool + skipBootstrap bool + writeRules bool + agent codingcontext.Agent + params taskparser.Params + includes selectors.Selectors + searchPaths []string + manifestURL string + taskName string + userPrompt string +} + func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer cancel() logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) - var workDir string - var resume bool - var skipBootstrap bool // When true, skips bootstrap (default false means bootstrap enabled) - var writeRules bool - var agent codingcontext.Agent - params := make(taskparser.Params) - includes := make(selectors.Selectors) - var searchPaths []string - var manifestURL string - - flag.StringVar(&workDir, "C", ".", "Change to directory before doing anything.") - flag.BoolVar(&resume, "r", false, "Resume mode: set 'resume=true' selector to filter tasks by their frontmatter resume field.") - flag.BoolVar(&skipBootstrap, "skip-bootstrap", false, "Skip bootstrap: skip discovering rules, skills, and running bootstrap scripts.") - flag.BoolVar(&writeRules, "w", false, "Write rules to the agent's user rules path and only print the prompt to stdout. Requires agent (via task 'agent' field or -a flag).") - flag.Var(&agent, "a", "Target agent to use. Required when using -w to write rules to the agent's user rules path. Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") - flag.Var(¶ms, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") - flag.Var(&includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") - flag.Func("d", "Directory containing rules and tasks. Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, file:// etc.).", func(s string) error { - searchPaths = append(searchPaths, s) - return nil - }) - flag.StringVar(&manifestURL, "m", "", "Go Getter URL to a manifest file containing search paths (one per line). Every line is included as-is.") + if err := run(ctx, logger); err != nil { + logger.Error("Error", "error", err) + cancel() + os.Exit(1) + } + + cancel() +} + +func run(ctx context.Context, logger *slog.Logger) error { + cfg, err := parseFlags(logger) + if err != nil { + return err + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + cfg.searchPaths = append(cfg.searchPaths, "file://"+cfg.workDir) + cfg.searchPaths = append(cfg.searchPaths, "file://"+homeDir) + + cc := codingcontext.New( + codingcontext.WithParams(cfg.params), + codingcontext.WithSelectors(cfg.includes), + codingcontext.WithSearchPaths(cfg.searchPaths...), + codingcontext.WithLogger(logger), + codingcontext.WithResume(cfg.resume), + codingcontext.WithBootstrap(!cfg.skipBootstrap), + codingcontext.WithAgent(cfg.agent), + codingcontext.WithManifestURL(cfg.manifestURL), + codingcontext.WithUserPrompt(cfg.userPrompt), + ) + + result, err := cc.Run(ctx, cfg.taskName) + if err != nil { + flag.Usage() + + return fmt.Errorf("%w", err) + } + + outputContent, err := buildOutputContent(result, cfg, homeDir, logger) + if err != nil { + return err + } + + if _, err := os.Stdout.Write(append([]byte(outputContent), '\n')); err != nil { + return fmt.Errorf("writing output: %w", err) + } + + return nil +} + +func buildOutputContent( + result *codingcontext.Result, cfg *cliConfig, homeDir string, logger *slog.Logger, +) (string, error) { + if !cfg.writeRules { + return result.Prompt, nil + } + + if err := writeRulesToAgent(result, homeDir, cfg.skipBootstrap, logger); err != nil { + return "", err + } + + return result.Task.Content, nil +} +func parseFlags(logger *slog.Logger) (*cliConfig, error) { + cfg := &cliConfig{ + params: make(taskparser.Params), + includes: make(selectors.Selectors), + } + + flag.StringVar(&cfg.workDir, "C", ".", "Change to directory before doing anything.") + flag.BoolVar(&cfg.resume, "r", false, + "Resume mode: set 'resume=true' selector to filter tasks by their frontmatter resume field.") + flag.BoolVar(&cfg.skipBootstrap, "skip-bootstrap", false, + "Skip bootstrap: skip discovering rules, skills, and running bootstrap scripts.") + flag.BoolVar(&cfg.writeRules, "w", false, + "Write rules to the agent's user rules path and only print the prompt to stdout. "+ + "Requires agent (via task 'agent' field or -a flag).") + flag.Var(&cfg.agent, "a", + "Target agent to use. Required when using -w to write rules to the agent's user rules path. "+ + "Supported agents: cursor, opencode, copilot, claude, gemini, augment, windsurf, codex.") + flag.Var(&cfg.params, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") + flag.Var(&cfg.includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") + flag.Func("d", + "Directory containing rules and tasks. Can be specified multiple times. "+ + "Supports various protocols via go-getter (http://, https://, git::, s3::, file:// etc.).", + func(s string) error { + cfg.searchPaths = append(cfg.searchPaths, s) + + return nil + }) + flag.StringVar(&cfg.manifestURL, "m", "", + "Go Getter URL to a manifest file containing search paths (one per line). Every line is included as-is.") + + setupUsage(logger) + flag.Parse() + + return parseFlagArgs(cfg) +} + +func setupUsage(logger *slog.Logger) { flag.Usage = func() { logger.Info("Usage:") logger.Info(" coding-context [options] [user-prompt]") @@ -59,99 +162,79 @@ func main() { logger.Info("Options:") flag.PrintDefaults() } - flag.Parse() +} +func parseFlagArgs(cfg *cliConfig) (*cliConfig, error) { args := flag.Args() - if len(args) < 1 || len(args) > 2 { - logger.Error("Error", "error", fmt.Errorf("invalid usage: expected one task name argument and optional user-prompt")) + + const maxArgs = 2 + + if len(args) < 1 || len(args) > maxArgs { flag.Usage() - os.Exit(1) + + return nil, errInvalidUsage } - taskName := args[0] - var userPrompt string - if len(args) == 2 { - userPrompt = args[1] + cfg.taskName = args[0] + + if len(args) == maxArgs { + cfg.userPrompt = args[1] } - homeDir, err := os.UserHomeDir() - if err != nil { - logger.Error("Error", "error", fmt.Errorf("failed to get user home directory: %w", err)) - os.Exit(1) + return cfg, nil +} + +func writeRulesToAgent(result *codingcontext.Result, homeDir string, skipBootstrap bool, logger *slog.Logger) error { + if !result.Agent.IsSet() { + return errWriteRulesNoAgent + } + + if skipBootstrap { + return nil } - searchPaths = append(searchPaths, "file://"+workDir) - searchPaths = append(searchPaths, "file://"+homeDir) + relativePath := result.Agent.UserRulePath() + if relativePath == "" { + return errNoUserRulePath + } - cc := codingcontext.New( - codingcontext.WithParams(params), - codingcontext.WithSelectors(includes), - codingcontext.WithSearchPaths(searchPaths...), - codingcontext.WithLogger(logger), - codingcontext.WithResume(resume), - codingcontext.WithBootstrap(!skipBootstrap), // Invert: skipBootstrap=false means bootstrap enabled - codingcontext.WithAgent(agent), - codingcontext.WithManifestURL(manifestURL), - codingcontext.WithUserPrompt(userPrompt), - ) + rulesFile := filepath.Join(homeDir, relativePath) + rulesFile = filepath.Clean(rulesFile) - result, err := cc.Run(ctx, taskName) - if err != nil { - logger.Error("Error", "error", err) - flag.Usage() - os.Exit(1) + homeDirAbs := filepath.Clean(homeDir) + string(filepath.Separator) + if !strings.HasPrefix(rulesFile, homeDirAbs) && rulesFile != filepath.Clean(homeDir) { + return fmt.Errorf("%w: %s", errRulesPathEscapesHome, rulesFile) } - // If writeRules flag is set, write rules to UserRulePath and only output task - if writeRules { - // Get the user rule path from the agent (could be from task or -a flag) - if !result.Agent.IsSet() { - logger.Error("Error", "error", fmt.Errorf("-w flag requires an agent to be specified (via task 'agent' field or -a flag)")) - os.Exit(1) - } + rulesDir := filepath.Dir(rulesFile) - // Skip writing rules file if bootstrap is disabled since no rules are collected - if !skipBootstrap { - relativePath := result.Agent.UserRulePath() - if relativePath == "" { - logger.Error("Error", "error", fmt.Errorf("no user rule path available for agent")) - os.Exit(1) - } - - // Construct full path by joining with home directory - rulesFile := filepath.Join(homeDir, relativePath) - rulesDir := filepath.Dir(rulesFile) - - // Create directory if it doesn't exist - if err := os.MkdirAll(rulesDir, 0o755); err != nil { - logger.Error("Error", "error", fmt.Errorf("failed to create rules directory %s: %w", rulesDir, err)) - os.Exit(1) - } - - // Build rules content, trimming each rule and joining with consistent spacing - var rulesContent strings.Builder - for i, rule := range result.Rules { - if i > 0 { - rulesContent.WriteString("\n\n") - } - rulesContent.WriteString(strings.TrimSpace(rule.Content)) - } - rulesContent.WriteString("\n") - - if err := os.WriteFile(rulesFile, []byte(rulesContent.String()), 0o644); err != nil { - logger.Error("Error", "error", fmt.Errorf("failed to write rules to %s: %w", rulesFile, err)) - os.Exit(1) - } - - logger.Info("Rules written", "path", rulesFile) + const dirMode = 0o750 + + // #nosec G703 -- rulesDir is validated to be within homeDir via rulesFile check above + if err := os.MkdirAll(rulesDir, dirMode); err != nil { + return fmt.Errorf("failed to create rules directory %s: %w", rulesDir, err) + } + + var rulesContent strings.Builder + + for i, rule := range result.Rules { + if i > 0 { + rulesContent.WriteString("\n\n") } - // Output only task content (task frontmatter is not included) - fmt.Println(result.Task.Content) - } else { - // Normal mode: output everything - // Output the combined prompt (rules + skills + task) - // Note: Task frontmatter is not included in the output - fmt.Println(result.Prompt) + rulesContent.WriteString(strings.TrimSpace(rule.Content)) + } + + rulesContent.WriteString("\n") + + const fileMode = 0o600 + + // #nosec G703 -- rulesFile is validated to be within homeDir via HasPrefix check above + if err := os.WriteFile(rulesFile, []byte(rulesContent.String()), fileMode); err != nil { + return fmt.Errorf("failed to write rules to %s: %w", rulesFile, err) } + + logger.Info("Rules written", "path", rulesFile) + + return nil } diff --git a/pkg/codingcontext/README.md b/pkg/codingcontext/README.md index 3b01f6b..2b7ed86 100644 --- a/pkg/codingcontext/README.md +++ b/pkg/codingcontext/README.md @@ -22,14 +22,14 @@ import ( "os" "github.com/kitproj/coding-context-cli/pkg/codingcontext" - "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) func main() { // Create a new context with options ctx := codingcontext.New( codingcontext.WithSearchPaths("file://.", "file://"+os.Getenv("HOME")), - codingcontext.WithParams(taskparams.Params{ + codingcontext.WithParams(taskparser.Params{ "issue_number": []string{"123"}, "feature": []string{"authentication"}, }), @@ -49,7 +49,6 @@ func main() { } fmt.Println(result.Task.Content) } -} ``` ### Advanced Example @@ -65,7 +64,7 @@ import ( "github.com/kitproj/coding-context-cli/pkg/codingcontext" "github.com/kitproj/coding-context-cli/pkg/codingcontext/selectors" - "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) func main() { @@ -80,7 +79,7 @@ func main() { "file://.", "git::https://github.com/org/repo//path/to/rules", ), - codingcontext.WithParams(taskparams.Params{ + codingcontext.WithParams(taskparser.Params{ "issue_number": []string{"123"}, }), codingcontext.WithSelectors(sel), @@ -269,7 +268,7 @@ Types for parsing task content with slash commands: - `Argument` - Slash command argument (can be positional or named key=value) **Methods:** -- `(*SlashCommand) Params() taskparams.Params` - Returns parsed parameters as map +- `(*SlashCommand) Params() taskparser.Params` - Returns parsed parameters as map - `(*Text) Content() string` - Returns text content as string - Various `String()` methods for formatting each type @@ -291,7 +290,7 @@ Creates a new Context with the given options. **Options:** - `WithSearchPaths(paths ...string)` - Add search paths (supports go-getter URLs) -- `WithParams(params taskparams.Params)` - Set parameters for substitution (import `taskparams` package) +- `WithParams(params taskparser.Params)` - Set parameters for substitution (import `taskparser` package) - `WithSelectors(selectors selectors.Selectors)` - Set selectors for filtering rules (import `selectors` package) - `WithAgent(agent Agent)` - Set target agent (excludes that agent's own rules) - `WithResume(resume bool)` - Set resume selector to "true" (for filtering tasks by frontmatter resume field) @@ -304,33 +303,33 @@ Creates a new Context with the given options. Executes the context assembly for the given task name and returns the assembled result structure with rule and task markdown files (including frontmatter and content). -#### `ParseMarkdownFile[T any](path string, frontmatter *T) (Markdown[T], error)` +#### `markdown.ParseMarkdownFile[T any](path string, frontmatter *T) (Markdown[T], error)` -Parses a markdown file into frontmatter and content. Generic function that works with any frontmatter type. +Parses a markdown file into frontmatter and content. Generic function that works with any frontmatter type. Import from `github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown`. -#### `ParseTask(text string) (Task, error)` +#### `taskparser.ParseTask(text string) (Task, error)` -Parses task text content into blocks of text and slash commands. +Parses task text content into blocks of text and slash commands. Import from `github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser`. -#### `taskparams.Parse(s string) (taskparams.Params, error)` +#### `taskparser.ParseParams(s string) (taskparser.Params, error)` Parses a string containing key=value pairs with quoted values. **Examples:** ```go -import "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparams" +import "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" // Parse quoted key-value pairs -params, _ := taskparams.Parse(`key1="value1" key2="value2"`) -// Result: taskparams.Params{"key1": []string{"value1"}, "key2": []string{"value2"}} +params, _ := taskparser.ParseParams(`key1="value1" key2="value2"`) +// Result: taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}} // Parse with spaces in values -params, _ := taskparams.Parse(`key1="value with spaces" key2="value2"`) -// Result: taskparams.Params{"key1": []string{"value with spaces"}, "key2": []string{"value2"}} +params, _ = taskparser.ParseParams(`key1="value with spaces" key2="value2"`) +// Result: taskparser.Params{"key1": []string{"value with spaces"}, "key2": []string{"value2"}} // Parse with escaped quotes -params, _ := taskparams.Parse(`key1="value with \"escaped\" quotes"`) -// Result: taskparams.Params{"key1": []string{"value with \"escaped\" quotes"}} +params, _ = taskparser.ParseParams(`key1="value with \"escaped\" quotes"`) +// Result: taskparser.Params{"key1": []string{"value with \"escaped\" quotes"}} ``` #### `ParseAgent(s string) (Agent, error)` diff --git a/pkg/codingcontext/agent.go b/pkg/codingcontext/agent.go index 56da557..52223be 100644 --- a/pkg/codingcontext/agent.go +++ b/pkg/codingcontext/agent.go @@ -1,15 +1,19 @@ package codingcontext import ( + "errors" "fmt" "path/filepath" "strings" ) -// Agent represents an AI coding agent +// ErrUnknownAgent is returned when parsing an unknown or unsupported agent name. +var ErrUnknownAgent = errors.New("unknown agent") + +// Agent represents an AI coding agent. type Agent string -// Supported agents +// Supported agents. const ( AgentCursor Agent = "cursor" AgentOpenCode Agent = "opencode" @@ -21,35 +25,50 @@ const ( AgentCodex Agent = "codex" ) -// ParseAgent parses a string into an Agent type +// ParseAgent parses a string into an Agent type. func ParseAgent(s string) (Agent, error) { agent := Agent(s) + patterns := getAgentPathPatterns() + // Check if agent exists in the path patterns map - if _, exists := agentPathPatterns[agent]; exists { + if _, exists := patterns[agent]; exists { return agent, nil } // Build list of supported agents for error message - supported := make([]string, 0, len(agentPathPatterns)) - for a := range agentPathPatterns { + supported := make([]string, 0, len(patterns)) + for a := range patterns { supported = append(supported, a.String()) } - return "", fmt.Errorf("unknown agent: %s (supported: %s)", s, strings.Join(supported, ", ")) + + return "", fmt.Errorf("%w: %s (supported: %s)", ErrUnknownAgent, s, strings.Join(supported, ", ")) } -// String returns the string representation of the agent -func (a Agent) String() string { - return string(a) +// String returns the string representation of the agent. +func (a *Agent) String() string { + if a == nil { + return "" + } + + return string(*a) } -// PathPatterns returns the path patterns associated with this agent -func (a Agent) PathPatterns() []string { - return agentPathPatterns[a] +// PathPatterns returns the path patterns associated with this agent. +func (a *Agent) PathPatterns() []string { + if a == nil { + return nil + } + + return getAgentPathPatterns()[*a] } -// MatchesPath returns true if the given path matches any of the agent's patterns -func (a Agent) MatchesPath(path string) bool { +// MatchesPath returns true if the given path matches any of the agent's patterns. +func (a *Agent) MatchesPath(path string) bool { + if a == nil { + return false + } + normalizedPath := filepath.ToSlash(path) patterns := a.PathPatterns() @@ -62,42 +81,44 @@ func (a Agent) MatchesPath(path string) bool { return false } -// agentPathPatterns maps agents to their associated path patterns -var agentPathPatterns = map[Agent][]string{ - AgentCursor: { - ".cursor/", - ".cursorrules", - }, - AgentOpenCode: { - ".opencode/", - }, - AgentCopilot: { - ".github/copilot-instructions.md", - ".github/agents/", - }, - AgentClaude: { - ".claude/", - "CLAUDE.md", - "CLAUDE.local.md", - }, - AgentGemini: { - ".gemini/", - "GEMINI.md", - }, - AgentAugment: { - ".augment/", - }, - AgentWindsurf: { - ".windsurf/", - ".windsurfrules", - }, - AgentCodex: { - ".codex/", - "AGENTS.md", - }, +// getAgentPathPatterns returns the map of agents to their associated path patterns. +func getAgentPathPatterns() map[Agent][]string { + return map[Agent][]string{ + AgentCursor: { + ".cursor/", + ".cursorrules", + }, + AgentOpenCode: { + ".opencode/", + }, + AgentCopilot: { + ".github/copilot-instructions.md", + ".github/agents/", + }, + AgentClaude: { + ".claude/", + "CLAUDE.md", + "CLAUDE.local.md", + }, + AgentGemini: { + ".gemini/", + "GEMINI.md", + }, + AgentAugment: { + ".augment/", + }, + AgentWindsurf: { + ".windsurf/", + ".windsurfrules", + }, + AgentCodex: { + ".codex/", + "AGENTS.md", + }, + } } -// Set implements the flag.Value interface for Agent +// Set implements the flag.Value interface for Agent. func (a *Agent) Set(value string) error { agent, err := ParseAgent(value) if err != nil { @@ -105,13 +126,14 @@ func (a *Agent) Set(value string) error { } *a = agent + return nil } // ShouldExcludePath returns true if the given path should be excluded based on this agent -// Empty agent means no exclusion -func (a Agent) ShouldExcludePath(path string) bool { - if a == "" { +// Empty agent means no exclusion. +func (a *Agent) ShouldExcludePath(path string) bool { + if a == nil || *a == "" { return false } @@ -121,39 +143,32 @@ func (a Agent) ShouldExcludePath(path string) bool { return a.MatchesPath(path) } -// IsSet returns true if an agent has been specified (non-empty) -func (a Agent) IsSet() bool { - return a != "" +// IsSet returns true if an agent has been specified (non-empty). +func (a *Agent) IsSet() bool { + return a != nil && *a != "" +} + +// getAgentUserRulePaths returns the map of each agent to its primary user rules path (relative to home directory). +func getAgentUserRulePaths() map[Agent]string { + return map[Agent]string{ + AgentCursor: filepath.Join(".cursor", "rules", "AGENTS.md"), + AgentOpenCode: filepath.Join(".opencode", "rules", "AGENTS.md"), + AgentCopilot: filepath.Join(".github", "agents", "AGENTS.md"), + AgentClaude: filepath.Join(".claude", "CLAUDE.md"), + AgentGemini: filepath.Join(".gemini", "GEMINI.md"), + AgentAugment: filepath.Join(".augment", "rules", "AGENTS.md"), + AgentWindsurf: filepath.Join(".windsurf", "rules", "AGENTS.md"), + AgentCodex: filepath.Join(".codex", "AGENTS.md"), + } } // UserRulePath returns the primary user-level rules path for this agent relative to home directory. // Returns an empty string if the agent is not set. // The path is relative and should be joined with the home directory. -func (a Agent) UserRulePath() string { - if !a.IsSet() { +func (a *Agent) UserRulePath() string { + if a == nil || !a.IsSet() { return "" } - // Map each agent to its primary user rules path (relative to home directory) - // All paths are files (not directories) - switch a { - case AgentCursor: - return filepath.Join(".cursor", "rules", "AGENTS.md") - case AgentOpenCode: - return filepath.Join(".opencode", "rules", "AGENTS.md") - case AgentCopilot: - return filepath.Join(".github", "agents", "AGENTS.md") - case AgentClaude: - return filepath.Join(".claude", "CLAUDE.md") - case AgentGemini: - return filepath.Join(".gemini", "GEMINI.md") - case AgentAugment: - return filepath.Join(".augment", "rules", "AGENTS.md") - case AgentWindsurf: - return filepath.Join(".windsurf", "rules", "AGENTS.md") - case AgentCodex: - return filepath.Join(".codex", "AGENTS.md") - default: - return "" - } + return getAgentUserRulePaths()[*a] } diff --git a/pkg/codingcontext/agent_paths.go b/pkg/codingcontext/agent_paths.go index 8b838ec..f662a8e 100644 --- a/pkg/codingcontext/agent_paths.go +++ b/pkg/codingcontext/agent_paths.go @@ -9,65 +9,67 @@ type agentPathsConfig struct { tasksPath string // Path to search for task files } -// agentsPaths maps each agent to its specific search paths. +// getAgentsPaths returns the map of each agent to its specific search paths. // Empty string agent ("") represents the generic .agents directory structure. // If a path is empty, it is not defined for that agent. -var agentsPaths = map[Agent]agentPathsConfig{ - // Generic .agents directory structure (empty agent name) - Agent(""): { - rulesPaths: []string{".agents/rules"}, - skillsPath: ".agents/skills", - commandsPath: ".agents/commands", - tasksPath: ".agents/tasks", - }, - // Cursor agent paths - AgentCursor: { - rulesPaths: []string{".cursor/rules", ".cursorrules"}, - skillsPath: ".cursor/skills", - commandsPath: ".cursor/commands", - // No tasks path defined for Cursor - }, - // OpenCode agent paths - AgentOpenCode: { - rulesPaths: []string{".opencode/agent", ".opencode/rules"}, - skillsPath: ".opencode/skills", - commandsPath: ".opencode/command", - // No tasks path defined for OpenCode - }, - // Copilot agent paths - AgentCopilot: { - rulesPaths: []string{".github/copilot-instructions.md", ".github/agents"}, - skillsPath: ".github/skills", - // No commands or tasks paths defined for Copilot - }, - // Claude agent paths - AgentClaude: { - rulesPaths: []string{".claude", "CLAUDE.md", "CLAUDE.local.md"}, - skillsPath: ".claude/skills", - // No commands or tasks paths defined for Claude - }, - // Gemini agent paths - AgentGemini: { - rulesPaths: []string{".gemini/styleguide.md", ".gemini", "GEMINI.md"}, - skillsPath: ".gemini/skills", - // No commands or tasks paths defined for Gemini - }, - // Augment agent paths - AgentAugment: { - rulesPaths: []string{".augment/rules", ".augment/guidelines.md"}, - skillsPath: ".augment/skills", - // No commands or tasks paths defined for Augment - }, - // Windsurf agent paths - AgentWindsurf: { - rulesPaths: []string{".windsurf/rules", ".windsurfrules"}, - skillsPath: ".windsurf/skills", - // No commands or tasks paths defined for Windsurf - }, - // Codex agent paths - AgentCodex: { - rulesPaths: []string{".codex", "AGENTS.md"}, - skillsPath: ".codex/skills", - // No commands or tasks paths defined for Codex - }, +func getAgentsPaths() map[Agent]agentPathsConfig { + return map[Agent]agentPathsConfig{ + // Generic .agents directory structure (empty agent name) + Agent(""): { + rulesPaths: []string{".agents/rules"}, + skillsPath: ".agents/skills", + commandsPath: ".agents/commands", + tasksPath: ".agents/tasks", + }, + // Cursor agent paths + AgentCursor: { + rulesPaths: []string{".cursor/rules", ".cursorrules"}, + skillsPath: ".cursor/skills", + commandsPath: ".cursor/commands", + // No tasks path defined for Cursor + }, + // OpenCode agent paths + AgentOpenCode: { + rulesPaths: []string{".opencode/agent", ".opencode/rules"}, + skillsPath: ".opencode/skills", + commandsPath: ".opencode/command", + // No tasks path defined for OpenCode + }, + // Copilot agent paths + AgentCopilot: { + rulesPaths: []string{".github/copilot-instructions.md", ".github/agents"}, + skillsPath: ".github/skills", + // No commands or tasks paths defined for Copilot + }, + // Claude agent paths + AgentClaude: { + rulesPaths: []string{".claude", "CLAUDE.md", "CLAUDE.local.md"}, + skillsPath: ".claude/skills", + // No commands or tasks paths defined for Claude + }, + // Gemini agent paths + AgentGemini: { + rulesPaths: []string{".gemini/styleguide.md", ".gemini", "GEMINI.md"}, + skillsPath: ".gemini/skills", + // No commands or tasks paths defined for Gemini + }, + // Augment agent paths + AgentAugment: { + rulesPaths: []string{".augment/rules", ".augment/guidelines.md"}, + skillsPath: ".augment/skills", + // No commands or tasks paths defined for Augment + }, + // Windsurf agent paths + AgentWindsurf: { + rulesPaths: []string{".windsurf/rules", ".windsurfrules"}, + skillsPath: ".windsurf/skills", + // No commands or tasks paths defined for Windsurf + }, + // Codex agent paths + AgentCodex: { + rulesPaths: []string{".codex", "AGENTS.md"}, + skillsPath: ".codex/skills", + // No commands or tasks paths defined for Codex + }, + } } diff --git a/pkg/codingcontext/agent_paths_test.go b/pkg/codingcontext/agent_paths_test.go index 40793f4..a0893ed 100644 --- a/pkg/codingcontext/agent_paths_test.go +++ b/pkg/codingcontext/agent_paths_test.go @@ -1,10 +1,13 @@ package codingcontext import ( + "strings" "testing" ) func TestAgentPaths_Structure(t *testing.T) { + t.Parallel() + tests := []struct { name string agent Agent @@ -49,9 +52,12 @@ func TestAgentPaths_Structure(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - paths, exists := agentsPaths[tt.agent] + t.Parallel() + + paths, exists := getAgentsPaths()[tt.agent] if !exists { - t.Errorf("Agent %q not found in agentsPaths", tt.agent) + t.Errorf("Agent %q not found in agents paths", tt.agent) + return } @@ -69,28 +75,34 @@ func TestAgentPaths_Structure(t *testing.T) { } func TestAgentPaths_EmptyAgentHasAllPaths(t *testing.T) { - paths, exists := agentsPaths[Agent("")] + t.Parallel() + + paths, exists := getAgentsPaths()[Agent("")] if !exists { - t.Fatal("Empty agent not found in agentsPaths") + t.Fatal("Empty agent not found in agents paths") } if len(paths.rulesPaths) == 0 { t.Error("Empty agent should have rulesPaths defined") } + if paths.skillsPath == "" { t.Error("Empty agent should have skillsPath defined") } + if paths.commandsPath == "" { t.Error("Empty agent should have commandsPath defined") } + if paths.tasksPath == "" { t.Error("Empty agent should have tasksPath defined") } } func TestAgentPaths_RulesPathsNotEmpty(t *testing.T) { + t.Parallel() // Every agent should have at least one rules path - for agent, paths := range agentsPaths { + for agent, paths := range getAgentsPaths() { if len(paths.rulesPaths) == 0 { t.Errorf("Agent %q should have at least one rulesPaths entry", agent) } @@ -98,34 +110,41 @@ func TestAgentPaths_RulesPathsNotEmpty(t *testing.T) { } func TestAgentPaths_NoAbsolutePaths(t *testing.T) { + t.Parallel() // All paths should be relative (not absolute) - for agent, paths := range agentsPaths { + for agent, paths := range getAgentsPaths() { for _, rulePath := range paths.rulesPaths { - if len(rulePath) > 0 && rulePath[0] == '/' { + if strings.HasPrefix(rulePath, "/") { t.Errorf("Agent %q rulesPaths contains absolute path: %q", agent, rulePath) } } - if len(paths.skillsPath) > 0 && paths.skillsPath[0] == '/' { + + if strings.HasPrefix(paths.skillsPath, "/") { t.Errorf("Agent %q skillsPath is absolute: %q", agent, paths.skillsPath) } - if len(paths.commandsPath) > 0 && paths.commandsPath[0] == '/' { + + if strings.HasPrefix(paths.commandsPath, "/") { t.Errorf("Agent %q commandsPath is absolute: %q", agent, paths.commandsPath) } - if len(paths.tasksPath) > 0 && paths.tasksPath[0] == '/' { + + if strings.HasPrefix(paths.tasksPath, "/") { t.Errorf("Agent %q tasksPath is absolute: %q", agent, paths.tasksPath) } } } func TestAgentPaths_Count(t *testing.T) { + t.Parallel() // Should have 9 entries: 1 empty agent + 8 named agents expectedCount := 9 - if len(agentsPaths) != expectedCount { - t.Errorf("agentsPaths should have %d entries, got %d", expectedCount, len(agentsPaths)) + if len(getAgentsPaths()) != expectedCount { + t.Errorf("agents paths should have %d entries, got %d", expectedCount, len(getAgentsPaths())) } } func TestAgent_Paths(t *testing.T) { + t.Parallel() + tests := []struct { name string agent Agent @@ -148,15 +167,18 @@ func TestAgent_Paths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - paths, exists := agentsPaths[tt.agent] + t.Parallel() + + paths, exists := getAgentsPaths()[tt.agent] if !exists { - t.Fatalf("Agent %q not found in agentsPaths", tt.agent) + t.Fatalf("Agent %q not found in agents paths", tt.agent) } gotRulesPaths := paths.rulesPaths if len(gotRulesPaths) != len(tt.wantRulesPaths) { t.Errorf("rulesPaths length = %d, want %d", len(gotRulesPaths), len(tt.wantRulesPaths)) } + for i, want := range tt.wantRulesPaths { if i < len(gotRulesPaths) && gotRulesPaths[i] != want { t.Errorf("rulesPaths[%d] = %q, want %q", i, gotRulesPaths[i], want) diff --git a/pkg/codingcontext/agent_test.go b/pkg/codingcontext/agent_test.go index 1a36fbd..4f46239 100644 --- a/pkg/codingcontext/agent_test.go +++ b/pkg/codingcontext/agent_test.go @@ -5,100 +5,48 @@ import ( "testing" ) -func TestParseAgent(t *testing.T) { - tests := []struct { +func parseAgentCases() []struct { + name string + input string + want Agent + wantErr bool +} { + return []struct { name string input string want Agent wantErr bool }{ - { - name: "valid - cursor", - input: "cursor", - want: AgentCursor, - wantErr: false, - }, - { - name: "valid - opencode", - input: "opencode", - want: AgentOpenCode, - wantErr: false, - }, - { - name: "valid - copilot", - input: "copilot", - want: AgentCopilot, - wantErr: false, - }, - { - name: "valid - claude", - input: "claude", - want: AgentClaude, - wantErr: false, - }, - { - name: "valid - gemini", - input: "gemini", - want: AgentGemini, - wantErr: false, - }, - { - name: "valid - augment", - input: "augment", - want: AgentAugment, - wantErr: false, - }, - { - name: "valid - windsurf", - input: "windsurf", - want: AgentWindsurf, - wantErr: false, - }, - { - name: "valid - codex", - input: "codex", - want: AgentCodex, - wantErr: false, - }, - { - name: "uppercase should fail", - input: "CURSOR", - want: "", - wantErr: true, - }, - { - name: "mixed case should fail", - input: "OpenCode", - want: "", - wantErr: true, - }, - { - name: "with spaces should fail", - input: " cursor ", - want: "", - wantErr: true, - }, - { - name: "invalid agent", - input: "invalid", - want: "", - wantErr: true, - }, - { - name: "empty string", - input: "", - want: "", - wantErr: true, - }, + {name: "valid - cursor", input: "cursor", want: AgentCursor}, + {name: "valid - opencode", input: "opencode", want: AgentOpenCode}, + {name: "valid - copilot", input: "copilot", want: AgentCopilot}, + {name: "valid - claude", input: "claude", want: AgentClaude}, + {name: "valid - gemini", input: "gemini", want: AgentGemini}, + {name: "valid - augment", input: "augment", want: AgentAugment}, + {name: "valid - windsurf", input: "windsurf", want: AgentWindsurf}, + {name: "valid - codex", input: "codex", want: AgentCodex}, + {name: "uppercase should fail", input: "CURSOR", want: "", wantErr: true}, + {name: "mixed case should fail", input: "OpenCode", want: "", wantErr: true}, + {name: "with spaces should fail", input: " cursor ", want: "", wantErr: true}, + {name: "invalid agent", input: "invalid", want: "", wantErr: true}, + {name: "empty string", input: "", want: "", wantErr: true}, } +} - for _, tt := range tests { +func TestParseAgent(t *testing.T) { + t.Parallel() + + for _, tt := range parseAgentCases() { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ParseAgent(tt.input) if (err != nil) != tt.wantErr { t.Errorf("ParseAgent() error = %v, wantErr %v", err, tt.wantErr) + return } + if got != tt.want { t.Errorf("ParseAgent() = %v, want %v", got, tt.want) } @@ -106,125 +54,47 @@ func TestParseAgent(t *testing.T) { } } -func TestAgent_MatchesPath(t *testing.T) { - tests := []struct { +func agentMatchesPathCases() []struct { + name string + agent Agent + path string + wantMatch bool +} { + return []struct { name string agent Agent path string wantMatch bool }{ - { - name: "cursor matches .cursor/rules", - agent: AgentCursor, - path: ".cursor/rules/example.md", - wantMatch: true, - }, - { - name: "cursor matches .cursorrules", - agent: AgentCursor, - path: ".cursorrules", - wantMatch: true, - }, - { - name: "cursor does not match .agents/rules", - agent: AgentCursor, - path: ".agents/rules/example.md", - wantMatch: false, - }, - { - name: "opencode matches .opencode/agent", - agent: AgentOpenCode, - path: ".opencode/agent/rule.md", - wantMatch: true, - }, - { - name: "opencode matches .opencode/command", - agent: AgentOpenCode, - path: ".opencode/command/task.md", - wantMatch: true, - }, - { - name: "copilot matches instructions", - agent: AgentCopilot, - path: ".github/copilot-instructions.md", - wantMatch: true, - }, - { - name: "copilot matches agents dir", - agent: AgentCopilot, - path: ".github/agents/rule.md", - wantMatch: true, - }, - { - name: "claude matches CLAUDE.md", - agent: AgentClaude, - path: "CLAUDE.md", - wantMatch: true, - }, - { - name: "claude matches CLAUDE.local.md", - agent: AgentClaude, - path: "CLAUDE.local.md", - wantMatch: true, - }, - { - name: "claude matches .claude dir", - agent: AgentClaude, - path: ".claude/CLAUDE.md", - wantMatch: true, - }, - { - name: "gemini matches GEMINI.md", - agent: AgentGemini, - path: "GEMINI.md", - wantMatch: true, - }, - { - name: "gemini matches .gemini/styleguide.md", - agent: AgentGemini, - path: ".gemini/styleguide.md", - wantMatch: true, - }, - { - name: "augment matches .augment/rules", - agent: AgentAugment, - path: ".augment/rules/example.md", - wantMatch: true, - }, - { - name: "windsurf matches .windsurf/rules", - agent: AgentWindsurf, - path: ".windsurf/rules/example.md", - wantMatch: true, - }, - { - name: "windsurf matches .windsurfrules", - agent: AgentWindsurf, - path: ".windsurfrules", - wantMatch: true, - }, - { - name: "codex matches .codex dir", - agent: AgentCodex, - path: ".codex/AGENTS.md", - wantMatch: true, - }, - { - name: "codex matches AGENTS.md", - agent: AgentCodex, - path: "AGENTS.md", - wantMatch: true, - }, - { - name: "absolute path matching", - agent: AgentCursor, - path: "/home/user/project/.cursor/rules/example.md", - wantMatch: true, - }, + {name: "cursor matches .cursor/rules", agent: AgentCursor, path: ".cursor/rules/example.md", wantMatch: true}, + {name: "cursor matches .cursorrules", agent: AgentCursor, path: ".cursorrules", wantMatch: true}, + {name: "cursor does not match .agents/rules", agent: AgentCursor, path: ".agents/rules/example.md", wantMatch: false}, + {name: "opencode matches .opencode/agent", agent: AgentOpenCode, path: ".opencode/agent/rule.md", wantMatch: true}, + {name: "opencode matches .opencode/command", agent: AgentOpenCode, + path: ".opencode/command/task.md", wantMatch: true}, + {name: "copilot matches instructions", agent: AgentCopilot, path: ".github/copilot-instructions.md", wantMatch: true}, + {name: "copilot matches agents dir", agent: AgentCopilot, path: ".github/agents/rule.md", wantMatch: true}, + {name: "claude matches CLAUDE.md", agent: AgentClaude, path: "CLAUDE.md", wantMatch: true}, + {name: "claude matches CLAUDE.local.md", agent: AgentClaude, path: "CLAUDE.local.md", wantMatch: true}, + {name: "claude matches .claude dir", agent: AgentClaude, path: ".claude/CLAUDE.md", wantMatch: true}, + {name: "gemini matches GEMINI.md", agent: AgentGemini, path: "GEMINI.md", wantMatch: true}, + {name: "gemini matches .gemini/styleguide.md", agent: AgentGemini, path: ".gemini/styleguide.md", wantMatch: true}, + {name: "augment matches .augment/rules", agent: AgentAugment, path: ".augment/rules/example.md", wantMatch: true}, + {name: "windsurf matches .windsurf/rules", agent: AgentWindsurf, path: ".windsurf/rules/example.md", wantMatch: true}, + {name: "windsurf matches .windsurfrules", agent: AgentWindsurf, path: ".windsurfrules", wantMatch: true}, + {name: "codex matches .codex dir", agent: AgentCodex, path: ".codex/AGENTS.md", wantMatch: true}, + {name: "codex matches AGENTS.md", agent: AgentCodex, path: "AGENTS.md", wantMatch: true}, + {name: "absolute path matching", agent: AgentCursor, + path: "/home/user/project/.cursor/rules/example.md", wantMatch: true}, } +} - for _, tt := range tests { +func TestAgent_MatchesPath(t *testing.T) { + t.Parallel() + + for _, tt := range agentMatchesPathCases() { t.Run(tt.name, func(t *testing.T) { + t.Parallel() // Normalize path for testing normalizedPath := filepath.FromSlash(tt.path) @@ -236,6 +106,8 @@ func TestAgent_MatchesPath(t *testing.T) { } func TestAgent_Set(t *testing.T) { + t.Parallel() + tests := []struct { name string value string @@ -284,11 +156,15 @@ func TestAgent_Set(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var a Agent + err := a.Set(tt.value) if (err != nil) != tt.wantErr { t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr) + return } @@ -302,6 +178,8 @@ func TestAgent_Set(t *testing.T) { } func TestAgent_ShouldExcludePath(t *testing.T) { + t.Parallel() + tests := []struct { name string targetAgent string @@ -354,6 +232,8 @@ func TestAgent_ShouldExcludePath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var a Agent if tt.targetAgent != "" { if err := a.Set(tt.targetAgent); err != nil { @@ -372,18 +252,25 @@ func TestAgent_ShouldExcludePath(t *testing.T) { } func TestAgent_IsSet(t *testing.T) { + t.Parallel() + var a Agent if a.IsSet() { t.Errorf("IsSet() on empty agent = true, want false") } - a.Set("cursor") + if err := a.Set("cursor"); err != nil { + t.Fatal(err) + } + if !a.IsSet() { t.Errorf("IsSet() on set agent = false, want true") } } func TestAgent_UserRulePath(t *testing.T) { + t.Parallel() + tests := []struct { name string agent Agent @@ -438,6 +325,8 @@ func TestAgent_UserRulePath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.agent.UserRulePath() if got != tt.wantPath { t.Errorf("UserRulePath() = %q, want %q", got, tt.wantPath) diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index a57e1b5..7895406 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -1,9 +1,12 @@ +// Package codingcontext provides context assembly for AI coding agents. package codingcontext import ( "bufio" "context" "crypto/sha256" + "encoding/hex" + "errors" "fmt" "log/slog" "maps" @@ -20,7 +23,34 @@ import ( "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" ) -// Context holds the configuration and state for assembling coding context +var ( + // ErrTaskNotFound is returned when the requested task file cannot be found. + ErrTaskNotFound = errors.New("task not found") + // ErrCommandNotFound is returned when a referenced command file cannot be found. + ErrCommandNotFound = errors.New("command not found") + // ErrSkillMissingName is returned when a skill's frontmatter lacks the required name field. + ErrSkillMissingName = errors.New("skill missing required 'name' field") + // ErrSkillNameLength is returned when a skill's name exceeds the maximum length. + ErrSkillNameLength = errors.New("skill 'name' field must be 1-64 characters") + // ErrSkillMissingDesc is returned when a skill's frontmatter lacks the required description field. + ErrSkillMissingDesc = errors.New("skill missing required 'description' field") + // ErrSkillDescriptionLength is returned when a skill's description exceeds the maximum length. + ErrSkillDescriptionLength = errors.New("skill 'description' field must be 1-1024 characters") + + // ErrInvalidTaskNameNamespace is returned when the task name has an empty namespace. + ErrInvalidTaskNameNamespace = errors.New("namespace must not be empty") + // ErrInvalidTaskNameBase is returned when the task name has an empty base name. + ErrInvalidTaskNameBase = errors.New("task base name must not be empty") + // ErrInvalidTaskNameDepth is returned when the task name has more than one level of namespacing. + ErrInvalidTaskNameDepth = errors.New("only one level of namespacing is supported (expected \"namespace/task\")") +) + +const ( + maxNamespacedParts = 2 + splitLimit = 3 +) + +// Context holds the configuration and state for assembling coding context. type Context struct { params taskparser.Params includes selectors.Selectors @@ -33,13 +63,43 @@ type Context struct { totalTokens int logger *slog.Logger cmdRunner func(cmd *exec.Cmd) error - resume bool - doBootstrap bool // Controls whether to discover rules, skills, and run bootstrap scripts + resume bool + doBootstrap bool // Controls whether to discover rules, skills, and run bootstrap scripts + includeByDefault bool // Controls whether unmatched rules/skills are included by default agent Agent + namespace string // Active namespace derived from task name (e.g. "myteam" from "myteam/fix-bug") userPrompt string // User-provided prompt to append to task + lintMode bool + lintCollector *lintCollector } -// New creates a new Context with the given options +// parseNamespacedTaskName splits a task name into its optional namespace and base name. +// "myteam/fix-bug" → ("myteam", "fix-bug", nil) +// "fix-bug" → ("", "fix-bug", nil) +// "a/b/c" → error (only one level of namespacing is supported) +// "/task" or "ns/" → error (empty namespace or base name). +func parseNamespacedTaskName(taskName string) (string, string, error) { + parts := strings.SplitN(taskName, "/", splitLimit) + switch len(parts) { + case 1: + return "", parts[0], nil + case maxNamespacedParts: + ns, base := parts[0], parts[1] + if ns == "" { + return "", "", fmt.Errorf("invalid task name %q: %w", taskName, ErrInvalidTaskNameNamespace) + } + + if base == "" { + return "", "", fmt.Errorf("invalid task name %q: %w", taskName, ErrInvalidTaskNameBase) + } + + return ns, base, nil + default: + return "", "", fmt.Errorf("invalid task name %q: %w", taskName, ErrInvalidTaskNameDepth) + } +} + +// New creates a new Context with the given options. func New(opts ...Option) *Context { c := &Context{ params: make(taskparser.Params), @@ -47,7 +107,8 @@ func New(opts ...Option) *Context { rules: make([]markdown.Markdown[markdown.RuleFrontMatter], 0), skills: skills.AvailableSkills{Skills: make([]skills.Skill, 0)}, logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), - doBootstrap: true, // Default to true for backward compatibility + doBootstrap: true, // Default to true for backward compatibility + includeByDefault: true, // Default to true for backward compatibility cmdRunner: func(cmd *exec.Cmd) error { return cmd.Run() }, @@ -55,6 +116,7 @@ func New(opts ...Option) *Context { for _, opt := range opts { opt(c) } + return c } @@ -62,162 +124,361 @@ func New(opts ...Option) *Context { func nameFromPath(path string) string { baseName := filepath.Base(path) ext := filepath.Ext(baseName) + return strings.TrimSuffix(baseName, ext) } type markdownVisitor func(path string, fm *markdown.BaseFrontMatter) error -// findMarkdownFile searches for a markdown file by name in the given directories. -// Returns the path to the file if found, or an error if not found or multiple files match. +// Run executes the context assembly for the given taskName and returns the assembled result. +// The taskName is looked up in task search paths and its content is parsed into blocks. +// If the taskName cannot be found as a task file, an error is returned. +func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { + // Parse manifest file first to get additional search paths + manifestPaths, err := cc.parseManifestFile(ctx) + if err != nil { + return nil, fmt.Errorf("failed to parse manifest file: %w", err) + } + + cc.searchPaths = append(cc.searchPaths, manifestPaths...) + + // Download all remote directories (including those from manifest) + if err := cc.downloadRemoteDirectories(ctx); err != nil { + return nil, fmt.Errorf("failed to download remote directories: %w", err) + } + defer cc.cleanupDownloadedDirectories() + + // If resume mode is enabled, add resume=true as a selector + if cc.resume { + cc.includes.SetValue("resume", "true") + } + + // Get the task by name + if err := cc.findTask(taskName); err != nil { + return nil, fmt.Errorf("task not found: %w", err) + } + + // Log parameters and selectors after task is found + // This ensures we capture any additions from task/command frontmatter + cc.logger.Info("Parameters", "params", cc.params.String()) + cc.logger.Info("Selectors", "selectors", cc.includes.String()) + + if err := cc.findExecuteRuleFiles(ctx); err != nil { + return nil, fmt.Errorf("failed to find and execute rule files: %w", err) + } + + // Discover skills (load metadata only for progressive disclosure) + if err := cc.discoverSkills(); err != nil { + return nil, fmt.Errorf("failed to discover skills: %w", err) + } + + // Estimate tokens for task + cc.logger.Info("Total estimated tokens", "tokens", cc.totalTokens) + + // Build the combined prompt from all rules and task content + var promptBuilder strings.Builder + for _, rule := range cc.rules { + promptBuilder.WriteString(rule.Content) + promptBuilder.WriteString("\n") + } + + // Add skills section if there are any skills + if len(cc.skills.Skills) > 0 { + promptBuilder.WriteString("\n# Skills\n\n") + promptBuilder.WriteString("You have access to the following skills. Skills are specialized capabilities ") + promptBuilder.WriteString("that provide ") + promptBuilder.WriteString("domain expertise, workflows, and procedural knowledge. When a task matches a skill's ") + promptBuilder.WriteString("description, you can load the full skill content by reading the SKILL.md file at the ") + promptBuilder.WriteString("location provided.\n\n") + + skillsXML, err := cc.skills.AsXML() + if err != nil { + return nil, fmt.Errorf("failed to encode skills as XML: %w", err) + } + + promptBuilder.WriteString(skillsXML) + promptBuilder.WriteString("\n\n") + } + + promptBuilder.WriteString(cc.task.Content) + + // Build and return the result + result := &Result{ + Name: taskName, + Namespace: cc.namespace, + Rules: cc.rules, + Task: cc.task, + Skills: cc.skills, + Tokens: cc.totalTokens, + Agent: cc.agent, + Prompt: promptBuilder.String(), + } + + return result, nil +} + func (cc *Context) visitMarkdownFiles(searchDirFn func(path string) []string, visitor markdownVisitor) error { - var searchDirs []string + searchDirs := make([]string, 0, len(cc.downloadedPaths)) for _, path := range cc.downloadedPaths { searchDirs = append(searchDirs, searchDirFn(path)...) } for _, dir := range searchDirs { - if _, err := os.Stat(dir); os.IsNotExist(err) { - continue - } else if err != nil { - return fmt.Errorf("failed to stat directory %s: %w", dir, err) + if err := cc.visitMarkdownInDir(dir, visitor); err != nil { + return err } + } - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return fmt.Errorf("failed to walk path %s: %w", path, err) - } - ext := filepath.Ext(path) // .md or .mdc - if info.IsDir() || ext != ".md" && ext != ".mdc" { - return nil - } + return nil +} - // If selectors are provided, check if the file matches - // Parse frontmatter to check selectors - var fm markdown.BaseFrontMatter - if _, err := markdown.ParseMarkdownFile(path, &fm); err != nil { - // Skip files that can't be parsed - return nil - } +func (cc *Context) visitMarkdownInDir(dir string, visitor markdownVisitor) error { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("failed to stat directory %s: %w", dir, err) + } + + if err := filepath.Walk(dir, cc.makeMarkdownWalkFunc(visitor)); err != nil { + return fmt.Errorf("failed to walk directory %s: %w", dir, err) + } - // Skip files that don't match selectors - matches, reason := cc.includes.MatchesIncludes(fm) - if !matches { - // Log why this file was skipped - if reason != "" { - cc.logger.Info("Skipping file", "path", path, "reason", reason) + return nil +} + +func (cc *Context) makeMarkdownWalkFunc(visitor markdownVisitor) filepath.WalkFunc { + return func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("failed to walk path %s: %w", path, err) + } + + ext := filepath.Ext(path) // .md or .mdc + if info.IsDir() || (ext != ".md" && ext != ".mdc") { + return nil + } + + var fm markdown.BaseFrontMatter + if _, parseErr := markdown.ParseMarkdownFile(path, &fm); parseErr != nil { + if cc.lintCollector != nil { + var pe *markdown.ParseError + if errors.As(parseErr, &pe) { + cc.lintCollector.recordParseError(pe) + } else { + cc.lintCollector.recordError(path, LintErrorKindParse, parseErr.Error()) } - return nil } - return visitor(path, &fm) - }) - if err != nil { - return fmt.Errorf("failed to walk directory %s: %w", dir, err) + + return nil } - } - return nil + if cc.lintCollector != nil { + cc.lintCollector.recordFrontmatterValues(fm) + } + + matches, reason := cc.includes.MatchesIncludes(fm, cc.includeByDefault) + if !matches { + if reason != "" { + cc.logger.Info("Skipping file", "path", path, "reason", reason) + } + + return nil + } + + return visitor(path, &fm) + } } -// findTask searches for a task markdown file and returns it with parameters substituted +// findTask searches for a task markdown file and returns it with parameters substituted. func (cc *Context) findTask(taskName string) error { - // Add task name to includes so rules can be filtered + namespace, baseName, err := parseNamespacedTaskName(taskName) + if err != nil { + return err + } + + cc.namespace = namespace + + // Add task_name for both the full namespaced form and the base name so that + // existing task_names selectors in rule frontmatter continue to work regardless + // of whether the task is invoked with or without a namespace prefix. cc.includes.SetValue("task_name", taskName) + if baseName != taskName { + cc.includes.SetValue("task_name", baseName) + } + + // Expose the namespace as a selector so rules can restrict themselves to a + // specific namespace via frontmatter (e.g. `namespace: myteam`). + // For non-namespaced tasks we add an empty-string sentinel so that rules + // which declare `namespace: somevalue` in their frontmatter are excluded by + // the selector matching logic (their value won't be in {"": true}). + cc.includes.SetValue("namespace", namespace) + taskFound := false - err := cc.visitMarkdownFiles(taskSearchPaths, func(path string, _ *markdown.BaseFrontMatter) error { - baseName := filepath.Base(path) - ext := filepath.Ext(baseName) - if strings.TrimSuffix(baseName, ext) != taskName { + + namespacedPaths := func(dir string) []string { + return namespacedTaskSearchPaths(dir, namespace) + } + + err = cc.visitMarkdownFiles(namespacedPaths, func(path string, _ *markdown.BaseFrontMatter) error { + // Stop after the first matching file so that a namespace task takes + // precedence over a global task with the same base name. + if taskFound { + return nil + } + + base := filepath.Base(path) + ext := filepath.Ext(base) + + if strings.TrimSuffix(base, ext) != baseName { return nil } taskFound = true - var frontMatter markdown.TaskFrontMatter - md, err := markdown.ParseMarkdownFile(path, &frontMatter) + + return cc.loadTask(path, taskName) + }) + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + if !taskFound { + return fmt.Errorf("%w: %s", ErrTaskNotFound, taskName) + } + + return nil +} + +// loadTask parses and processes a task file, populating cc.task. +func (cc *Context) loadTask(path, taskName string) error { + var frontMatter markdown.TaskFrontMatter + + md, err := markdown.ParseMarkdownFile(path, &frontMatter) + if err != nil { + return fmt.Errorf("failed to parse task file %s: %w", path, err) + } + + if cc.lintCollector != nil { + cc.lintCollector.recordFile(path, LoadedFileKindTask) + } + + if frontMatter.Name == "" { + frontMatter.Name = nameFromPath(path) + } + + // Extract selector labels from task frontmatter and add them to cc.includes. + // This combines CLI selectors (from -s flag) with task selectors using OR logic: + // rules match if their frontmatter value matches ANY selector value for a given key. + cc.mergeSelectors(frontMatter.Selectors) + + // Apply the task's default inclusion policy for unmatched rules/skills. + if frontMatter.IncludeUnmatched != nil { + cc.includeByDefault = *frontMatter.IncludeUnmatched + } + + // Task frontmatter agent field overrides -a flag + if frontMatter.Agent != "" { + agent, err := ParseAgent(frontMatter.Agent) if err != nil { - return fmt.Errorf("failed to parse task file %s: %w", path, err) + return fmt.Errorf("failed to parse agent from task frontmatter: %w", err) } - if frontMatter.Name == "" { - frontMatter.Name = nameFromPath(path) + + cc.agent = agent + } + + // Use the task already parsed by the goldmark extension in ParseMarkdownFile. + // If a user prompt was appended the content changed, so re-parse the combined string + // to pick up any slash commands in the appended prompt. + task := md.Task + if cc.userPrompt != "" { + taskContent := cc.appendUserPrompt(md.Content) + + var parseErr error + + task, parseErr = taskparser.ParseTask(taskContent) + if parseErr != nil { + return fmt.Errorf("failed to parse task content in file %s: %w", path, parseErr) } + } - // Extract selector labels from task frontmatter and add them to cc.includes. - // This combines CLI selectors (from -s flag) with task selectors using OR logic: - // rules match if their frontmatter value matches ANY selector value for a given key. - // For example: if CLI has env=development and task has env=production, - // rules with either env=development OR env=production will be included. - cc.mergeSelectors(frontMatter.Selectors) + finalContent, err := cc.buildFinalContent(task, path, frontMatter.ExpandParams) + if err != nil { + return err + } - // Task frontmatter agent field overrides -a flag - if frontMatter.Agent != "" { - agent, err := ParseAgent(frontMatter.Agent) - if err != nil { - return fmt.Errorf("failed to parse agent from task frontmatter: %w", err) - } - cc.agent = agent + cc.task = markdown.FromContent(frontMatter, finalContent) + cc.totalTokens += cc.task.Tokens + + cc.logger.Info("Including task", "name", taskName, + "reason", fmt.Sprintf("task name matches '%s'", taskName), "tokens", cc.task.Tokens) + + return nil +} + +// appendUserPrompt appends the user prompt to the task content if present. +func (cc *Context) appendUserPrompt(content string) string { + if cc.userPrompt == "" { + return content + } + + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + + cc.logger.Info("Appended user_prompt to task", "user_prompt_length", len(cc.userPrompt)) + + return content + "---\n" + cc.userPrompt +} + +// buildFinalContent processes each block of a pre-parsed task into a final string. +func (cc *Context) buildFinalContent(task taskparser.Task, path string, expandParams *bool) (string, error) { + var finalContent strings.Builder + + for _, block := range task { + blockContent, err := cc.processTaskBlock(block, path, expandParams) + if err != nil { + return "", err } - // Append user_prompt to task content before parsing - // This allows user_prompt to be processed uniformly with task content - taskContent := md.Content - if cc.userPrompt != "" { - // Add delimiter to separate task from user_prompt - if !strings.HasSuffix(taskContent, "\n") { - taskContent += "\n" + finalContent.WriteString(blockContent) + } + + return finalContent.String(), nil +} + +// processTaskBlock processes a single task block (text or slash command) and returns its content. +func (cc *Context) processTaskBlock(block taskparser.Block, path string, expandParams *bool) (string, error) { + if block.Text != nil { + textContent := block.Text.Content() + + if shouldExpandParams(expandParams) { + var err error + + textContent, err = cc.expandParams(textContent, nil) + if err != nil { + return "", fmt.Errorf("failed to expand parameters in task file %s: %w", path, err) } - taskContent += "---\n" + cc.userPrompt - cc.logger.Info("Appended user_prompt to task", "user_prompt_length", len(cc.userPrompt)) } - // Parse the task content (including user_prompt) to separate text blocks from slash commands - task, err := taskparser.ParseTask(taskContent) + return textContent, nil + } + + if block.SlashCommand != nil { + commandContent, err := cc.findCommand(block.SlashCommand.Name, block.SlashCommand.Params()) if err != nil { - return fmt.Errorf("failed to parse task content in file %s: %w", path, err) - } - - // Build the final content by processing each block - // Text blocks are expanded if expand is not false - // Slash command arguments are NOT expanded here - they are passed as literals - // to command files where they may be substituted via ${param} templates - finalContent := strings.Builder{} - for _, block := range task { - if block.Text != nil { - textContent := block.Text.Content() - // Expand parameters in text blocks only if expand is not explicitly set to false - if shouldExpandParams(frontMatter.ExpandParams) { - textContent, err = cc.expandParams(textContent, nil) - if err != nil { - return fmt.Errorf("failed to expand parameters in task file %s: %w", path, err) - } - } - finalContent.WriteString(textContent) - } else if block.SlashCommand != nil { - commandContent, err := cc.findCommand(block.SlashCommand.Name, block.SlashCommand.Params()) - if err != nil { - return fmt.Errorf("failed to find command %s: %w", block.SlashCommand.Name, err) - } - finalContent.WriteString(commandContent) + if cc.lintMode && errors.Is(err, ErrCommandNotFound) { + cc.lintCollector.recordError(path, LintErrorKindMissingCommand, + "command not found: "+block.SlashCommand.Name) + + return "/" + block.SlashCommand.Name, nil } - } - cc.task = markdown.Markdown[markdown.TaskFrontMatter]{ - FrontMatter: frontMatter, - Content: finalContent.String(), - Tokens: tokencount.EstimateTokens(finalContent.String()), + return "", fmt.Errorf("failed to find command %s: %w", block.SlashCommand.Name, err) } - cc.totalTokens += cc.task.Tokens - - cc.logger.Info("Including task", "name", taskName, "reason", fmt.Sprintf("task name matches '%s'", taskName), "tokens", cc.task.Tokens) - return nil - }) - if err != nil { - return fmt.Errorf("failed to find task: %w", err) + return commandContent, nil } - if !taskFound { - return fmt.Errorf("task not found: %s", taskName) - } - return nil + + return "", nil } // findCommand searches for a command markdown file and returns its content. @@ -228,18 +489,36 @@ func (cc *Context) findTask(taskName string) error { // to allow commands to specify which rules they need. func (cc *Context) findCommand(commandName string, params taskparser.Params) (string, error) { var content *string - err := cc.visitMarkdownFiles(commandSearchPaths, func(path string, _ *markdown.BaseFrontMatter) error { + + namespacedCmdPaths := func(dir string) []string { + return namespacedCommandSearchPaths(dir, cc.namespace) + } + + err := cc.visitMarkdownFiles(namespacedCmdPaths, func(path string, _ *markdown.BaseFrontMatter) error { + // Stop after the first matching command so that a namespace command takes + // precedence over a global command with the same name. + if content != nil { + return nil + } + baseName := filepath.Base(path) + ext := filepath.Ext(baseName) if strings.TrimSuffix(baseName, ext) != commandName { return nil } var frontMatter markdown.CommandFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontMatter) if err != nil { return fmt.Errorf("failed to parse command file %s: %w", path, err) } + + if cc.lintCollector != nil { + cc.lintCollector.recordFile(path, LoadedFileKindCommand) + } + if frontMatter.Name == "" { frontMatter.Name = nameFromPath(path) } @@ -259,18 +538,22 @@ func (cc *Context) findCommand(commandName string, params taskparser.Params) (st } else { processedContent = md.Content } + content = &processedContent - cc.logger.Info("Including command", "name", commandName, "reason", fmt.Sprintf("referenced by slash command '/%s'", commandName), "path", path) + cc.logger.Info("Including command", "name", commandName, + "reason", fmt.Sprintf("referenced by slash command '/%s'", commandName), "path", path) return nil }) if err != nil { return "", err } + if content == nil { - return "", fmt.Errorf("command not found: %s", commandName) + return "", fmt.Errorf("%w: %s", ErrCommandNotFound, commandName) } + return *content, nil } @@ -296,110 +579,58 @@ func (cc *Context) mergeSelectors(selectors map[string]any) { // - Path expansion: @path // If params is provided, it is merged with cc.params (with params taking precedence). func (cc *Context) expandParams(content string, params taskparser.Params) (string, error) { + if cc.lintMode { + return cc.expandParamsLint(content, params) + } + // Merge params with cc.params mergedParams := make(taskparser.Params) maps.Copy(mergedParams, cc.params) maps.Copy(mergedParams, params) // Use the expand function to handle all expansion types - return mergedParams.Expand(content) -} - -// shouldExpandParams returns true if parameter expansion should occur based on the expandParams field. -// If expandParams is nil (not specified), it defaults to true. -func shouldExpandParams(expandParams *bool) bool { - if expandParams == nil { - return true - } - return *expandParams -} - -// Run executes the context assembly for the given taskName and returns the assembled result. -// The taskName is looked up in task search paths and its content is parsed into blocks. -// If the taskName cannot be found as a task file, an error is returned. -func (cc *Context) Run(ctx context.Context, taskName string) (*Result, error) { - // Parse manifest file first to get additional search paths - manifestPaths, err := cc.parseManifestFile(ctx) + expanded, err := mergedParams.Expand(content) if err != nil { - return nil, fmt.Errorf("failed to parse manifest file: %w", err) + return "", fmt.Errorf("failed to expand parameters: %w", err) } - cc.searchPaths = append(cc.searchPaths, manifestPaths...) - // Download all remote directories (including those from manifest) - if err := cc.downloadRemoteDirectories(ctx); err != nil { - return nil, fmt.Errorf("failed to download remote directories: %w", err) - } - defer cc.cleanupDownloadedDirectories() - - // If resume mode is enabled, add resume=true as a selector - if cc.resume { - cc.includes.SetValue("resume", "true") - } - - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get user home directory: %w", err) - } - - // Get the task by name - if err := cc.findTask(taskName); err != nil { - return nil, fmt.Errorf("task not found: %w", err) - } - - // Log parameters and selectors after task is found - // This ensures we capture any additions from task/command frontmatter - cc.logger.Info("Parameters", "params", cc.params.String()) - cc.logger.Info("Selectors", "selectors", cc.includes.String()) - - if err := cc.findExecuteRuleFiles(ctx, homeDir); err != nil { - return nil, fmt.Errorf("failed to find and execute rule files: %w", err) - } + return expanded, nil +} - // Discover skills (load metadata only for progressive disclosure) - if err := cc.discoverSkills(); err != nil { - return nil, fmt.Errorf("failed to discover skills: %w", err) - } +// expandParamsLint is a lint-mode variant of expandParams that skips shell command +// execution and tracks @path file references in the lint collector. +func (cc *Context) expandParamsLint(content string, params taskparser.Params) (string, error) { + mergedParams := make(taskparser.Params) + maps.Copy(mergedParams, cc.params) + maps.Copy(mergedParams, params) - // Estimate tokens for task - cc.logger.Info("Total estimated tokens", "tokens", cc.totalTokens) + var pathRefs []string - // Build the combined prompt from all rules and task content - var promptBuilder strings.Builder - for _, rule := range cc.rules { - promptBuilder.WriteString(rule.Content) - promptBuilder.WriteString("\n") + expanded, err := mergedParams.ExpandWith(content, taskparser.ExpandOptions{ + SkipCommands: true, + PathRefs: &pathRefs, + }) + if err != nil { + return "", fmt.Errorf("failed to expand parameters: %w", err) } - // Add skills section if there are any skills - if len(cc.skills.Skills) > 0 { - promptBuilder.WriteString("\n# Skills\n\n") - promptBuilder.WriteString("You have access to the following skills. Skills are specialized capabilities that provide ") - promptBuilder.WriteString("domain expertise, workflows, and procedural knowledge. When a task matches a skill's ") - promptBuilder.WriteString("description, you can load the full skill content by reading the SKILL.md file at the ") - promptBuilder.WriteString("location provided.\n\n") - - skillsXML, err := cc.skills.AsXML() - if err != nil { - return nil, fmt.Errorf("failed to encode skills as XML: %w", err) + if cc.lintCollector != nil { + for _, ref := range pathRefs { + cc.lintCollector.recordFile(ref, LoadedFileKindPathRef) } - promptBuilder.WriteString(skillsXML) - promptBuilder.WriteString("\n\n") } - promptBuilder.WriteString(cc.task.Content) + return expanded, nil +} - // Build and return the result - result := &Result{ - Name: taskName, - Rules: cc.rules, - Task: cc.task, - Skills: cc.skills, - Tokens: cc.totalTokens, - Agent: cc.agent, - Prompt: promptBuilder.String(), +// shouldExpandParams returns true if parameter expansion should occur based on the expandParams field. +// If expandParams is nil (not specified), it defaults to true. +func shouldExpandParams(expandParams *bool) bool { + if expandParams == nil { + return true } - return result, nil + return *expandParams } // isLocalPath checks if a path is a local file system path. @@ -427,9 +658,10 @@ func isLocalPath(path string) bool { // For file:// URLs, it strips the protocol prefix. // For other local paths, it returns them as-is. func normalizeLocalPath(path string) string { - if strings.HasPrefix(path, "file://") { - return strings.TrimPrefix(path, "file://") + if rest, ok := strings.CutPrefix(path, "file://"); ok { + return rest } + return path } @@ -437,7 +669,8 @@ func downloadDir(path string) string { // hash the path and prepend it with a temporary directory hash := sha256.Sum256([]byte(path)) tempDir := os.TempDir() - return filepath.Join(tempDir, fmt.Sprintf("%x", hash)) + + return filepath.Join(tempDir, hex.EncodeToString(hash[:])) } // parseManifestFile downloads a manifest file from a Go Getter URL and returns @@ -454,18 +687,22 @@ func (cc *Context) parseManifestFile(ctx context.Context) ([]string, error) { if _, err := getter.GetFile(ctx, manifestFile, cc.manifestURL); err != nil { return nil, fmt.Errorf("failed to download manifest file %s: %w", cc.manifestURL, err) } - defer os.RemoveAll(manifestFile) + + defer func() { _ = os.RemoveAll(manifestFile) }() cc.logger.Info("Downloaded manifest file", "path", manifestFile) - // Read and parse the manifest file - file, err := os.Open(manifestFile) + cleanPath := filepath.Clean(manifestFile) + + file, err := os.Open(cleanPath) if err != nil { return nil, fmt.Errorf("failed to open manifest file: %w", err) } - defer file.Close() + + defer func() { _ = file.Close() }() var paths []string + scanner := bufio.NewScanner(file) for scanner.Scan() { paths = append(paths, scanner.Text()) @@ -487,15 +724,18 @@ func (cc *Context) downloadRemoteDirectories(ctx context.Context) error { localPath := normalizeLocalPath(path) cc.logger.Info("Using local directory", "path", localPath) cc.downloadedPaths = append(cc.downloadedPaths, localPath) + continue } // Download remote directories cc.logger.Info("Downloading remote directory", "path", path) + dst := downloadDir(path) if _, err := getter.Get(ctx, dst, path); err != nil { return fmt.Errorf("failed to download remote directory %s: %w", path, err) } + cc.logger.Info("Downloaded to", "path", dst) cc.downloadedPaths = append(cc.downloadedPaths, dst) } @@ -518,18 +758,28 @@ func (cc *Context) cleanupDownloadedDirectories() { } } -func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) error { +func (cc *Context) findExecuteRuleFiles(ctx context.Context) error { // Skip rule file discovery if bootstrap is disabled if !cc.doBootstrap { return nil } - err := cc.visitMarkdownFiles(rulePaths, func(path string, baseFm *markdown.BaseFrontMatter) error { + namespacedRulePaths := func(dir string) []string { + return namespacedRuleSearchPaths(dir, cc.namespace) + } + + err := cc.visitMarkdownFiles(namespacedRulePaths, func(path string, baseFm *markdown.BaseFrontMatter) error { var frontmatter markdown.RuleFrontMatter + md, err := markdown.ParseMarkdownFile(path, &frontmatter) if err != nil { return fmt.Errorf("failed to parse markdown file %s: %w", path, err) } + + if cc.lintCollector != nil { + cc.lintCollector.recordFile(path, LoadedFileKindRule) + } + if frontmatter.Name == "" { frontmatter.Name = nameFromPath(path) } @@ -544,18 +794,15 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err } else { processedContent = md.Content } + tokens := tokencount.EstimateTokens(processedContent) - cc.rules = append(cc.rules, markdown.Markdown[markdown.RuleFrontMatter]{ - FrontMatter: frontmatter, - Content: processedContent, - Tokens: tokens, - }) + cc.rules = append(cc.rules, markdown.FromContent(frontmatter, processedContent)) cc.totalTokens += tokens // Get match reason to explain why this rule was included - _, reason := cc.includes.MatchesIncludes(*baseFm) + _, reason := cc.includes.MatchesIncludes(*baseFm, cc.includeByDefault) cc.logger.Info("Including rule file", "path", path, "reason", reason, "tokens", tokens) if err := cc.runBootstrapScript(ctx, path, frontmatter.Bootstrap); err != nil { @@ -572,30 +819,44 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context, homeDir string) err } func (cc *Context) runBootstrapScript(ctx context.Context, path string, frontmatterBootstrap string) error { + // executablePerm is the permission for executable scripts (0755) - required for direct execution and shebang support. + const executablePerm = 0o755 + + // In lint mode, skip execution but stat-check companion bootstrap files. + if cc.lintMode { + cc.recordLintBootstrap(path, frontmatterBootstrap) + + return nil + } + // Prefer frontmatter bootstrap if present if frontmatterBootstrap != "" { cc.logger.Info("Running bootstrap from frontmatter", "path", path) - // Create a temporary file for the bootstrap script tmpFile, err := os.CreateTemp("", "bootstrap-*.sh") if err != nil { return fmt.Errorf("failed to create temp file for bootstrap script from %s: %w", path, err) } + tmpFilePath := tmpFile.Name() - defer os.Remove(tmpFilePath) - // Write the bootstrap script to the temp file + defer func() { _ = os.Remove(tmpFilePath) }() + if _, err := tmpFile.WriteString(frontmatterBootstrap); err != nil { - tmpFile.Close() + _ = tmpFile.Close() + return fmt.Errorf("failed to write bootstrap script from %s: %w", path, err) } - tmpFile.Close() - // Make it executable - if err := os.Chmod(tmpFilePath, 0o755); err != nil { + _ = tmpFile.Close() + + // Scripts must be executable to run; supports shebangs (e.g. #!/usr/bin/python3) + // #nosec G302 G703 -- bootstrap scripts require 0755; tmpFilePath from CreateTemp is system-generated + if err := os.Chmod(tmpFilePath, executablePerm); err != nil { return fmt.Errorf("failed to chmod bootstrap script from %s: %w", path, err) } + // #nosec G204 G702 -- intentionally executing user-defined bootstrap scripts; path from CreateTemp cmd := exec.CommandContext(ctx, tmpFilePath) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr @@ -603,29 +864,28 @@ func (cc *Context) runBootstrapScript(ctx context.Context, path string, frontmat if err := cc.cmdRunner(cmd); err != nil { return fmt.Errorf("frontmatter bootstrap script failed for %s: %w", path, err) } + return nil } // Fall back to file-based bootstrap - // Check for a bootstrap file named -bootstrap - // For example, setup.md -> setup-bootstrap, setup.mdc -> setup-bootstrap baseNameWithoutExt := strings.TrimSuffix(path, filepath.Ext(path)) bootstrapFilePath := baseNameWithoutExt + "-bootstrap" if _, err := os.Stat(bootstrapFilePath); os.IsNotExist(err) { - // Doesn't exist, just skip. return nil } else if err != nil { return fmt.Errorf("failed to stat bootstrap file %s: %w", bootstrapFilePath, err) } - // Bootstrap file exists, make it executable and run it before printing content - if err := os.Chmod(bootstrapFilePath, 0o755); err != nil { + cc.logger.Info("Running bootstrap script", "path", bootstrapFilePath) + + // #nosec G302 -- bootstrap scripts require executablePerm for direct execution and shebang support + if err := os.Chmod(bootstrapFilePath, executablePerm); err != nil { return fmt.Errorf("failed to chmod bootstrap file %s: %w", bootstrapFilePath, err) } - cc.logger.Info("Running bootstrap script", "path", bootstrapFilePath) - + // #nosec G204 G702 -- intentionally executing user-defined bootstrap scripts cmd := exec.CommandContext(ctx, bootstrapFilePath) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr @@ -633,6 +893,7 @@ func (cc *Context) runBootstrapScript(ctx context.Context, path string, frontmat if err := cc.cmdRunner(cmd); err != nil { return fmt.Errorf("file-based bootstrap script failed for %s: %w", path, err) } + return nil } @@ -645,87 +906,138 @@ func (cc *Context) discoverSkills() error { } var skillPaths []string + for _, path := range cc.downloadedPaths { - skillPaths = append(skillPaths, skillSearchPaths(path)...) + skillPaths = append(skillPaths, namespacedSkillSearchPaths(path, cc.namespace)...) } for _, dir := range skillPaths { - if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := cc.discoverSkillsInDir(dir); err != nil { + return err + } + } + + return nil +} + +// discoverSkillsInDir discovers skills within a single directory. +func (cc *Context) discoverSkillsInDir(dir string) error { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("failed to stat skill directory %s: %w", dir, err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("failed to read skill directory %s: %w", dir, err) + } + + for _, entry := range entries { + if !entry.IsDir() { continue - } else if err != nil { - return fmt.Errorf("failed to stat skill directory %s: %w", dir, err) } - // List all subdirectories in the skills directory - entries, err := os.ReadDir(dir) - if err != nil { - return fmt.Errorf("failed to read skill directory %s: %w", dir, err) + skillFile := filepath.Join(dir, entry.Name(), "SKILL.md") + + if err := cc.loadSkillEntry(skillFile); err != nil { + return err } + } - for _, entry := range entries { - if !entry.IsDir() { - continue - } + return nil +} - skillDir := filepath.Join(dir, entry.Name()) - skillFile := filepath.Join(skillDir, "SKILL.md") +// loadSkillEntry loads and validates a single skill from its SKILL.md file. +func (cc *Context) loadSkillEntry(skillFile string) error { + if _, err := os.Stat(skillFile); os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("failed to stat skill file %s: %w", skillFile, err) + } - // Check if SKILL.md exists - if _, err := os.Stat(skillFile); os.IsNotExist(err) { - continue - } else if err != nil { - return fmt.Errorf("failed to stat skill file %s: %w", skillFile, err) - } + var frontmatter markdown.SkillFrontMatter - // Parse only the frontmatter (metadata) - var frontmatter markdown.SkillFrontMatter - _, err := markdown.ParseMarkdownFile(skillFile, &frontmatter) - if err != nil { - return fmt.Errorf("failed to parse skill file %s: %w", skillFile, err) - } + if _, err := markdown.ParseMarkdownFile(skillFile, &frontmatter); err != nil { + return fmt.Errorf("failed to parse skill file %s: %w", skillFile, err) + } - // Check if the skill matches the selectors first (before validation) - matches, reason := cc.includes.MatchesIncludes(frontmatter.BaseFrontMatter) - if !matches { - // Log why this skill was skipped - if reason != "" { - cc.logger.Info("Skipping skill", "name", frontmatter.Name, "path", skillFile, "reason", reason) - } - continue - } + if cc.lintCollector != nil { + cc.lintCollector.recordFile(skillFile, LoadedFileKindSkill) + } - // Validate required fields and their lengths - if frontmatter.Name == "" { - return fmt.Errorf("skill %s missing required 'name' field", skillFile) - } - if len(frontmatter.Name) > 64 { - return fmt.Errorf("skill %s 'name' field must be 1-64 characters, got %d", skillFile, len(frontmatter.Name)) - } + matches, reason := cc.includes.MatchesIncludes(frontmatter.BaseFrontMatter, cc.includeByDefault) + if !matches { + if reason != "" { + cc.logger.Info("Skipping skill", "name", frontmatter.Name, "path", skillFile, "reason", reason) + } - if frontmatter.Description == "" { - return fmt.Errorf("skill %s missing required 'description' field", skillFile) - } - if len(frontmatter.Description) > 1024 { - return fmt.Errorf("skill %s 'description' field must be 1-1024 characters, got %d", skillFile, len(frontmatter.Description)) - } + return nil + } - // Get absolute path for the skill file - absPath, err := filepath.Abs(skillFile) - if err != nil { - return fmt.Errorf("failed to get absolute path for skill %s: %w", skillFile, err) - } + return cc.validateAndAddSkill(frontmatter, skillFile, reason) +} - // Add skill to the collection - cc.skills.Skills = append(cc.skills.Skills, skills.Skill{ - Name: frontmatter.Name, - Description: frontmatter.Description, - Location: absPath, - }) +// validateAndAddSkill validates skill metadata and adds it to the skill collection. +func (cc *Context) validateAndAddSkill(frontmatter markdown.SkillFrontMatter, skillFile, reason string) error { + if frontmatter.Name == "" { + if cc.lintMode { + cc.lintCollector.recordError(skillFile, LintErrorKindSkillValidation, + fmt.Sprintf("%v: %s", ErrSkillMissingName, skillFile)) - // Log with explanation of why skill was included - cc.logger.Info("Discovered skill", "name", frontmatter.Name, "reason", reason, "path", absPath) + return nil } + + return fmt.Errorf("%w: %s", ErrSkillMissingName, skillFile) + } + + const maxSkillNameLen = 64 + if len(frontmatter.Name) > maxSkillNameLen { + if cc.lintMode { + cc.lintCollector.recordError(skillFile, LintErrorKindSkillValidation, + fmt.Sprintf("%v: %s (got %d)", ErrSkillNameLength, skillFile, len(frontmatter.Name))) + + return nil + } + + return fmt.Errorf("%w: %s (got %d)", ErrSkillNameLength, skillFile, len(frontmatter.Name)) + } + + if frontmatter.Description == "" { + if cc.lintMode { + cc.lintCollector.recordError(skillFile, LintErrorKindSkillValidation, + fmt.Sprintf("%v: %s", ErrSkillMissingDesc, skillFile)) + + return nil + } + + return fmt.Errorf("%w: %s", ErrSkillMissingDesc, skillFile) } + const maxSkillDescLen = 1024 + if len(frontmatter.Description) > maxSkillDescLen { + if cc.lintMode { + cc.lintCollector.recordError(skillFile, LintErrorKindSkillValidation, + fmt.Sprintf("%v: %s (got %d)", ErrSkillDescriptionLength, skillFile, len(frontmatter.Description))) + + return nil + } + + return fmt.Errorf("%w: %s (got %d)", ErrSkillDescriptionLength, skillFile, len(frontmatter.Description)) + } + + absPath, err := filepath.Abs(skillFile) + if err != nil { + return fmt.Errorf("failed to get absolute path for skill %s: %w", skillFile, err) + } + + cc.skills.Skills = append(cc.skills.Skills, skills.Skill{ + Name: frontmatter.Name, + Description: frontmatter.Description, + Location: absPath, + }) + + cc.logger.Info("Discovered skill", "name", frontmatter.Name, "reason", reason, "path", absPath) + return nil } diff --git a/pkg/codingcontext/context_coverage_test.go b/pkg/codingcontext/context_coverage_test.go new file mode 100644 index 0000000..a02a95d --- /dev/null +++ b/pkg/codingcontext/context_coverage_test.go @@ -0,0 +1,38 @@ +package codingcontext + +import ( + "encoding/hex" + "testing" +) + +// TestDownloadDir_Deterministic verifies that downloadDir produces the same output +// for the same input (SHA-256 is deterministic) and different outputs for different inputs. +func TestDownloadDir_Deterministic(t *testing.T) { + t.Parallel() + + path := "github.com/example/repo//some/path" + got1 := downloadDir(path) + got2 := downloadDir(path) + + if got1 != got2 { + t.Errorf("downloadDir is not deterministic: %q != %q", got1, got2) + } + + other := downloadDir("github.com/other/repo") + if got1 == other { + t.Errorf("downloadDir should produce different results for different inputs") + } +} + +// TestDownloadDir_ContainsHex verifies that the last component of the path +// returned by downloadDir is a valid hex string (SHA-256 hash encoded). +func TestDownloadDir_ContainsHex(t *testing.T) { + t.Parallel() + + result := downloadDir("https://example.com/repo") + // filepath.Base of result should be a hex-encoded 32-byte SHA-256 hash (64 hex chars) + base := result[len(result)-64:] + if _, err := hex.DecodeString(base); err != nil { + t.Errorf("downloadDir last 64 chars %q is not valid hex: %v", base, err) + } +} diff --git a/pkg/codingcontext/context_edge_cases_test.go b/pkg/codingcontext/context_edge_cases_test.go new file mode 100644 index 0000000..55cb09d --- /dev/null +++ b/pkg/codingcontext/context_edge_cases_test.go @@ -0,0 +1,414 @@ +package codingcontext + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestMdcExtensionRuleDiscovery verifies that .mdc files are treated as rule files. +// The walk function explicitly allows .mdc extensions alongside .md; if that check is +// ever dropped, this test will catch the regression. +func TestMdcExtensionRuleDiscovery(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "task", "", "Task content") + + // Place a rule as a .mdc file + ruleDir := filepath.Join(dir, ".agents", "rules") + if err := os.MkdirAll(ruleDir, 0o750); err != nil { + t.Fatalf("failed to create rule dir: %v", err) + } + + rulePath := filepath.Join(ruleDir, "style.mdc") + if err := os.WriteFile(rulePath, []byte("MDC rule content"), 0o600); err != nil { + t.Fatalf("failed to write .mdc file: %v", err) + } + + c := New(WithSearchPaths(dir)) + + result, err := c.Run(context.Background(), "task") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if len(result.Rules) != 1 { + t.Fatalf("expected 1 rule from .mdc file, got %d", len(result.Rules)) + } + + if !strings.Contains(result.Rules[0].Content, "MDC rule content") { + t.Errorf("expected .mdc rule content, got %q", result.Rules[0].Content) + } +} + +// TestSkillValidation_NameExactlyAtLimit verifies that a skill name of exactly 64 +// characters is accepted (boundary should be inclusive). +func TestSkillValidation_NameExactlyAtLimit(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "task", "", "Task") + + name64 := strings.Repeat("x", 64) + skillContent := "---\nname: " + name64 + "\ndescription: Valid description.\n---\n" + createSkill(t, dir, ".agents/skills/myskill", skillContent) + + c := New(WithSearchPaths(dir)) + + result, err := c.Run(context.Background(), "task") + if err != nil { + t.Fatalf("Run() error for 64-char skill name: %v", err) + } + + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) + } + + if result.Skills.Skills[0].Name != name64 { + t.Errorf("expected skill name %q, got %q", name64, result.Skills.Skills[0].Name) + } +} + +// TestSkillValidation_NameOverLimit verifies that a skill name of 65 characters is +// rejected with ErrSkillNameLength. +func TestSkillValidation_NameOverLimit(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "task", "", "Task") + + name65 := strings.Repeat("x", 65) + skillContent := "---\nname: " + name65 + "\ndescription: Valid description.\n---\n" + createSkill(t, dir, ".agents/skills/myskill", skillContent) + + c := New(WithSearchPaths(dir)) + + _, err := c.Run(context.Background(), "task") + if err == nil { + t.Fatal("expected Run() to fail for 65-char skill name") + } + + if !errors.Is(err, ErrSkillNameLength) { + t.Errorf("expected ErrSkillNameLength, got: %v", err) + } +} + +// TestSkillValidation_DescExactlyAtLimit verifies that a skill description of exactly +// 1024 characters is accepted. +func TestSkillValidation_DescExactlyAtLimit(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "task", "", "Task") + + desc1024 := strings.Repeat("d", 1024) + skillContent := "---\nname: valid-skill\ndescription: " + desc1024 + "\n---\n" + createSkill(t, dir, ".agents/skills/myskill", skillContent) + + c := New(WithSearchPaths(dir)) + + result, err := c.Run(context.Background(), "task") + if err != nil { + t.Fatalf("Run() error for 1024-char description: %v", err) + } + + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) + } +} + +// TestSkillValidation_DescOverLimit verifies that a skill description of 1025 +// characters is rejected with ErrSkillDescriptionLength. +func TestSkillValidation_DescOverLimit(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "task", "", "Task") + + desc1025 := strings.Repeat("d", 1025) + skillContent := "---\nname: valid-skill\ndescription: " + desc1025 + "\n---\n" + createSkill(t, dir, ".agents/skills/myskill", skillContent) + + c := New(WithSearchPaths(dir)) + + _, err := c.Run(context.Background(), "task") + if err == nil { + t.Fatal("expected Run() to fail for 1025-char description") + } + + if !errors.Is(err, ErrSkillDescriptionLength) { + t.Errorf("expected ErrSkillDescriptionLength, got: %v", err) + } +} + +// TestBootstrapFailurePropagates verifies that when the bootstrap script runner +// returns an error, Run() propagates it as a failure. The rule is appended to +// cc.rules before bootstrap runs, so the error must surface via Run() rather than +// being silently ignored. +func TestBootstrapFailurePropagates(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "task", "", "Task") + createRule(t, dir, ".agents/rules/rule.md", "", "Rule content") + createBootstrapScript(t, dir, ".agents/rules/rule.md", "#!/bin/sh\nexit 1") + + c := New(WithSearchPaths(dir)) + // Replace the default runner with one that always fails + c.cmdRunner = func(_ *exec.Cmd) error { + return errors.New("simulated bootstrap failure") //nolint:err113 + } + + _, err := c.Run(context.Background(), "task") + if err == nil { + t.Fatal("expected Run() to fail when bootstrap script errors") + } + + if !strings.Contains(err.Error(), "bootstrap") { + t.Errorf("expected bootstrap-related error message, got: %v", err) + } +} + +// TestSkillSelectorFiltering verifies that skills whose frontmatter contains a +// selector key that does NOT match any active selector value are excluded. +// This exercises the MatchesIncludes path inside loadSkillEntry. +func TestSkillSelectorFiltering(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + // Task adds env: development to the includes via its frontmatter selectors + createTask(t, dir, "task", "selectors:\n env: development", "Task content") + + // This skill declares env: production — it should be excluded + createSkill(t, dir, ".agents/skills/prod-skill", + "---\nname: prod-skill\ndescription: A production skill.\nenv: production\n---\n") + + // This skill has no env selector — it should be included + createSkill(t, dir, ".agents/skills/generic-skill", + "---\nname: generic-skill\ndescription: A generic skill.\n---\n") + + c := New(WithSearchPaths(dir)) + + result, err := c.Run(context.Background(), "task") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + skillNames := make([]string, 0, len(result.Skills.Skills)) + for _, s := range result.Skills.Skills { + skillNames = append(skillNames, s.Name) + } + + for _, name := range skillNames { + if name == "prod-skill" { + t.Error("prod-skill (env: production) should be excluded when env: development is active") + } + } + + found := false + + for _, name := range skillNames { + if name == "generic-skill" { + found = true + } + } + + if !found { + t.Errorf("generic-skill (no env selector) should be included; got skills: %v", skillNames) + } +} + +// TestMergeSelectorsIntegerYAMLValue verifies that task frontmatter selectors whose +// YAML values parse as integers (not strings) still match rule frontmatter correctly. +// The mergeSelectors function uses fmt.Sprint, and MatchesIncludes does the same; +// if either were changed to a type assertion, this test would panic or fail. +func TestMergeSelectorsIntegerYAMLValue(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + // YAML parses bare integers without quotes — the selector value is an int + createTask(t, dir, "task", "selectors:\n priority: 1", "Task content") + createRule(t, dir, ".agents/rules/p1.md", "priority: 1", "Priority 1 rule") + createRule(t, dir, ".agents/rules/p2.md", "priority: 2", "Priority 2 rule") + createRule(t, dir, ".agents/rules/any.md", "", "No priority rule") + + c := New(WithSearchPaths(dir)) + + result, err := c.Run(context.Background(), "task") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + // Priority-1 rule and the no-priority rule should be included; priority-2 excluded + var p1Found, p2Found bool + + for _, rule := range result.Rules { + if strings.Contains(rule.Content, "Priority 1") { + p1Found = true + } + + if strings.Contains(rule.Content, "Priority 2") { + p2Found = true + } + } + + if !p1Found { + t.Error("expected Priority 1 rule to be included (priority: 1 matches)") + } + + if p2Found { + t.Error("expected Priority 2 rule to be excluded (priority: 2 does not match priority: 1)") + } +} + +// TestMergeSelectorsYAMLBoolValue verifies selector matching when YAML parses a +// selector value as a boolean (true/false rather than a quoted string). +func TestMergeSelectorsYAMLBoolValue(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + // YAML parses bare `true` as a bool, not a string + createTask(t, dir, "task", "selectors:\n experimental: true", "Task content") + createRule(t, dir, ".agents/rules/exp.md", "experimental: true", "Experimental rule") + createRule(t, dir, ".agents/rules/stable.md", "experimental: false", "Stable rule") + createRule(t, dir, ".agents/rules/any.md", "", "No experimental flag") + + c := New(WithSearchPaths(dir)) + + result, err := c.Run(context.Background(), "task") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + var expFound, stableFound bool + + for _, rule := range result.Rules { + if strings.Contains(rule.Content, "Experimental rule") { + expFound = true + } + + if strings.Contains(rule.Content, "Stable rule") { + stableFound = true + } + } + + if !expFound { + t.Error("expected Experimental rule to be included (experimental: true matches)") + } + + if stableFound { + t.Error("expected Stable rule to be excluded (experimental: false does not match true)") + } +} + +// TestEmptyTaskBody verifies that a task file containing only frontmatter and an +// empty body is accepted without error and produces an empty content string. +func TestEmptyTaskBody(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "empty-body", "priority: high", "") + + c := New(WithSearchPaths(dir), WithBootstrap(false)) + + result, err := c.Run(context.Background(), "empty-body") + if err != nil { + t.Fatalf("Run() error for empty task body: %v", err) + } + + if strings.TrimSpace(result.Task.Content) != "" { + t.Errorf("expected empty task content, got %q", result.Task.Content) + } +} + +// TestCommandPrecedenceAcrossSearchPaths verifies that when the same command name +// exists in two search paths, the first search path wins. This documents the +// "first match wins" contract of findCommand. +func TestCommandPrecedenceAcrossSearchPaths(t *testing.T) { + t.Parallel() + + dir1 := t.TempDir() + dir2 := t.TempDir() + + createTask(t, dir1, "task", "", "/deploy") + createCommand(t, dir1, "deploy", "", "Deploy from path one") + createCommand(t, dir2, "deploy", "", "Deploy from path two") + + c := New(WithSearchPaths(dir1, dir2), WithBootstrap(false)) + + result, err := c.Run(context.Background(), "task") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if !strings.Contains(result.Task.Content, "Deploy from path one") { + t.Errorf("expected first search path command to win, got %q", result.Task.Content) + } + + if strings.Contains(result.Task.Content, "Deploy from path two") { + t.Error("second search path command must not appear when first path has same command") + } +} + +// TestEmptyTaskName verifies that passing an empty task name to Run() results in a +// task-not-found error rather than a panic or unexpected success. +func TestEmptyTaskName(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + c := New(WithSearchPaths(dir), WithBootstrap(false)) + + _, err := c.Run(context.Background(), "") + if err == nil { + t.Fatal("expected error for empty task name") + } + + if !errors.Is(err, ErrTaskNotFound) { + t.Errorf("expected ErrTaskNotFound for empty task name, got: %v", err) + } +} + +// TestUserPromptSeparatorInContent verifies that when a user prompt is appended, +// the resulting content contains the task text, the --- separator, and the prompt +// text — confirming the documented append format. +func TestUserPromptSeparatorInContent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "task", "", "Task body") + + c := New(WithSearchPaths(dir), WithBootstrap(false), WithUserPrompt("User text")) + + result, err := c.Run(context.Background(), "task") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + content := result.Task.Content + + taskIdx := strings.Index(content, "Task body") + userIdx := strings.Index(content, "User text") + + if taskIdx < 0 { + t.Error("task body missing from content") + } + + if userIdx < 0 { + t.Error("user prompt missing from content") + } + + if taskIdx >= 0 && userIdx >= 0 && taskIdx >= userIdx { + t.Error("expected task body to appear before user prompt") + } + + // The separator "---" must appear between them + between := content[taskIdx:userIdx] + if !strings.Contains(between, "---") { + t.Errorf("expected '---' separator between task and user prompt; between section: %q", between) + } +} diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index 245928a..9ad5b02 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -13,226 +13,122 @@ import ( "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) +const cursorSkillName = "cursor-skill" + // Test helper functions for creating fixtures -// createTask creates a task file in the .agents/tasks directory -func createTask(t *testing.T, dir, name, frontmatter, content string) { - t.Helper() - taskDir := filepath.Join(dir, ".agents", "tasks") - if err := os.MkdirAll(taskDir, 0o755); err != nil { - t.Fatalf("failed to create task directory: %v", err) +// buildMarkdownContent wraps content with a YAML frontmatter block when +// frontmatter is non-empty. Returns content unchanged when frontmatter is empty. +func buildMarkdownContent(frontmatter, content string) string { + if frontmatter == "" { + return content } + return fmt.Sprintf("---\n%s\n---\n%s", frontmatter, content) +} - var fileContent string - if frontmatter != "" { - fileContent = fmt.Sprintf("---\n%s\n---\n%s", frontmatter, content) - } else { - fileContent = content +// writeMarkdownFile writes a markdown file (with optional frontmatter) to path, +// creating any missing parent directories. +func writeMarkdownFile(t *testing.T, path, frontmatter, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) } - - taskPath := filepath.Join(taskDir, name+".md") - if err := os.WriteFile(taskPath, []byte(fileContent), 0o644); err != nil { - t.Fatalf("failed to create task file: %v", err) + if err := os.WriteFile(path, []byte(buildMarkdownContent(frontmatter, content)), 0o600); err != nil { + t.Fatalf("failed to write file %s: %v", path, err) } } -// createRule creates a rule file in the specified path within dir -func createRule(t *testing.T, dir, relPath, frontmatter, content string) { +// createTask creates a task file in the .agents/tasks directory. +func createTask(t *testing.T, dir, name, frontmatter, content string) { t.Helper() - rulePath := filepath.Join(dir, relPath) - ruleDir := filepath.Dir(rulePath) - if err := os.MkdirAll(ruleDir, 0o755); err != nil { - t.Fatalf("failed to create rule directory: %v", err) - } - - var fileContent string - if frontmatter != "" { - fileContent = fmt.Sprintf("---\n%s\n---\n%s", frontmatter, content) - } else { - fileContent = content - } + writeMarkdownFile(t, filepath.Join(dir, ".agents", "tasks", name+".md"), frontmatter, content) +} - if err := os.WriteFile(rulePath, []byte(fileContent), 0o644); err != nil { - t.Fatalf("failed to create rule file: %v", err) - } +// createRule creates a rule file at relPath within dir. +func createRule(t *testing.T, dir, relPath, frontmatter, content string) { + t.Helper() + writeMarkdownFile(t, filepath.Join(dir, relPath), frontmatter, content) } -// createCommand creates a command file in the .agents/commands directory +// createCommand creates a command file in the .agents/commands directory. func createCommand(t *testing.T, dir, name, frontmatter, content string) { t.Helper() - cmdDir := filepath.Join(dir, ".agents", "commands") - if err := os.MkdirAll(cmdDir, 0o755); err != nil { - t.Fatalf("failed to create command directory: %v", err) - } - - var fileContent string - if frontmatter != "" { - fileContent = fmt.Sprintf("---\n%s\n---\n%s", frontmatter, content) - } else { - fileContent = content - } - - cmdPath := filepath.Join(cmdDir, name+".md") - if err := os.WriteFile(cmdPath, []byte(fileContent), 0o644); err != nil { - t.Fatalf("failed to create command file: %v", err) - } + writeMarkdownFile(t, filepath.Join(dir, ".agents", "commands", name+".md"), frontmatter, content) } -// createBootstrapScript creates a bootstrap script for a rule file +// createBootstrapScript creates a bootstrap script for a rule file. func createBootstrapScript(t *testing.T, dir, rulePath, scriptContent string) { t.Helper() + fullRulePath := filepath.Join(dir, rulePath) baseNameWithoutExt := strings.TrimSuffix(fullRulePath, filepath.Ext(fullRulePath)) bootstrapPath := baseNameWithoutExt + "-bootstrap" + // Bootstrap scripts are executed directly (support shebangs); require 0755 + // #nosec G306 -- bootstrap scripts require 0755 for direct execution if err := os.WriteFile(bootstrapPath, []byte(scriptContent), 0o755); err != nil { t.Fatalf("failed to create bootstrap script: %v", err) } } -// TestNew tests the constructor with various options +func createSkill(t *testing.T, dir, subdir, content string) { + t.Helper() + + skillDir := filepath.Join(dir, subdir) + + if err := os.MkdirAll(skillDir, 0o750); err != nil { + t.Fatalf("failed to create skill directory: %v", err) + } + + skillPath := filepath.Join(skillDir, "SKILL.md") + + if err := os.WriteFile(skillPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to create skill file: %v", err) + } +} + +// TestNew tests the constructor with various options. func TestNew(t *testing.T) { + t.Parallel() tests := []struct { name string opts []Option check func(t *testing.T, c *Context) }{ + {name: "default context", opts: nil, check: checkNewDefault}, { - name: "default context", - opts: nil, - check: func(t *testing.T, c *Context) { - if c.params == nil { - t.Error("expected params to be initialized") - } - if c.includes == nil { - t.Error("expected includes to be initialized") - } - if c.logger == nil { - t.Error("expected logger to be initialized") - } - if c.cmdRunner == nil { - t.Error("expected cmdRunner to be initialized") - } - }, - }, - { - name: "with params", - opts: []Option{ - WithParams(taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}}), - }, - check: func(t *testing.T, c *Context) { - if c.params.Value("key1") != "value1" { - t.Errorf("expected params[key1]=value1, got %v", c.params.Value("key1")) - } - if c.params.Value("key2") != "value2" { - t.Errorf("expected params[key2]=value2, got %v", c.params.Value("key2")) - } - }, + name: "with params", + opts: []Option{WithParams(taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}})}, + check: checkNewWithParams, }, { - name: "with selectors", - opts: []Option{ - WithSelectors(selectors.Selectors{"env": {"dev": true, "test": true}}), - }, - check: func(t *testing.T, c *Context) { - if !c.includes.GetValue("env", "dev") { - t.Error("expected env=dev selector") - } - if !c.includes.GetValue("env", "test") { - t.Error("expected env=test selector") - } - }, + name: "with selectors", + opts: []Option{WithSelectors(selectors.Selectors{"env": {"dev": true, "test": true}})}, + check: checkNewWithSelectors, }, { - name: "with manifest URL", - opts: []Option{ - WithManifestURL("https://example.com/manifest.txt"), - }, - check: func(t *testing.T, c *Context) { - if c.manifestURL != "https://example.com/manifest.txt" { - t.Errorf("expected manifestURL to be set, got %v", c.manifestURL) - } - }, - }, - { - name: "with search paths", - opts: []Option{ - WithSearchPaths("/path/one", "/path/two"), - }, - check: func(t *testing.T, c *Context) { - if len(c.searchPaths) != 2 { - t.Errorf("expected 2 search paths, got %d", len(c.searchPaths)) - } - if c.searchPaths[0] != "/path/one" { - t.Errorf("expected first path to be /path/one, got %v", c.searchPaths[0]) - } - if c.searchPaths[1] != "/path/two" { - t.Errorf("expected second path to be /path/two, got %v", c.searchPaths[1]) - } - }, - }, - { - name: "with custom logger", - opts: []Option{ - WithLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))), - }, - check: func(t *testing.T, c *Context) { - if c.logger == nil { - t.Error("expected logger to be set") - } - }, - }, - { - name: "with resume mode", - opts: []Option{ - WithResume(true), - }, - check: func(t *testing.T, c *Context) { - if !c.resume { - t.Error("expected resume to be true") - } - if !c.doBootstrap { - t.Error("expected doBootstrap to be true by default") - } - }, + name: "with manifest URL", + opts: []Option{WithManifestURL("https://example.com/manifest.txt")}, + check: checkNewWithManifestURL, }, { - name: "with bootstrap disabled", - opts: []Option{ - WithBootstrap(false), - }, - check: func(t *testing.T, c *Context) { - if c.doBootstrap { - t.Error("expected doBootstrap to be false") - } - }, + name: "with search paths", + opts: []Option{WithSearchPaths("/path/one", "/path/two")}, + check: checkNewWithSearchPaths, }, { - name: "resume and bootstrap are independent", - opts: []Option{ - WithResume(true), - WithBootstrap(false), - }, - check: func(t *testing.T, c *Context) { - if !c.resume { - t.Error("expected resume to be true") - } - if c.doBootstrap { - t.Error("expected doBootstrap to be false") - } - }, + name: "with custom logger", + opts: []Option{WithLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))}, + check: checkNewWithLogger, }, + {name: "with resume mode", opts: []Option{WithResume(true)}, check: checkNewWithResume}, + {name: "with bootstrap disabled", opts: []Option{WithBootstrap(false)}, check: checkNewBootstrapDisabled}, { - name: "with agent", - opts: []Option{ - WithAgent(AgentCursor), - }, - check: func(t *testing.T, c *Context) { - if c.agent != AgentCursor { - t.Errorf("expected agent to be cursor, got %v", c.agent) - } - }, + name: "resume and bootstrap are independent", + opts: []Option{WithResume(true), WithBootstrap(false)}, + check: checkNewResumeAndBootstrapIndependent, }, + {name: "with agent", opts: []Option{WithAgent(AgentCursor)}, check: checkNewWithAgent}, { name: "multiple options combined", opts: []Option{ @@ -242,28 +138,14 @@ func TestNew(t *testing.T) { WithResume(false), WithAgent(AgentCopilot), }, - check: func(t *testing.T, c *Context) { - if c.params.Value("env") != "production" { - t.Error("params not set correctly") - } - if !c.includes.GetValue("lang", "go") { - t.Error("selectors not set correctly") - } - if len(c.searchPaths) != 1 || c.searchPaths[0] != "/custom/path" { - t.Error("search paths not set correctly") - } - if c.resume != false { - t.Error("resume not set correctly") - } - if c.agent != AgentCopilot { - t.Error("agent not set correctly") - } - }, + check: checkNewMultipleCombined, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := New(tt.opts...) if tt.check != nil { tt.check(t, c) @@ -272,8 +154,212 @@ func TestNew(t *testing.T) { } } -// TestContext_Run_Basic tests basic task execution scenarios +func checkRunBasicSimpleTask(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "This is a simple task.") { + t.Errorf("expected task content 'This is a simple task.', got %q", result.Task.Content) + } + + if result.Tokens <= 0 { + t.Error("expected positive token count") + } +} + +func checkRunBasicFrontmatter(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Task content here.") { + t.Errorf("expected task content, got %q", result.Task.Content) + } + + if result.Task.FrontMatter.Content["priority"] != "high" { + t.Error("expected priority=high in frontmatter") + } +} + +func checkRunBasicParamSubstitution(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Environment: production") { + t.Errorf("expected 'Environment: production' in content, got %q", result.Task.Content) + } + + if !strings.Contains(result.Task.Content, "Feature: auth") { + t.Errorf("expected 'Feature: auth' in content, got %q", result.Task.Content) + } +} + +func checkRunBasicUnresolvedParam(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "${missing_param}") { + t.Errorf("expected unresolved parameter to remain as ${missing_param}, got %q", result.Task.Content) + } +} + +func checkRunBasicSelectors(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Task with selectors") { + t.Errorf("unexpected content: %q", result.Task.Content) + } +} + +func checkRunBasicMultipleParams(t *testing.T, result *Result) { + t.Helper() + + expected := "User: alice, Email: alice@example.com, Role: admin" + if !strings.Contains(result.Task.Content, expected) { + t.Errorf("expected %q in content, got %q", expected, result.Task.Content) + } +} + +func checkNewDefault(t *testing.T, c *Context) { + t.Helper() + + if c.params == nil { + t.Error("expected params to be initialized") + } + + if c.includes == nil { + t.Error("expected includes to be initialized") + } + + if c.logger == nil { + t.Error("expected logger to be initialized") + } + + if c.cmdRunner == nil { + t.Error("expected cmdRunner to be initialized") + } +} + +func checkNewWithParams(t *testing.T, c *Context) { + t.Helper() + + if c.params.Value("key1") != "value1" { + t.Errorf("expected params[key1]=value1, got %v", c.params.Value("key1")) + } + + if c.params.Value("key2") != "value2" { + t.Errorf("expected params[key2]=value2, got %v", c.params.Value("key2")) + } +} + +func checkNewWithSelectors(t *testing.T, c *Context) { + t.Helper() + + if !c.includes.GetValue("env", "dev") { + t.Error("expected env=dev selector") + } + + if !c.includes.GetValue("env", "test") { + t.Error("expected env=test selector") + } +} + +func checkNewWithManifestURL(t *testing.T, c *Context) { + t.Helper() + + if c.manifestURL != "https://example.com/manifest.txt" { + t.Errorf("expected manifestURL to be set, got %v", c.manifestURL) + } +} + +func checkNewWithSearchPaths(t *testing.T, c *Context) { + t.Helper() + + if len(c.searchPaths) != 2 { + t.Errorf("expected 2 search paths, got %d", len(c.searchPaths)) + } + + if c.searchPaths[0] != "/path/one" { + t.Errorf("expected first path to be /path/one, got %v", c.searchPaths[0]) + } + + if c.searchPaths[1] != "/path/two" { + t.Errorf("expected second path to be /path/two, got %v", c.searchPaths[1]) + } +} + +func checkNewWithLogger(t *testing.T, c *Context) { + t.Helper() + + if c.logger == nil { + t.Error("expected logger to be set") + } +} + +func checkNewWithResume(t *testing.T, c *Context) { + t.Helper() + + if !c.resume { + t.Error("expected resume to be true") + } + + if !c.doBootstrap { + t.Error("expected doBootstrap to be true by default") + } +} + +func checkNewBootstrapDisabled(t *testing.T, c *Context) { + t.Helper() + + if c.doBootstrap { + t.Error("expected doBootstrap to be false") + } +} + +func checkNewResumeAndBootstrapIndependent(t *testing.T, c *Context) { + t.Helper() + + if !c.resume { + t.Error("expected resume to be true") + } + + if c.doBootstrap { + t.Error("expected doBootstrap to be false") + } +} + +func checkNewWithAgent(t *testing.T, c *Context) { + t.Helper() + + if c.agent != AgentCursor { + t.Errorf("expected agent to be cursor, got %v", c.agent) + } +} + +func checkNewMultipleCombined(t *testing.T, c *Context) { + t.Helper() + + if c.params.Value("env") != "production" { + t.Error("params not set correctly") + } + + if !c.includes.GetValue("lang", "go") { + t.Error("selectors not set correctly") + } + + if len(c.searchPaths) != 1 || c.searchPaths[0] != "/custom/path" { + t.Error("search paths not set correctly") + } + + if c.resume != false { + t.Error("resume not set correctly") + } + + if c.agent != AgentCopilot { + t.Error("agent not set correctly") + } +} + +// TestContext_Run_Basic tests basic task execution scenarios. +// +//nolint:funlen func TestContext_Run_Basic(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(t *testing.T, dir string) @@ -286,38 +372,27 @@ func TestContext_Run_Basic(t *testing.T) { { name: "simple task with plain text", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "simple", "", "This is a simple task.") }, taskName: "simple", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "This is a simple task.") { - t.Errorf("expected task content 'This is a simple task.', got %q", result.Task.Content) - } - if result.Tokens <= 0 { - t.Error("expected positive token count") - } - }, + check: checkRunBasicSimpleTask, }, { name: "task with frontmatter", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "with-frontmatter", "priority: high\nenv: dev", "Task content here.") }, taskName: "with-frontmatter", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Task content here.") { - t.Errorf("expected task content, got %q", result.Task.Content) - } - if result.Task.FrontMatter.Content["priority"] != "high" { - t.Error("expected priority=high in frontmatter") - } - }, + check: checkRunBasicFrontmatter, }, { name: "task with parameter substitution", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "params-task", "", "Environment: ${env}\nFeature: ${feature}") }, opts: []Option{ @@ -325,31 +400,21 @@ func TestContext_Run_Basic(t *testing.T) { }, taskName: "params-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Environment: production") { - t.Errorf("expected 'Environment: production' in content, got %q", result.Task.Content) - } - if !strings.Contains(result.Task.Content, "Feature: auth") { - t.Errorf("expected 'Feature: auth' in content, got %q", result.Task.Content) - } - }, + check: checkRunBasicParamSubstitution, }, { name: "task with unresolved parameter", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "unresolved", "", "Missing: ${missing_param}") }, taskName: "unresolved", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "${missing_param}") { - t.Errorf("expected unresolved parameter to remain as ${missing_param}, got %q", result.Task.Content) - } - }, + check: checkRunBasicUnresolvedParam, }, { name: "task not found returns error", - setup: func(t *testing.T, dir string) {}, + setup: func(t *testing.T, _ string) { t.Helper() }, taskName: "nonexistent", wantErr: true, errContains: "task not found", @@ -357,37 +422,33 @@ func TestContext_Run_Basic(t *testing.T) { { name: "task with selectors sets includes", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "selector-task", "selectors:\n env: production\n lang: go", "Task with selectors") }, taskName: "selector-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Task with selectors") { - t.Errorf("unexpected content: %q", result.Task.Content) - } - }, + check: checkRunBasicSelectors, }, { name: "multiple params in same content", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "multi-params", "", "User: ${user}, Email: ${email}, Role: ${role}") }, opts: []Option{ - WithParams(taskparser.Params{"user": []string{"alice"}, "email": []string{"alice@example.com"}, "role": []string{"admin"}}), + WithParams(taskparser.Params{ + "user": []string{"alice"}, "email": []string{"alice@example.com"}, "role": []string{"admin"}, + }), }, taskName: "multi-params", wantErr: false, - check: func(t *testing.T, result *Result) { - expected := "User: alice, Email: alice@example.com, Role: admin" - if !strings.Contains(result.Task.Content, expected) { - t.Errorf("expected %q in content, got %q", expected, result.Task.Content) - } - }, + check: checkRunBasicMultipleParams, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tt.setup(t, tmpDir) @@ -398,6 +459,7 @@ func TestContext_Run_Basic(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return } @@ -405,6 +467,7 @@ func TestContext_Run_Basic(t *testing.T) { if !strings.Contains(err.Error(), tt.errContains) { t.Errorf("expected error to contain %q, got %v", tt.errContains, err) } + return } @@ -415,8 +478,82 @@ func TestContext_Run_Basic(t *testing.T) { } } -// TestContext_Run_Rules tests rule discovery and filtering +func checkRulesCount(n int) func(t *testing.T, result *Result) { + return func(t *testing.T, result *Result) { + t.Helper() + + if len(result.Rules) != n { + t.Errorf("expected %d rules, got %d", n, len(result.Rules)) + } + } +} + +func checkRulesFilteredBySelectors(t *testing.T, result *Result) { + t.Helper() + + if len(result.Rules) != 2 { + t.Errorf("expected 2 rules, got %d", len(result.Rules)) + } + + foundProd := false + + for _, rule := range result.Rules { + if strings.Contains(rule.Content, "Production rule") { + foundProd = true + + break + } + } + + if !foundProd { + t.Error("expected to find production rule") + } +} + +func checkRulesParamSubstitution(t *testing.T, result *Result) { + t.Helper() + + if len(result.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(result.Rules)) + } + + if !strings.Contains(result.Rules[0].Content, "Project: myapp") { + t.Errorf("expected parameter substitution in rule, got %q", result.Rules[0].Content) + } +} + +func checkRulesTokenCounting(t *testing.T, result *Result) { + t.Helper() + + if result.Tokens <= 0 { + t.Error("expected positive total token count") + } + + totalRuleTokens := 0 + + for _, rule := range result.Rules { + if rule.Tokens <= 0 { + t.Error("expected positive token count for each rule") + } + + totalRuleTokens += rule.Tokens + } + + if result.Task.Tokens <= 0 { + t.Error("expected positive token count for task") + } + + expectedTotal := totalRuleTokens + result.Task.Tokens + if result.Tokens != expectedTotal { + t.Errorf("expected total tokens %d, got %d", expectedTotal, result.Tokens) + } +} + +// TestContext_Run_Rules tests rule discovery and filtering. +// +//nolint:funlen,maintidx func TestContext_Run_Rules(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(t *testing.T, dir string) @@ -428,21 +565,19 @@ func TestContext_Run_Rules(t *testing.T) { { name: "discover rules in standard paths", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "task1", "", "Task content") createRule(t, dir, ".agents/rules/rule1.md", "", "Rule 1 content") createRule(t, dir, ".cursor/rules/rule2.md", "", "Rule 2 content") }, taskName: "task1", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 2 { - t.Errorf("expected 2 rules, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(2), }, { name: "filter rules by selectors from task frontmatter", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "filtered-task", "selectors:\n env: production", "Task with selectors") createRule(t, dir, ".agents/rules/prod-rule.md", "env: production", "Production rule") createRule(t, dir, ".agents/rules/dev-rule.md", "env: development", "Development rule") @@ -450,25 +585,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "filtered-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 3 { - t.Errorf("expected 3 rules, got %d", len(result.Rules)) - } - foundProd := false - for _, rule := range result.Rules { - if strings.Contains(rule.Content, "Production rule") { - foundProd = true - break - } - } - if !foundProd { - t.Error("expected to find production rule") - } - }, + check: checkRulesFilteredBySelectors, }, { name: "rules with parameter substitution", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "param-task", "", "Task") createRule(t, dir, ".agents/rules/param-rule.md", "", "Project: ${project}") }, @@ -477,18 +599,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "param-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Fatalf("expected 1 rule, got %d", len(result.Rules)) - } - if !strings.Contains(result.Rules[0].Content, "Project: myapp") { - t.Errorf("expected parameter substitution in rule, got %q", result.Rules[0].Content) - } - }, + check: checkRulesParamSubstitution, }, { name: "bootstrap disabled skips rule discovery", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "bootstrap-task", "", "Task content") createRule(t, dir, ".agents/rules/rule1.md", "", "Rule content") }, @@ -497,15 +613,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "bootstrap-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 0 { - t.Errorf("expected 0 rules when bootstrap is disabled, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(0), }, { name: "resume mode does not skip rule discovery", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "resume-task", "", "Task content") createRule(t, dir, ".agents/rules/rule1.md", "", "Rule content") }, @@ -514,30 +627,24 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "resume-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Errorf("expected 1 rule when resume is true but bootstrap is enabled, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(1), }, { name: "bootstrap script executed for rules", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "bootstrap-task", "", "Task") createRule(t, dir, ".agents/rules/rule-with-bootstrap.md", "", "Rule content") createBootstrapScript(t, dir, ".agents/rules/rule-with-bootstrap.md", "#!/bin/sh\necho 'bootstrapped'") }, taskName: "bootstrap-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Errorf("expected 1 rule, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(1), }, { name: "bootstrap disabled skips bootstrap scripts", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "no-bootstrap", "", "Task") createRule(t, dir, ".agents/rules/rule1.md", "", "Rule") createBootstrapScript(t, dir, ".agents/rules/rule1.md", "#!/bin/sh\nexit 1") @@ -547,16 +654,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "no-bootstrap", wantErr: false, - check: func(t *testing.T, result *Result) { - // When bootstrap is disabled, rules aren't discovered, so bootstrap scripts won't run - if len(result.Rules) != 0 { - t.Error("expected no rules when bootstrap is disabled") - } - }, + check: checkRulesCount(0), }, { name: "bootstrap from frontmatter is preferred", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "frontmatter-bootstrap", "", "Task") // Create rule with bootstrap in frontmatter that writes a marker file createRule(t, dir, ".agents/rules/rule-with-frontmatter.md", @@ -565,16 +668,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "frontmatter-bootstrap", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Errorf("expected 1 rule, got %d", len(result.Rules)) - } - // The integration tests verify frontmatter bootstrap actually ran - }, + check: checkRulesCount(1), }, { name: "bootstrap from frontmatter preferred over file", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "frontmatter-priority", "", "Task") // Create rule with BOTH frontmatter and file bootstrap // Frontmatter writes "frontmatter", file writes "file" @@ -588,16 +687,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "frontmatter-priority", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Errorf("expected 1 rule, got %d", len(result.Rules)) - } - // The integration tests verify which bootstrap actually ran - }, + check: checkRulesCount(1), }, { name: "bootstrap from file when frontmatter empty", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "file-fallback", "", "Task") // Create rule WITHOUT frontmatter bootstrap markerPath := filepath.Join(dir, "bootstrap-marker.txt") @@ -608,16 +703,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "file-fallback", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Errorf("expected 1 rule, got %d", len(result.Rules)) - } - // The integration tests verify the file-based bootstrap ran - }, + check: checkRulesCount(1), }, { name: "agent option collects all rules", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "agent-task", "", "Task") createRule(t, dir, ".agents/rules/generic.md", "", "Generic rule") createRule(t, dir, ".cursor/rules/cursor-rule.md", "", "Cursor rule") @@ -627,17 +718,13 @@ func TestContext_Run_Rules(t *testing.T) { WithAgent(AgentCursor), }, taskName: "agent-task", - wantErr: false, - check: func(t *testing.T, result *Result) { - // Agent filtering is not implemented, so all rules are included - if len(result.Rules) != 3 { - t.Errorf("expected 3 rules, got %d", len(result.Rules)) - } - }, + wantErr: false, + check: checkRulesCount(3), }, { name: "task frontmatter agent overrides option", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "override-task", "agent: copilot", "Task") createRule(t, dir, ".cursor/rules/cursor-rule.md", "", "Cursor rule") createRule(t, dir, ".github/agents/copilot-rule.md", "", "Copilot rule") @@ -647,16 +734,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "override-task", wantErr: false, - check: func(t *testing.T, result *Result) { - // Verify all rules are collected (agent filtering not implemented) - if len(result.Rules) != 2 { - t.Errorf("expected 2 rules, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(2), }, { name: "multiple selector values with OR logic", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "multi-selector", "selectors:\n env:\n - dev\n - test", "Task") createRule(t, dir, ".agents/rules/dev-rule.md", "env: dev", "Dev rule") createRule(t, dir, ".agents/rules/test-rule.md", "env: test", "Test rule") @@ -664,15 +747,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "multi-selector", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 3 { - t.Errorf("expected 3 rules, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(2), }, { name: "CLI selectors combined with task selectors use OR logic", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "or-task", "selectors:\n env: production", "Task with env=production") createRule(t, dir, ".agents/rules/prod-rule.md", "env: production", "Production rule") createRule(t, dir, ".agents/rules/dev-rule.md", "env: development", "Development rule") @@ -683,25 +763,12 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "or-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Errorf("expected 1 rule, got %d", len(result.Rules)) - } - foundDev := false - for _, rule := range result.Rules { - if strings.Contains(rule.Content, "Development rule") { - foundDev = true - break - } - } - if !foundDev { - t.Error("expected to find development rule") - } - }, + check: checkRulesCount(2), // prod + dev; test excluded }, { name: "CLI selectors combined with array task selectors use OR logic", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "array-or", "selectors:\n env:\n - production\n - staging", "Task with array selectors") createRule(t, dir, ".agents/rules/prod-rule.md", "env: production", "Production rule") createRule(t, dir, ".agents/rules/staging-rule.md", "env: staging", "Staging rule") @@ -713,55 +780,25 @@ func TestContext_Run_Rules(t *testing.T) { }, taskName: "array-or", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Errorf("expected 1 rule, got %d", len(result.Rules)) - } - foundDev := false - for _, rule := range result.Rules { - if strings.Contains(rule.Content, "Development rule") { - foundDev = true - break - } - } - if !foundDev { - t.Error("expected to find development rule") - } - }, + check: checkRulesCount(3), // prod + staging + dev; test excluded }, { name: "token counting for rules", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "token-task", "", "Task content") createRule(t, dir, ".agents/rules/rule1.md", "", "This is rule 1 content") createRule(t, dir, ".agents/rules/rule2.md", "", "This is rule 2 content") }, taskName: "token-task", wantErr: false, - check: func(t *testing.T, result *Result) { - if result.Tokens <= 0 { - t.Error("expected positive total token count") - } - totalRuleTokens := 0 - for _, rule := range result.Rules { - if rule.Tokens <= 0 { - t.Error("expected positive token count for each rule") - } - totalRuleTokens += rule.Tokens - } - if result.Task.Tokens <= 0 { - t.Error("expected positive token count for task") - } - expectedTotal := totalRuleTokens + result.Task.Tokens - if result.Tokens != expectedTotal { - t.Errorf("expected total tokens %d, got %d", expectedTotal, result.Tokens) - } - }, + check: checkRulesTokenCounting, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tt.setup(t, tmpDir) @@ -772,6 +809,7 @@ func TestContext_Run_Rules(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return } @@ -782,8 +820,84 @@ func TestContext_Run_Rules(t *testing.T) { } } -// TestContext_Run_Commands tests command substitution in tasks +func checkTaskContains(s string) func(t *testing.T, result *Result) { + return func(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, s) { + t.Errorf("expected %q in task content, got %q", s, result.Task.Content) + } + } +} + +func checkTaskNotEmpty(t *testing.T, result *Result) { + t.Helper() + + if strings.TrimSpace(result.Task.Content) == "" { + t.Errorf("expected non-empty content, got %q", result.Task.Content) + } +} + +func checkCommandsSingleRef(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Before command") { + t.Error("expected task content before command") + } + + if !strings.Contains(result.Task.Content, "Hello, World!") { + t.Error("expected command content to be substituted") + } + + if !strings.Contains(result.Task.Content, "After command") { + t.Error("expected task content after command") + } +} + +func checkCommandsMixedText(t *testing.T, result *Result) { + t.Helper() + + content := result.Task.Content + if !strings.Contains(content, "# Title") { + t.Error("expected title text") + } + + if !strings.Contains(content, "Middle text") { + t.Error("expected middle text") + } + + if !strings.Contains(content, "End text") { + t.Error("expected end text") + } +} + +func checkCommandsSelectorsFilterRules(t *testing.T, result *Result) { + t.Helper() + + if len(result.Rules) != 2 { + t.Errorf("expected 2 rules, got %d", len(result.Rules)) + } + + foundPostgres := false + + for _, rule := range result.Rules { + if strings.Contains(rule.Content, "PostgreSQL rule") { + foundPostgres = true + + break + } + } + + if !foundPostgres { + t.Error("expected to find PostgreSQL rule") + } +} + +// TestContext_Run_Commands tests command substitution in tasks. +// +//nolint:funlen func TestContext_Run_Commands(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(t *testing.T, dir string) @@ -796,40 +910,29 @@ func TestContext_Run_Commands(t *testing.T) { { name: "task with single command reference", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "with-command", "", "Before command\n/greet\nAfter command") createCommand(t, dir, "greet", "", "Hello, World!") }, taskName: "with-command", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Before command") { - t.Error("expected task content before command") - } - if !strings.Contains(result.Task.Content, "Hello, World!") { - t.Error("expected command content to be substituted") - } - if !strings.Contains(result.Task.Content, "After command") { - t.Error("expected task content after command") - } - }, + check: checkCommandsSingleRef, }, { name: "command with parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "cmd-with-params", "", "/greet name=\"Alice\"") createCommand(t, dir, "greet", "", "Hello, ${name}!") }, taskName: "cmd-with-params", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Hello, Alice!") { - t.Errorf("expected parameter substitution in command, got %q", result.Task.Content) - } - }, + check: checkTaskContains("Hello, Alice!"), }, { name: "command with context parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "ctx-params", "", "/deploy") createCommand(t, dir, "deploy", "", "Deploying to ${env}") }, @@ -838,15 +941,12 @@ func TestContext_Run_Commands(t *testing.T) { }, taskName: "ctx-params", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Deploying to staging") { - t.Errorf("expected context parameter substitution, got %q", result.Task.Content) - } - }, + check: checkTaskContains("Deploying to staging"), }, { name: "multiple commands in task", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "multi-cmd", "", "/intro\n\n/body\n\n/outro\n") createCommand(t, dir, "intro", "", "Introduction") createCommand(t, dir, "body", "", "Main content") @@ -854,18 +954,12 @@ func TestContext_Run_Commands(t *testing.T) { }, taskName: "multi-cmd", wantErr: false, - check: func(t *testing.T, result *Result) { - content := result.Task.Content - // Each command may or may not be substituted depending on parsing - // Just verify we got some content - if strings.TrimSpace(content) == "" { - t.Errorf("expected non-empty content, got %q", content) - } - }, + check: checkTaskNotEmpty, }, { name: "command not found returns error", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "missing-cmd", "", "/nonexistent") }, taskName: "missing-cmd", @@ -875,6 +969,7 @@ func TestContext_Run_Commands(t *testing.T) { { name: "command parameter overrides context parameter", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "override-param", "", "/msg value=\"specific\"") createCommand(t, dir, "msg", "", "Value: ${value}") }, @@ -883,52 +978,35 @@ func TestContext_Run_Commands(t *testing.T) { }, taskName: "override-param", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Value: specific") { - t.Errorf("expected command param to override context param, got %q", result.Task.Content) - } - }, + check: checkTaskContains("Value: specific"), }, { name: "command with multiple parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "multi-params", "", "/info name=\"Bob\" age=\"30\" role=\"developer\"") createCommand(t, dir, "info", "", "Name: ${name}, Age: ${age}, Role: ${role}") }, taskName: "multi-params", wantErr: false, - check: func(t *testing.T, result *Result) { - expected := "Name: Bob, Age: 30, Role: developer" - if !strings.Contains(result.Task.Content, expected) { - t.Errorf("expected %q in content, got %q", expected, result.Task.Content) - } - }, + check: checkTaskContains("Name: Bob, Age: 30, Role: developer"), }, { name: "mixed text and commands", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "mixed", "", "# Title\n\n/section1\n\nMiddle text\n\n/section2\n\nEnd text") createCommand(t, dir, "section1", "", "Section 1 content") createCommand(t, dir, "section2", "", "Section 2 content") }, taskName: "mixed", wantErr: false, - check: func(t *testing.T, result *Result) { - content := result.Task.Content - if !strings.Contains(content, "# Title") { - t.Error("expected title text") - } - if !strings.Contains(content, "Middle text") { - t.Error("expected middle text") - } - if !strings.Contains(content, "End text") { - t.Error("expected end text") - } - }, + check: checkCommandsMixedText, }, { name: "command with selectors filters rules", setup: func(t *testing.T, dir string) { + t.Helper() // Task uses a command that has selectors createTask(t, dir, "task-with-cmd", "", "/setup-db") // Command has selectors that should be applied to rule filtering @@ -940,25 +1018,12 @@ func TestContext_Run_Commands(t *testing.T) { }, taskName: "task-with-cmd", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 3 { - t.Errorf("expected 3 rules, got %d", len(result.Rules)) - } - foundPostgres := false - for _, rule := range result.Rules { - if strings.Contains(rule.Content, "PostgreSQL rule") { - foundPostgres = true - break - } - } - if !foundPostgres { - t.Error("expected to find PostgreSQL rule") - } - }, + check: checkCommandsSelectorsFilterRules, }, { name: "command selectors combine with task selectors", setup: func(t *testing.T, dir string) { + t.Helper() // Task has its own selectors createTask(t, dir, "combined-selectors", "selectors:\n env: production", "/enable-feature") // Command also has selectors @@ -971,16 +1036,13 @@ func TestContext_Run_Commands(t *testing.T) { }, taskName: "combined-selectors", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 4 { - t.Errorf("expected 4 rules, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(3), // prod-auth + prod + auth; dev excluded }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tt.setup(t, tmpDir) @@ -991,6 +1053,7 @@ func TestContext_Run_Commands(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return } @@ -998,6 +1061,7 @@ func TestContext_Run_Commands(t *testing.T) { if !strings.Contains(err.Error(), tt.errContains) { t.Errorf("expected error to contain %q, got %v", tt.errContains, err) } + return } @@ -1008,8 +1072,56 @@ func TestContext_Run_Commands(t *testing.T) { } } -// TestContext_Run_Integration tests end-to-end integration scenarios +func checkIntegrationFullWorkflow(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Deploy myservice") { + t.Error("expected app param substitution") + } + + if !strings.Contains(result.Task.Content, "Deploy to production") { + t.Error("expected command with param substitution") + } + + if len(result.Rules) != 2 { + t.Errorf("expected 2 rules, got %d", len(result.Rules)) + } + + if result.Tokens <= 0 { + t.Error("expected positive token count") + } +} + +func checkIntegrationComplexTask(t *testing.T, result *Result) { + t.Helper() + + content := result.Task.Content + if !strings.Contains(content, "# Project Setup") { + t.Error("expected markdown header") + } + + if strings.TrimSpace(content) == "" { + t.Error("expected non-empty content") + } +} + +func checkIntegrationBootstrapDisabled(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Continue this task") { + t.Errorf("unexpected task content: %q", result.Task.Content) + } + + if len(result.Rules) != 0 { + t.Errorf("expected 0 rules when bootstrap is disabled, got %d", len(result.Rules)) + } +} + +// TestContext_Run_Integration tests end-to-end integration scenarios. +// +//nolint:funlen func TestContext_Run_Integration(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(t *testing.T, dir string) @@ -1021,6 +1133,7 @@ func TestContext_Run_Integration(t *testing.T) { { name: "full workflow with task, rules, commands, and parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "fullworkflow", "selectors:\n env: production\n lang: go", "Deploy ${app}\n/deploy-steps") createCommand(t, dir, "deploy-steps", "", "1. Build\n2. Test\n3. Deploy to ${env}") createRule(t, dir, ".agents/rules/prod.md", "env: production", "Production guidelines") @@ -1032,28 +1145,14 @@ func TestContext_Run_Integration(t *testing.T) { }, taskName: "fullworkflow", wantErr: false, - check: func(t *testing.T, result *Result) { - // Check task content includes params and command - if !strings.Contains(result.Task.Content, "Deploy myservice") { - t.Error("expected app param substitution") - } - if !strings.Contains(result.Task.Content, "Deploy to production") { - t.Error("expected command with param substitution") - } - // Check rules - if len(result.Rules) != 3 { - t.Errorf("expected 3 rules, got %d", len(result.Rules)) - } - // Check token counting - if result.Tokens <= 0 { - t.Error("expected positive token count") - } - }, + check: checkIntegrationFullWorkflow, }, { name: "complex task with multiple slash commands and mixed content", setup: func(t *testing.T, dir string) { - createTask(t, dir, "complex", "", "# Project Setup\n\n/intro\n\n## Steps\n\n/step1\n\n/step2\n\n## Conclusion\n\n/outro\n") + t.Helper() + createTask(t, dir, "complex", "", + "# Project Setup\n\n/intro\n\n## Steps\n\n/step1\n\n/step2\n\n## Conclusion\n\n/outro\n") createCommand(t, dir, "intro", "", "Welcome to the project") createCommand(t, dir, "step1", "", "First, initialize the repository") createCommand(t, dir, "step2", "", "Then, configure the settings") @@ -1061,20 +1160,12 @@ func TestContext_Run_Integration(t *testing.T) { }, taskName: "complex", wantErr: false, - check: func(t *testing.T, result *Result) { - content := result.Task.Content - if !strings.Contains(content, "# Project Setup") { - t.Error("expected markdown header") - } - // Commands may or may not be substituted - just check we got content - if strings.TrimSpace(content) == "" { - t.Error("expected non-empty content") - } - }, + check: checkIntegrationComplexTask, }, { name: "bootstrap disabled workflow skips rules but includes task", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "bootstrap", "", "Continue this task") createRule(t, dir, ".agents/rules/rule1.md", "", "Should be skipped") createBootstrapScript(t, dir, ".agents/rules/rule1.md", "#!/bin/sh\necho 'should not run'") @@ -1084,18 +1175,12 @@ func TestContext_Run_Integration(t *testing.T) { }, taskName: "bootstrap", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Continue this task") { - t.Errorf("unexpected task content: %q", result.Task.Content) - } - if len(result.Rules) != 0 { - t.Errorf("expected 0 rules when bootstrap is disabled, got %d", len(result.Rules)) - } - }, + check: checkIntegrationBootstrapDisabled, }, { name: "agent-specific workflow", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "for-cursor", "agent: cursor", "Task for Cursor") createRule(t, dir, ".cursor/rules/cursor.md", "", "Cursor-specific") createRule(t, dir, ".agents/rules/general.md", "", "General rule") @@ -1103,25 +1188,22 @@ func TestContext_Run_Integration(t *testing.T) { }, taskName: "for-cursor", wantErr: false, - check: func(t *testing.T, result *Result) { - // Agent filtering is not implemented, so all rules are collected - if len(result.Rules) != 3 { - t.Errorf("expected 3 rules, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(3), }, { name: "multiple search paths", setup: func(t *testing.T, dir string) { + t.Helper() // Create first directory with task and rule createTask(t, dir, "multi-path", "", "Multi-path task") createRule(t, dir, ".agents/rules/rule1.md", "", "Rule from first path") // Create second directory with additional rule secondDir := filepath.Join(dir, "second") - if err := os.MkdirAll(secondDir, 0o755); err != nil { + if err := os.MkdirAll(secondDir, 0o750); err != nil { t.Fatalf("failed to create second dir: %v", err) } + createRule(t, secondDir, ".agents/rules/rule2.md", "", "Rule from second path") }, opts: []Option{ @@ -1129,17 +1211,13 @@ func TestContext_Run_Integration(t *testing.T) { }, taskName: "multi-path", wantErr: false, - check: func(t *testing.T, result *Result) { - // This test only finds rules from the first path since we don't add the second path - if len(result.Rules) != 1 { - t.Errorf("expected 1 rule from first path, got %d", len(result.Rules)) - } - }, + check: checkRulesCount(1), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tt.setup(t, tmpDir) @@ -1150,6 +1228,7 @@ func TestContext_Run_Integration(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return } @@ -1160,8 +1239,9 @@ func TestContext_Run_Integration(t *testing.T) { } } -// TestContext_Run_Errors tests error scenarios +// TestContext_Run_Errors tests error scenarios. func TestContext_Run_Errors(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(t *testing.T, dir string) @@ -1173,6 +1253,7 @@ func TestContext_Run_Errors(t *testing.T) { { name: "command not found in task", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "bad-cmd", "", "/missing-command\n") }, taskName: "bad-cmd", @@ -1182,15 +1263,18 @@ func TestContext_Run_Errors(t *testing.T) { { name: "invalid agent in task frontmatter", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "bad-agent", "agent: invalidagent", "Task content") }, - taskName: "bad-agent", - wantErr: false, + taskName: "bad-agent", + wantErr: true, + errContains: "unknown agent", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tt.setup(t, tmpDir) @@ -1201,6 +1285,7 @@ func TestContext_Run_Errors(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return } @@ -1208,6 +1293,7 @@ func TestContext_Run_Errors(t *testing.T) { if result != nil { t.Error("expected nil result on error") } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { t.Errorf("expected error to contain %q, got %v", tt.errContains, err) } @@ -1216,8 +1302,104 @@ func TestContext_Run_Errors(t *testing.T) { } } -// TestContext_Run_ExpandParams tests parameter expansion opt-out functionality +func checkOneRuleContains(s string) func(t *testing.T, result *Result) { + return func(t *testing.T, result *Result) { + t.Helper() + + if len(result.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(result.Rules)) + } + + if !strings.Contains(result.Rules[0].Content, s) { + t.Errorf("expected %q in rule, got %q", s, result.Rules[0].Content) + } + } +} + +func checkExpandIssueAndTitle(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Issue: 123") { + t.Errorf("expected 'Issue: 123', got %q", result.Task.Content) + } + + if !strings.Contains(result.Task.Content, "Title: Bug fix") { + t.Errorf("expected 'Title: Bug fix', got %q", result.Task.Content) + } +} + +// checkMixedNoExpandCommandExpand: task has expand:false (text unexpanded), command has expand:true (expanded). +func checkMixedNoExpandCommandExpand(t *testing.T, result *Result) { + t.Helper() + + content := result.Task.Content + if !strings.Contains(content, "Task ${task_var}") { + t.Errorf("expected task param unexpanded (expand:false), got %q", content) + } + + if !strings.Contains(content, "Command cmd_value") { + t.Errorf("expected command param expanded (expand:true), got %q", content) + } +} + +// checkMixedTaskExpandNoCommand: task has expand:true (text expanded), command has expand:false (unexpanded). +func checkMixedTaskExpandNoCommand(t *testing.T, result *Result) { + t.Helper() + + content := result.Task.Content + if !strings.Contains(content, "Task task_value") { + t.Errorf("expected task param expanded (expand:true), got %q", content) + } + + if !strings.Contains(content, "Command ${cmd_var}") { + t.Errorf("expected command param unexpanded (expand:false), got %q", content) + } +} + +func checkExpandInlineNoExpand(t *testing.T, result *Result) { + t.Helper() + + // expand:false means inline params and global params are NOT substituted + if !strings.Contains(result.Task.Content, "${name}") { + t.Errorf("expected '${name}' unexpanded in output, got %q", result.Task.Content) + } + + if !strings.Contains(result.Task.Content, "${id}") { + t.Errorf("expected '${id}' unexpanded in output, got %q", result.Task.Content) + } +} + +func checkExpandMultipleRules(t *testing.T, result *Result) { + t.Helper() + + if len(result.Rules) != 3 { + t.Fatalf("expected 3 rules, got %d", len(result.Rules)) + } + + for _, rule := range result.Rules { + switch { + case strings.Contains(rule.Content, "Rule1:"): + // Rule1 has expand:false — parameter should NOT be substituted + if !strings.Contains(rule.Content, "Rule1: ${var1}") { + t.Errorf("expected 'Rule1: ${var1}' (unexpanded), got %q", rule.Content) + } + case strings.Contains(rule.Content, "Rule2:"): + if !strings.Contains(rule.Content, "Rule2: val2") { + t.Errorf("expected 'Rule2: val2', got %q", rule.Content) + } + case strings.Contains(rule.Content, "Rule3:"): + if !strings.Contains(rule.Content, "Rule3: val3") { + t.Errorf("expected 'Rule3: val3', got %q", rule.Content) + } + } + } +} + +// TestContext_Run_ExpandParams tests parameter expansion opt-out functionality. +// +//nolint:funlen func TestContext_Run_ExpandParams(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(t *testing.T, dir string) @@ -1230,6 +1412,7 @@ func TestContext_Run_ExpandParams(t *testing.T) { { name: "task with expand: false preserves parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "no-expand", "expand: false", "Issue: ${issue_number}\nTitle: ${issue_title}") }, opts: []Option{ @@ -1237,18 +1420,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "no-expand", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Issue: 123") { - t.Errorf("expected 'Issue: 123', got %q", result.Task.Content) - } - if !strings.Contains(result.Task.Content, "Title: Bug fix") { - t.Errorf("expected 'Title: Bug fix', got %q", result.Task.Content) - } - }, + check: checkTaskContains("${issue_number}"), // expand:false means no substitution }, { name: "task with expand: true expands parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "expand", "expand: true", "Issue: ${issue_number}\nTitle: ${issue_title}") }, opts: []Option{ @@ -1256,18 +1433,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "expand", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Issue: 123") { - t.Errorf("expected 'Issue: 123', got %q", result.Task.Content) - } - if !strings.Contains(result.Task.Content, "Title: Bug fix") { - t.Errorf("expected 'Title: Bug fix', got %q", result.Task.Content) - } - }, + check: checkExpandIssueAndTitle, }, { name: "task without expand defaults to expanding", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "default", "", "Env: ${env}") }, opts: []Option{ @@ -1275,15 +1446,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "default", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Env: production") { - t.Errorf("expected 'Env: production', got %q", result.Task.Content) - } - }, + check: checkTaskContains("Env: production"), }, { name: "command with expand: false preserves parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "cmd-no-expand", "", "/deploy") createCommand(t, dir, "deploy", "expand: false", "Deploying to ${env}") }, @@ -1292,15 +1460,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "cmd-no-expand", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Deploying to staging") { - t.Errorf("expected 'Deploying to staging', got %q", result.Task.Content) - } - }, + check: checkTaskContains("Deploying to ${env}"), // expand:false means no substitution }, { name: "command with expand: true expands parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "cmd-expand", "", "/deploy") createCommand(t, dir, "deploy", "expand: true", "Deploying to ${env}") }, @@ -1309,15 +1474,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "cmd-expand", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Deploying to staging") { - t.Errorf("expected 'Deploying to staging', got %q", result.Task.Content) - } - }, + check: checkTaskContains("Deploying to staging"), }, { name: "command without expand defaults to expanding", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "cmd-default", "", "/info") createCommand(t, dir, "info", "", "Project: ${project}") }, @@ -1326,15 +1488,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "cmd-default", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Project: myapp") { - t.Errorf("expected 'Project: myapp', got %q", result.Task.Content) - } - }, + check: checkTaskContains("Project: myapp"), }, { name: "rule with expand: false preserves parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "rule-no-expand", "", "Task content") createRule(t, dir, ".agents/rules/rule1.md", "expand: false", "Version: ${version}") }, @@ -1343,18 +1502,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "rule-no-expand", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Fatalf("expected 1 rule, got %d", len(result.Rules)) - } - if !strings.Contains(result.Rules[0].Content, "Version: 1.0.0") { - t.Errorf("expected 'Version: 1.0.0' in rule, got %q", result.Rules[0].Content) - } - }, + check: checkOneRuleContains("Version: ${version}"), // expand:false means no substitution }, { name: "rule with expand: true expands parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "rule-expand", "", "Task content") createRule(t, dir, ".agents/rules/rule1.md", "expand: true", "Version: ${version}") }, @@ -1363,18 +1516,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "rule-expand", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Fatalf("expected 1 rule, got %d", len(result.Rules)) - } - if !strings.Contains(result.Rules[0].Content, "Version: 1.0.0") { - t.Errorf("expected 'Version: 1.0.0' in rule, got %q", result.Rules[0].Content) - } - }, + check: checkOneRuleContains("Version: 1.0.0"), }, { name: "rule without expand defaults to expanding", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "rule-default", "", "Task content") createRule(t, dir, ".agents/rules/rule1.md", "", "App: ${app}") }, @@ -1383,18 +1530,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "rule-default", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 1 { - t.Fatalf("expected 1 rule, got %d", len(result.Rules)) - } - if !strings.Contains(result.Rules[0].Content, "App: service") { - t.Errorf("expected 'App: service' in rule, got %q", result.Rules[0].Content) - } - }, + check: checkOneRuleContains("App: service"), }, { name: "mixed: task no expand, command with expand", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "mixed1", "expand: false", "Task ${task_var}\n/cmd") createCommand(t, dir, "cmd", "expand: true", "Command ${cmd_var}") }, @@ -1403,19 +1544,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "mixed1", wantErr: false, - check: func(t *testing.T, result *Result) { - content := result.Task.Content - if !strings.Contains(content, "Task task_value") { - t.Errorf("expected task param expanded, got %q", content) - } - if !strings.Contains(content, "Command cmd_value") { - t.Errorf("expected command param expanded, got %q", content) - } - }, + check: checkMixedNoExpandCommandExpand, // task text unexpanded, command expanded }, { name: "mixed: task with expand, command no expand", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "mixed2", "expand: true", "Task ${task_var}\n/cmd") createCommand(t, dir, "cmd", "expand: false", "Command ${cmd_var}") }, @@ -1424,19 +1558,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "mixed2", wantErr: false, - check: func(t *testing.T, result *Result) { - content := result.Task.Content - if !strings.Contains(content, "Task task_value") { - t.Errorf("expected task param expanded, got %q", content) - } - if !strings.Contains(content, "Command cmd_value") { - t.Errorf("expected command param expanded, got %q", content) - } - }, + check: checkMixedTaskExpandNoCommand, // task text expanded, command unexpanded }, { name: "command with inline parameters and expand: false", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "inline-no-expand", "", "/greet name=\"Alice\"") createCommand(t, dir, "greet", "expand: false", "Hello, ${name}! Your ID: ${id}") }, @@ -1445,18 +1572,12 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "inline-no-expand", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Hello, Alice!") { - t.Errorf("expected 'Hello, Alice!', got %q", result.Task.Content) - } - if !strings.Contains(result.Task.Content, "123") { - t.Errorf("expected id 123 in output, got %q", result.Task.Content) - } - }, + check: checkExpandInlineNoExpand, // expand:false means all ${...} stay unexpanded }, { name: "multiple rules with different expand settings", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "multi-rules", "", "Task") createRule(t, dir, ".agents/rules/rule1.md", "expand: false", "Rule1: ${var1}") createRule(t, dir, ".agents/rules/rule2.md", "expand: true", "Rule2: ${var2}") @@ -1467,31 +1588,13 @@ func TestContext_Run_ExpandParams(t *testing.T) { }, taskName: "multi-rules", wantErr: false, - check: func(t *testing.T, result *Result) { - if len(result.Rules) != 3 { - t.Fatalf("expected 3 rules, got %d", len(result.Rules)) - } - for _, rule := range result.Rules { - if strings.Contains(rule.Content, "Rule1:") { - if !strings.Contains(rule.Content, "Rule1: val1") { - t.Errorf("expected 'Rule1: val1', got %q", rule.Content) - } - } else if strings.Contains(rule.Content, "Rule2:") { - if !strings.Contains(rule.Content, "Rule2: val2") { - t.Errorf("expected 'Rule2: val2', got %q", rule.Content) - } - } else if strings.Contains(rule.Content, "Rule3:") { - if !strings.Contains(rule.Content, "Rule3: val3") { - t.Errorf("expected 'Rule3: val3', got %q", rule.Content) - } - } - } - }, + check: checkExpandMultipleRules, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tt.setup(t, tmpDir) @@ -1502,25 +1605,114 @@ func TestContext_Run_ExpandParams(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return } - if tt.wantErr && tt.errContains != "" { - if !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("expected error to contain %q, got %v", tt.errContains, err) - } - return - } + if tt.wantErr && tt.errContains != "" { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("expected error to contain %q, got %v", tt.errContains, err) + } + + return + } + + if !tt.wantErr && tt.check != nil { + tt.check(t, result) + } + }) + } +} + +// TestUserPrompt tests the user_prompt parameter functionality. +func checkUserPromptSimple(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Task content") { + t.Error("expected task content to contain 'Task content'") + } + + if !strings.Contains(result.Task.Content, "User prompt content") { + t.Error("expected task content to contain 'User prompt content'") + } + + taskIdx := strings.Index(result.Task.Content, "Task content") + + userIdx := strings.Index(result.Task.Content, "User prompt content") + if taskIdx >= userIdx { + t.Error("expected user_prompt to come after task content") + } +} + +func checkUserPromptWithCommand(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Task content") { + t.Error("expected task content to contain 'Task content'") + } + + if !strings.Contains(result.Task.Content, "User says:") { + t.Error("expected task content to contain 'User says: '") + } + + if !strings.Contains(result.Task.Content, "Hello from command!") { + t.Error("expected slash command in user_prompt to be expanded") + } +} + +func checkUserPromptWithComplexCommand(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Please fix:") { + t.Error("expected task content to contain 'Please fix: '") + } + + if !strings.Contains(result.Task.Content, "Issue 456: Fix bug") { + t.Error("expected slash command to be expanded with parameter substitution") + } +} + +func checkUserPromptUnchanged(t *testing.T, result *Result) { + t.Helper() + + if result.Task.Content != "Task content\n" { + t.Errorf("expected task content to be unchanged, got %q", result.Task.Content) + } +} + +func checkUserPromptMultipleCommands(t *testing.T, result *Result) { + t.Helper() + + if !strings.Contains(result.Task.Content, "Command 1") { + t.Error("expected first slash command to be expanded") + } - if !tt.wantErr && tt.check != nil { - tt.check(t, result) - } - }) + if !strings.Contains(result.Task.Content, "Command 2") { + t.Error("expected second slash command to be expanded") + } +} + +func checkUserPromptBothParsed(t *testing.T, result *Result) { + t.Helper() + + checks := []struct{ substr, msg string }{ + {"Task prompt with text", "expected task content to contain 'Task prompt with text'"}, + {"More task text", "expected task content to contain 'More task text'"}, + {"User prompt with text", "expected task content to contain 'User prompt with text'"}, + {"More user text", "expected task content to contain 'More user text'"}, + {"Task command output value1", "expected task command to be expanded with param1=value1"}, + {"User command output value2", "expected user command to be expanded with param2=value2"}, + } + for _, c := range checks { + if !strings.Contains(result.Task.Content, c.substr) { + t.Error(c.msg) + } } } -// TestUserPrompt tests the user_prompt parameter functionality +//nolint:funlen func TestUserPrompt(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(t *testing.T, dir string) @@ -1533,6 +1725,7 @@ func TestUserPrompt(t *testing.T) { { name: "simple user_prompt appended to task", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "simple", "", "Task content\n") }, opts: []Option{ @@ -1540,24 +1733,12 @@ func TestUserPrompt(t *testing.T) { }, taskName: "simple", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Task content") { - t.Error("expected task content to contain 'Task content'") - } - if !strings.Contains(result.Task.Content, "User prompt content") { - t.Error("expected task content to contain 'User prompt content'") - } - // Check that user_prompt comes after task content - taskIdx := strings.Index(result.Task.Content, "Task content") - userIdx := strings.Index(result.Task.Content, "User prompt content") - if taskIdx >= userIdx { - t.Error("expected user_prompt to come after task content") - } - }, + check: checkUserPromptSimple, }, { name: "user_prompt with slash command", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "with-command", "", "Task content\n") createCommand(t, dir, "greet", "", "Hello from command!") }, @@ -1566,21 +1747,12 @@ func TestUserPrompt(t *testing.T) { }, taskName: "with-command", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Task content") { - t.Error("expected task content to contain 'Task content'") - } - if !strings.Contains(result.Task.Content, "User says:") { - t.Error("expected task content to contain 'User says: '") - } - if !strings.Contains(result.Task.Content, "Hello from command!") { - t.Error("expected slash command in user_prompt to be expanded") - } - }, + check: checkUserPromptWithCommand, }, { name: "user_prompt with parameter substitution", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "with-params", "", "Task content\n") }, opts: []Option{ @@ -1591,15 +1763,12 @@ func TestUserPrompt(t *testing.T) { }, taskName: "with-params", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Issue: 123") { - t.Error("expected parameter substitution in user_prompt") - } - }, + check: checkTaskContains("Issue: 123"), }, { name: "user_prompt with slash command and parameters", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "complex", "", "Task content\n") createCommand(t, dir, "issue-info", "", "Issue ${issue_number}: ${issue_title}") }, @@ -1612,18 +1781,12 @@ func TestUserPrompt(t *testing.T) { }, taskName: "complex", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Please fix:") { - t.Error("expected task content to contain 'Please fix: '") - } - if !strings.Contains(result.Task.Content, "Issue 456: Fix bug") { - t.Error("expected slash command to be expanded with parameter substitution") - } - }, + check: checkUserPromptWithComplexCommand, }, { name: "empty user_prompt should not affect task", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "empty", "", "Task content\n") }, opts: []Option{ @@ -1631,29 +1794,23 @@ func TestUserPrompt(t *testing.T) { }, taskName: "empty", wantErr: false, - check: func(t *testing.T, result *Result) { - if result.Task.Content != "Task content\n" { - t.Errorf("expected task content to be unchanged, got %q", result.Task.Content) - } - }, + check: checkUserPromptUnchanged, }, { name: "no user_prompt parameter should not affect task", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "no-prompt", "", "Task content\n") }, opts: []Option{}, taskName: "no-prompt", wantErr: false, - check: func(t *testing.T, result *Result) { - if result.Task.Content != "Task content\n" { - t.Errorf("expected task content to be unchanged, got %q", result.Task.Content) - } - }, + check: checkUserPromptUnchanged, }, { name: "user_prompt with multiple slash commands", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "multi", "", "Task content\n") createCommand(t, dir, "cmd1", "", "Command 1") createCommand(t, dir, "cmd2", "", "Command 2") @@ -1663,18 +1820,12 @@ func TestUserPrompt(t *testing.T) { }, taskName: "multi", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "Command 1") { - t.Error("expected first slash command to be expanded") - } - if !strings.Contains(result.Task.Content, "Command 2") { - t.Error("expected second slash command to be expanded") - } - }, + check: checkUserPromptMultipleCommands, }, { name: "user_prompt respects task expand setting", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "no-expand", "expand: false", "Task content\n") }, opts: []Option{ @@ -1685,15 +1836,12 @@ func TestUserPrompt(t *testing.T) { }, taskName: "no-expand", wantErr: false, - check: func(t *testing.T, result *Result) { - if !strings.Contains(result.Task.Content, "789") { - t.Error("expected parameter to be expanded in output") - } - }, + check: checkTaskContains("${issue_number}"), // expand:false applies to user_prompt too }, { name: "user_prompt with invalid slash command", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "invalid", "", "Task content\n") }, opts: []Option{ @@ -1706,6 +1854,7 @@ func TestUserPrompt(t *testing.T) { { name: "both task prompt and user prompt parse correctly", setup: func(t *testing.T, dir string) { + t.Helper() // Task has text and slash command createTask(t, dir, "parse-test", "", "Task prompt with text\n/task-command arg1\nMore task text\n") createCommand(t, dir, "task-command", "", "Task command output ${param1}") @@ -1720,45 +1869,13 @@ func TestUserPrompt(t *testing.T) { }, taskName: "parse-test", wantErr: false, - check: func(t *testing.T, result *Result) { - // Verify task content contains both task and user prompt elements - if !strings.Contains(result.Task.Content, "Task prompt with text") { - t.Error("expected task content to contain 'Task prompt with text'") - } - if !strings.Contains(result.Task.Content, "More task text") { - t.Error("expected task content to contain 'More task text'") - } - if !strings.Contains(result.Task.Content, "User prompt with text") { - t.Error("expected task content to contain 'User prompt with text'") - } - if !strings.Contains(result.Task.Content, "More user text") { - t.Error("expected task content to contain 'More user text'") - } - // Verify both commands were expanded with correct parameters - if !strings.Contains(result.Task.Content, "Task command output value1") { - t.Error("expected task command to be expanded with param1=value1") - } - if !strings.Contains(result.Task.Content, "User command output value2") { - t.Error("expected user command to be expanded with param2=value2") - } - // Verify delimiter is present (separating task from user prompt) - if !strings.Contains(result.Task.Content, "---") { - t.Error("expected delimiter '---' between task and user prompt") - } - // Verify order: task content comes before user content - taskIdx := strings.Index(result.Task.Content, "Task prompt with text") - userIdx := strings.Index(result.Task.Content, "User prompt with text") - if taskIdx == -1 || userIdx == -1 { - t.Error("expected both task and user prompt text to be found in result") - } else if taskIdx >= userIdx { - t.Error("expected task content to come before user prompt content") - } - }, + check: checkUserPromptBothParsed, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tt.setup(t, tmpDir) @@ -1774,6 +1891,7 @@ func TestUserPrompt(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return } @@ -1781,6 +1899,7 @@ func TestUserPrompt(t *testing.T) { if !strings.Contains(err.Error(), tt.errContains) { t.Errorf("expected error to contain %q, got %v", tt.errContains, err) } + return } @@ -1791,8 +1910,10 @@ func TestUserPrompt(t *testing.T) { } } -// TestIsLocalPath tests the isLocalPath helper function +// TestIsLocalPath tests the isLocalPath helper function. func TestIsLocalPath(t *testing.T) { + t.Parallel() + tests := []struct { name string path string @@ -1847,6 +1968,8 @@ func TestIsLocalPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := isLocalPath(tt.path) if result != tt.expected { t.Errorf("isLocalPath(%q) = %v, expected %v", tt.path, result, tt.expected) @@ -1855,8 +1978,10 @@ func TestIsLocalPath(t *testing.T) { } } -// TestNormalizeLocalPath tests the normalizeLocalPath helper function +// TestNormalizeLocalPath tests the normalizeLocalPath helper function. func TestNormalizeLocalPath(t *testing.T) { + t.Parallel() + tests := []struct { name string path string @@ -1896,6 +2021,8 @@ func TestNormalizeLocalPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := normalizeLocalPath(tt.path) if result != tt.expected { t.Errorf("normalizeLocalPath(%q) = %q, expected %q", tt.path, result, tt.expected) @@ -1907,6 +2034,8 @@ func TestNormalizeLocalPath(t *testing.T) { // TestLogsParametersAndSelectors verifies that parameters and selectors are logged // exactly once after the task is found (which may add selectors from task frontmatter). func TestLogsParametersAndSelectors(t *testing.T) { + t.Parallel() + tests := []struct { name string params taskparser.Params @@ -1959,6 +2088,7 @@ func TestLogsParametersAndSelectors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() // Create a simple task @@ -1966,6 +2096,7 @@ func TestLogsParametersAndSelectors(t *testing.T) { // Create a custom logger that captures log output var logOutput strings.Builder + logger := slog.New(slog.NewTextHandler(&logOutput, nil)) // Create context with test options @@ -2015,29 +2146,139 @@ func TestLogsParametersAndSelectors(t *testing.T) { } } -// TestSkillDiscovery tests skill discovery functionality -func TestSkillDiscovery(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, dir string) - opts []Option - taskName string - wantErr bool - checkFunc func(t *testing.T, result *Result) - }{ +type skillDiscoveryCase struct { + name string + setup func(t *testing.T, dir string) + opts []Option + taskName string + wantErr bool + checkFunc func(t *testing.T, result *Result) +} + +func checkSkillMetadata(t *testing.T, result *Result) { + t.Helper() + + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) + } + + skill := result.Skills.Skills[0] + if skill.Name != "test-skill" { + t.Errorf("expected skill name 'test-skill', got %q", skill.Name) + } + + if skill.Description != "A test skill for unit testing" { + t.Errorf("expected skill description 'A test skill for unit testing', got %q", skill.Description) + } + + if skill.Location == "" { + t.Error("expected skill Location to be set") + } +} + +func checkSkillsOneAndTwo(t *testing.T, result *Result) { + t.Helper() + + if len(result.Skills.Skills) != 2 { + t.Fatalf("expected 2 skills, got %d", len(result.Skills.Skills)) + } + + names := []string{result.Skills.Skills[0].Name, result.Skills.Skills[1].Name} + if (names[0] != "skill-one" && names[0] != "skill-two") || //nolint:gosec + (names[1] != "skill-one" && names[1] != "skill-two") { + t.Errorf("expected skills 'skill-one' and 'skill-two', got %v", names) + } +} + +func checkSkillFilteredBySelector(t *testing.T, result *Result) { + t.Helper() + + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill matching selector, got %d", len(result.Skills.Skills)) + } + + if result.Skills.Skills[0].Name != "dev-skill" { + t.Errorf("expected skill name 'dev-skill', got %q", result.Skills.Skills[0].Name) + } +} + +func checkSkillsBootstrapDisabled(t *testing.T, result *Result) { + t.Helper() + + if len(result.Skills.Skills) != 0 { + t.Errorf("expected 0 skills when bootstrap is disabled, got %d", len(result.Skills.Skills)) + } +} + +func checkSkillResumeMode(t *testing.T, result *Result) { + t.Helper() + + if len(result.Skills.Skills) != 1 { + t.Errorf("expected 1 skill when resume is true but bootstrap is enabled, got %d", len(result.Skills.Skills)) + } +} + +func checkCursorSkill(t *testing.T, result *Result) { + t.Helper() + + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) + } + + skill := result.Skills.Skills[0] + if skill.Name != cursorSkillName { + t.Errorf("expected skill name %q, got %q", cursorSkillName, skill.Name) + } + + if skill.Description != "A skill for Cursor IDE" { + t.Errorf("expected skill description 'A skill for Cursor IDE', got %q", skill.Description) + } + + if skill.Location == "" { + t.Error("expected skill Location to be set") + } +} + +func checkAgentsAndCursorSkills(t *testing.T, result *Result) { + t.Helper() + + if len(result.Skills.Skills) != 2 { + t.Fatalf("expected 2 skills, got %d", len(result.Skills.Skills)) + } + + names := []string{result.Skills.Skills[0].Name, result.Skills.Skills[1].Name} + if (names[0] != "agents-skill" && names[0] != cursorSkillName) || //nolint:gosec + (names[1] != "agents-skill" && names[1] != cursorSkillName) { + t.Errorf("expected skills 'agents-skill' and %q, got %v", cursorSkillName, names) + } +} + +func checkSingleSkillNamed(name string) func(t *testing.T, result *Result) { + return func(t *testing.T, result *Result) { + t.Helper() + + if len(result.Skills.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) + } + + if result.Skills.Skills[0].Name != name { + t.Errorf("expected skill name %q, got %q", name, result.Skills.Skills[0].Name) + } + } +} + +//nolint:funlen,maintidx +func skillDiscoveryCases() []skillDiscoveryCase { + return []skillDiscoveryCase{ { name: "discover skills with metadata", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill directory with SKILL.md - skillDir := filepath.Join(dir, ".agents", "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "test-skill"), `--- name: test-skill description: A test skill for unit testing license: MIT @@ -2049,106 +2290,55 @@ metadata: # Test Skill This is a test skill. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "test-skill" { - t.Errorf("expected skill name 'test-skill', got %q", skill.Name) - } - if skill.Description != "A test skill for unit testing" { - t.Errorf("expected skill description 'A test skill for unit testing', got %q", skill.Description) - } - // Check that Location is set to absolute path - if skill.Location == "" { - t.Error("expected skill Location to be set") - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSkillMetadata, }, { name: "discover multiple skills", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create first skill - skillDir1 := filepath.Join(dir, ".agents", "skills", "skill-one") - if err := os.MkdirAll(skillDir1, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent1 := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "skill-one"), `--- name: skill-one description: First test skill --- # Skill One -` - skillPath1 := filepath.Join(skillDir1, "SKILL.md") - if err := os.WriteFile(skillPath1, []byte(skillContent1), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } +`) // Create second skill - skillDir2 := filepath.Join(dir, ".agents", "skills", "skill-two") - if err := os.MkdirAll(skillDir2, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent2 := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "skill-two"), `--- name: skill-two description: Second test skill --- # Skill Two -` - skillPath2 := filepath.Join(skillDir2, "SKILL.md") - if err := os.WriteFile(skillPath2, []byte(skillContent2), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 2 { - t.Fatalf("expected 2 skills, got %d", len(result.Skills.Skills)) - } - // Skills should be in order of discovery - names := []string{result.Skills.Skills[0].Name, result.Skills.Skills[1].Name} - if (names[0] != "skill-one" && names[0] != "skill-two") || - (names[1] != "skill-one" && names[1] != "skill-two") { - t.Errorf("expected skills 'skill-one' and 'skill-two', got %v", names) - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSkillsOneAndTwo, }, { name: "error on skills with missing required fields", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill with missing name - should cause error - skillDir1 := filepath.Join(dir, ".agents", "skills", "invalid-skill-1") - if err := os.MkdirAll(skillDir1, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent1 := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "invalid-skill-1"), `--- description: Missing name field --- # Invalid Skill -` - skillPath1 := filepath.Join(skillDir1, "SKILL.md") - if err := os.WriteFile(skillPath1, []byte(skillContent1), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } +`) }, taskName: "test-task", wantErr: true, @@ -2156,24 +2346,17 @@ description: Missing name field { name: "error on skill with missing description", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill with missing description - should cause error - skillDir := filepath.Join(dir, ".agents", "skills", "invalid-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "invalid-skill"), `--- name: invalid-skill --- # Invalid Skill -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } +`) }, taskName: "test-task", wantErr: true, @@ -2181,81 +2364,52 @@ name: invalid-skill { name: "skills filtered by selectors", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill with environment selector - skillDir1 := filepath.Join(dir, ".agents", "skills", "dev-skill") - if err := os.MkdirAll(skillDir1, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent1 := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "dev-skill"), `--- name: dev-skill description: Development environment skill env: development --- # Dev Skill -` - skillPath1 := filepath.Join(skillDir1, "SKILL.md") - if err := os.WriteFile(skillPath1, []byte(skillContent1), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } +`) // Create skill with production selector - skillDir2 := filepath.Join(dir, ".agents", "skills", "prod-skill") - if err := os.MkdirAll(skillDir2, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent2 := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "prod-skill"), `--- name: prod-skill description: Production environment skill env: production --- # Prod Skill -` - skillPath2 := filepath.Join(skillDir2, "SKILL.md") - if err := os.WriteFile(skillPath2, []byte(skillContent2), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } +`) }, opts: []Option{ WithSelectors(selectors.Selectors{"env": {"development": true}}), }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill matching selector, got %d", len(result.Skills.Skills)) - } - if result.Skills.Skills[0].Name != "dev-skill" { - t.Errorf("expected skill name 'dev-skill', got %q", result.Skills.Skills[0].Name) - } - }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSkillFilteredBySelector, }, { name: "error on skills with invalid field lengths", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill with name too long (>64 chars) - should cause error - skillDir1 := filepath.Join(dir, ".agents", "skills", "long-name-skill") - if err := os.MkdirAll(skillDir1, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent1 := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "long-name-skill"), `--- name: this-is-a-very-long-skill-name-that-exceeds-the-maximum-allowed-length-of-64-characters description: Valid description --- # Long Name Skill -` - skillPath1 := filepath.Join(skillDir1, "SKILL.md") - if err := os.WriteFile(skillPath1, []byte(skillContent1), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } +`) }, taskName: "test-task", wantErr: true, @@ -2263,26 +2417,19 @@ description: Valid description { name: "error on skill with description too long", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill with description too long (>1024 chars) - should cause error - skillDir := filepath.Join(dir, ".agents", "skills", "long-desc-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } longDesc := strings.Repeat("a", 1025) - skillContent := fmt.Sprintf(`--- + createSkill(t, dir, filepath.Join(".agents", "skills", "long-desc-skill"), fmt.Sprintf(`--- name: long-desc-skill description: %s --- # Long Desc Skill -`, longDesc) - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } +`, longDesc)) }, taskName: "test-task", wantErr: true, @@ -2290,16 +2437,12 @@ description: %s { name: "bootstrap disabled skips skill discovery", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Task content") // Create skill directory with SKILL.md - skillDir := filepath.Join(dir, ".agents", "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "test-skill"), `--- name: test-skill description: A test skill that should not be discovered when bootstrap is disabled --- @@ -2307,34 +2450,22 @@ description: A test skill that should not be discovered when bootstrap is disabl # Test Skill This is a test skill. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - opts: []Option{WithBootstrap(false)}, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 0 { - t.Errorf("expected 0 skills when bootstrap is disabled, got %d", len(result.Skills.Skills)) - } +`) }, + opts: []Option{WithBootstrap(false)}, + taskName: "test-task", + wantErr: false, + checkFunc: checkSkillsBootstrapDisabled, }, { name: "resume mode does not skip skill discovery", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Task content") // Create skill directory with SKILL.md - skillDir := filepath.Join(dir, ".agents", "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "test-skill"), `--- name: test-skill description: A test skill that should be discovered even in resume mode --- @@ -2342,132 +2473,62 @@ description: A test skill that should be discovered even in resume mode # Test Skill This is a test skill. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - opts: []Option{WithResume(true)}, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Errorf("expected 1 skill when resume is true but bootstrap is enabled, got %d", len(result.Skills.Skills)) - } +`) }, + opts: []Option{WithResume(true)}, + taskName: "test-task", + wantErr: false, + checkFunc: checkSkillResumeMode, }, { name: "discover skills from .cursor/skills directory", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .cursor/skills directory - skillDir := filepath.Join(dir, ".cursor", "skills", "cursor-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- -name: cursor-skill -description: A skill for Cursor IDE ---- - -# Cursor Skill - -This is a skill for Cursor. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "cursor-skill" { - t.Errorf("expected skill name 'cursor-skill', got %q", skill.Name) - } - if skill.Description != "A skill for Cursor IDE" { - t.Errorf("expected skill description 'A skill for Cursor IDE', got %q", skill.Description) - } - if skill.Location == "" { - t.Error("expected skill Location to be set") - } + cursorContent := "---\nname: " + cursorSkillName + + "\ndescription: A skill for Cursor IDE\n---\n\n# Cursor Skill\n\nThis is a skill for Cursor.\n" + createSkill(t, dir, filepath.Join(".cursor", "skills", cursorSkillName), cursorContent) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkCursorSkill, }, { name: "discover skills from both .agents/skills and .cursor/skills", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .agents/skills directory - skillDir1 := filepath.Join(dir, ".agents", "skills", "agents-skill") - if err := os.MkdirAll(skillDir1, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent1 := `--- + createSkill(t, dir, filepath.Join(".agents", "skills", "agents-skill"), `--- name: agents-skill description: A generic agents skill --- # Agents Skill -` - skillPath1 := filepath.Join(skillDir1, "SKILL.md") - if err := os.WriteFile(skillPath1, []byte(skillContent1), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } +`) // Create skill in .cursor/skills directory - skillDir2 := filepath.Join(dir, ".cursor", "skills", "cursor-skill") - if err := os.MkdirAll(skillDir2, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - skillContent2 := `--- -name: cursor-skill -description: A Cursor IDE skill ---- - -# Cursor Skill -` - skillPath2 := filepath.Join(skillDir2, "SKILL.md") - if err := os.WriteFile(skillPath2, []byte(skillContent2), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 2 { - t.Fatalf("expected 2 skills, got %d", len(result.Skills.Skills)) - } - names := []string{result.Skills.Skills[0].Name, result.Skills.Skills[1].Name} - // Verify both skills are present (order doesn't matter) - if (names[0] != "agents-skill" && names[0] != "cursor-skill") || - (names[1] != "agents-skill" && names[1] != "cursor-skill") { - t.Errorf("expected skills 'agents-skill' and 'cursor-skill', got %v", names) - } + createSkill(t, dir, filepath.Join(".cursor", "skills", cursorSkillName), + "---\nname: "+cursorSkillName+"\ndescription: A Cursor IDE skill\n---\n\n# Cursor Skill\n") }, + taskName: "test-task", + wantErr: false, + checkFunc: checkAgentsAndCursorSkills, }, { name: "discover skills from .opencode/skills directory", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .opencode/skills directory - skillDir := filepath.Join(dir, ".opencode", "skills", "opencode-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".opencode", "skills", "opencode-skill"), `--- name: opencode-skill description: A skill for OpenCode --- @@ -2475,37 +2536,21 @@ description: A skill for OpenCode # OpenCode Skill This is a skill for OpenCode. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "opencode-skill" { - t.Errorf("expected skill name 'opencode-skill', got %q", skill.Name) - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSingleSkillNamed("opencode-skill"), }, { name: "discover skills from .github/skills directory", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .github/skills directory - skillDir := filepath.Join(dir, ".github", "skills", "copilot-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".github", "skills", "copilot-skill"), `--- name: copilot-skill description: A skill for GitHub Copilot --- @@ -2513,37 +2558,21 @@ description: A skill for GitHub Copilot # Copilot Skill This is a skill for GitHub Copilot. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "copilot-skill" { - t.Errorf("expected skill name 'copilot-skill', got %q", skill.Name) - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSingleSkillNamed("copilot-skill"), }, { name: "discover skills from .augment/skills directory", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .augment/skills directory - skillDir := filepath.Join(dir, ".augment", "skills", "augment-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".augment", "skills", "augment-skill"), `--- name: augment-skill description: A skill for Augment --- @@ -2551,37 +2580,21 @@ description: A skill for Augment # Augment Skill This is a skill for Augment. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "augment-skill" { - t.Errorf("expected skill name 'augment-skill', got %q", skill.Name) - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSingleSkillNamed("augment-skill"), }, { name: "discover skills from .windsurf/skills directory", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .windsurf/skills directory - skillDir := filepath.Join(dir, ".windsurf", "skills", "windsurf-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".windsurf", "skills", "windsurf-skill"), `--- name: windsurf-skill description: A skill for Windsurf --- @@ -2589,37 +2602,21 @@ description: A skill for Windsurf # Windsurf Skill This is a skill for Windsurf. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "windsurf-skill" { - t.Errorf("expected skill name 'windsurf-skill', got %q", skill.Name) - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSingleSkillNamed("windsurf-skill"), }, { name: "discover skills from .claude/skills directory", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .claude/skills directory - skillDir := filepath.Join(dir, ".claude", "skills", "claude-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".claude", "skills", "claude-skill"), `--- name: claude-skill description: A skill for Claude --- @@ -2627,37 +2624,21 @@ description: A skill for Claude # Claude Skill This is a skill for Claude. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "claude-skill" { - t.Errorf("expected skill name 'claude-skill', got %q", skill.Name) - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSingleSkillNamed("claude-skill"), }, { name: "discover skills from .gemini/skills directory", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .gemini/skills directory - skillDir := filepath.Join(dir, ".gemini", "skills", "gemini-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".gemini", "skills", "gemini-skill"), `--- name: gemini-skill description: A skill for Gemini --- @@ -2665,37 +2646,21 @@ description: A skill for Gemini # Gemini Skill This is a skill for Gemini. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "gemini-skill" { - t.Errorf("expected skill name 'gemini-skill', got %q", skill.Name) - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSingleSkillNamed("gemini-skill"), }, { name: "discover skills from .codex/skills directory", setup: func(t *testing.T, dir string) { + t.Helper() // Create task createTask(t, dir, "test-task", "", "Test task content") // Create skill in .codex/skills directory - skillDir := filepath.Join(dir, ".codex", "skills", "codex-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("failed to create skill directory: %v", err) - } - - skillContent := `--- + createSkill(t, dir, filepath.Join(".codex", "skills", "codex-skill"), `--- name: codex-skill description: A skill for Codex --- @@ -2703,52 +2668,44 @@ description: A skill for Codex # Codex Skill This is a skill for Codex. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(skillContent), 0o644); err != nil { - t.Fatalf("failed to create skill file: %v", err) - } - }, - taskName: "test-task", - wantErr: false, - checkFunc: func(t *testing.T, result *Result) { - if len(result.Skills.Skills) != 1 { - t.Fatalf("expected 1 skill, got %d", len(result.Skills.Skills)) - } - skill := result.Skills.Skills[0] - if skill.Name != "codex-skill" { - t.Errorf("expected skill name 'codex-skill', got %q", skill.Name) - } +`) }, + taskName: "test-task", + wantErr: false, + checkFunc: checkSingleSkillNamed("codex-skill"), }, } +} - for _, tt := range tests { +// TestSkillDiscovery tests skill discovery functionality. +func TestSkillDiscovery(t *testing.T) { + t.Parallel() + + for _, tt := range skillDiscoveryCases() { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() - // Setup test fixtures tt.setup(t, tmpDir) - // Create context with test directory and options opts := append([]Option{ WithSearchPaths("file://" + tmpDir), }, tt.opts...) cc := New(opts...) - // Run the context result, err := cc.Run(context.Background(), tt.taskName) if tt.wantErr { if err == nil { t.Fatal("expected error but got none") } + return } + if err != nil { t.Fatalf("unexpected error: %v", err) } - // Run checks if tt.checkFunc != nil { tt.checkFunc(t, result) } diff --git a/pkg/codingcontext/enumerate.go b/pkg/codingcontext/enumerate.go new file mode 100644 index 0000000..53cf0cf --- /dev/null +++ b/pkg/codingcontext/enumerate.go @@ -0,0 +1,137 @@ +package codingcontext + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// DiscoveredTask represents a task found during enumeration of search paths. +type DiscoveredTask struct { + // Name is the task name as passed to Lint or Run (e.g. "my-task" or "myteam/my-task"). + Name string + // Path is the absolute path to the task markdown file. + Path string + // Namespace is the namespace prefix; empty for global tasks. + Namespace string +} + +// ListTasks enumerates all available tasks from the configured search paths without +// running any of them. It resolves remote directories (same as Run) and scans both +// global (.agents/tasks/) and namespace-specific (.agents/namespaces//tasks/) +// task directories. +// +// If the same task name appears in multiple search paths the first occurrence wins +// (consistent with how Run/Lint resolve tasks). +func (cc *Context) ListTasks(ctx context.Context) ([]DiscoveredTask, error) { + manifestPaths, err := cc.parseManifestFile(ctx) + if err != nil { + return nil, fmt.Errorf("failed to parse manifest file: %w", err) + } + + cc.searchPaths = append(cc.searchPaths, manifestPaths...) + + if err := cc.downloadRemoteDirectories(ctx); err != nil { + return nil, fmt.Errorf("failed to download remote directories: %w", err) + } + + defer cc.cleanupDownloadedDirectories() + + var tasks []DiscoveredTask + + seen := make(map[string]bool) + + for _, dir := range cc.downloadedPaths { + // Global tasks. + for _, taskDir := range taskSearchPaths(dir) { + found, err := listTasksInDir(taskDir, "") + if err != nil { + return nil, fmt.Errorf("failed to list tasks in %s: %w", taskDir, err) + } + + for _, t := range found { + if !seen[t.Name] { + tasks = append(tasks, t) + seen[t.Name] = true + } + } + } + + // Namespace tasks: walk .agents/namespaces//tasks/. + nsRootDir := filepath.Join(dir, ".agents/namespaces") + + entries, err := os.ReadDir(nsRootDir) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to read namespace directory %s: %w", nsRootDir, err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + ns := entry.Name() + nsTaskDir := filepath.Join(nsRootDir, ns, "tasks") + + found, err := listTasksInDir(nsTaskDir, ns) + if err != nil { + return nil, fmt.Errorf("failed to list namespace tasks in %s: %w", nsTaskDir, err) + } + + for _, t := range found { + if !seen[t.Name] { + tasks = append(tasks, t) + seen[t.Name] = true + } + } + } + } + + return tasks, nil +} + +// listTasksInDir scans dir for .md/.mdc task files and returns a DiscoveredTask for each. +// namespace is empty for global tasks; for namespaced tasks it is prepended to the name. +func listTasksInDir(dir, namespace string) ([]DiscoveredTask, error) { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("failed to stat directory %s: %w", dir, err) + } + + var tasks []DiscoveredTask + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + ext := filepath.Ext(path) + if ext != ".md" && ext != ".mdc" { + return nil + } + + baseName := strings.TrimSuffix(filepath.Base(path), ext) + + name := baseName + if namespace != "" { + name = namespace + "/" + baseName + } + + tasks = append(tasks, DiscoveredTask{ + Name: name, + Path: path, + Namespace: namespace, + }) + + return nil + }) + + return tasks, err +} diff --git a/pkg/codingcontext/lint.go b/pkg/codingcontext/lint.go new file mode 100644 index 0000000..1fd04b2 --- /dev/null +++ b/pkg/codingcontext/lint.go @@ -0,0 +1,176 @@ +package codingcontext + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" +) + +// LoadedFileKind identifies the role of a file loaded during context assembly. +type LoadedFileKind string + +// Valid LoadedFileKind values. +const ( + LoadedFileKindTask LoadedFileKind = "task" + LoadedFileKindRule LoadedFileKind = "rule" + LoadedFileKindCommand LoadedFileKind = "command" + LoadedFileKindSkill LoadedFileKind = "skill" + LoadedFileKindPathRef LoadedFileKind = "path-ref" + LoadedFileKindBootstrap LoadedFileKind = "bootstrap" +) + +// LintErrorKind identifies the category of a structural problem. +type LintErrorKind string + +// Valid LintErrorKind values. +const ( + LintErrorKindParse LintErrorKind = "parse" + LintErrorKindMissingCommand LintErrorKind = "missing-command" + LintErrorKindSkillValidation LintErrorKind = "skill-validation" + LintErrorKindSelectorNoMatch LintErrorKind = "selector-no-match" +) + +// LoadedFile records a file accessed during context assembly. +type LoadedFile struct { + Path string + Kind LoadedFileKind +} + +// LintError records a non-fatal structural problem found during linting. +type LintError struct { + Path string // May be empty + Kind LintErrorKind + Message string + Line int // 1-indexed; 0 means unknown (only set for parse errors) + Column int // 1-indexed; 0 means unknown (only set for parse errors) +} + +// LintResult is returned by Lint(). It embeds the assembled Result plus tracking +// data collected during the dry run. +type LintResult struct { + *Result + + LoadedFiles []LoadedFile + Errors []LintError +} + +// lintCollector is internal state attached to Context during lint mode. +type lintCollector struct { + files []LoadedFile + errors []LintError + // allFrontmatterValues tracks key→value pairs seen across ALL discovered markdown + // files (matched or skipped), used to validate selector coverage after assembly. + allFrontmatterValues map[string]map[string]bool +} + +func (lc *lintCollector) recordFile(path string, kind LoadedFileKind) { + lc.files = append(lc.files, LoadedFile{Path: path, Kind: kind}) +} + +func (lc *lintCollector) recordError(path string, kind LintErrorKind, message string) { + lc.errors = append(lc.errors, LintError{Path: path, Kind: kind, Message: message}) +} + +func (lc *lintCollector) recordParseError(pe *markdown.ParseError) { + lc.errors = append(lc.errors, LintError{ + Path: pe.File, + Kind: LintErrorKindParse, + Message: pe.Message, + Line: pe.Line, + Column: pe.Column, + }) +} + +func (lc *lintCollector) recordFrontmatterValues(fm markdown.BaseFrontMatter) { + if lc.allFrontmatterValues == nil { + lc.allFrontmatterValues = make(map[string]map[string]bool) + } + + for key, value := range fm.Content { + if lc.allFrontmatterValues[key] == nil { + lc.allFrontmatterValues[key] = make(map[string]bool) + } + + switch v := value.(type) { + case []any: + for _, item := range v { + lc.allFrontmatterValues[key][fmt.Sprint(item)] = true + } + default: + lc.allFrontmatterValues[key][fmt.Sprint(v)] = true + } + } +} + +// Lint runs context assembly in dry-run mode and returns validation results. +// It skips bootstrap script execution and shell command expansion (!`cmd`), but +// otherwise performs the same file loading, parsing, and selector matching as Run(). +// Fatal errors (e.g. task not found) are returned as errors; structural problems +// are collected in LintResult.Errors. +func (cc *Context) Lint(ctx context.Context, taskName string) (*LintResult, error) { + cc.lintMode = true + cc.lintCollector = &lintCollector{} + cc.doBootstrap = true // ensure rule + skill discovery runs + + result, err := cc.Run(ctx, taskName) + if err != nil { + return nil, err + } + + cc.validateSelectorCoverage() + + return &LintResult{ + Result: result, + LoadedFiles: cc.lintCollector.files, + Errors: cc.lintCollector.errors, + }, nil +} + +// recordLintBootstrap logs or records a bootstrap entry during lint mode without executing it. +// For frontmatter-based bootstraps, it just logs. For file-based bootstraps, it stat-checks +// the companion script and records it in LoadedFiles if it exists. +func (cc *Context) recordLintBootstrap(rulePath, frontmatterBootstrap string) { + if frontmatterBootstrap != "" { + cc.logger.Info("Lint mode: skipping frontmatter bootstrap", "path", rulePath) + + return + } + + bootstrapFilePath := strings.TrimSuffix(rulePath, filepath.Ext(rulePath)) + "-bootstrap" + if _, err := os.Stat(bootstrapFilePath); err == nil && cc.lintCollector != nil { + cc.lintCollector.recordFile(bootstrapFilePath, LoadedFileKindBootstrap) + } +} + +// validateSelectorCoverage checks that each user-specified selector key-value pair +// appeared in at least one discovered file's frontmatter. Records a LintError for +// each unmatched pair. Auto-set keys (task_name, resume) are excluded from this check. +func (cc *Context) validateSelectorCoverage() { + // Anchor selector errors to the task file so they appear as file annotations. + taskPath := "" + for _, f := range cc.lintCollector.files { + if f.Kind == LoadedFileKindTask { + taskPath = f.Path + break + } + } + + autoKeys := map[string]bool{"task_name": true, "resume": true, "namespace": true} + for key, values := range cc.includes { + if autoKeys[key] { + continue + } + + seen := cc.lintCollector.allFrontmatterValues[key] + for value := range values { + if !seen[value] { + cc.lintCollector.recordError(taskPath, LintErrorKindSelectorNoMatch, + fmt.Sprintf("selector '%s=%s' matched no discovered files", key, value)) + } + } + } +} diff --git a/pkg/codingcontext/lint_extra_test.go b/pkg/codingcontext/lint_extra_test.go new file mode 100644 index 0000000..9f561e8 --- /dev/null +++ b/pkg/codingcontext/lint_extra_test.go @@ -0,0 +1,106 @@ +package codingcontext + +import ( + "context" + "strings" + "testing" +) + +// TestLint_FrontmatterListValue exercises the []any branch in recordFrontmatterValues. +// When a rule has a list-valued frontmatter field (e.g. "languages: [go, python]"), +// each item is tracked individually in allFrontmatterValues. This is required for +// selector coverage validation to report list-keyed selectors correctly. +func TestLint_FrontmatterListValue(t *testing.T) { + t.Parallel() + + dir := lintTestDir(t) + + createTask(t, dir, "task", "", "Task content.") + // Rule with a list-valued frontmatter field + createRule(t, dir, ".agents/rules/multi-lang.md", "languages:\n - go\n - python\n", "Multi-language rule.") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "task") + if err != nil { + t.Fatalf("Lint() error: %v", err) + } + + // The rule should be included (no selector filter active) + if !hasLoadedFile(result, "multi-lang.md", LoadedFileKindRule) { + t.Errorf("expected multi-lang rule in LoadedFiles, got %+v", result.LoadedFiles) + } + + // allFrontmatterValues should contain "go" and "python" under "languages" + // We verify indirectly: add a selector that matches and one that doesn't match, + // and check that the selector-no-match logic sees list items properly. + seen := cc.lintCollector.allFrontmatterValues["languages"] + if !seen["go"] || !seen["python"] { + t.Errorf("expected 'go' and 'python' in allFrontmatterValues[\"languages\"], got %v", seen) + } +} + +// TestLint_FrontmatterBootstrapSkipped exercises the frontmatterBootstrap != "" +// branch in recordLintBootstrap. When a rule has an inline bootstrap script in +// its frontmatter, lint mode must log and skip — not execute — it, and must NOT +// record it in LoadedFiles (it has no companion file to stat). +func TestLint_FrontmatterBootstrapSkipped(t *testing.T) { + t.Parallel() + + dir := lintTestDir(t) + + createTask(t, dir, "task", "", "Task content.") + // Rule with frontmatter-based bootstrap (inline script) + createRule(t, dir, ".agents/rules/fmrule.md", "bootstrap: |\n #!/bin/sh\n exit 1\n", "Rule with inline bootstrap.") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "task") + if err != nil { + t.Fatalf("Lint() should not fail when frontmatter bootstrap is skipped: %v", err) + } + + // The rule itself should be loaded + if !hasLoadedFile(result, "fmrule.md", LoadedFileKindRule) { + t.Errorf("expected fmrule.md in LoadedFiles, got %+v", result.LoadedFiles) + } + + // No bootstrap file entry should appear (frontmatter bootstrap has no companion file) + for _, f := range result.LoadedFiles { + if f.Kind == LoadedFileKindBootstrap && strings.HasSuffix(f.Path, "fmrule-bootstrap") { + t.Errorf("unexpected bootstrap file entry for frontmatter bootstrap: %+v", f) + } + } +} + +// TestLint_ParseErrorInRuleFile exercises the parse-error branch in makeMarkdownWalkFunc. +// When a rule file has invalid YAML frontmatter, lint mode records a parse error +// (non-fatal) and continues assembly instead of aborting. +func TestLint_ParseErrorInRuleFile(t *testing.T) { + t.Parallel() + + dir := lintTestDir(t) + + createTask(t, dir, "task", "", "Task content.") + // Rule with syntactically invalid YAML (duplicate mapping key causes error) + createRule(t, dir, ".agents/rules/badrule.md", "invalid: yaml: : syntax\n", "Bad rule content.") + // A valid rule that should still be loaded despite the bad one + createRule(t, dir, ".agents/rules/goodrule.md", "", "Good rule content.") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "task") + if err != nil { + t.Fatalf("Lint() should not fatal on parse error in rule: %v", err) + } + + // A parse error should be recorded + if !hasLintError(result, LintErrorKindParse, "") { + t.Errorf("expected parse error for bad rule YAML, got errors: %+v", result.Errors) + } + + // The good rule should still be loaded + if !hasLoadedFile(result, "goodrule.md", LoadedFileKindRule) { + t.Errorf("expected goodrule.md in LoadedFiles despite bad rule, got %+v", result.LoadedFiles) + } +} diff --git a/pkg/codingcontext/lint_test.go b/pkg/codingcontext/lint_test.go new file mode 100644 index 0000000..8355a5d --- /dev/null +++ b/pkg/codingcontext/lint_test.go @@ -0,0 +1,271 @@ +package codingcontext + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// lintTestDir creates a temp dir registered for cleanup and returns its path. +func lintTestDir(t *testing.T) string { + t.Helper() + + return t.TempDir() +} + +// newLintContext creates a Context configured to use dir as the sole search path. +func newLintContext(dir string) *Context { + return New(WithSearchPaths(dir)) +} + +// hasLoadedFile reports whether result contains a loaded file with the given path suffix and kind. +func hasLoadedFile(result *LintResult, pathSuffix string, kind LoadedFileKind) bool { + for _, f := range result.LoadedFiles { + if strings.HasSuffix(f.Path, pathSuffix) && f.Kind == kind { + return true + } + } + + return false +} + +// hasLintError reports whether result contains a LintError with the given kind and message substring. +func hasLintError(result *LintResult, kind LintErrorKind, msgSubstr string) bool { + for _, e := range result.Errors { + if e.Kind == kind && strings.Contains(e.Message, msgSubstr) { + return true + } + } + + return false +} + +func TestLint_BasicTaskAndRule(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + createTask(t, dir, "deploy", "", "Deploy the application.") + createRule(t, dir, ".agents/rules/base.md", "", "Always write tests.") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "deploy") + if err != nil { + t.Fatalf("Lint() returned error: %v", err) + } + + if !hasLoadedFile(result, "deploy.md", LoadedFileKindTask) { + t.Errorf("expected task file in LoadedFiles, got %+v", result.LoadedFiles) + } + + if !hasLoadedFile(result, "base.md", LoadedFileKindRule) { + t.Errorf("expected rule file in LoadedFiles, got %+v", result.LoadedFiles) + } + + if result.Prompt == "" { + t.Error("expected non-empty Prompt") + } +} + +func TestLint_TaskNotFound_FatalError(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + cc := newLintContext(dir) + + _, err := cc.Lint(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error for missing task, got nil") + } + + if !errors.Is(err, ErrTaskNotFound) { + t.Errorf("expected ErrTaskNotFound, got %v", err) + } +} + +func TestLint_BootstrapFileSkipped(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + createTask(t, dir, "mytask", "", "Do something.") + createRule(t, dir, ".agents/rules/myrule.md", "", "Some rule.") + createBootstrapScript(t, dir, ".agents/rules/myrule.md", "#!/bin/sh\nexit 1\n") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "mytask") + if err != nil { + t.Fatalf("Lint() returned error (bootstrap should be skipped): %v", err) + } + + if !hasLoadedFile(result, "myrule-bootstrap", LoadedFileKindBootstrap) { + t.Errorf("expected bootstrap file in LoadedFiles, got %+v", result.LoadedFiles) + } +} + +func TestLint_CommandExpansionSkipped(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + // Task content with a !`cmd` expansion that would fail if executed + createTask(t, dir, "cmdtask", "", "Result: !`exit 1`") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "cmdtask") + if err != nil { + t.Fatalf("Lint() returned error: %v", err) + } + // The literal !`exit 1` should be preserved in the prompt + if !strings.Contains(result.Prompt, "!`exit 1`") { + t.Errorf("expected literal command in prompt, got: %s", result.Prompt) + } + // No lint error should be generated for skipped commands + for _, e := range result.Errors { + if e.Kind == LintErrorKindMissingCommand { + t.Errorf("unexpected missing-command error: %+v", e) + } + } +} + +func TestLint_PathRefTracked(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + // Create a file to reference + refFile := filepath.Join(dir, "data.txt") + if err := os.WriteFile(refFile, []byte("some data"), 0o600); err != nil { + t.Fatalf("failed to create ref file: %v", err) + } + + createTask(t, dir, "pathtask", "", "Content: @"+refFile) + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "pathtask") + if err != nil { + t.Fatalf("Lint() returned error: %v", err) + } + + if !hasLoadedFile(result, "data.txt", LoadedFileKindPathRef) { + t.Errorf("expected path-ref in LoadedFiles, got %+v", result.LoadedFiles) + } + // File content should be included in prompt + if !strings.Contains(result.Prompt, "some data") { + t.Errorf("expected file content in prompt, got: %s", result.Prompt) + } +} + +func TestLint_MissingCommand_NonFatal(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + createTask(t, dir, "task1", "", "/missingcmd arg1\nSome text after.") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "task1") + if err != nil { + t.Fatalf("Lint() returned error: %v", err) + } + + if !hasLintError(result, LintErrorKindMissingCommand, "missingcmd") { + t.Errorf("expected missing-command error, got errors: %+v", result.Errors) + } + // Assembly should have continued — prompt should still have the text after + if !strings.Contains(result.Prompt, "Some text after.") { + t.Errorf("expected remaining task content in prompt, got: %s", result.Prompt) + } +} + +func TestLint_SkillValidation_NonFatal(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + createTask(t, dir, "task1", "", "Do stuff.") + // Skill with missing name — invalid + createSkill(t, dir, ".agents/skills/badskill", "---\ndescription: Some description\n---\nSkill content.") + // Valid skill + createSkill(t, dir, ".agents/skills/goodskill", "---\nname: good-skill\ndescription: A good skill\n---\nGood content.") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "task1") + if err != nil { + t.Fatalf("Lint() returned error: %v", err) + } + + if !hasLintError(result, LintErrorKindSkillValidation, "skill missing required 'name'") { + t.Errorf("expected skill-validation error, got errors: %+v", result.Errors) + } + // Good skill should still appear in result + found := false + + for _, s := range result.Skills.Skills { + if s.Name == "good-skill" { + found = true + + break + } + } + + if !found { + t.Errorf("expected good-skill in Skills, got: %+v", result.Skills.Skills) + } +} + +func TestLint_SelectorNoMatch(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + createTask(t, dir, "task1", "", "Do stuff.") + createRule(t, dir, ".agents/rules/rule1.md", "name: rule1\nlanguage: go\n", "Go rule.") + + cc := New( + WithSearchPaths(dir), + // Selector value that no file has in its frontmatter + WithSelectors(map[string]map[string]bool{ + "environment": {"production": true}, + }), + ) + + result, err := cc.Lint(context.Background(), "task1") + if err != nil { + t.Fatalf("Lint() returned error: %v", err) + } + + if !hasLintError(result, LintErrorKindSelectorNoMatch, "environment=production") { + t.Errorf("expected selector-no-match error, got errors: %+v", result.Errors) + } +} + +func TestLint_WithLintOption(t *testing.T) { + t.Parallel() + + cc := New(WithLint(true)) + if !cc.lintMode { + t.Error("expected lintMode to be true after WithLint(true)") + } +} + +func TestLint_Command_Tracked(t *testing.T) { + t.Parallel() + dir := lintTestDir(t) + + createTask(t, dir, "task1", "", "/mycmd\nText after.") + createCommand(t, dir, "mycmd", "", "Command content.") + + cc := newLintContext(dir) + + result, err := cc.Lint(context.Background(), "task1") + if err != nil { + t.Fatalf("Lint() returned error: %v", err) + } + + if !hasLoadedFile(result, "mycmd.md", LoadedFileKindCommand) { + t.Errorf("expected command file in LoadedFiles, got %+v", result.LoadedFiles) + } +} diff --git a/pkg/codingcontext/markdown/frontmatter.go b/pkg/codingcontext/markdown/frontmatter.go index 5fc760d..a723582 100644 --- a/pkg/codingcontext/markdown/frontmatter.go +++ b/pkg/codingcontext/markdown/frontmatter.go @@ -1,3 +1,4 @@ +// Package markdown provides parsing and structs for markdown frontmatter. package markdown import ( @@ -5,98 +6,80 @@ import ( "fmt" "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" - "gopkg.in/yaml.v3" ) -// BaseFrontMatter represents parsed YAML frontmatter from markdown files +// BaseFrontMatter represents parsed YAML frontmatter from markdown files. type BaseFrontMatter struct { // Name is the skill identifier // Must be 1-64 characters, lowercase alphanumeric and hyphens only - Name string `yaml:"name,omitempty" json:"name,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` // Description explains what the prompt does and when to use it // Must be 1-1024 characters - Description string `yaml:"description,omitempty" json:"description,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + // Content captures any frontmatter fields not explicitly declared in the struct. + // With yaml:",inline", goccy/go-yaml populates this map with all unknown keys, + // while known fields on the embedding struct (e.g. TaskNames, License) are set + // directly on those fields. This ensures outer-struct fields are not shadowed. Content map[string]any `json:"-" yaml:",inline"` } -type baseFrontMatterRaw struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Content map[string]any `yaml:",inline"` -} - -// UnmarshalYAML ensures inline fields are properly captured in Content. -func (b *BaseFrontMatter) UnmarshalYAML(value *yaml.Node) error { - var raw baseFrontMatterRaw - if err := value.Decode(&raw); err != nil { - return err - } - b.Name = raw.Name - b.Description = raw.Description - b.Content = raw.Content - if raw.Content == nil { - b.Content = make(map[string]any) - } - return nil -} - -// TaskFrontMatter represents the standard frontmatter fields for task files +// TaskFrontMatter represents the standard frontmatter fields for task files. type TaskFrontMatter struct { BaseFrontMatter `yaml:",inline"` // Agent specifies the default agent if not specified via -a flag // This is not used for selecting tasks or rules, only as a default - Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` + Agent string `json:"agent,omitempty" yaml:"agent,omitempty"` // Languages specifies the programming language(s) for filtering rules // Array of languages for OR logic (e.g., ["go", "python"]) - Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` + Languages []string `json:"languages,omitempty" yaml:"languages,omitempty"` // Model specifies the AI model identifier // Does not filter rules, metadata only - Model string `yaml:"model,omitempty" json:"model,omitempty"` + Model string `json:"model,omitempty" yaml:"model,omitempty"` // SingleShot indicates whether the task runs once or multiple times // Does not filter rules, metadata only - SingleShot bool `yaml:"single_shot,omitempty" json:"single_shot,omitempty"` + SingleShot bool `json:"single_shot,omitempty" yaml:"single_shot,omitempty"` // Timeout specifies the task timeout in time.Duration format (e.g., "10m", "1h") // Does not filter rules, metadata only - Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` + Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"` // Resume indicates if this task should be resumed - Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` + Resume bool `json:"resume,omitempty" yaml:"resume,omitempty"` // Selectors contains additional custom selectors for filtering rules - Selectors map[string]any `yaml:"selectors,omitempty" json:"selectors,omitempty"` + Selectors map[string]any `json:"selectors,omitempty" yaml:"selectors,omitempty"` // ExpandParams controls whether parameter expansion should occur // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` + ExpandParams *bool `json:"expand,omitempty" yaml:"expand,omitempty"` + + // IncludeUnmatched controls whether rules/skills that don't explicitly match + // any active selector are included by default. Defaults to true (current behaviour). + // Set to false to require an explicit selector match (strict/opt-in mode). + IncludeUnmatched *bool `json:"include_unmatched,omitempty" yaml:"include_unmatched,omitempty"` } -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +// populateContent unmarshals raw JSON into the inline Content map. +// Called by each concrete frontmatter type's UnmarshalJSON after the +// typed fields have been populated via the alias trick. +func (b *BaseFrontMatter) populateContent(data []byte) error { + return json.Unmarshal(data, &b.Content) +} + +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map. func (t *TaskFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion type Alias TaskFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(t), - } - + aux := &struct{ *Alias }{Alias: (*Alias)(t)} if err := json.Unmarshal(data, aux); err != nil { return fmt.Errorf("failed to unmarshal task frontmatter: %w", err) } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &t.BaseFrontMatter.Content); err != nil { - return fmt.Errorf("failed to unmarshal task frontmatter content: %w", err) - } - - return nil + return t.populateContent(data) } // CommandFrontMatter represents the frontmatter fields for command files. @@ -107,121 +90,85 @@ type CommandFrontMatter struct { // ExpandParams controls whether parameter expansion should occur // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` + ExpandParams *bool `json:"expand,omitempty" yaml:"expand,omitempty"` // Selectors contains additional custom selectors for filtering rules // When a command is used in a task, its selectors are combined with task selectors - Selectors map[string]any `yaml:"selectors,omitempty" json:"selectors,omitempty"` + Selectors map[string]any `json:"selectors,omitempty" yaml:"selectors,omitempty"` } -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map. func (c *CommandFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion type Alias CommandFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(c), - } - + aux := &struct{ *Alias }{Alias: (*Alias)(c)} if err := json.Unmarshal(data, aux); err != nil { return fmt.Errorf("failed to unmarshal command frontmatter: %w", err) } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &c.BaseFrontMatter.Content); err != nil { - return fmt.Errorf("failed to unmarshal command frontmatter content: %w", err) - } - - return nil + return c.populateContent(data) } -// RuleFrontMatter represents the standard frontmatter fields for rule files +// RuleFrontMatter represents the standard frontmatter fields for rule files. type RuleFrontMatter struct { BaseFrontMatter `yaml:",inline"` // TaskNames specifies which task(s) this rule applies to // Array of task names for OR logic - TaskNames []string `yaml:"task_names,omitempty" json:"task_names,omitempty"` + TaskNames []string `json:"task_names,omitempty" yaml:"task_names,omitempty"` // Languages specifies which programming language(s) this rule applies to // Array of languages for OR logic (e.g., ["go", "python"]) - Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` + Languages []string `json:"languages,omitempty" yaml:"languages,omitempty"` // Agent specifies which AI agent this rule is intended for - Agent string `yaml:"agent,omitempty" json:"agent,omitempty"` + Agent string `json:"agent,omitempty" yaml:"agent,omitempty"` // MCPServer specifies a single MCP server configuration // Metadata only, does not filter - MCPServer mcp.MCPServerConfig `yaml:"mcp_server,omitempty" json:"mcp_server,omitempty"` + MCPServer mcp.MCPServerConfig `json:"mcp_server,omitzero" yaml:"mcp_server,omitzero"` // ExpandParams controls whether parameter expansion should occur // Defaults to true if not specified - ExpandParams *bool `yaml:"expand,omitempty" json:"expand,omitempty"` + ExpandParams *bool `json:"expand,omitempty" yaml:"expand,omitempty"` // Bootstrap contains a shell script to execute before including the rule // This is preferred over file-based bootstrap scripts - Bootstrap string `yaml:"bootstrap,omitempty" json:"bootstrap,omitempty"` + Bootstrap string `json:"bootstrap,omitempty" yaml:"bootstrap,omitempty"` } -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map. func (r *RuleFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion type Alias RuleFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(r), - } - + aux := &struct{ *Alias }{Alias: (*Alias)(r)} if err := json.Unmarshal(data, aux); err != nil { return fmt.Errorf("failed to unmarshal rule frontmatter: %w", err) } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &r.BaseFrontMatter.Content); err != nil { - return fmt.Errorf("failed to unmarshal rule frontmatter content: %w", err) - } - - return nil + return r.populateContent(data) } -// SkillFrontMatter represents the standard frontmatter fields for skill files +// SkillFrontMatter represents the standard frontmatter fields for skill files. type SkillFrontMatter struct { BaseFrontMatter `yaml:",inline"` // License specifies the license applied to the skill (optional) - License string `yaml:"license,omitempty" json:"license,omitempty"` + License string `json:"license,omitempty" yaml:"license,omitempty"` // Compatibility indicates environment requirements (optional) // Max 500 characters - Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"` + Compatibility string `json:"compatibility,omitempty" yaml:"compatibility,omitempty"` // Metadata contains arbitrary key-value pairs (optional) - Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"` + Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` // AllowedTools is a space-delimited list of pre-approved tools (optional, experimental) - AllowedTools string `yaml:"allowed-tools,omitempty" json:"allowed-tools,omitempty"` + AllowedTools string `json:"allowed_tools,omitempty" yaml:"allowed_tools,omitempty"` } -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map. func (s *SkillFrontMatter) UnmarshalJSON(data []byte) error { - // First unmarshal into a temporary type to avoid infinite recursion type Alias SkillFrontMatter - aux := &struct { - *Alias - }{ - Alias: (*Alias)(s), - } - + aux := &struct{ *Alias }{Alias: (*Alias)(s)} if err := json.Unmarshal(data, aux); err != nil { return fmt.Errorf("failed to unmarshal skill frontmatter: %w", err) } - - // Also unmarshal into Content map - if err := json.Unmarshal(data, &s.BaseFrontMatter.Content); err != nil { - return fmt.Errorf("failed to unmarshal skill frontmatter content: %w", err) - } - - return nil + return s.populateContent(data) } diff --git a/pkg/codingcontext/markdown/frontmatter_command_test.go b/pkg/codingcontext/markdown/frontmatter_command_test.go index 9ab2d6b..b811543 100644 --- a/pkg/codingcontext/markdown/frontmatter_command_test.go +++ b/pkg/codingcontext/markdown/frontmatter_command_test.go @@ -1,12 +1,15 @@ package markdown import ( + "encoding/json" "testing" - "gopkg.in/yaml.v3" + yaml "github.com/goccy/go-yaml" ) func TestCommandFrontMatter_Marshal(t *testing.T) { + t.Parallel() + tests := []struct { name string command CommandFrontMatter @@ -25,7 +28,7 @@ func TestCommandFrontMatter_Marshal(t *testing.T) { Description: "This is a standard command with metadata", }, }, - want: "{}\n", + want: "name: Standard Command\ndescription: This is a standard command with metadata\n", }, { name: "command with expand false", @@ -36,10 +39,11 @@ func TestCommandFrontMatter_Marshal(t *testing.T) { }, ExpandParams: func() *bool { b := false + return &b }(), }, - want: "expand: false\n", + want: "name: No Expand Command\ndescription: Command with expansion disabled\nexpand: false\n", }, { name: "command with selectors", @@ -53,16 +57,20 @@ func TestCommandFrontMatter_Marshal(t *testing.T) { "feature": "auth", }, }, - want: "selectors:\n database: postgres\n feature: auth\n", + want: "name: Selector Command\ndescription: Command with selectors\nselectors:\n database: postgres\n " + + "feature: auth\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := yaml.Marshal(&tt.command) if err != nil { t.Fatalf("Marshal() error = %v", err) } + if string(got) != tt.want { t.Errorf("Marshal() = %q, want %q", string(got), tt.want) } @@ -71,6 +79,8 @@ func TestCommandFrontMatter_Marshal(t *testing.T) { } func TestCommandFrontMatter_Unmarshal(t *testing.T) { + t.Parallel() + tests := []struct { name string yaml string @@ -132,30 +142,147 @@ selectors: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got CommandFrontMatter + err := yaml.Unmarshal([]byte(tt.yaml), &got) + + assertCommandFrontMatter(t, got, tt.want, err, tt.wantErr) + }) + } +} + +func assertCommandFrontMatter(t *testing.T, got, want CommandFrontMatter, err error, wantErr bool) { + t.Helper() + + if (err != nil) != wantErr { + t.Fatalf("Unmarshal() error = %v, wantErr %v", err, wantErr) + } + + if err != nil { + return + } + + if got.Name != want.Name { + t.Errorf("Name = %q, want %q", got.Name, want.Name) + } + + if got.Description != want.Description { + t.Errorf("Description = %q, want %q", got.Description, want.Description) + } + + assertExpandParams(t, got.ExpandParams, want.ExpandParams) +} + +func assertExpandParams(t *testing.T, got, want *bool) { + t.Helper() + + if want == nil { + return + } + + if (got == nil) != (want == nil) { + t.Errorf("ExpandParams nil mismatch: got %v, want %v", got == nil, want == nil) + + return + } + + if got != nil && *got != *want { + t.Errorf("ExpandParams = %v, want %v", *got, *want) + } +} + +func TestCommandFrontMatter_UnmarshalJSON(t *testing.T) { + t.Parallel() + + expandFalse := false + expandTrue := true + + tests := []struct { + name string + input string + wantErr bool + validate func(t *testing.T, fm CommandFrontMatter) + }{ + { + name: "empty JSON", + input: `{}`, + validate: func(t *testing.T, fm CommandFrontMatter) { + t.Helper() + + if fm.Content == nil { + t.Error("Content should be non-nil empty map for {}") + } + + if fm.ExpandParams != nil { + t.Errorf("ExpandParams should be nil, got %v", *fm.ExpandParams) + } + }, + }, + { + name: "expand false", + input: `{"expand": false, "name": "no-expand-cmd"}`, + validate: func(t *testing.T, fm CommandFrontMatter) { + t.Helper() + assertExpandParams(t, fm.ExpandParams, &expandFalse) + + if fm.Name != "no-expand-cmd" { + t.Errorf("Name = %q, want no-expand-cmd", fm.Name) + } + }, + }, + { + name: "expand true with selectors", + input: `{"expand": true, "selectors": {"env": "prod"}}`, + validate: func(t *testing.T, fm CommandFrontMatter) { + t.Helper() + assertExpandParams(t, fm.ExpandParams, &expandTrue) + + if v, ok := fm.Selectors["env"]; !ok || v != "prod" { + t.Errorf("Selectors[env] = %v, want prod", v) + } + }, + }, + { + name: "extra fields populate Content map", + input: `{"name": "my-cmd", "extra-field": "extra-value"}`, + validate: func(t *testing.T, fm CommandFrontMatter) { + t.Helper() + + if fm.Name != "my-cmd" { + t.Errorf("Name = %q, want my-cmd", fm.Name) + } + + if fm.Content == nil { + t.Fatal("Content should not be nil") + } + + if v, ok := fm.Content["extra-field"]; !ok || v != "extra-value" { + t.Errorf("Content[extra-field] = %v, want extra-value", v) + } + }, + }, + { + name: "invalid JSON returns error", + input: `{bad`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var fm CommandFrontMatter + + err := json.Unmarshal([]byte(tt.input), &fm) if (err != nil) != tt.wantErr { - t.Fatalf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) - } - if err != nil { - return + t.Fatalf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) } - // Compare fields individually - if got.Name != tt.want.Name { - t.Errorf("Name = %q, want %q", got.Name, tt.want.Name) - } - if got.Description != tt.want.Description { - t.Errorf("Description = %q, want %q", got.Description, tt.want.Description) - } - if tt.want.ExpandParams != nil { - if (got.ExpandParams == nil) != (tt.want.ExpandParams == nil) { - t.Errorf("ExpandParams nil mismatch: got %v, want %v", got.ExpandParams == nil, tt.want.ExpandParams == nil) - } else if got.ExpandParams != nil && tt.want.ExpandParams != nil { - if *got.ExpandParams != *tt.want.ExpandParams { - t.Errorf("ExpandParams = %v, want %v", *got.ExpandParams, *tt.want.ExpandParams) - } - } + if err == nil && tt.validate != nil { + tt.validate(t, fm) } }) } diff --git a/pkg/codingcontext/markdown/frontmatter_rule_test.go b/pkg/codingcontext/markdown/frontmatter_rule_test.go index cf29f11..dde4d28 100644 --- a/pkg/codingcontext/markdown/frontmatter_rule_test.go +++ b/pkg/codingcontext/markdown/frontmatter_rule_test.go @@ -1,13 +1,62 @@ package markdown import ( + "encoding/json" "testing" + yaml "github.com/goccy/go-yaml" "github.com/kitproj/coding-context-cli/pkg/codingcontext/mcp" - "gopkg.in/yaml.v3" ) +const agentCursor = "cursor" + +func validateRuleEmptyJSON(t *testing.T, fm RuleFrontMatter) { + t.Helper() + + if fm.Content == nil { + t.Error("Content should be non-nil empty map for {}") + } +} + +func validateRuleStandardFields(t *testing.T, fm RuleFrontMatter) { + t.Helper() + + if len(fm.TaskNames) != 1 || fm.TaskNames[0] != "fix-bug" { + t.Errorf("TaskNames = %v, want [fix-bug]", fm.TaskNames) + } + + if len(fm.Languages) != 1 || fm.Languages[0] != "go" { + t.Errorf("Languages = %v, want [go]", fm.Languages) + } + + if fm.Agent != agentCursor { + t.Errorf("Agent = %q, want %s", fm.Agent, agentCursor) + } + + if fm.Bootstrap == "" { + t.Error("Bootstrap should be set") + } +} + +func validateRuleExtraFields(t *testing.T, fm RuleFrontMatter) { + t.Helper() + + if fm.Agent != "copilot" { + t.Errorf("Agent = %q, want copilot", fm.Agent) + } + + if fm.Content == nil { + t.Fatal("Content should not be nil") + } + + if v, ok := fm.Content["custom-key"]; !ok || v != "custom-val" { + t.Errorf("Content[custom-key] = %v, want custom-val", v) + } +} + func TestRuleFrontMatter_Marshal(t *testing.T) { + t.Parallel() + tests := []struct { name string rule RuleFrontMatter @@ -26,7 +75,7 @@ func TestRuleFrontMatter_Marshal(t *testing.T) { Description: "This is a standard rule with metadata", }, }, - want: "{}\n", + want: "name: Standard Rule\ndescription: This is a standard rule with metadata\n", }, { name: "rule with task_names", @@ -34,7 +83,7 @@ func TestRuleFrontMatter_Marshal(t *testing.T) { TaskNames: []string{"implement-feature"}, Languages: []string{"go"}, }, - want: "task_names:\n - implement-feature\nlanguages:\n - go\n", + want: "task_names:\n- implement-feature\nlanguages:\n- go\n", }, { name: "rule with multiple task_names", @@ -43,7 +92,7 @@ func TestRuleFrontMatter_Marshal(t *testing.T) { Languages: []string{"go"}, Agent: "cursor", }, - want: "task_names:\n - fix-bug\n - implement-feature\nlanguages:\n - go\nagent: cursor\n", + want: "task_names:\n- fix-bug\n- implement-feature\nlanguages:\n- go\nagent: cursor\n", }, { name: "rule with all fields", @@ -61,16 +110,21 @@ func TestRuleFrontMatter_Marshal(t *testing.T) { Args: []string{"--port", "5432"}, }, }, - want: "task_names:\n - test-task\nlanguages:\n - go\n - python\nagent: copilot\nmcp_server:\n type: stdio\n command: database-server\n args:\n - --port\n - \"5432\"\n", + want: "name: Complete Rule\ndescription: A rule with all fields\ntask_names:\n- test-task\n" + + "languages:\n- go\n- python\nagent: copilot\nmcp_server:\n type: stdio\n command: database-server\n" + + " args:\n - --port\n - \"5432\"\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := yaml.Marshal(&tt.rule) if err != nil { t.Fatalf("Marshal() error = %v", err) } + if string(got) != tt.want { t.Errorf("Marshal() = %q, want %q", string(got), tt.want) } @@ -79,6 +133,8 @@ func TestRuleFrontMatter_Marshal(t *testing.T) { } func TestRuleFrontMatter_Unmarshal(t *testing.T) { + t.Parallel() + tests := []struct { name string yaml string @@ -110,7 +166,7 @@ agent: cursor want: RuleFrontMatter{ TaskNames: []string{"implement-feature"}, Languages: []string{"go"}, - Agent: "", + Agent: "cursor", }, }, { @@ -141,11 +197,15 @@ languages: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got RuleFrontMatter + err := yaml.Unmarshal([]byte(tt.yaml), &got) if (err != nil) != tt.wantErr { t.Fatalf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) } + if err != nil { return } @@ -154,12 +214,57 @@ languages: if got.Name != tt.want.Name { t.Errorf("Name = %q, want %q", got.Name, tt.want.Name) } + if got.Description != tt.want.Description { t.Errorf("Description = %q, want %q", got.Description, tt.want.Description) } + if got.Agent != tt.want.Agent { t.Errorf("Agent = %q, want %q", got.Agent, tt.want.Agent) } }) } } + +//nolint:dupl // table-driven test structure mirrors TaskFrontMatter test +//nolint:dupl // Table-driven test structure is similar to TaskFrontMatter but uses different types. +func TestRuleFrontMatter_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + validate func(t *testing.T, fm RuleFrontMatter) + }{ + {name: "empty JSON", input: `{}`, validate: validateRuleEmptyJSON}, + { + name: "standard typed fields", + input: `{"task_names": ["fix-bug"], "languages": ["go"], "agent": "cursor", "bootstrap": "#!/bin/bash\necho hi"}`, + validate: validateRuleStandardFields, + }, + { + name: "extra fields populate Content map", + input: `{"agent": "copilot", "custom-key": "custom-val"}`, + validate: validateRuleExtraFields, + }, + {name: "invalid JSON returns error", input: `{bad json`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var fm RuleFrontMatter + + err := json.Unmarshal([]byte(tt.input), &fm) + if (err != nil) != tt.wantErr { + t.Fatalf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + + if err == nil && tt.validate != nil { + tt.validate(t, fm) + } + }) + } +} diff --git a/pkg/codingcontext/markdown/frontmatter_skill_test.go b/pkg/codingcontext/markdown/frontmatter_skill_test.go new file mode 100644 index 0000000..4ceb52b --- /dev/null +++ b/pkg/codingcontext/markdown/frontmatter_skill_test.go @@ -0,0 +1,267 @@ +package markdown + +import ( + "encoding/json" + "testing" + + yaml "github.com/goccy/go-yaml" +) + +func TestSkillFrontMatter_Marshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + skill SkillFrontMatter + want string + }{ + { + name: "minimal skill", + skill: SkillFrontMatter{}, + want: "{}\n", + }, + { + name: "skill with name and description", + skill: SkillFrontMatter{ + BaseFrontMatter: BaseFrontMatter{ + Name: "my-skill", + Description: "Does something useful", + }, + }, + want: "name: my-skill\ndescription: Does something useful\n", + }, + { + name: "skill with all fields", + skill: SkillFrontMatter{ + BaseFrontMatter: BaseFrontMatter{ + Name: "full-skill", + Description: "A complete skill", + }, + License: "MIT", + Compatibility: "go>=1.21", + AllowedTools: "Bash Read Write", + Metadata: map[string]string{ + "version": "1.0.0", + }, + }, + want: "name: full-skill\ndescription: A complete skill\nlicense: MIT\ncompatibility: go>=1.21\n" + + "metadata:\n version: 1.0.0\nallowed_tools: Bash Read Write\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := yaml.Marshal(&tt.skill) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + if string(got) != tt.want { + t.Errorf("Marshal() = %q, want %q", string(got), tt.want) + } + }) + } +} + +func validateSkillEmptyYAML(t *testing.T, fm SkillFrontMatter) { + t.Helper() + + if fm.Name != "" { + t.Errorf("Name = %q, want empty", fm.Name) + } +} + +func validateSkillNameDescLicense(t *testing.T, fm SkillFrontMatter) { + t.Helper() + + if fm.Name != "my-skill" { + t.Errorf("Name = %q, want my-skill", fm.Name) + } + + if fm.Description != "Does something useful" { + t.Errorf("Description = %q, want 'Does something useful'", fm.Description) + } + + if fm.License != "MIT" { + t.Errorf("License = %q, want MIT", fm.License) + } +} + +func validateSkillCompatAllowedTools(t *testing.T, fm SkillFrontMatter) { + t.Helper() + + if fm.Compatibility != "go>=1.21" { + t.Errorf("Compatibility = %q, want go>=1.21", fm.Compatibility) + } + + if fm.AllowedTools != "Bash Read" { + t.Errorf("AllowedTools = %q, want 'Bash Read'", fm.AllowedTools) + } +} + +func validateSkillMetadata(t *testing.T, fm SkillFrontMatter) { + t.Helper() + + if fm.Metadata["version"] != "2.0" { + t.Errorf("Metadata[version] = %q, want 2.0", fm.Metadata["version"]) + } + + if fm.Metadata["author"] != "team" { + t.Errorf("Metadata[author] = %q, want team", fm.Metadata["author"]) + } +} + +func validateSkillExtraFields(t *testing.T, fm SkillFrontMatter) { + t.Helper() + + if fm.Name != "extra-skill" { + t.Errorf("Name = %q, want extra-skill", fm.Name) + } + + if fm.Content == nil { + t.Fatal("Content should not be nil") + } + + if fm.Content["unknown-field"] == nil { + t.Error("Content[unknown-field] should be set") + } +} + +func TestSkillFrontMatter_Unmarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yamlStr string + wantErr bool + validate func(t *testing.T, fm SkillFrontMatter) + }{ + {name: "empty YAML", yamlStr: "{}\n", validate: validateSkillEmptyYAML}, + { + name: "skill with name, description, license", + yamlStr: "name: my-skill\ndescription: Does something useful\nlicense: MIT\n", + validate: validateSkillNameDescLicense, + }, + { + name: "skill with compatibility and allowed_tools", + yamlStr: "name: compat-skill\ncompatibility: go>=1.21\nallowed_tools: Bash Read\n", + validate: validateSkillCompatAllowedTools, + }, + { + name: "skill with metadata map", + yamlStr: "name: meta-skill\nmetadata:\n version: \"2.0\"\n author: team\n", + validate: validateSkillMetadata, + }, + { + name: "extra fields captured in Content map", + yamlStr: "name: extra-skill\nunknown-field: some-value\n", + validate: validateSkillExtraFields, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var fm SkillFrontMatter + + err := yaml.Unmarshal([]byte(tt.yamlStr), &fm) + if (err != nil) != tt.wantErr { + t.Fatalf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + } + + if err == nil && tt.validate != nil { + tt.validate(t, fm) + } + }) + } +} + +func TestSkillFrontMatter_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + validate func(t *testing.T, fm SkillFrontMatter) + }{ + { + name: "empty JSON", + input: `{}`, + validate: func(t *testing.T, fm SkillFrontMatter) { + t.Helper() + + if fm.Content == nil { + t.Error("Content should be non-nil empty map for {}") + } + }, + }, + { + name: "typed fields parsed correctly", + input: `{"name": "my-skill", "license": "MIT", "compatibility": "go>=1.21", "allowed_tools": "Bash"}`, + validate: func(t *testing.T, fm SkillFrontMatter) { + t.Helper() + + if fm.Name != "my-skill" { + t.Errorf("Name = %q, want my-skill", fm.Name) + } + + if fm.License != "MIT" { + t.Errorf("License = %q, want MIT", fm.License) + } + + if fm.Compatibility != "go>=1.21" { + t.Errorf("Compatibility = %q, want go>=1.21", fm.Compatibility) + } + + if fm.AllowedTools != "Bash" { + t.Errorf("AllowedTools = %q, want Bash", fm.AllowedTools) + } + }, + }, + { + name: "extra fields populate Content map", + input: `{"name": "extra-skill", "custom-key": "custom-val"}`, + validate: func(t *testing.T, fm SkillFrontMatter) { + t.Helper() + + if fm.Name != "extra-skill" { + t.Errorf("Name = %q, want extra-skill", fm.Name) + } + + if fm.Content == nil { + t.Fatal("Content should not be nil") + } + + if v, ok := fm.Content["custom-key"]; !ok || v != "custom-val" { + t.Errorf("Content[custom-key] = %v, want custom-val", v) + } + }, + }, + { + name: "invalid JSON returns error", + input: `{bad json`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var fm SkillFrontMatter + + err := json.Unmarshal([]byte(tt.input), &fm) + if (err != nil) != tt.wantErr { + t.Fatalf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + + if err == nil && tt.validate != nil { + tt.validate(t, fm) + } + }) + } +} diff --git a/pkg/codingcontext/markdown/frontmatter_task_test.go b/pkg/codingcontext/markdown/frontmatter_task_test.go index 83cbe11..353f1c4 100644 --- a/pkg/codingcontext/markdown/frontmatter_task_test.go +++ b/pkg/codingcontext/markdown/frontmatter_task_test.go @@ -1,12 +1,15 @@ package markdown import ( + "encoding/json" "testing" - "gopkg.in/yaml.v3" + yaml "github.com/goccy/go-yaml" ) func TestTaskFrontMatter_Marshal(t *testing.T) { + t.Parallel() + tests := []struct { name string task TaskFrontMatter @@ -19,7 +22,7 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { Content: map[string]any{"task_name": "test-task"}, }, }, - want: "{}\n", + want: "task_name: test-task\n", }, { name: "task with standard id, name, description", @@ -30,7 +33,7 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { Content: map[string]any{"task_name": "standard-task"}, }, }, - want: "{}\n", + want: "name: Standard Test Task\ndescription: This is a test task with standard fields\ntask_name: standard-task\n", }, { name: "task with all fields", @@ -49,7 +52,8 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { "stage": "implementation", }, }, - want: "agent: cursor\nlanguages:\n - go\nmodel: gpt-4\nsingle_shot: true\ntimeout: 10m\nselectors:\n stage: implementation\n", + want: "name: Full Task\ndescription: A task with all fields\ntask_name: full-task\nagent: cursor\n" + + "languages:\n- go\nmodel: gpt-4\nsingle_shot: true\ntimeout: 10m\nselectors:\n stage: implementation\n", }, { name: "task with multiple languages", @@ -59,16 +63,19 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { }, Languages: []string{"go", "python", "javascript"}, }, - want: "languages:\n - go\n - python\n - javascript\n", + want: "task_name: polyglot-task\nlanguages:\n- go\n- python\n- javascript\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := yaml.Marshal(&tt.task) if err != nil { t.Fatalf("Marshal() error = %v", err) } + if string(got) != tt.want { t.Errorf("Marshal() = %q, want %q", string(got), tt.want) } @@ -76,8 +83,13 @@ func TestTaskFrontMatter_Marshal(t *testing.T) { } } -func TestTaskFrontMatter_Unmarshal(t *testing.T) { - tests := []struct { +func taskFrontMatterUnmarshalCases() []struct { + name string + yaml string + want TaskFrontMatter + wantErr bool +} { + return []struct { name string yaml string want TaskFrontMatter @@ -134,6 +146,18 @@ languages: Languages: []string{"go", "python"}, }, }, + { + name: "task with include_unmatched false", + yaml: `task_name: test-task +include_unmatched: false +`, + want: TaskFrontMatter{ + BaseFrontMatter: BaseFrontMatter{ + Content: map[string]any{"task_name": "test-task"}, + }, + IncludeUnmatched: func() *bool { b := false; return &b }(), + }, + }, { name: "full task", yaml: `task_name: full-task @@ -158,52 +182,177 @@ selectors: "id": "urn:agents:task:full-task", }, }, - Agent: "", + Agent: "cursor", Languages: []string{"go"}, - Model: "", - SingleShot: false, - Timeout: "", + Model: "gpt-4", + SingleShot: true, + Timeout: "10m", Selectors: map[string]any{ "stage": "implementation", }, }, }, } +} - for _, tt := range tests { +func TestTaskFrontMatter_Unmarshal(t *testing.T) { + t.Parallel() + + for _, tt := range taskFrontMatterUnmarshalCases() { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got TaskFrontMatter + err := yaml.Unmarshal([]byte(tt.yaml), &got) + + assertTaskFrontMatter(t, got, tt.want, err, tt.wantErr) + }) + } +} + +func assertTaskFrontMatter(t *testing.T, got, want TaskFrontMatter, err error, wantErr bool) { + t.Helper() + + if (err != nil) != wantErr { + t.Fatalf("Unmarshal() error = %v, wantErr %v", err, wantErr) + } + + if err != nil { + return + } + + gotTaskName, _ := got.Content["task_name"].(string) + wantTaskName, _ := want.Content["task_name"].(string) + + if gotTaskName != wantTaskName { + t.Errorf("TaskName = %q, want %q", gotTaskName, wantTaskName) + } + + if got.Name != want.Name { + t.Errorf("Name = %q, want %q", got.Name, want.Name) + } + + if got.Description != want.Description { + t.Errorf("Description = %q, want %q", got.Description, want.Description) + } + + if got.Agent != want.Agent { + t.Errorf("Agent = %q, want %q", got.Agent, want.Agent) + } + + if got.Model != want.Model { + t.Errorf("Model = %q, want %q", got.Model, want.Model) + } + + if got.SingleShot != want.SingleShot { + t.Errorf("SingleShot = %v, want %v", got.SingleShot, want.SingleShot) + } + + if got.Timeout != want.Timeout { + t.Errorf("Timeout = %q, want %q", got.Timeout, want.Timeout) + } + + switch { + case got.IncludeUnmatched == nil && want.IncludeUnmatched == nil: + // both unset — ok + case got.IncludeUnmatched == nil || want.IncludeUnmatched == nil: + t.Errorf("IncludeUnmatched = %v, want %v", got.IncludeUnmatched, want.IncludeUnmatched) + case *got.IncludeUnmatched != *want.IncludeUnmatched: + t.Errorf("IncludeUnmatched = %v, want %v", *got.IncludeUnmatched, *want.IncludeUnmatched) + } +} + +func validateTaskEmptyJSON(t *testing.T, fm TaskFrontMatter) { + t.Helper() + + if fm.Agent != "" { + t.Errorf("Agent = %q, want empty", fm.Agent) + } + + if fm.Content == nil { + t.Error("Content should be non-nil empty map for {}") + } +} + +func validateTaskStandardFields(t *testing.T, fm TaskFrontMatter) { + t.Helper() + + if fm.Agent != "cursor" { + t.Errorf("Agent = %q, want cursor", fm.Agent) + } + + if len(fm.Languages) != 2 || fm.Languages[0] != "go" || fm.Languages[1] != "python" { + t.Errorf("Languages = %v, want [go python]", fm.Languages) + } + + if fm.Model != "gpt-4" { + t.Errorf("Model = %q, want gpt-4", fm.Model) + } + + if !fm.SingleShot { + t.Error("SingleShot should be true") + } +} + +func validateTaskExtraFields(t *testing.T, fm TaskFrontMatter) { + t.Helper() + + if fm.Agent != "cursor" { + t.Errorf("Agent = %q, want cursor", fm.Agent) + } + + if fm.Content == nil { + t.Fatal("Content should not be nil") + } + + if v, ok := fm.Content["custom-field"]; !ok || v != "custom-value" { + t.Errorf("Content[custom-field] = %v, want custom-value", v) + } + + if _, ok := fm.Content["agent"]; !ok { + t.Error("Content should also contain typed fields like agent") + } +} + +//nolint:dupl // table-driven test structure mirrors RuleFrontMatter test +//nolint:dupl // Table-driven test structure is similar to RuleFrontMatter but uses different types. +func TestTaskFrontMatter_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + validate func(t *testing.T, fm TaskFrontMatter) + }{ + {name: "empty JSON", input: `{}`, validate: validateTaskEmptyJSON}, + { + name: "standard typed fields", + input: `{"agent": "cursor", "languages": ["go", "python"], "model": "gpt-4", "single_shot": true}`, + validate: validateTaskStandardFields, + }, + { + name: "extra fields populate Content map", + input: `{"agent": "cursor", "custom-field": "custom-value", "priority": 42}`, + validate: validateTaskExtraFields, + }, + {name: "invalid JSON returns error", input: `{invalid json}`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var fm TaskFrontMatter + + err := json.Unmarshal([]byte(tt.input), &fm) if (err != nil) != tt.wantErr { - t.Fatalf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) - } - if err != nil { - return + t.Fatalf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) } - // Compare fields individually for better error messages - gotTaskName, _ := got.Content["task_name"].(string) - wantTaskName, _ := tt.want.Content["task_name"].(string) - if gotTaskName != wantTaskName { - t.Errorf("TaskName = %q, want %q", gotTaskName, wantTaskName) - } - if got.Name != tt.want.Name { - t.Errorf("Name = %q, want %q", got.Name, tt.want.Name) - } - if got.Description != tt.want.Description { - t.Errorf("Description = %q, want %q", got.Description, tt.want.Description) - } - if got.Agent != tt.want.Agent { - t.Errorf("Agent = %q, want %q", got.Agent, tt.want.Agent) - } - if got.Model != tt.want.Model { - t.Errorf("Model = %q, want %q", got.Model, tt.want.Model) - } - if got.SingleShot != tt.want.SingleShot { - t.Errorf("SingleShot = %v, want %v", got.SingleShot, tt.want.SingleShot) - } - if got.Timeout != tt.want.Timeout { - t.Errorf("Timeout = %q, want %q", got.Timeout, tt.want.Timeout) + if err == nil && tt.validate != nil { + tt.validate(t, fm) } }) } diff --git a/pkg/codingcontext/markdown/markdown.go b/pkg/codingcontext/markdown/markdown.go index df581ff..8c1f21b 100644 --- a/pkg/codingcontext/markdown/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -1,87 +1,192 @@ package markdown import ( - "bufio" "bytes" "fmt" "os" + "path/filepath" + "strings" + yaml "github.com/goccy/go-yaml" + goldmark "github.com/yuin/goldmark" + meta "github.com/yuin/goldmark-meta" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" - "gopkg.in/yaml.v3" ) -// Markdown represents a markdown file with frontmatter and content +// ParseError is a markdown parsing error with file, line, and column position. +type ParseError struct { + File string + Line int // 1-indexed; 0 means unknown + Column int // 1-indexed; 0 means unknown + Message string +} + +func (e *ParseError) Error() string { + switch { + case e.Line > 0 && e.Column > 0: + return fmt.Sprintf("%s:%d:%d: %s", e.File, e.Line, e.Column, e.Message) + case e.Line > 0: + return fmt.Sprintf("%s:%d: %s", e.File, e.Line, e.Message) + default: + return fmt.Sprintf("%s: %s", e.File, e.Message) + } +} + +// Markdown represents a markdown file with frontmatter and content. type Markdown[T any] struct { - FrontMatter T // Parsed YAML frontmatter - Content string // Expanded content of the markdown - Tokens int // Estimated token count + FrontMatter T // Parsed YAML frontmatter + Content string // Markdown body, excluding frontmatter + Structure ast.Node // Document AST from goldmark parse + Task taskparser.Task // Parsed task structure (slash commands and text blocks) + Tokens int // Estimated token count +} + +// FromContent creates a Markdown from processed content string (e.g. after +// parameter expansion). The content is parsed to produce the Structure AST. +func FromContent[T any](frontMatter T, content string) Markdown[T] { + source := []byte(content) + doc := goldmark.New().Parser().Parse(text.NewReader(source)) + + return Markdown[T]{ + FrontMatter: frontMatter, + Content: content, + Structure: doc, + Tokens: tokencount.EstimateTokens(content), + } } -// TaskMarkdown is a Markdown with TaskFrontMatter +// TaskMarkdown is a Markdown with TaskFrontMatter. type TaskMarkdown = Markdown[TaskFrontMatter] -// RuleMarkdown is a Markdown with RuleFrontMatter +// RuleMarkdown is a Markdown with RuleFrontMatter. type RuleMarkdown = Markdown[RuleFrontMatter] -// ParseMarkdownFile parses a markdown file into frontmatter and content +// ParseMarkdownFile parses a markdown file into frontmatter and content using goldmark. +// Errors include file path and, where available, line and column position. func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) { - fh, err := os.Open(path) + cleanPath := filepath.Clean(path) + + source, err := os.ReadFile(cleanPath) if err != nil { return Markdown[T]{}, fmt.Errorf("failed to open file %s: %w", path, err) } - defer fh.Close() - - s := bufio.NewScanner(fh) - var content bytes.Buffer - var frontMatterBytes bytes.Buffer + // Parse with goldmark+meta+taskparser in a single pass: meta extracts frontmatter, + // taskparser.Extension captures task structure (slash commands) from the body. + pctx := parser.NewContext() + doc := goldmark.New(goldmark.WithExtensions(meta.Meta, taskparser.Extension)).Parser(). + Parse(text.NewReader(source), parser.WithContext(pctx)) - // State machine: 0 = unknown, 1 = scanning frontmatter, 2 = scanning content - state := 0 + // Get frontmatter map from goldmark-meta (parsed during goldmark parse). + metaData, yamlErr := meta.TryGet(pctx) + if yamlErr != nil { + line := yamlMessageLine(yamlErr.Error()) + // Offset by 1 to account for the opening "---" delimiter line. + if line > 0 { + line++ + } - for s.Scan() { - line := s.Text() + return Markdown[T]{}, &ParseError{ + File: path, + Line: line, + Message: fmt.Sprintf("failed to parse YAML frontmatter: %v", yamlErr), + } + } - switch state { - case 0: // State unknown - first line - if line == "---" { - state = 1 // Start scanning frontmatter - } else { - state = 2 // No frontmatter, start scanning content - if _, err := content.WriteString(line + "\n"); err != nil { - return Markdown[T]{}, fmt.Errorf("failed to write content: %w", err) - } + if len(metaData) > 0 { + // Marshal map to YAML and unmarshal into typed struct to reproject onto T. + yamlBytes, err := yaml.Marshal(metaData) + if err != nil { + return Markdown[T]{}, &ParseError{ + File: path, + Message: fmt.Sprintf("failed to marshal frontmatter: %v", err), } - case 1: // Scanning frontmatter - if line == "---" { - state = 2 // End of frontmatter, start scanning content - // From here on, just copy everything as-is to content - } else { - if _, err := frontMatterBytes.WriteString(line + "\n"); err != nil { - return Markdown[T]{}, fmt.Errorf("failed to write frontmatter: %w", err) - } + } + + if err := yaml.Unmarshal(yamlBytes, frontMatter); err != nil { + line, col := yamlErrorPosition(err) + if line > 0 { + line++ // offset for opening "---" delimiter } - case 2: // Scanning content - copy everything as-is - if _, err := content.WriteString(line + "\n"); err != nil { - return Markdown[T]{}, fmt.Errorf("failed to write content: %w", err) + + return Markdown[T]{}, &ParseError{ + File: path, + Line: line, + Column: col, + Message: fmt.Sprintf("failed to parse YAML frontmatter: %v", err), } } } - if err := s.Err(); err != nil { - return Markdown[T]{}, fmt.Errorf("failed to scan file %s: %w", path, err) - } + content := string(source[contentStartOffset(source):]) - // Parse frontmatter if we collected any - if frontMatterBytes.Len() > 0 { - if err := yaml.Unmarshal(frontMatterBytes.Bytes(), frontMatter); err != nil { - return Markdown[T]{}, fmt.Errorf("failed to unmarshal frontmatter in file %s: %w", path, err) - } - } + task, _ := taskparser.GetTask(pctx) return Markdown[T]{ FrontMatter: *frontMatter, - Content: content.String(), - Tokens: tokencount.EstimateTokens(content.String()), + Content: content, + Structure: doc, + Task: task, + Tokens: tokencount.EstimateTokens(content), }, nil } + +// contentStartOffset returns the byte offset at which document content begins, +// after the frontmatter block delimited by "---". Returns 0 when no frontmatter +// is present. +func contentStartOffset(source []byte) int { + const sep = "---\n" + if !bytes.HasPrefix(source, []byte(sep)) { + return 0 + } + + pos := len(sep) + for pos < len(source) { + next := bytes.IndexByte(source[pos:], '\n') + if next < 0 { + break + } + + lineEnd := pos + next + 1 + + line := bytes.TrimRight(source[pos:lineEnd], "\r\n") + if bytes.Equal(line, []byte("---")) { + return lineEnd + } + + pos = lineEnd + } + + return 0 +} + +// yamlMessageLine parses a line number from a yaml.v2-style error message. +// yaml.v2 errors look like "yaml: line N: message". +func yamlMessageLine(msg string) int { + var line int + if n, _ := fmt.Sscanf(msg, "yaml: line %d:", &line); n == 1 && line > 0 { + return line + } + + return 0 +} + +// yamlErrorPosition extracts line and column from a goccy/go-yaml error. +// goccy formats errors as "[line:col] message\n". +func yamlErrorPosition(err error) (int, int) { + const maxLinesForPosition = 2 + // goccy/go-yaml formats errors as "[line:col] message\n..." + firstLine := strings.SplitN(err.Error(), "\n", maxLinesForPosition)[0] + + var l, c int + if n, _ := fmt.Sscanf(firstLine, "[%d:%d]", &l, &c); n == 2 && l > 0 { + return l, c + } + // Fallback: yaml.v2-style "yaml: line N: message" (from goldmark-meta errors) + return yamlMessageLine(firstLine), 0 +} diff --git a/pkg/codingcontext/markdown/markdown_internal_test.go b/pkg/codingcontext/markdown/markdown_internal_test.go new file mode 100644 index 0000000..6effe57 --- /dev/null +++ b/pkg/codingcontext/markdown/markdown_internal_test.go @@ -0,0 +1,160 @@ +package markdown + +import ( + "errors" + "fmt" + "testing" +) + +// TestContentStartOffset_NoFrontmatter verifies that source not starting with +// "---\n" returns offset 0 (no frontmatter to skip). +func TestContentStartOffset_NoFrontmatter(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input []byte + }{ + {"empty", []byte{}}, + {"plain text", []byte("Hello world\n")}, + {"dashes without newline", []byte("---")}, + {"indented dashes", []byte(" ---\ncontent\n---\n")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := contentStartOffset(tc.input); got != 0 { + t.Errorf("contentStartOffset(%q) = %d, want 0", tc.input, got) + } + }) + } +} + +// TestContentStartOffset_NoClosingDelimiter verifies that source starting with +// "---\n" but lacking a closing "---" line returns 0. +func TestContentStartOffset_NoClosingDelimiter(t *testing.T) { + t.Parallel() + + source := []byte("---\nkey: value\nmore: data\n") + if got := contentStartOffset(source); got != 0 { + t.Errorf("contentStartOffset(no closing ---) = %d, want 0", got) + } +} + +// TestContentStartOffset_NoNewlineAfterOpening verifies that source starting with +// "---\n" but with no subsequent newline returns 0 (loop break path). +func TestContentStartOffset_NoNewlineAfterOpening(t *testing.T) { + t.Parallel() + + // "---\n" prefix followed by bytes with no '\n' + source := []byte("---\nnoNewlineHere") + if got := contentStartOffset(source); got != 0 { + t.Errorf("contentStartOffset(no newline in body) = %d, want 0", got) + } +} + +// TestContentStartOffset_ValidFrontmatter verifies that a proper "---\n...\n---\n" +// block returns the byte offset immediately after the closing delimiter. +func TestContentStartOffset_ValidFrontmatter(t *testing.T) { + t.Parallel() + + source := []byte("---\nkey: val\n---\nbody content\n") + got := contentStartOffset(source) + body := string(source[got:]) + + if body != "body content\n" { + t.Errorf("contentStartOffset: body = %q, want \"body content\\n\"", body) + } +} + +// TestContentStartOffset_EmptyFrontmatter verifies that an empty frontmatter +// block ("---\n---\n") returns the offset after the closing delimiter. +func TestContentStartOffset_EmptyFrontmatter(t *testing.T) { + t.Parallel() + + source := []byte("---\n---\ncontent\n") + got := contentStartOffset(source) + body := string(source[got:]) + + if body != "content\n" { + t.Errorf("contentStartOffset(empty frontmatter): body = %q, want \"content\\n\"", body) + } +} + +// TestYamlErrorPosition_GoccyFormat verifies that errors formatted as "[line:col] msg" +// are parsed correctly by yamlErrorPosition. +func TestYamlErrorPosition_GoccyFormat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + errMsg string + wantL int + wantC int + }{ + { + name: "standard goccy format", + errMsg: "[3:7] unexpected key", + wantL: 3, + wantC: 7, + }, + { + name: "line 1 col 1", + errMsg: "[1:1] some error", + wantL: 1, + wantC: 1, + }, + { + name: "multi-line error (only first line parsed)", + errMsg: "[2:5] first line error\nsecond line context\n", + wantL: 2, + wantC: 5, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("%s", tc.errMsg) //nolint:err113 + l, c := yamlErrorPosition(err) + + if l != tc.wantL || c != tc.wantC { + t.Errorf("yamlErrorPosition(%q) = (%d, %d), want (%d, %d)", + tc.errMsg, l, c, tc.wantL, tc.wantC) + } + }) + } +} + +// TestYamlErrorPosition_YamlV2Fallback verifies that errors formatted as +// "yaml: line N: msg" (goldmark-meta style) are parsed via the fallback path. +func TestYamlErrorPosition_YamlV2Fallback(t *testing.T) { + t.Parallel() + + err := errors.New("yaml: line 4: some yaml error") //nolint:err113 + l, c := yamlErrorPosition(err) + + if l != 4 { + t.Errorf("yamlErrorPosition(yaml v2 format) line = %d, want 4", l) + } + + if c != 0 { + t.Errorf("yamlErrorPosition(yaml v2 format) col = %d, want 0", c) + } +} + +// TestYamlErrorPosition_UnknownFormat verifies that an unrecognized error string +// returns (0, 0) without panicking. +func TestYamlErrorPosition_UnknownFormat(t *testing.T) { + t.Parallel() + + err := errors.New("some unrelated error with no position info") //nolint:err113 + l, c := yamlErrorPosition(err) + + if l != 0 || c != 0 { + t.Errorf("yamlErrorPosition(unknown) = (%d, %d), want (0, 0)", l, c) + } +} diff --git a/pkg/codingcontext/markdown/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go index 1bbff10..cb1e4e0 100644 --- a/pkg/codingcontext/markdown/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -1,6 +1,7 @@ package markdown import ( + "errors" "os" "path/filepath" "strings" @@ -9,7 +10,138 @@ import ( "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) +func TestParseError_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err ParseError + want string + }{ + { + name: "line and column set", + err: ParseError{File: "test.md", Line: 3, Column: 5, Message: "syntax error"}, + want: "test.md:3:5: syntax error", + }, + { + name: "line set without column", + err: ParseError{File: "test.md", Line: 3, Column: 0, Message: "syntax error"}, + want: "test.md:3: syntax error", + }, + { + name: "neither line nor column", + err: ParseError{File: "test.md", Line: 0, Column: 0, Message: "syntax error"}, + want: "test.md: syntax error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.err.Error() + if got != tt.want { + t.Errorf("ParseError.Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFromContent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + }{ + { + name: "empty content", + content: "", + }, + { + name: "simple content", + content: "Hello world", + }, + { + name: "markdown content", + content: "# Title\n\nThis is a paragraph with **bold** text.\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fm := BaseFrontMatter{Name: "test-rule", Description: "a test rule"} + md := FromContent(fm, tt.content) + + if md.Content != tt.content { + t.Errorf("Content = %q, want %q", md.Content, tt.content) + } + + if md.FrontMatter.Name != fm.Name { + t.Errorf("FrontMatter.Name = %q, want %q", md.FrontMatter.Name, fm.Name) + } + + if md.FrontMatter.Description != fm.Description { + t.Errorf("FrontMatter.Description = %q, want %q", md.FrontMatter.Description, fm.Description) + } + + if md.Structure == nil { + t.Error("Structure should not be nil") + } + + if len(tt.content) > 0 && md.Tokens == 0 { + t.Error("Tokens should be non-zero for non-empty content") + } + + if len(tt.content) == 0 && md.Tokens != 0 { + t.Errorf("Tokens should be zero for empty content, got %d", md.Tokens) + } + }) + } +} + +func TestParseMarkdownFile_YAMLErrorHasLineInfo(t *testing.T) { + t.Parallel() + + // Unclosed bracket in YAML produces a parse error that goldmark-meta surfaces + // with line information. Verify ParseError carries the line number. + content := "---\nkey: [unclosed\n---\ncontent\n" + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.md") + + if err := os.WriteFile(tmpFile, []byte(content), 0o600); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + var fm BaseFrontMatter + + _, err := ParseMarkdownFile(tmpFile, &fm) + if err == nil { + t.Fatal("expected error for invalid YAML frontmatter, got nil") + } + + parseErr := &ParseError{} + + ok := errors.As(err, &parseErr) + if !ok { + t.Fatalf("expected *ParseError, got %T: %v", err, err) + } + + if parseErr.Line == 0 { + t.Errorf("ParseError.Line should be > 0 when YAML error includes line info, got 0; error: %v", parseErr) + } + + if parseErr.File != tmpFile { + t.Errorf("ParseError.File = %q, want %q", parseErr.File, tmpFile) + } +} + func TestParseMarkdownFile(t *testing.T) { + t.Parallel() + tests := []struct { name string content string @@ -63,20 +195,24 @@ This is the content. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() // Create a temporary file tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o600); err != nil { t.Fatalf("failed to create temp file: %v", err) } // Parse the file var frontmatter BaseFrontMatter + md, err := ParseMarkdownFile(tmpFile, &frontmatter) // Check error if (err != nil) != tt.wantErr { t.Errorf("ParseMarkdownFile() error = %v, wantErr %v", err, tt.wantErr) + return } @@ -89,6 +225,7 @@ This is the content. if len(frontmatter.Content) != len(tt.wantFrontmatter) { t.Errorf("ParseMarkdownFile() frontmatter length = %d, want %d", len(frontmatter.Content), len(tt.wantFrontmatter)) } + for k, v := range tt.wantFrontmatter { if fmVal, ok := frontmatter.Content[k].(string); !ok || fmVal != v { t.Errorf("ParseMarkdownFile() frontmatter[%q] = %v, want %q", k, frontmatter.Content[k], v) @@ -99,7 +236,10 @@ This is the content. } func TestParseMarkdownFile_FileNotFound(t *testing.T) { + t.Parallel() + var frontmatter BaseFrontMatter + _, err := ParseMarkdownFile("/nonexistent/file.md", &frontmatter) if err == nil { t.Error("ParseMarkdownFile() expected error for non-existent file, got nil") @@ -111,6 +251,8 @@ func TestParseMarkdownFile_FileNotFound(t *testing.T) { } func TestParseMarkdownFile_ErrorsIncludeFilePath(t *testing.T) { + t.Parallel() + tests := []struct { name string content string @@ -122,21 +264,24 @@ func TestParseMarkdownFile_ErrorsIncludeFilePath(t *testing.T) { invalid: yaml: : syntax --- Content here`, - want: "failed to unmarshal frontmatter in file", + want: "failed to parse YAML frontmatter", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() // Create a temporary file tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o600); err != nil { t.Fatalf("failed to create temp file: %v", err) } // Parse the file var frontmatter BaseFrontMatter + _, err := ParseMarkdownFile(tmpFile, &frontmatter) // Check that we got an error @@ -157,15 +302,54 @@ Content here`, } } -func TestParseMarkdownFile_CustomStruct(t *testing.T) { - // Define a custom struct for task frontmatter - type TaskFrontmatter struct { - TaskName string `yaml:"task_name"` - Resume bool `yaml:"resume"` - Priority string `yaml:"priority"` - Tags []string `yaml:"tags"` +type testTaskFrontmatter struct { + TaskName string `yaml:"task_name"` + Resume bool `yaml:"resume"` + Priority string `yaml:"priority"` + Tags []string `yaml:"tags"` +} + +func assertCustomFrontmatter(t *testing.T, fm testTaskFrontmatter, md Markdown[testTaskFrontmatter], err error, + wantErr bool, wantContent, wantTaskName, wantPriority string, wantResume bool, wantTags []string, +) { + t.Helper() + + if (err != nil) != wantErr { + t.Errorf("ParseMarkdownFile() error = %v, wantErr %v", err, wantErr) + + return + } + + if md.Content != wantContent { + t.Errorf("ParseMarkdownFile() content = %q, want %q", md.Content, wantContent) } + if fm.TaskName != wantTaskName { + t.Errorf("frontmatter.TaskName = %q, want %q", fm.TaskName, wantTaskName) + } + + if fm.Resume != wantResume { + t.Errorf("frontmatter.Resume = %v, want %v", fm.Resume, wantResume) + } + + if fm.Priority != wantPriority { + t.Errorf("frontmatter.Priority = %q, want %q", fm.Priority, wantPriority) + } + + if len(fm.Tags) != len(wantTags) { + t.Errorf("frontmatter.Tags length = %d, want %d", len(fm.Tags), len(wantTags)) + } + + for i, tag := range wantTags { + if i < len(fm.Tags) && fm.Tags[i] != tag { + t.Errorf("frontmatter.Tags[%d] = %q, want %q", i, fm.Tags[i], tag) + } + } +} + +func TestParseMarkdownFile_CustomStruct(t *testing.T) { + t.Parallel() + tests := []struct { name string content string @@ -231,51 +415,27 @@ This task has no frontmatter. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create a temporary file + t.Parallel() + tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { + + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o600); err != nil { t.Fatalf("failed to create temp file: %v", err) } - // Parse the file into custom struct - var frontmatter TaskFrontmatter - md, err := ParseMarkdownFile(tmpFile, &frontmatter) - - // Check error - if (err != nil) != tt.wantErr { - t.Errorf("ParseMarkdownFile() error = %v, wantErr %v", err, tt.wantErr) - return - } + var frontmatter testTaskFrontmatter - // Check content - if md.Content != tt.wantContent { - t.Errorf("ParseMarkdownFile() content = %q, want %q", md.Content, tt.wantContent) - } + md, err := ParseMarkdownFile(tmpFile, &frontmatter) - // Check frontmatter fields - if frontmatter.TaskName != tt.wantTaskName { - t.Errorf("frontmatter.TaskName = %q, want %q", frontmatter.TaskName, tt.wantTaskName) - } - if frontmatter.Resume != tt.wantResume { - t.Errorf("frontmatter.Resume = %v, want %v", frontmatter.Resume, tt.wantResume) - } - if frontmatter.Priority != tt.wantPriority { - t.Errorf("frontmatter.Priority = %q, want %q", frontmatter.Priority, tt.wantPriority) - } - if len(frontmatter.Tags) != len(tt.wantTags) { - t.Errorf("frontmatter.Tags length = %d, want %d", len(frontmatter.Tags), len(tt.wantTags)) - } - for i, tag := range tt.wantTags { - if i < len(frontmatter.Tags) && frontmatter.Tags[i] != tag { - t.Errorf("frontmatter.Tags[%d] = %q, want %q", i, frontmatter.Tags[i], tag) - } - } + assertCustomFrontmatter(t, frontmatter, md, err, tt.wantErr, + tt.wantContent, tt.wantTaskName, tt.wantPriority, tt.wantResume, tt.wantTags) }) } } func TestParseMarkdownFile_MultipleNewlinesAfterFrontmatter(t *testing.T) { + t.Parallel() // This test verifies that multiple newlines after the frontmatter // closing delimiter are handled correctly. // The parser should: @@ -327,21 +487,25 @@ Start of context Start of context `, - wantContent: " \n\t \n\nStart of context\n", // Content copied as-is, preserving whitespace (newline after --- is preserved) + // Content copied as-is, preserving whitespace (newline after --- is preserved) + wantContent: " \n\t \n\nStart of context\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() // Create a temporary file tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o600); err != nil { t.Fatalf("failed to create temp file: %v", err) } // Parse the file var frontmatter BaseFrontMatter + md, err := ParseMarkdownFile(tmpFile, &frontmatter) if err != nil { t.Fatalf("ParseMarkdownFile() error = %v", err) @@ -358,6 +522,7 @@ Start of context if err != nil { t.Fatalf("ParseTask() failed: %v, content = %q", err, md.Content) } + if len(task) == 0 && strings.TrimSpace(md.Content) != "" { t.Errorf("ParseTask() returned empty task for non-empty content: %q", md.Content) } diff --git a/pkg/codingcontext/mcp/mcp.go b/pkg/codingcontext/mcp/mcp.go index 456072f..87c03fb 100644 --- a/pkg/codingcontext/mcp/mcp.go +++ b/pkg/codingcontext/mcp/mcp.go @@ -1,3 +1,4 @@ +// Package mcp provides types for MCP (Model Context Protocol) server configuration. package mcp import ( @@ -23,6 +24,8 @@ const ( // MCPServerConfig defines the common configuration fields supported by both platforms. // It also supports arbitrary additional fields via the Content map. +// +//revive:disable-next-line:exported // keep name for API compatibility type MCPServerConfig struct { // Type specifies the connection protocol. // Values: "stdio", "sse", "http". @@ -50,10 +53,11 @@ type MCPServerConfig struct { Content map[string]any `json:"-" yaml:",inline"` } -// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map +// UnmarshalJSON custom unmarshaler that populates both typed fields and Content map. func (m *MCPServerConfig) UnmarshalJSON(data []byte) error { // First unmarshal into a temporary type to avoid infinite recursion type Alias MCPServerConfig + aux := &struct { *Alias }{ @@ -78,4 +82,6 @@ func (m *MCPServerConfig) UnmarshalJSON(data []byte) error { } // MCPServerConfigs maps server names to their configurations. +// +//revive:disable-next-line:exported // keep name for API compatibility type MCPServerConfigs map[string]MCPServerConfig diff --git a/pkg/codingcontext/mcp/mcp_test.go b/pkg/codingcontext/mcp/mcp_test.go index ebd056d..4ceef1a 100644 --- a/pkg/codingcontext/mcp/mcp_test.go +++ b/pkg/codingcontext/mcp/mcp_test.go @@ -4,10 +4,53 @@ import ( "encoding/json" "testing" - "gopkg.in/yaml.v3" + yaml "github.com/goccy/go-yaml" ) +// assertMCPConfig checks common MCPServerConfig fields against expected values. +func assertMCPConfig(t *testing.T, got, want MCPServerConfig, err error, wantErr bool) { + t.Helper() + + if (err != nil) != wantErr { + t.Errorf("Unmarshal() error = %v, wantErr %v", err, wantErr) + + return + } + + if got.Type != want.Type { + t.Errorf("Type = %v, want %v", got.Type, want.Type) + } + + if got.Command != want.Command { + t.Errorf("Command = %v, want %v", got.Command, want.Command) + } + + if got.URL != want.URL { + t.Errorf("URL = %v, want %v", got.URL, want.URL) + } + + for key := range want.Content { + if _, exists := got.Content[key]; !exists { + t.Errorf("Content missing key %q", key) + } + } +} + +// requireConfig retrieves a named config from MCPServerConfigs or fails the test. +func requireConfig(t *testing.T, configs MCPServerConfigs, name string) MCPServerConfig { + t.Helper() + + cfg, ok := configs[name] + if !ok { + t.Fatalf("%s config not found", name) + } + + return cfg +} + func TestMCPServerConfig_YAML_ArbitraryFields(t *testing.T) { + t.Parallel() + tests := []struct { name string yaml string @@ -94,36 +137,20 @@ python_version: "3.11" for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var got MCPServerConfig - err := yaml.Unmarshal([]byte(tt.yaml), &got) + t.Parallel() - if (err != nil) != tt.wantErr { - t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) - return - } + var got MCPServerConfig - // Compare standard fields - if got.Type != tt.want.Type { - t.Errorf("Type = %v, want %v", got.Type, tt.want.Type) - } - if got.Command != tt.want.Command { - t.Errorf("Command = %v, want %v", got.Command, tt.want.Command) - } - if got.URL != tt.want.URL { - t.Errorf("URL = %v, want %v", got.URL, tt.want.URL) - } + err := yaml.Unmarshal([]byte(tt.yaml), &got) - // Compare Content map - at least verify keys exist - for key := range tt.want.Content { - if _, exists := got.Content[key]; !exists { - t.Errorf("Content missing key %q", key) - } - } + assertMCPConfig(t, got, tt.want, err, tt.wantErr) }) } } func TestMCPServerConfig_JSON_ArbitraryFields(t *testing.T) { + t.Parallel() + tests := []struct { name string json string @@ -187,36 +214,20 @@ func TestMCPServerConfig_JSON_ArbitraryFields(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var got MCPServerConfig - err := json.Unmarshal([]byte(tt.json), &got) + t.Parallel() - if (err != nil) != tt.wantErr { - t.Errorf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) - return - } + var got MCPServerConfig - // Compare standard fields - if got.Type != tt.want.Type { - t.Errorf("Type = %v, want %v", got.Type, tt.want.Type) - } - if got.Command != tt.want.Command { - t.Errorf("Command = %v, want %v", got.Command, tt.want.Command) - } - if got.URL != tt.want.URL { - t.Errorf("URL = %v, want %v", got.URL, tt.want.URL) - } + err := json.Unmarshal([]byte(tt.json), &got) - // Compare Content map - at least verify keys exist - for key := range tt.want.Content { - if _, exists := got.Content[key]; !exists { - t.Errorf("Content missing key %q", key) - } - } + assertMCPConfig(t, got, tt.want, err, tt.wantErr) }) } } func TestMCPServerConfig_Marshal_YAML(t *testing.T) { + t.Parallel() + tests := []struct { name string config MCPServerConfig @@ -243,6 +254,8 @@ func TestMCPServerConfig_Marshal_YAML(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + data, err := yaml.Marshal(&tt.config) if err != nil { t.Fatalf("Marshal() error = %v", err) @@ -258,6 +271,7 @@ func TestMCPServerConfig_Marshal_YAML(t *testing.T) { if got.Type != tt.config.Type { t.Errorf("Type = %v, want %v", got.Type, tt.config.Type) } + if got.Command != tt.config.Command { t.Errorf("Command = %v, want %v", got.Command, tt.config.Command) } @@ -266,6 +280,8 @@ func TestMCPServerConfig_Marshal_YAML(t *testing.T) { } func TestMCPServerConfigs_WithArbitraryFields(t *testing.T) { + t.Parallel() + yamlContent := ` filesystem: type: stdio @@ -282,6 +298,7 @@ api: ` var configs MCPServerConfigs + err := yaml.Unmarshal([]byte(yamlContent), &configs) if err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -291,47 +308,137 @@ api: t.Errorf("Expected 3 configs, got %d", len(configs)) } - // Check filesystem config - if fs, ok := configs["filesystem"]; ok { - if fs.Type != TransportTypeStdio { - t.Errorf("filesystem.Type = %v, want %v", fs.Type, TransportTypeStdio) - } - if fs.Command != "filesystem" { - t.Errorf("filesystem.Command = %v, want %v", fs.Command, "filesystem") - } - if fs.Content["cache_enabled"] != true { - t.Errorf("filesystem.Content[cache_enabled] = %v, want true", fs.Content["cache_enabled"]) - } - } else { - t.Error("filesystem config not found") + checkFilesystemConfig(t, configs) + checkGitConfig(t, configs) + checkAPIConfig(t, configs) +} + +func checkFilesystemConfig(t *testing.T, configs MCPServerConfigs) { + t.Helper() + + fs := requireConfig(t, configs, "filesystem") + + if fs.Type != TransportTypeStdio { + t.Errorf("filesystem.Type = %v, want %v", fs.Type, TransportTypeStdio) } - // Check git config - if git, ok := configs["git"]; ok { - if git.Type != TransportTypeStdio { - t.Errorf("git.Type = %v, want %v", git.Type, TransportTypeStdio) - } - // Verify custom field exists - if _, exists := git.Content["max_depth"]; !exists { - t.Error("git.Content[max_depth] not found") - } - } else { - t.Error("git config not found") + if fs.Command != "filesystem" { + t.Errorf("filesystem.Command = %v, want %v", fs.Command, "filesystem") } - // Check api config - if api, ok := configs["api"]; ok { - if api.Type != TransportTypeHTTP { - t.Errorf("api.Type = %v, want %v", api.Type, TransportTypeHTTP) - } - if api.URL != "https://api.example.com" { - t.Errorf("api.URL = %v, want %v", api.URL, "https://api.example.com") - } - // Verify custom field exists - if _, exists := api.Content["rate_limit"]; !exists { - t.Error("api.Content[rate_limit] not found") - } - } else { - t.Error("api config not found") + if fs.Content["cache_enabled"] != true { + t.Errorf("filesystem.Content[cache_enabled] = %v, want true", fs.Content["cache_enabled"]) + } +} + +func checkGitConfig(t *testing.T, configs MCPServerConfigs) { + t.Helper() + + git := requireConfig(t, configs, "git") + + if git.Type != TransportTypeStdio { + t.Errorf("git.Type = %v, want %v", git.Type, TransportTypeStdio) + } + + if _, exists := git.Content["max_depth"]; !exists { + t.Error("git.Content[max_depth] not found") + } +} + +func checkAPIConfig(t *testing.T, configs MCPServerConfigs) { + t.Helper() + + api := requireConfig(t, configs, "api") + + if api.Type != TransportTypeHTTP { + t.Errorf("api.Type = %v, want %v", api.Type, TransportTypeHTTP) + } + + if api.URL != "https://api.example.com" { + t.Errorf("api.URL = %v, want %v", api.URL, "https://api.example.com") + } + + if _, exists := api.Content["rate_limit"]; !exists { + t.Error("api.Content[rate_limit] not found") + } +} + +func TestMCPServerConfig_JSON_EdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + validate func(t *testing.T, cfg MCPServerConfig) + }{ + { + name: "empty JSON object gives zero-value struct", + input: `{}`, + validate: func(t *testing.T, cfg MCPServerConfig) { + t.Helper() + + if cfg.Type != "" { + t.Errorf("Type = %q, want empty", cfg.Type) + } + + if cfg.Command != "" { + t.Errorf("Command = %q, want empty", cfg.Command) + } + + if cfg.Content == nil { + t.Error("Content should be non-nil empty map for {}") + } + }, + }, + { + name: "only unknown fields go to Content map", + input: `{"foo": "bar", "num": 42, "flag": true}`, + validate: func(t *testing.T, cfg MCPServerConfig) { + t.Helper() + + if cfg.Type != "" { + t.Errorf("Type = %q, want empty", cfg.Type) + } + + if cfg.Content == nil { + t.Fatal("Content should not be nil") + } + + if cfg.Content["foo"] != "bar" { + t.Errorf("Content[foo] = %v, want bar", cfg.Content["foo"]) + } + + if cfg.Content["num"] != float64(42) { + t.Errorf("Content[num] = %v, want 42", cfg.Content["num"]) + } + + if cfg.Content["flag"] != true { + t.Errorf("Content[flag] = %v, want true", cfg.Content["flag"]) + } + }, + }, + { + name: "invalid JSON returns error", + input: `{not valid`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var cfg MCPServerConfig + + err := json.Unmarshal([]byte(tt.input), &cfg) + if (err != nil) != tt.wantErr { + t.Fatalf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + + if err == nil && tt.validate != nil { + tt.validate(t, cfg) + } + }) } } diff --git a/pkg/codingcontext/namespace_test.go b/pkg/codingcontext/namespace_test.go new file mode 100644 index 0000000..7008f4c --- /dev/null +++ b/pkg/codingcontext/namespace_test.go @@ -0,0 +1,793 @@ +package codingcontext + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// ── fixture helpers ────────────────────────────────────────────────────────── + +// createNamespaceTask creates a task file under .agents/namespaces//tasks/. +func createNamespaceTask(t *testing.T, dir, namespace, name, content string) { + t.Helper() + + taskDir := filepath.Join(dir, ".agents", "namespaces", namespace, "tasks") + if err := os.MkdirAll(taskDir, 0o750); err != nil { + t.Fatalf("failed to create namespace task dir: %v", err) + } + + fileContent := content + + if err := os.WriteFile(filepath.Join(taskDir, name+".md"), []byte(fileContent), 0o600); err != nil { + t.Fatalf("failed to write namespace task file: %v", err) + } +} + +// createNamespaceRule creates a rule file under .agents/namespaces//rules/. +func createNamespaceRule(t *testing.T, dir, namespace, name, content string) { + t.Helper() + + relPath := filepath.Join(".agents", "namespaces", namespace, "rules", name+".md") + createRule(t, dir, relPath, "", content) +} + +// createNamespaceCommand creates a command file under .agents/namespaces//commands/. +func createNamespaceCommand(t *testing.T, dir, namespace, name, frontmatter, content string) { + t.Helper() + relPath := filepath.Join(".agents", "namespaces", namespace, "commands", name+".md") + createRule(t, dir, relPath, frontmatter, content) +} + +// createNamespaceSkill creates a skill under .agents/namespaces//skills//SKILL.md. +func createNamespaceSkill(t *testing.T, dir, namespace, subdir, content string) { + t.Helper() + + skillDir := filepath.Join(dir, ".agents", "namespaces", namespace, "skills", subdir) + if err := os.MkdirAll(skillDir, 0o750); err != nil { + t.Fatalf("failed to create namespace skill dir: %v", err) + } + + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o600); err != nil { + t.Fatalf("failed to write namespace SKILL.md: %v", err) + } +} + +// newRunContext creates a Context scoped to a single temp directory, with +// bootstrap disabled (to avoid accidentally running scripts in unit tests). +func newRunContext(dir string) *Context { + return New(WithSearchPaths(dir), WithBootstrap(false)) +} + +// newFullContext creates a Context scoped to a single temp directory with +// bootstrap enabled (for tests that need rule/skill discovery). +func newFullContext(dir string) *Context { + return New(WithSearchPaths(dir)) +} + +// ── parseNamespacedTaskName ────────────────────────────────────────────────── + +func TestParseNamespacedTaskName(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + wantNS string + wantBase string + wantErr bool + errSubstr string + }{ + {input: "fix-bug", wantNS: "", wantBase: "fix-bug"}, + {input: "myteam/fix-bug", wantNS: "myteam", wantBase: "fix-bug"}, + {input: "team-a/deploy", wantNS: "team-a", wantBase: "deploy"}, + {input: "a/b/c", wantErr: true, errSubstr: "one level"}, + {input: "/task", wantErr: true, errSubstr: "namespace must not be empty"}, + {input: "ns/", wantErr: true, errSubstr: "task base name must not be empty"}, + {input: "a/b/c/d", wantErr: true, errSubstr: "one level"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + + ns, base, err := parseNamespacedTaskName(tt.input) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got nil", tt.input) + } + + if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("expected error to contain %q, got %q", tt.errSubstr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error for %q: %v", tt.input, err) + } + + if ns != tt.wantNS { + t.Errorf("namespace: got %q, want %q", ns, tt.wantNS) + } + + if base != tt.wantBase { + t.Errorf("baseName: got %q, want %q", base, tt.wantBase) + } + }) + } +} + +// ── namespace-aware path functions ─────────────────────────────────────────── + +func TestNamespacedTaskSearchPaths(t *testing.T) { + t.Parallel() + + dir := testProjectDir + ns := testNamespace + + paths := namespacedTaskSearchPaths(dir, ns) + + nsPath := filepath.Join(dir, ".agents", "namespaces", ns, "tasks") + globalPath := filepath.Join(dir, ".agents", "tasks") + + if !slices.Contains(paths, nsPath) { + t.Errorf("expected namespace task path %q in result", nsPath) + } + + if !slices.Contains(paths, globalPath) { + t.Errorf("expected global task path %q in result", globalPath) + } + // namespace path must come before global path + nsIdx := slices.Index(paths, nsPath) + + globalIdx := slices.Index(paths, globalPath) + if nsIdx >= globalIdx { + t.Errorf("expected namespace path (idx %d) before global path (idx %d)", nsIdx, globalIdx) + } +} + +func TestNamespacedTaskSearchPaths_NoNamespace(t *testing.T) { + t.Parallel() + + dir := testProjectDir + paths := namespacedTaskSearchPaths(dir, "") + + // Should equal plain taskSearchPaths output + plain := taskSearchPaths(dir) + for _, p := range plain { + if !slices.Contains(paths, p) { + t.Errorf("expected global task path %q in no-namespace result", p) + } + } + + // Must not include any namespaces directory + for _, p := range paths { + if strings.Contains(p, "namespaces") { + t.Errorf("no-namespace call should not include 'namespaces' path, got %q", p) + } + } +} + +func TestNamespacedRuleSearchPaths(t *testing.T) { + t.Parallel() + + dir := testProjectDir + ns := testNamespace + + paths := namespacedRuleSearchPaths(dir, ns) + + nsPath := filepath.Join(dir, ".agents", "namespaces", ns, "rules") + globalPath := filepath.Join(dir, ".agents", "rules") + + if !slices.Contains(paths, nsPath) { + t.Errorf("expected namespace rule path %q", nsPath) + } + + if !slices.Contains(paths, globalPath) { + t.Errorf("expected global rule path %q", globalPath) + } + // namespace path must come first + if slices.Index(paths, nsPath) >= slices.Index(paths, globalPath) { + t.Error("expected namespace rule path to precede global rule path") + } +} + +func TestNamespacedCommandSearchPaths(t *testing.T) { + t.Parallel() + + dir := testProjectDir + ns := testNamespace + + paths := namespacedCommandSearchPaths(dir, ns) + + nsPath := filepath.Join(dir, ".agents", "namespaces", ns, "commands") + globalPath := filepath.Join(dir, ".agents", "commands") + + if !slices.Contains(paths, nsPath) { + t.Errorf("expected namespace command path %q", nsPath) + } + + if !slices.Contains(paths, globalPath) { + t.Errorf("expected global command path %q", globalPath) + } + + if slices.Index(paths, nsPath) >= slices.Index(paths, globalPath) { + t.Error("expected namespace command path to precede global command path") + } +} + +func TestNamespacedSkillSearchPaths(t *testing.T) { + t.Parallel() + + dir := testProjectDir + ns := testNamespace + + paths := namespacedSkillSearchPaths(dir, ns) + + nsPath := filepath.Join(dir, ".agents", "namespaces", ns, "skills") + globalPath := filepath.Join(dir, ".agents", "skills") + + if !slices.Contains(paths, nsPath) { + t.Errorf("expected namespace skill path %q", nsPath) + } + + if !slices.Contains(paths, globalPath) { + t.Errorf("expected global skill path %q", globalPath) + } + + if slices.Index(paths, nsPath) >= slices.Index(paths, globalPath) { + t.Error("expected namespace skill path to precede global skill path") + } +} + +// ── Run() with namespace ───────────────────────────────────────────────────── + +func TestRun_NamespacedTask_Found(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "build", "Namespace build task.") + + cc := newRunContext(dir) + + result, err := cc.Run(context.Background(), "myteam/build") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if !strings.Contains(result.Task.Content, "Namespace build task.") { + t.Errorf("unexpected task content: %q", result.Task.Content) + } +} + +func TestRun_NamespacedTask_FallsBackToGlobal(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + // Only a global task exists; no namespace task file + createTask(t, dir, "deploy", "", "Global deploy task.") + + cc := newRunContext(dir) + + result, err := cc.Run(context.Background(), "myteam/deploy") + if err != nil { + t.Fatalf("Run() error (expected global fallback): %v", err) + } + + if !strings.Contains(result.Task.Content, "Global deploy task.") { + t.Errorf("expected global task content, got %q", result.Task.Content) + } +} + +func TestRun_NamespacedTask_NamespaceTakesPrecedenceOverGlobal(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "build", "", "Global build task.") + createNamespaceTask(t, dir, "myteam", "build", "Namespace build task.") + + cc := newRunContext(dir) + + result, err := cc.Run(context.Background(), "myteam/build") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if !strings.Contains(result.Task.Content, "Namespace build task.") { + t.Errorf("expected namespace task to win, got %q", result.Task.Content) + } + + if strings.Contains(result.Task.Content, "Global build task.") { + t.Errorf("global task should not appear when namespace task exists") + } +} + +func TestRun_NamespacedTask_NotFound_GlobalNotFound_Error(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cc := newRunContext(dir) + + _, err := cc.Run(context.Background(), "myteam/missing") + if err == nil { + t.Fatal("expected error for missing task") + } + + if !errors.Is(err, ErrTaskNotFound) { + t.Errorf("expected ErrTaskNotFound, got %v", err) + } +} + +func TestRun_InvalidNamespace_TooManySlashes(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cc := newRunContext(dir) + + _, err := cc.Run(context.Background(), "a/b/c") + if err == nil { + t.Fatal("expected error for deeply nested task name") + } + + if !strings.Contains(err.Error(), "one level") { + t.Errorf("expected 'one level' in error, got %v", err) + } +} + +func TestRun_InvalidNamespace_EmptyNamespace(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cc := newRunContext(dir) + + _, err := cc.Run(context.Background(), "/task") + if err == nil { + t.Fatal("expected error for empty namespace") + } +} + +func TestRun_InvalidNamespace_EmptyBaseName(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cc := newRunContext(dir) + + _, err := cc.Run(context.Background(), "ns/") + if err == nil { + t.Fatal("expected error for empty base name") + } +} + +// ── Rules with namespace ───────────────────────────────────────────────────── + +func TestRun_NamespaceRules_IncludedBeforeGlobalRules(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + createNamespaceRule(t, dir, "myteam", "ns-rule", "Namespace rule content.") + createRule(t, dir, ".agents/rules/global-rule.md", "", "Global rule content.") + + cc := newFullContext(dir) + + result, err := cc.Run(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if len(result.Rules) < 2 { + t.Fatalf("expected at least 2 rules (ns + global), got %d", len(result.Rules)) + } + + // Namespace rule must appear before global rule in the assembled prompt + nsIdx := strings.Index(result.Prompt, "Namespace rule content.") + globalIdx := strings.Index(result.Prompt, "Global rule content.") + + if nsIdx < 0 { + t.Error("namespace rule content missing from prompt") + } + + if globalIdx < 0 { + t.Error("global rule content missing from prompt") + } + + if nsIdx >= globalIdx { + t.Error("expected namespace rule to appear before global rule in prompt") + } +} + +func TestRun_GlobalRules_AlwaysIncludedWithNamespace(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + createRule(t, dir, ".agents/rules/global-rule.md", "", "Global rule content.") + // No namespace-specific rules + + cc := newFullContext(dir) + + result, err := cc.Run(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if !strings.Contains(result.Prompt, "Global rule content.") { + t.Error("global rule should always be included even when using a namespace") + } +} + +func TestRun_NamespaceSelector_FiltersRules(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + // This rule restricts itself to myteam via the selector system + createRule(t, dir, ".agents/rules/myteam-only.md", "namespace: myteam", "myteam-only rule.") + // This rule restricts itself to otherteam + createRule(t, dir, ".agents/rules/otherteam-only.md", "namespace: otherteam", "otherteam-only rule.") + // Unrestricted global rule + createRule(t, dir, ".agents/rules/everyone.md", "", "everyone rule.") + + cc := newFullContext(dir) + + result, err := cc.Run(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if !strings.Contains(result.Prompt, "myteam-only rule.") { + t.Error("myteam-scoped rule should be included for myteam task") + } + + if strings.Contains(result.Prompt, "otherteam-only rule.") { + t.Error("otherteam-scoped rule must not be included for myteam task") + } + + if !strings.Contains(result.Prompt, "everyone rule.") { + t.Error("unrestricted global rule should always be included") + } +} + +func TestRun_NoNamespace_NamespaceScopedRulesExcluded(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "plain", "", "Plain task.") + // A rule restricted to myteam — should NOT appear for a non-namespaced task + createRule(t, dir, ".agents/rules/myteam-only.md", "namespace: myteam", "myteam-only rule.") + createRule(t, dir, ".agents/rules/global.md", "", "Global rule.") + + cc := newFullContext(dir) + + result, err := cc.Run(context.Background(), "plain") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if strings.Contains(result.Prompt, "myteam-only rule.") { + t.Error("namespace-scoped rule must not be included for non-namespaced tasks") + } + + if !strings.Contains(result.Prompt, "Global rule.") { + t.Error("global rule should be included for non-namespaced tasks") + } +} + +func TestRun_DifferentNamespaces_DoNotShareRules(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "teamA", "work", "TeamA work.") + createNamespaceTask(t, dir, "teamB", "work", "TeamB work.") + createNamespaceRule(t, dir, "teamA", "rule", "TeamA namespace rule.") + createNamespaceRule(t, dir, "teamB", "rule", "TeamB namespace rule.") + + // Run as teamA + ccA := newFullContext(dir) + + resultA, err := ccA.Run(context.Background(), "teamA/work") + if err != nil { + t.Fatalf("teamA Run() error: %v", err) + } + + if !strings.Contains(resultA.Prompt, "TeamA namespace rule.") { + t.Error("teamA should see its own namespace rule") + } + + if strings.Contains(resultA.Prompt, "TeamB namespace rule.") { + t.Error("teamA must not see teamB namespace rules") + } + + // Run as teamB (needs a fresh Context) + ccB := newFullContext(dir) + + resultB, err := ccB.Run(context.Background(), "teamB/work") + if err != nil { + t.Fatalf("teamB Run() error: %v", err) + } + + if !strings.Contains(resultB.Prompt, "TeamB namespace rule.") { + t.Error("teamB should see its own namespace rule") + } + + if strings.Contains(resultB.Prompt, "TeamA namespace rule.") { + t.Error("teamB must not see teamA namespace rules") + } +} + +// ── Commands with namespace ────────────────────────────────────────────────── + +func TestRun_NamespaceCommand_OverridesGlobalCommand(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "deploy", "/deploy") + createCommand(t, dir, "deploy", "", "Global deploy command.") + createNamespaceCommand(t, dir, "myteam", "deploy", "", "Namespace deploy command.") + + cc := newFullContext(dir) + + result, err := cc.Run(context.Background(), "myteam/deploy") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if !strings.Contains(result.Task.Content, "Namespace deploy command.") { + t.Error("namespace command should override global command") + } + + if strings.Contains(result.Task.Content, "Global deploy command.") { + t.Error("global command must not appear when namespace command has same name") + } +} + +func TestRun_NamespaceCommand_FallsBackToGlobalCommand(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "/shared-cmd") + createCommand(t, dir, "shared-cmd", "", "Shared global command.") + // No namespace override for this command + + cc := newFullContext(dir) + + result, err := cc.Run(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if !strings.Contains(result.Task.Content, "Shared global command.") { + t.Error("should fall back to global command when no namespace override exists") + } +} + +// ── Skills with namespace ──────────────────────────────────────────────────── + +func TestRun_NamespaceSkills_DiscoveredAlongsideGlobal(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + createNamespaceSkill(t, dir, "myteam", "team-tool", + "---\nname: team-tool\ndescription: A team-specific tool skill.\n---\n") + createSkill(t, dir, ".agents/skills/global-tool", + "---\nname: global-tool\ndescription: A global tool skill.\n---\n") + + cc := newFullContext(dir) + + result, err := cc.Run(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + names := make([]string, 0, len(result.Skills.Skills)) + for _, s := range result.Skills.Skills { + names = append(names, s.Name) + } + + if !slices.Contains(names, "team-tool") { + t.Errorf("namespace skill 'team-tool' not discovered; got %v", names) + } + + if !slices.Contains(names, "global-tool") { + t.Errorf("global skill 'global-tool' not discovered; got %v", names) + } +} + +func TestRun_NamespaceSkills_ListedBeforeGlobalSkills(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + createNamespaceSkill(t, dir, "myteam", "alpha-skill", + "---\nname: alpha-skill\ndescription: Namespace alpha skill.\n---\n") + createSkill(t, dir, ".agents/skills/beta-skill", + "---\nname: beta-skill\ndescription: Global beta skill.\n---\n") + + cc := newFullContext(dir) + + result, err := cc.Run(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if len(result.Skills.Skills) < 2 { + t.Fatalf("expected at least 2 skills, got %d", len(result.Skills.Skills)) + } + + // Namespace skill must appear first + if result.Skills.Skills[0].Name != "alpha-skill" { + t.Errorf("expected namespace skill first, got %q", result.Skills.Skills[0].Name) + } +} + +// ── Selector state after findTask ──────────────────────────────────────────── + +func TestRun_NamespacedTask_SetsNamespaceSelector(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + + cc := newRunContext(dir) + + _, err := cc.Run(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if !cc.includes.GetValue("namespace", "myteam") { + t.Error("expected namespace=myteam to be set in selectors after Run()") + } +} + +func TestRun_NamespacedTask_SetsBothTaskNameForms(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "fix-bug", "Fix the bug.") + + cc := newRunContext(dir) + + _, err := cc.Run(context.Background(), "myteam/fix-bug") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + // Both full and base task names should be present + if !cc.includes.GetValue("task_name", "myteam/fix-bug") { + t.Error("expected task_name=myteam/fix-bug in selectors") + } + + if !cc.includes.GetValue("task_name", "fix-bug") { + t.Error("expected task_name=fix-bug (base) in selectors") + } +} + +func TestRun_NonNamespacedTask_NamespaceSelectorIsEmpty(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createTask(t, dir, "plain", "", "Plain task.") + + cc := newRunContext(dir) + + _, err := cc.Run(context.Background(), "plain") + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + // For non-namespaced tasks, namespace selector is set to "" (empty string sentinel) + // so that rules declaring a specific namespace in frontmatter are excluded. + if !cc.includes.GetValue("namespace", "") { + t.Error("expected namespace=\"\" (empty sentinel) for non-namespaced tasks") + } + + if !cc.includes.GetValue("task_name", "plain") { + t.Error("expected task_name=plain in selectors") + } +} + +// ── Lint() with namespace ──────────────────────────────────────────────────── + +func TestLint_NamespacedTask_NoErrors(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + createNamespaceRule(t, dir, "myteam", "ns-rule", "Namespace rule.") + createRule(t, dir, ".agents/rules/global.md", "", "Global rule.") + + cc := New(WithSearchPaths(dir)) + + result, err := cc.Lint(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Lint() error: %v", err) + } + + if len(result.Errors) != 0 { + t.Errorf("expected no lint errors, got %+v", result.Errors) + } +} + +func TestLint_NamespacedTask_TracksNamespaceTaskFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + + cc := New(WithSearchPaths(dir)) + + result, err := cc.Lint(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Lint() error: %v", err) + } + + if !hasLoadedFile(result, filepath.Join("namespaces", "myteam", "tasks", "work.md"), LoadedFileKindTask) { + t.Errorf("expected namespace task file in LoadedFiles, got %+v", result.LoadedFiles) + } +} + +func TestLint_NamespacedTask_NamespaceNotFlaggedAsUnmatchedSelector(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + + cc := New(WithSearchPaths(dir)) + + result, err := cc.Lint(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Lint() error: %v", err) + } + + if hasLintError(result, LintErrorKindSelectorNoMatch, "namespace") { + t.Error("'namespace' selector must not produce a SelectorNoMatch lint error") + } +} + +func TestLint_InvalidNamespacedTaskName_FatalError(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cc := New(WithSearchPaths(dir)) + + _, err := cc.Lint(context.Background(), "a/b/c") + if err == nil { + t.Fatal("expected fatal error for invalid task name") + } + + if !strings.Contains(err.Error(), "one level") { + t.Errorf("expected 'one level' in error, got %v", err) + } +} + +func TestLint_NamespacedTask_TracksBothNamespaceAndGlobalRules(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + createNamespaceTask(t, dir, "myteam", "work", "Do work.") + createNamespaceRule(t, dir, "myteam", "ns-rule", "NS rule.") + createRule(t, dir, ".agents/rules/global-rule.md", "", "Global rule.") + + cc := New(WithSearchPaths(dir)) + + result, err := cc.Lint(context.Background(), "myteam/work") + if err != nil { + t.Fatalf("Lint() error: %v", err) + } + + if !hasLoadedFile(result, "ns-rule.md", LoadedFileKindRule) { + t.Errorf("expected namespace rule in LoadedFiles, got %+v", result.LoadedFiles) + } + + if !hasLoadedFile(result, "global-rule.md", LoadedFileKindRule) { + t.Errorf("expected global rule in LoadedFiles, got %+v", result.LoadedFiles) + } +} diff --git a/pkg/codingcontext/options.go b/pkg/codingcontext/options.go index c075127..69b6651 100644 --- a/pkg/codingcontext/options.go +++ b/pkg/codingcontext/options.go @@ -7,38 +7,38 @@ import ( "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) -// Option is a functional option for configuring a Context +// Option is a functional option for configuring a Context. type Option func(*Context) -// WithParams sets the parameters +// WithParams sets the parameters. func WithParams(params taskparser.Params) Option { return func(c *Context) { c.params = params } } -// WithSelectors sets the selectors +// WithSelectors sets the selectors. func WithSelectors(selectors selectors.Selectors) Option { return func(c *Context) { c.includes = selectors } } -// WithManifestURL sets the manifest URL +// WithManifestURL sets the manifest URL. func WithManifestURL(manifestURL string) Option { return func(c *Context) { c.manifestURL = manifestURL } } -// WithSearchPaths adds one or more search paths +// WithSearchPaths adds one or more search paths. func WithSearchPaths(paths ...string) Option { return func(c *Context) { c.searchPaths = append(c.searchPaths, paths...) } } -// WithLogger sets the logger +// WithLogger sets the logger. func WithLogger(logger *slog.Logger) Option { return func(c *Context) { c.logger = logger @@ -61,16 +61,25 @@ func WithBootstrap(doBootstrap bool) Option { } } -// WithAgent sets the target agent, which excludes that agent's own rules +// WithAgent sets the target agent, which excludes that agent's own rules. func WithAgent(agent Agent) Option { return func(c *Context) { c.agent = agent } } -// WithUserPrompt sets the user prompt to append to the task +// WithUserPrompt sets the user prompt to append to the task. func WithUserPrompt(userPrompt string) Option { return func(c *Context) { c.userPrompt = userPrompt } } + +// WithLint enables lint mode: skips bootstrap script execution and shell command +// expansion (!`cmd`). File access is tracked and non-fatal structural errors are +// collected in LintResult. Use Lint() instead of Run() to retrieve results. +func WithLint(lint bool) Option { + return func(c *Context) { + c.lintMode = lint + } +} diff --git a/pkg/codingcontext/paths.go b/pkg/codingcontext/paths.go index a015f52..8e5a377 100644 --- a/pkg/codingcontext/paths.go +++ b/pkg/codingcontext/paths.go @@ -2,13 +2,66 @@ package codingcontext import "path/filepath" +// namespacedTaskSearchPaths returns task search paths for the given namespace. +// Namespace task dir is searched first; global task dirs follow as fallback. +func namespacedTaskSearchPaths(dir, namespace string) []string { + var paths []string + if namespace != "" { + paths = append(paths, filepath.Join(dir, ".agents/namespaces", namespace, "tasks")) + } + + paths = append(paths, taskSearchPaths(dir)...) + + return paths +} + +// namespacedRuleSearchPaths returns rule search paths for the given namespace. +// Namespace rule dir is prepended so namespace rules appear first in context output. +// Global rule dirs always follow (both are included). +func namespacedRuleSearchPaths(dir, namespace string) []string { + var paths []string + if namespace != "" { + paths = append(paths, filepath.Join(dir, ".agents/namespaces", namespace, "rules")) + } + + paths = append(paths, rulePaths(dir)...) + + return paths +} + +// namespacedCommandSearchPaths returns command search paths for the given namespace. +// Namespace command dir is searched first; the first match wins (namespace overrides global). +func namespacedCommandSearchPaths(dir, namespace string) []string { + var paths []string + if namespace != "" { + paths = append(paths, filepath.Join(dir, ".agents/namespaces", namespace, "commands")) + } + + paths = append(paths, commandSearchPaths(dir)...) + + return paths +} + +// namespacedSkillSearchPaths returns skill search paths for the given namespace. +// Namespace skill dir is listed first so namespace skills appear earlier in discovery. +func namespacedSkillSearchPaths(dir, namespace string) []string { + var paths []string + if namespace != "" { + paths = append(paths, filepath.Join(dir, ".agents/namespaces", namespace, "skills")) + } + + paths = append(paths, skillSearchPaths(dir)...) + + return paths +} + // rulePaths returns the search paths for rule files in a directory. -// It collects rule paths from all agents in the agentsPaths configuration. +// It collects rule paths from all agents in the agents paths configuration. func rulePaths(dir string) []string { var paths []string // Iterate through all configured agents - for _, config := range agentsPaths { + for _, config := range getAgentsPaths() { // Add each rule path for this agent for _, rulePath := range config.rulesPaths { paths = append(paths, filepath.Join(dir, rulePath)) @@ -19,12 +72,12 @@ func rulePaths(dir string) []string { } // taskSearchPaths returns the search paths for task files in a directory. -// It collects task paths from all agents in the agentsPaths configuration. +// It collects task paths from all agents in the agents paths configuration. func taskSearchPaths(dir string) []string { var paths []string // Iterate through all configured agents - for _, config := range agentsPaths { + for _, config := range getAgentsPaths() { if config.tasksPath != "" { paths = append(paths, filepath.Join(dir, config.tasksPath)) } @@ -34,12 +87,12 @@ func taskSearchPaths(dir string) []string { } // commandSearchPaths returns the search paths for command files in a directory. -// It collects command paths from all agents in the agentsPaths configuration. +// It collects command paths from all agents in the agents paths configuration. func commandSearchPaths(dir string) []string { var paths []string // Iterate through all configured agents - for _, config := range agentsPaths { + for _, config := range getAgentsPaths() { if config.commandsPath != "" { paths = append(paths, filepath.Join(dir, config.commandsPath)) } @@ -49,12 +102,12 @@ func commandSearchPaths(dir string) []string { } // skillSearchPaths returns the search paths for skill directories in a directory. -// It collects skill paths from all agents in the agentsPaths configuration. +// It collects skill paths from all agents in the agents paths configuration. func skillSearchPaths(dir string) []string { var paths []string // Iterate through all configured agents - for _, config := range agentsPaths { + for _, config := range getAgentsPaths() { if config.skillsPath != "" { paths = append(paths, filepath.Join(dir, config.skillsPath)) } diff --git a/pkg/codingcontext/paths_test.go b/pkg/codingcontext/paths_test.go index e87362d..b495ed6 100644 --- a/pkg/codingcontext/paths_test.go +++ b/pkg/codingcontext/paths_test.go @@ -2,10 +2,18 @@ package codingcontext import ( "path/filepath" + "slices" "testing" ) +const ( + testProjectDir = "/project" + testNamespace = "myteam" +) + func TestRulePaths(t *testing.T) { + t.Parallel() + tests := []struct { name string dir string @@ -13,31 +21,26 @@ func TestRulePaths(t *testing.T) { }{ { name: "directory includes all agent paths", - dir: "/project", + dir: testProjectDir, wantContains: []string{ - filepath.Join("/project", ".agents", "rules"), - filepath.Join("/project", ".cursor", "rules"), - filepath.Join("/project", ".cursorrules"), - filepath.Join("/project", ".claude"), - filepath.Join("/project", ".codex"), + filepath.Join(testProjectDir, ".agents", "rules"), + filepath.Join(testProjectDir, ".cursor", "rules"), + filepath.Join(testProjectDir, ".cursorrules"), + filepath.Join(testProjectDir, ".claude"), + filepath.Join(testProjectDir, ".codex"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + paths := rulePaths(tt.dir) // Check that expected paths are present for _, want := range tt.wantContains { - found := false - for _, path := range paths { - if path == want { - found = true - break - } - } - if !found { + if !slices.Contains(paths, want) { t.Errorf("Expected path %q not found in rulePaths", want) } } @@ -46,25 +49,22 @@ func TestRulePaths(t *testing.T) { } func TestTaskSearchPaths(t *testing.T) { - dir := "/project" + t.Parallel() + + dir := testProjectDir paths := taskSearchPaths(dir) // Should contain at least the .agents/tasks path expectedPath := filepath.Join(dir, ".agents", "tasks") - found := false - for _, path := range paths { - if path == expectedPath { - found = true - break - } - } - if !found { + if !slices.Contains(paths, expectedPath) { t.Errorf("Expected path %q not found in taskSearchPaths", expectedPath) } } func TestCommandSearchPaths(t *testing.T) { - dir := "/project" + t.Parallel() + + dir := testProjectDir paths := commandSearchPaths(dir) // Should contain at least the .agents/commands path @@ -75,41 +75,29 @@ func TestCommandSearchPaths(t *testing.T) { } for _, expected := range expectedPaths { - found := false - for _, path := range paths { - if path == expected { - found = true - break - } - } - if !found { + if !slices.Contains(paths, expected) { t.Errorf("Expected path %q not found in commandSearchPaths", expected) } } } func TestSkillSearchPaths(t *testing.T) { - dir := "/project" + t.Parallel() + + dir := testProjectDir paths := skillSearchPaths(dir) // Should contain at least the .agents/skills path expectedPath := filepath.Join(dir, ".agents", "skills") - found := false - for _, path := range paths { - if path == expectedPath { - found = true - break - } - } - if !found { + if !slices.Contains(paths, expectedPath) { t.Errorf("Expected path %q not found in skillSearchPaths", expectedPath) } } func TestPathsUseAgentsPaths(t *testing.T) { + t.Parallel() // Verify that all path functions are using the agentsPaths configuration // by checking that they return paths for all configured agents - dir := "/test" // Get paths from functions diff --git a/pkg/codingcontext/result.go b/pkg/codingcontext/result.go index b46119b..53a0996 100644 --- a/pkg/codingcontext/result.go +++ b/pkg/codingcontext/result.go @@ -6,15 +6,16 @@ import ( "github.com/kitproj/coding-context-cli/pkg/codingcontext/skills" ) -// Result holds the assembled context from running a task +// Result holds the assembled context from running a task. type Result struct { - Name string // Name of the task - Rules []markdown.Markdown[markdown.RuleFrontMatter] // List of included rule files - Task markdown.Markdown[markdown.TaskFrontMatter] // Task file with frontmatter and content - Skills skills.AvailableSkills // List of discovered skills (metadata only) - Tokens int // Total token count - Agent Agent // The agent used (from task or -a flag) - Prompt string // Combined prompt: all rules and task content + Name string // Name of the task + Namespace string // Active namespace (e.g. "myteam" from "myteam/fix-bug"), empty if none + Rules []markdown.Markdown[markdown.RuleFrontMatter] // List of included rule files + Task markdown.Markdown[markdown.TaskFrontMatter] // Task file with frontmatter and content + Skills skills.AvailableSkills // List of discovered skills (metadata only) + Tokens int // Total token count + Agent Agent // The agent used (from task or -a flag) + Prompt string // Combined prompt: all rules and task content } // MCPServers returns all MCP server configurations from rules as a map. diff --git a/pkg/codingcontext/result_test.go b/pkg/codingcontext/result_test.go index 5106660..401ec98 100644 --- a/pkg/codingcontext/result_test.go +++ b/pkg/codingcontext/result_test.go @@ -11,6 +11,7 @@ import ( ) func TestResult_Prompt(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(t *testing.T, dir string) @@ -20,6 +21,7 @@ func TestResult_Prompt(t *testing.T) { { name: "task only without rules", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "test-task", "task_name: test-task", "Task content\n") }, taskName: "test-task", @@ -28,6 +30,7 @@ func TestResult_Prompt(t *testing.T) { { name: "single rule and task", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "test-task", "task_name: test-task", "Task content\n") createRule(t, dir, ".agents/rules/rule1.md", "", "Rule 1 content\n") }, @@ -37,6 +40,7 @@ func TestResult_Prompt(t *testing.T) { { name: "multiple rules and task", setup: func(t *testing.T, dir string) { + t.Helper() createTask(t, dir, "test-task", "task_name: test-task", "Task content\n") createRule(t, dir, ".agents/rules/rule1.md", "", "Rule 1 content\n") createRule(t, dir, ".agents/rules/rule2.md", "", "Rule 2 content\n") @@ -48,6 +52,7 @@ func TestResult_Prompt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir := t.TempDir() tt.setup(t, tmpDir) @@ -72,129 +77,89 @@ func TestResult_Prompt(t *testing.T) { } } -func TestResult_MCPServers(t *testing.T) { - tests := []struct { +func rule(cfg mcp.MCPServerConfig) markdown.Markdown[markdown.RuleFrontMatter] { + return markdown.Markdown[markdown.RuleFrontMatter]{ + FrontMatter: markdown.RuleFrontMatter{MCPServer: cfg}, + } +} + +func emptyRule() markdown.Markdown[markdown.RuleFrontMatter] { + return markdown.Markdown[markdown.RuleFrontMatter]{FrontMatter: markdown.RuleFrontMatter{}} +} + +func resultWithRules(name string, rules ...markdown.Markdown[markdown.RuleFrontMatter]) Result { + return Result{Name: name, Rules: rules, Task: markdown.Markdown[markdown.TaskFrontMatter]{}} +} + +func mcpServersCases() []struct { + name string + result Result + want map[string]mcp.MCPServerConfig +} { + return []struct { name string result Result want map[string]mcp.MCPServerConfig }{ { - name: "no MCP servers", - result: Result{ - Name: "test-task", - Rules: []markdown.Markdown[markdown.RuleFrontMatter]{}, - Task: markdown.Markdown[markdown.TaskFrontMatter]{ - FrontMatter: markdown.TaskFrontMatter{}, - }, - }, - want: map[string]mcp.MCPServerConfig{}, + name: "no MCP servers", + result: resultWithRules("test-task"), + want: map[string]mcp.MCPServerConfig{}, }, { name: "MCP servers from rules with URNs", - result: Result{ - Name: "test-task", - Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ - { - FrontMatter: markdown.RuleFrontMatter{ - MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "jira"}, - }, - }, - { - FrontMatter: markdown.RuleFrontMatter{ - MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeHTTP, URL: "https://api.example.com"}, - }, - }, - }, - Task: markdown.Markdown[markdown.TaskFrontMatter]{ - FrontMatter: markdown.TaskFrontMatter{}, - }, - }, + result: resultWithRules("test-task", + rule(mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "jira"}), + rule(mcp.MCPServerConfig{Type: mcp.TransportTypeHTTP, URL: "https://api.example.com"}), + ), want: map[string]mcp.MCPServerConfig{ "": {Type: mcp.TransportTypeHTTP, URL: "https://api.example.com"}, }, }, { name: "multiple rules with MCP servers and empty rule", - result: Result{ - Name: "test-task", - Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ - { - FrontMatter: markdown.RuleFrontMatter{ - MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server1"}, - }, - }, - { - FrontMatter: markdown.RuleFrontMatter{ - MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server2"}, - }, - }, - { - FrontMatter: markdown.RuleFrontMatter{}, - }, - }, - Task: markdown.Markdown[markdown.TaskFrontMatter]{ - FrontMatter: markdown.TaskFrontMatter{}, - }, - }, + result: resultWithRules("test-task", + rule(mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server1"}), + rule(mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server2"}), + emptyRule(), + ), want: map[string]mcp.MCPServerConfig{ "": {Type: mcp.TransportTypeStdio, Command: "server2"}, }, }, { - name: "rule without MCP server", - result: Result{ - Name: "test-task", - Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ - { - FrontMatter: markdown.RuleFrontMatter{}, - }, - }, - Task: markdown.Markdown[markdown.TaskFrontMatter]{ - FrontMatter: markdown.TaskFrontMatter{}, - }, - }, - want: map[string]mcp.MCPServerConfig{ - // Empty rule MCP server is filtered out - }, + name: "rule without MCP server", + result: resultWithRules("test-task", emptyRule()), + want: map[string]mcp.MCPServerConfig{}, }, { name: "mixed rules with URNs", - result: Result{ - Rules: []markdown.Markdown[markdown.RuleFrontMatter]{ - { - FrontMatter: markdown.RuleFrontMatter{ - MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server1"}, - }, - }, - { - FrontMatter: markdown.RuleFrontMatter{ - MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server2"}, - }, - }, - { - FrontMatter: markdown.RuleFrontMatter{ - MCPServer: mcp.MCPServerConfig{Type: mcp.TransportTypeHTTP, URL: "https://example.com"}, - }, - }, - }, - Task: markdown.Markdown[markdown.TaskFrontMatter]{ - FrontMatter: markdown.TaskFrontMatter{}, - }, - }, + result: resultWithRules("", + rule(mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server1"}), + rule(mcp.MCPServerConfig{Type: mcp.TransportTypeStdio, Command: "server2"}), + rule(mcp.MCPServerConfig{Type: mcp.TransportTypeHTTP, URL: "https://example.com"}), + ), want: map[string]mcp.MCPServerConfig{ "": {Type: mcp.TransportTypeHTTP, URL: "https://example.com"}, }, }, } +} - for _, tt := range tests { +func TestResult_MCPServers(t *testing.T) { + t.Parallel() + + for _, tt := range mcpServersCases() { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.result.MCPServers() if len(got) != len(tt.want) { t.Errorf("MCPServers() returned %d servers, want %d", len(got), len(tt.want)) t.Logf("Got keys: %v", mapKeys(got)) t.Logf("Want keys: %v", mapKeys(tt.want)) + return } @@ -202,15 +167,18 @@ func TestResult_MCPServers(t *testing.T) { gotServer, ok := got[key] if !ok { t.Errorf("MCPServers() missing key %q", key) + continue } if gotServer.Type != wantServer.Type { t.Errorf("MCPServers()[%q].Type = %v, want %v", key, gotServer.Type, wantServer.Type) } + if gotServer.Command != wantServer.Command { t.Errorf("MCPServers()[%q].Command = %q, want %q", key, gotServer.Command, wantServer.Command) } + if gotServer.URL != wantServer.URL { t.Errorf("MCPServers()[%q].URL = %q, want %q", key, gotServer.URL, wantServer.URL) } @@ -219,11 +187,12 @@ func TestResult_MCPServers(t *testing.T) { } } -// Helper function to get map keys for debugging +// Helper function to get map keys for debugging. func mapKeys(m map[string]mcp.MCPServerConfig) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } + return keys } diff --git a/pkg/codingcontext/selectors/selectors.go b/pkg/codingcontext/selectors/selectors.go index a9c19c9..95ff2fd 100644 --- a/pkg/codingcontext/selectors/selectors.go +++ b/pkg/codingcontext/selectors/selectors.go @@ -1,47 +1,59 @@ +// Package selectors provides selector parsing and matching for rule/skill frontmatter. package selectors import ( + "errors" "fmt" "strings" "github.com/kitproj/coding-context-cli/pkg/codingcontext/markdown" ) +// ErrInvalidSelectorFormat is returned when a selector string is not in key=value format. +var ErrInvalidSelectorFormat = errors.New("invalid selector format") + // Selectors stores selector key-value pairs where values are stored in inner maps // Multiple values for the same key use OR logic (match any value in the inner map) -// Each value can be represented exactly once per key +// Each value can be represented exactly once per key. type Selectors map[string]map[string]bool -// String implements the fmt.Stringer interface for Selectors +// String implements the fmt.Stringer interface for Selectors. func (s *Selectors) String() string { if *s == nil { return "{}" } + var parts []string + for k, v := range *s { values := make([]string, 0, len(v)) for val := range v { values = append(values, val) } + if len(values) == 1 { parts = append(parts, fmt.Sprintf("%s=%s", k, values[0])) } else { parts = append(parts, fmt.Sprintf("%s=%v", k, values)) } } + return fmt.Sprintf("{%s}", strings.Join(parts, ", ")) } -// Set implements the flag.Value interface for Selectors +// Set implements the flag.Value interface for Selectors. func (s *Selectors) Set(value string) error { + const keyValueParts = 2 // Parse key=value format with trimming - kv := strings.SplitN(value, "=", 2) - if len(kv) != 2 { - return fmt.Errorf("invalid selector format: %s", value) + kv := strings.SplitN(value, "=", keyValueParts) + if len(kv) != keyValueParts { + return fmt.Errorf("%w: %s", ErrInvalidSelectorFormat, value) } + if *s == nil { *s = make(Selectors) } + key := strings.TrimSpace(kv[0]) newValue := strings.TrimSpace(kv[1]) @@ -50,11 +62,12 @@ func (s *Selectors) Set(value string) error { if _, exists := (*s)[key]; !exists { (*s)[key] = make(map[string]bool) } + return nil - } else { - s.SetValue(key, newValue) } + s.SetValue(key, newValue) + return nil } @@ -65,9 +78,11 @@ func (s *Selectors) SetValue(key, value string) { if *s == nil { *s = make(Selectors) } + if (*s)[key] == nil { (*s)[key] = make(map[string]bool) } + (*s)[key][value] = true } @@ -77,16 +92,19 @@ func (s *Selectors) GetValue(key, value string) bool { if *s == nil { return false } + innerMap, exists := (*s)[key] if !exists { return false } + return innerMap[value] } // MatchesIncludes returns whether the frontmatter matches all include selectors, // along with a human-readable reason explaining the result. -// If a key doesn't exist in frontmatter, it's allowed. +// If a key doesn't exist in frontmatter, it's allowed when includeByDefault is true (the default). +// When includeByDefault is false, rules/skills that produce no explicit selector match are excluded. // Multiple values for the same key use OR logic (matches if frontmatter value is in the inner map). // This enables combining CLI selectors (-s flag) with task frontmatter selectors: // both are added to the same Selectors map, creating an OR condition for rules to match. @@ -94,15 +112,17 @@ func (s *Selectors) GetValue(key, value string) bool { // Returns: // - bool: true if all selectors match, false otherwise // - string: reason explaining why (matched selectors or mismatch details) -func (includes *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter) (bool, string) { - if *includes == nil || len(*includes) == 0 { +func (s *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter, includeByDefault bool) (bool, string) { + if len(*s) == 0 { return true, "" } - var matchedSelectors []string - var noMatchReasons []string + var ( + matchedSelectors []string + noMatchReasons []string + ) - for key, values := range *includes { + for key, values := range *s { fmValue, exists := frontmatter.Content[key] if !exists { // If key doesn't exist in frontmatter, allow it @@ -119,24 +139,30 @@ func (includes *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter) for val := range values { expectedValues = append(expectedValues, val) } + if len(expectedValues) == 1 { noMatchReasons = append(noMatchReasons, fmt.Sprintf("%s=%s (expected %s=%s)", key, fmStr, key, expectedValues[0])) } else { - noMatchReasons = append(noMatchReasons, fmt.Sprintf("%s=%s (expected %s in [%s])", key, fmStr, key, strings.Join(expectedValues, ", "))) + noMatchReasons = append(noMatchReasons, + fmt.Sprintf("%s=%s (expected %s in [%s])", key, fmStr, key, strings.Join(expectedValues, ", "))) } } } // If any selector didn't match, return false with the mismatch reasons if len(noMatchReasons) > 0 { - return false, fmt.Sprintf("selectors did not match: %s", strings.Join(noMatchReasons, ", ")) + return false, "selectors did not match: " + strings.Join(noMatchReasons, ", ") } // All selectors matched if len(matchedSelectors) > 0 { - return true, fmt.Sprintf("matched selectors: %s", strings.Join(matchedSelectors, ", ")) + return true, "matched selectors: " + strings.Join(matchedSelectors, ", ") + } + + // No explicit selector match + if !includeByDefault { + return false, "excluded by default (no matching selectors)" } - // No selectors specified return true, "no selectors specified (included by default)" } diff --git a/pkg/codingcontext/selectors/selectors_nil_test.go b/pkg/codingcontext/selectors/selectors_nil_test.go new file mode 100644 index 0000000..c186c9b --- /dev/null +++ b/pkg/codingcontext/selectors/selectors_nil_test.go @@ -0,0 +1,68 @@ +package selectors + +import "testing" + +// TestSelectors_NilString verifies that calling String() on a nil (zero-value) +// Selectors does not panic and returns the expected "{}" representation. +func TestSelectors_NilString(t *testing.T) { + t.Parallel() + + var s Selectors + + got := s.String() + + if got != "{}" { + t.Errorf("String() on nil Selectors = %q, want \"{}\"", got) + } +} + +// TestSelectors_GetValue_NilReceiver verifies that GetValue on a nil Selectors +// safely returns false instead of panicking. +func TestSelectors_GetValue_NilReceiver(t *testing.T) { + t.Parallel() + + var s Selectors + + if s.GetValue("env", "production") { + t.Error("GetValue() on nil Selectors should return false, got true") + } +} + +// TestSelectors_GetValue_MissingKey verifies that GetValue returns false when +// the key does not exist in a non-nil Selectors map. +func TestSelectors_GetValue_MissingKey(t *testing.T) { + t.Parallel() + + s := make(Selectors) + s.SetValue("env", "production") + + if s.GetValue("language", "go") { + t.Error("GetValue() for missing key should return false, got true") + } +} + +// TestSelectors_GetValue_MissingValue verifies that GetValue returns false when +// the key exists but the specific value is absent. +func TestSelectors_GetValue_MissingValue(t *testing.T) { + t.Parallel() + + s := make(Selectors) + s.SetValue("env", "production") + + if s.GetValue("env", "development") { + t.Error("GetValue() for present key but absent value should return false") + } +} + +// TestSelectors_SetValue_NilReceiver verifies that SetValue on a nil Selectors +// auto-initializes the map and stores the value correctly. +func TestSelectors_SetValue_NilReceiver(t *testing.T) { + t.Parallel() + + var s Selectors + s.SetValue("env", "production") + + if !s.GetValue("env", "production") { + t.Error("SetValue() on nil Selectors should auto-initialize and store the value") + } +} diff --git a/pkg/codingcontext/selectors/selectors_test.go b/pkg/codingcontext/selectors/selectors_test.go index 74fccdc..e06f9f7 100644 --- a/pkg/codingcontext/selectors/selectors_test.go +++ b/pkg/codingcontext/selectors/selectors_test.go @@ -8,6 +8,8 @@ import ( ) func TestSelectorMap_Set(t *testing.T) { + t.Parallel() + tests := []struct { name string value string @@ -43,19 +45,24 @@ func TestSelectorMap_Set(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s := make(Selectors) err := s.Set(tt.value) if (err != nil) != tt.wantErr { t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr) + return } if !tt.wantErr { if len(s) != 1 { t.Errorf("Set() resulted in %d selectors, want 1", len(s)) + return } + if !s.GetValue(tt.wantKey, tt.wantVal) { t.Errorf("Set() s[%q] does not contain value %q", tt.wantKey, tt.wantVal) } @@ -65,10 +72,13 @@ func TestSelectorMap_Set(t *testing.T) { } func TestSelectorMap_SetMultiple(t *testing.T) { + t.Parallel() + s := make(Selectors) if err := s.Set("env=production"); err != nil { t.Fatalf("Set() error = %v", err) } + if err := s.Set("language=go"); err != nil { t.Fatalf("Set() error = %v", err) } @@ -78,190 +88,135 @@ func TestSelectorMap_SetMultiple(t *testing.T) { } } -func TestSelectorMap_MatchesIncludes(t *testing.T) { - tests := []struct { - name string - selectors []string - setupSelectors func(s Selectors) // Optional function to set up array selectors directly - frontmatter markdown.BaseFrontMatter - wantMatch bool - }{ - { - name: "single selector - match", - selectors: []string{"env=production"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, - wantMatch: true, - }, - { - name: "single selector - no match", - selectors: []string{"env=production"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "development"}}, - wantMatch: false, - }, - { - name: "single selector - key missing (allowed)", - selectors: []string{"env=production"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"language": "go"}}, - wantMatch: true, - }, - { - name: "multiple selectors - all match", - selectors: []string{"env=production", "language=go"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "go"}}, - wantMatch: true, - }, - { - name: "multiple selectors - one doesn't match", - selectors: []string{"env=production", "language=go"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "python"}}, - wantMatch: false, - }, - { - name: "multiple selectors - one key missing (allowed)", - selectors: []string{"env=production", "language=go"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, - wantMatch: true, - }, - { - name: "empty selectors - always match", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, - wantMatch: true, - }, - { - name: "boolean value conversion - match", - selectors: []string{"is_active=true"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"is_active": true}}, - wantMatch: true, - }, - { - name: "array selector - match", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule2"}}, - wantMatch: true, - setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") - s.SetValue("rule_name", "rule3") - }, - }, - { - name: "array selector - no match", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule4"}}, - wantMatch: false, - setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") - s.SetValue("rule_name", "rule3") - }, - }, - { - name: "array selector - key missing (allowed)", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod"}}, - wantMatch: true, - setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") - }, - }, - { - name: "mixed selectors - array and string both match", - selectors: []string{"env=prod"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, - wantMatch: true, - setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") - }, - }, - { - name: "mixed selectors - string doesn't match", - selectors: []string{"env=dev"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "prod", "rule_name": "rule1"}}, - wantMatch: false, - setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") - }, - }, - { - name: "multiple array selectors - both match", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "go"}}, - wantMatch: true, +type matchesIncludesCase struct { + name string + selectors []string + setupSelectors func(s Selectors) + frontmatter markdown.BaseFrontMatter + excludeUnmatched bool // when true, unmatched rules are excluded; default false = include by default + wantMatch bool +} + +func fm(content map[string]any) markdown.BaseFrontMatter { + return markdown.BaseFrontMatter{Content: content} +} + +func setupRuleNames(names ...string) func(s Selectors) { + return func(s Selectors) { + for _, n := range names { + s.SetValue("rule_name", n) + } + } +} + +func matchesIncludesCases() []matchesIncludesCase { + return []matchesIncludesCase{ + {name: "single selector - match", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"env": "production"}), wantMatch: true}, + {name: "single selector - no match", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"env": "development"}), wantMatch: false}, + {name: "single selector - key missing (allowed)", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"language": "go"}), wantMatch: true}, + {name: "multiple selectors - all match", selectors: []string{"env=production", "language=go"}, + frontmatter: fm(map[string]any{"env": "production", "language": "go"}), wantMatch: true}, + {name: "multiple selectors - one doesn't match", selectors: []string{"env=production", "language=go"}, + frontmatter: fm(map[string]any{"env": "production", "language": "python"}), wantMatch: false}, + {name: "multiple selectors - one key missing (allowed)", selectors: []string{"env=production", "language=go"}, + frontmatter: fm(map[string]any{"env": "production"}), wantMatch: true}, + {name: "empty selectors - always match", selectors: []string{}, + frontmatter: fm(map[string]any{"env": "production"}), wantMatch: true}, + {name: "boolean value conversion - match", selectors: []string{"is_active=true"}, + frontmatter: fm(map[string]any{"is_active": true}), wantMatch: true}, + {name: "array selector - match", selectors: []string{}, + frontmatter: fm(map[string]any{"rule_name": "rule2"}), wantMatch: true, + setupSelectors: setupRuleNames("rule1", "rule2", "rule3")}, + {name: "array selector - no match", selectors: []string{}, + frontmatter: fm(map[string]any{"rule_name": "rule4"}), wantMatch: false, + setupSelectors: setupRuleNames("rule1", "rule2", "rule3")}, + {name: "array selector - key missing (allowed)", selectors: []string{}, + frontmatter: fm(map[string]any{"env": "prod"}), wantMatch: true, + setupSelectors: setupRuleNames("rule1", "rule2")}, + {name: "mixed selectors - array and string both match", selectors: []string{"env=prod"}, + frontmatter: fm(map[string]any{"env": "prod", "rule_name": "rule1"}), wantMatch: true, + setupSelectors: setupRuleNames("rule1", "rule2")}, + {name: "mixed selectors - string doesn't match", selectors: []string{"env=dev"}, + frontmatter: fm(map[string]any{"env": "prod", "rule_name": "rule1"}), wantMatch: false, + setupSelectors: setupRuleNames("rule1", "rule2")}, + {name: "multiple array selectors - both match", selectors: []string{}, + frontmatter: fm(map[string]any{"rule_name": "rule1", "language": "go"}), wantMatch: true, setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") + setupRuleNames("rule1", "rule2")(s) s.SetValue("language", "go") s.SetValue("language", "python") - }, - }, - { - name: "multiple array selectors - one doesn't match", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule1", "language": "java"}}, - wantMatch: false, + }}, + {name: "multiple array selectors - one doesn't match", selectors: []string{}, + frontmatter: fm(map[string]any{"rule_name": "rule1", "language": "java"}), wantMatch: false, setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") + setupRuleNames("rule1", "rule2")(s) s.SetValue("language", "go") s.SetValue("language", "python") - }, - }, - { - name: "OR logic - same key multiple values matches", - selectors: []string{"env=prod", "env=dev"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "dev"}}, - wantMatch: true, - }, - { - name: "OR logic - same key multiple values no match", - selectors: []string{"env=prod", "env=dev"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "staging"}}, - wantMatch: false, - }, - { - name: "empty value selector - key exists in frontmatter (no match)", - selectors: []string{"env="}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, - wantMatch: false, - }, - { - name: "empty value selector - key missing in frontmatter (match)", - selectors: []string{"env="}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"language": "go"}}, - wantMatch: true, - }, + }}, + {name: "OR logic - same key multiple values matches", selectors: []string{"env=prod", "env=dev"}, + frontmatter: fm(map[string]any{"env": "dev"}), wantMatch: true}, + {name: "OR logic - same key multiple values no match", selectors: []string{"env=prod", "env=dev"}, + frontmatter: fm(map[string]any{"env": "staging"}), wantMatch: false}, + {name: "empty value selector - key exists in frontmatter (no match)", selectors: []string{"env="}, + frontmatter: fm(map[string]any{"env": "production"}), wantMatch: false}, + {name: "empty value selector - key missing in frontmatter (match)", selectors: []string{"env="}, + frontmatter: fm(map[string]any{"language": "go"}), wantMatch: true}, + // excludeUnmatched=true cases + {name: "exclude by default - key missing", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"language": "go"}), excludeUnmatched: true, wantMatch: false}, + {name: "exclude by default - explicit match still included", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"env": "production"}), excludeUnmatched: true, wantMatch: true}, + {name: "exclude by default - no active selectors (early return)", selectors: []string{}, + frontmatter: fm(map[string]any{"language": "go"}), excludeUnmatched: true, wantMatch: true}, } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := make(Selectors) - for _, sel := range tt.selectors { - if err := s.Set(sel); err != nil { - t.Fatalf("Set() error = %v", err) - } - } +func runMatchesIncludes(t *testing.T, tt matchesIncludesCase) { + t.Helper() - // Set up array selectors if provided - if tt.setupSelectors != nil { - tt.setupSelectors(s) - } + s := make(Selectors) - gotMatch, gotReason := s.MatchesIncludes(tt.frontmatter) - if gotMatch != tt.wantMatch { - t.Errorf("MatchesIncludes() = %v, want %v (reason: %s)", gotMatch, tt.wantMatch, gotReason) - } + for _, sel := range tt.selectors { + if err := s.Set(sel); err != nil { + t.Fatalf("Set() error = %v", err) + } + } + + if tt.setupSelectors != nil { + tt.setupSelectors(s) + } + + gotMatch, gotReason := s.MatchesIncludes(tt.frontmatter, !tt.excludeUnmatched) + if gotMatch != tt.wantMatch { + t.Errorf("MatchesIncludes() = %v, want %v (reason: %s)", gotMatch, tt.wantMatch, gotReason) + } +} + +func TestSelectorMap_MatchesIncludes(t *testing.T) { + t.Parallel() + + for _, tt := range matchesIncludesCases() { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runMatchesIncludes(t, tt) }) } } func TestSelectorMap_String(t *testing.T) { + t.Parallel() + s := make(Selectors) - s.Set("env=production") - s.Set("language=go") + if err := s.Set("env=production"); err != nil { + t.Fatal(err) + } + + if err := s.Set("language=go"); err != nil { + t.Fatal(err) + } str := s.String() if str == "" { @@ -269,129 +224,106 @@ func TestSelectorMap_String(t *testing.T) { } } -func TestSelectorMap_MatchesIncludesReasons(t *testing.T) { - tests := []struct { - name string - selectors []string - setupSelectors func(s Selectors) - frontmatter markdown.BaseFrontMatter - wantMatch bool - wantReason string - checkReason func(t *testing.T, reason string) // For cases where reason order varies - }{ - { - name: "single selector - match", - selectors: []string{"env=production"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, - wantMatch: true, - wantReason: "matched selectors: env=production", - }, - { - name: "single selector - no match", - selectors: []string{"env=production"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "development"}}, - wantMatch: false, - wantReason: "selectors did not match: env=development (expected env=production)", - }, - { - name: "single selector - key missing (allowed)", - selectors: []string{"env=production"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"language": "go"}}, - wantMatch: true, - wantReason: "no selectors specified (included by default)", - }, - { - name: "multiple selectors - all match", - selectors: []string{"env=production", "language=go"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "go"}}, - wantMatch: true, - checkReason: func(t *testing.T, reason string) { - if !strings.Contains(reason, "matched selectors:") { - t.Errorf("Expected reason to contain 'matched selectors:', got %q", reason) - } - if !strings.Contains(reason, "env=production") || !strings.Contains(reason, "language=go") { - t.Errorf("Expected reason to contain both selectors, got %q", reason) - } - }, - }, - { - name: "multiple selectors - one doesn't match", - selectors: []string{"env=production", "language=go"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production", "language": "python"}}, - wantMatch: false, - wantReason: "selectors did not match: language=python (expected language=go)", - }, - { - name: "empty selectors", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"env": "production"}}, - wantMatch: true, - wantReason: "", - }, - { - name: "array selector - match", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule2"}}, - wantMatch: true, - wantReason: "matched selectors: rule_name=rule2", - setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") - s.SetValue("rule_name", "rule3") - }, - }, - { - name: "array selector - no match", - selectors: []string{}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"rule_name": "rule4"}}, - wantMatch: false, - setupSelectors: func(s Selectors) { - s.SetValue("rule_name", "rule1") - s.SetValue("rule_name", "rule2") - s.SetValue("rule_name", "rule3") - }, - checkReason: func(t *testing.T, reason string) { - if !strings.Contains(reason, "selectors did not match:") { - t.Errorf("Expected reason to start with 'selectors did not match:', got %q", reason) - } - if !strings.Contains(reason, "rule_name=rule4") { - t.Errorf("Expected reason to contain 'rule_name=rule4', got %q", reason) - } - if !strings.Contains(reason, "rule1") || !strings.Contains(reason, "rule2") || !strings.Contains(reason, "rule3") { - t.Errorf("Expected reason to contain all expected values, got %q", reason) - } - }, - }, - { - name: "boolean value conversion", - selectors: []string{"is_active=true"}, - frontmatter: markdown.BaseFrontMatter{Content: map[string]any{"is_active": true}}, - wantMatch: true, - wantReason: "matched selectors: is_active=true", - }, +func checkMultipleSelectorsReason(t *testing.T, reason string) { + t.Helper() + + if !strings.Contains(reason, "matched selectors:") { + t.Errorf("Expected reason to contain 'matched selectors:', got %q", reason) } - for _, tt := range tests { + if !strings.Contains(reason, "env=production") || !strings.Contains(reason, "language=go") { + t.Errorf("Expected reason to contain both selectors, got %q", reason) + } +} + +func checkArraySelectorNoMatchReason(t *testing.T, reason string) { + t.Helper() + + if !strings.Contains(reason, "selectors did not match:") { + t.Errorf("Expected reason to start with 'selectors did not match:', got %q", reason) + } + + if !strings.Contains(reason, "rule_name=rule4") { + t.Errorf("Expected reason to contain 'rule_name=rule4', got %q", reason) + } + + if !strings.Contains(reason, "rule1") || !strings.Contains(reason, "rule2") || !strings.Contains(reason, "rule3") { + t.Errorf("Expected reason to contain all expected values, got %q", reason) + } +} + +type matchesIncludesReasonCase struct { + name string + selectors []string + setupSelectors func(s Selectors) + frontmatter markdown.BaseFrontMatter + excludeUnmatched bool // when true, unmatched rules are excluded; default false = include by default + wantMatch bool + wantReason string + checkReason func(t *testing.T, reason string) +} + +func matchesIncludesReasonCases() []matchesIncludesReasonCase { + return []matchesIncludesReasonCase{ + {name: "single selector - match", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"env": "production"}), wantMatch: true, + wantReason: "matched selectors: env=production"}, + {name: "single selector - no match", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"env": "development"}), wantMatch: false, + wantReason: "selectors did not match: env=development (expected env=production)"}, + {name: "single selector - key missing (allowed)", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"language": "go"}), wantMatch: true, + wantReason: "no selectors specified (included by default)"}, + {name: "multiple selectors - all match", selectors: []string{"env=production", "language=go"}, + frontmatter: fm(map[string]any{"env": "production", "language": "go"}), wantMatch: true, + checkReason: checkMultipleSelectorsReason}, + {name: "multiple selectors - one doesn't match", selectors: []string{"env=production", "language=go"}, + frontmatter: fm(map[string]any{"env": "production", "language": "python"}), wantMatch: false, + wantReason: "selectors did not match: language=python (expected language=go)"}, + {name: "empty selectors", selectors: []string{}, + frontmatter: fm(map[string]any{"env": "production"}), wantMatch: true, wantReason: ""}, + {name: "array selector - match", selectors: []string{}, + frontmatter: fm(map[string]any{"rule_name": "rule2"}), wantMatch: true, + wantReason: "matched selectors: rule_name=rule2", + setupSelectors: setupRuleNames("rule1", "rule2", "rule3")}, + {name: "array selector - no match", selectors: []string{}, + frontmatter: fm(map[string]any{"rule_name": "rule4"}), wantMatch: false, + setupSelectors: setupRuleNames("rule1", "rule2", "rule3"), + checkReason: checkArraySelectorNoMatchReason}, + {name: "boolean value conversion", selectors: []string{"is_active=true"}, + frontmatter: fm(map[string]any{"is_active": true}), wantMatch: true, + wantReason: "matched selectors: is_active=true"}, + {name: "exclude by default - key missing", selectors: []string{"env=production"}, + frontmatter: fm(map[string]any{"language": "go"}), excludeUnmatched: true, + wantMatch: false, wantReason: "excluded by default (no matching selectors)"}, + } +} + +func TestSelectorMap_MatchesIncludesReasons(t *testing.T) { + t.Parallel() + + for _, tt := range matchesIncludesReasonCases() { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s := make(Selectors) + for _, sel := range tt.selectors { if err := s.Set(sel); err != nil { t.Fatalf("Set() error = %v", err) } } - // Set up array selectors if provided if tt.setupSelectors != nil { tt.setupSelectors(s) } - gotMatch, gotReason := s.MatchesIncludes(tt.frontmatter) + gotMatch, gotReason := s.MatchesIncludes(tt.frontmatter, !tt.excludeUnmatched) if gotMatch != tt.wantMatch { t.Errorf("MatchesIncludes() match = %v, want %v (reason: %s)", gotMatch, tt.wantMatch, gotReason) } - // Check reason if tt.checkReason != nil { tt.checkReason(t, gotReason) } else if gotReason != tt.wantReason { diff --git a/pkg/codingcontext/skills/skills.go b/pkg/codingcontext/skills/skills.go index fe1bf60..52fa41a 100644 --- a/pkg/codingcontext/skills/skills.go +++ b/pkg/codingcontext/skills/skills.go @@ -1,10 +1,12 @@ +// Package skills provides types and serialization for agent skills. package skills import ( "encoding/xml" + "fmt" ) -// Skill represents a discovered skill with its metadata +// Skill represents a discovered skill with its metadata. type Skill struct { XMLName xml.Name `xml:"skill"` Name string `xml:"name"` @@ -12,18 +14,18 @@ type Skill struct { Location string `xml:"location"` // Absolute path to the SKILL.md file } -// AvailableSkills represents a collection of discovered skills +// AvailableSkills represents a collection of discovered skills. type AvailableSkills struct { XMLName xml.Name `xml:"available_skills"` Skills []Skill `xml:"skill"` } -// AsXML returns the XML representation of available skills +// AsXML returns the XML representation of available skills. func (a AvailableSkills) AsXML() (string, error) { // Use xml.MarshalIndent to properly encode the XML with indentation xmlBytes, err := xml.MarshalIndent(a, "", " ") if err != nil { - return "", err + return "", fmt.Errorf("failed to marshal skills to XML: %w", err) } return string(xmlBytes), nil diff --git a/pkg/codingcontext/skills/skills_test.go b/pkg/codingcontext/skills/skills_test.go index 36e0176..be43563 100644 --- a/pkg/codingcontext/skills/skills_test.go +++ b/pkg/codingcontext/skills/skills_test.go @@ -4,100 +4,72 @@ import ( "testing" ) -func TestAvailableSkills_AsXML(t *testing.T) { - tests := []struct { +func availableSkillsCases() []struct { + name string + skills AvailableSkills + want string + wantErr bool +} { + return []struct { name string skills AvailableSkills want string wantErr bool }{ { - name: "empty skills", - skills: AvailableSkills{ - Skills: []Skill{}, - }, + name: "empty skills", + skills: AvailableSkills{Skills: []Skill{}}, want: "", wantErr: false, }, { name: "single skill", - skills: AvailableSkills{ - Skills: []Skill{ - { - Name: "test-skill", - Description: "A test skill", - Location: "/path/to/skill/SKILL.md", - }, - }, - }, - want: ` - - test-skill - A test skill - /path/to/skill/SKILL.md - -`, - wantErr: false, + skills: AvailableSkills{Skills: []Skill{ + {Name: "test-skill", Description: "A test skill", Location: "/path/to/skill/SKILL.md"}, + }}, + want: "\n \n test-skill\n" + + " A test skill\n" + + " /path/to/skill/SKILL.md\n \n", }, { name: "multiple skills", - skills: AvailableSkills{ - Skills: []Skill{ - { - Name: "skill-one", - Description: "First skill", - Location: "/path/to/skill-one/SKILL.md", - }, - { - Name: "skill-two", - Description: "Second skill", - Location: "/path/to/skill-two/SKILL.md", - }, - }, - }, - want: ` - - skill-one - First skill - /path/to/skill-one/SKILL.md - - - skill-two - Second skill - /path/to/skill-two/SKILL.md - -`, - wantErr: false, + skills: AvailableSkills{Skills: []Skill{ + {Name: "skill-one", Description: "First skill", Location: "/path/to/skill-one/SKILL.md"}, + {Name: "skill-two", Description: "Second skill", Location: "/path/to/skill-two/SKILL.md"}, + }}, + want: "\n \n skill-one\n" + + " First skill\n" + + " /path/to/skill-one/SKILL.md\n \n" + + " \n skill-two\n Second skill\n" + + " /path/to/skill-two/SKILL.md\n \n", }, { name: "skill with special XML characters", - skills: AvailableSkills{ - Skills: []Skill{ - { - Name: "special-chars", - Description: "Test & \"quotes\" 'apostrophes'", - Location: "/path/to/skill/SKILL.md", - }, - }, - }, - want: ` - - special-chars - Test <tag> & "quotes" 'apostrophes' - /path/to/skill/SKILL.md - -`, - wantErr: false, + skills: AvailableSkills{Skills: []Skill{ + {Name: "special-chars", Description: "Test & \"quotes\" 'apostrophes'", + Location: "/path/to/skill/SKILL.md"}, + }}, + want: "\n \n special-chars\n" + + " Test <tag> & "quotes" 'apostrophes'\n" + + " /path/to/skill/SKILL.md\n \n", }, } +} - for _, tt := range tests { +func TestAvailableSkills_AsXML(t *testing.T) { + t.Parallel() + + for _, tt := range availableSkillsCases() { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := tt.skills.AsXML() if (err != nil) != tt.wantErr { t.Errorf("AvailableSkills.AsXML() error = %v, wantErr %v", err, tt.wantErr) + return } + if got != tt.want { t.Errorf("AvailableSkills.AsXML() mismatch\nGot:\n%s\n\nWant:\n%s", got, tt.want) } @@ -106,6 +78,7 @@ func TestAvailableSkills_AsXML(t *testing.T) { } func TestSkillsNotNil(t *testing.T) { + t.Parallel() // Ensure we can create skills without panicking skill := Skill{ Name: "test", diff --git a/pkg/codingcontext/taskparser/expander.go b/pkg/codingcontext/taskparser/expander.go index 4f3baee..507cd4d 100644 --- a/pkg/codingcontext/taskparser/expander.go +++ b/pkg/codingcontext/taskparser/expander.go @@ -1,112 +1,79 @@ package taskparser import ( - "fmt" + "errors" "os" "os/exec" + "path/filepath" "strings" ) -// expand performs all types of expansion on the content in a single pass: +// ErrPathContainsNullByte is returned when a path expansion contains a null byte. +var ErrPathContainsNullByte = errors.New("path contains null byte") + +// ExpandOptions controls optional behavior of content expansion. +type ExpandOptions struct { + // SkipCommands disables !`cmd` shell execution; the literal !`cmd` text is preserved in output. + SkipCommands bool + // PathRefs, if non-nil, is appended with each successfully resolved @path reference. + PathRefs *[]string +} + +// Expand performs all types of expansion on the content in a single pass: // 1. Parameter expansion: ${param_name} // 2. Command expansion: !`command` // 3. Path expansion: @path // SECURITY: Processes rune-by-rune to prevent injection attacks where expanded // content contains further expansion sequences (e.g., command output with ${param}). func (p Params) Expand(content string) (string, error) { + return p.ExpandWith(content, ExpandOptions{}) +} + +// ExpandWith is like Expand but accepts options for lint/dry-run mode. +func (p Params) ExpandWith(content string, opts ExpandOptions) (string, error) { var result strings.Builder + result.Grow(len(content)) + runes := []rune(content) - i := 0 - - for i < len(runes) { - // Check for parameter expansion: ${...} - if i+2 < len(runes) && runes[i] == '$' && runes[i+1] == '{' { - // Find the closing } - end := i + 2 - for end < len(runes) && runes[end] != '}' { - end++ - } - if end < len(runes) { - // Extract parameter name - paramName := string(runes[i+2 : end]) - if val, ok := p.Lookup(paramName); ok { - result.WriteString(val) - } else { - result.WriteString(string(runes[i : end+1])) - } - i = end + 1 - continue - } + + for i := 0; i < len(runes); { + if val, newI, ok := tryExpandParam(runes, i, p); ok { + result.WriteString(val) + + i = newI + + continue } - // Check for command expansion: !`...` - if i+2 < len(runes) && runes[i] == '!' && runes[i+1] == '`' { - // Find the closing ` - end := i + 2 - for end < len(runes) && runes[end] != '`' { - end++ + if opts.SkipCommands { + if val, newI, ok := trySkipCommand(runes, i); ok { + result.Write(val) + + i = newI + + continue } - if end < len(runes) { - // Extract command - command := string(runes[i+2 : end]) - cmd := exec.Command("sh", "-c", command) - output, _ := cmd.CombinedOutput() - // Write command output (even if command failed, output may contain error info) - result.WriteString(string(output)) - i = end + 1 + } else { + if val, newI, ok := tryExpandCommand(runes, i); ok { + result.Write(val) + + i = newI + continue } } - // Check for path expansion: @path if runes[i] == '@' && (i == 0 || isWhitespaceRune(runes[i-1])) { - // Found potential path expansion at start or after whitespace - pathStart := i + 1 - pathEnd := pathStart - - // Scan for the end of the path (whitespace or end of string) - // Handle escaped spaces - for pathEnd < len(runes) { - if runes[pathEnd] == '\\' && pathEnd+1 < len(runes) && runes[pathEnd+1] == ' ' { - // Escaped space, skip both characters - pathEnd += 2 - } else if isWhitespaceRune(runes[pathEnd]) { - // Unescaped whitespace marks end of path - break - } else { - pathEnd++ - } - } + if pathContent, newI, ok := tryExpandPath(runes, i, opts.PathRefs); ok { + result.Write(pathContent) + + i = newI - if pathEnd > pathStart { - // Extract and unescape the path - path := unescapePath(string(runes[pathStart:pathEnd])) - - // Validate the path - if err := ValidatePath(path); err != nil { - // Return the original @path if validation fails - result.WriteString(string(runes[i:pathEnd])) - i = pathEnd - continue - } - - // Read the file - fileContent, err := os.ReadFile(path) - if err != nil { - // Return the original @path if file doesn't exist - result.WriteString(string(runes[i:pathEnd])) - } else { - // Expand to file content - result.Write(fileContent) - } - - i = pathEnd continue } } - // No expansion found, write the current rune result.WriteRune(runes[i]) i++ } @@ -114,12 +81,174 @@ func (p Params) Expand(content string) (string, error) { return result.String(), nil } -// isWhitespaceRune checks if a rune is whitespace (space, tab, newline, carriage return) +// tryExpandParam attempts ${param} expansion at position i. +// Returns the expanded value, the new index past the expansion, and true if matched. +func tryExpandParam(runes []rune, i int, p Params) (string, int, bool) { + const prefixLen = 2 + if i+prefixLen >= len(runes) || runes[i] != '$' || runes[i+1] != '{' { + return "", 0, false + } + + end := i + prefixLen + for end < len(runes) && runes[end] != '}' { + end++ + } + + if end >= len(runes) { + return "", 0, false + } + + paramName := string(runes[i+2 : end]) + if val, ok := p.Lookup(paramName); ok { + return val, end + 1, true + } + + return string(runes[i : end+1]), end + 1, true +} + +// tryExpandCommand attempts !`command` expansion at position i. +// Returns the command output, the new index past the expansion, and true if matched. +func tryExpandCommand(runes []rune, i int) ([]byte, int, bool) { + const prefixLen = 2 + if i+prefixLen >= len(runes) || runes[i] != '!' || runes[i+1] != '`' { + return nil, 0, false + } + + end := i + prefixLen + for end < len(runes) && runes[end] != '`' { + end++ + } + + if end >= len(runes) { + return nil, 0, false + } + + command := string(runes[i+prefixLen : end]) + // #nosec G204 -- slash command expansion is an intentional feature; commands come from task content + //nolint:noctx // Expand has no context; command output is best-effort + cmd := exec.Command("sh", "-c", command) + output, _ := cmd.CombinedOutput() + + return output, end + 1, true +} + +// tryExpandPathAt attempts to expand @path at the given index. +// Returns the content to write (either file content or original @path), the new index, +// and true if expansion was attempted. +func tryExpandPathAt(runes []rune, i int) ([]byte, int, bool) { + pathStart := i + 1 + pathEnd := pathStart + +pathScan: + for pathEnd < len(runes) { + switch { + case pathEnd+1 < len(runes) && runes[pathEnd] == '\\' && runes[pathEnd+1] == ' ': + pathEnd += 2 + case isWhitespaceRune(runes[pathEnd]): + break pathScan + default: + pathEnd++ + } + } + + if pathEnd <= pathStart { + return nil, 0, false + } + + path := unescapePath(string(runes[pathStart:pathEnd])) + if err := ValidatePath(path); err != nil { + return []byte(string(runes[i:pathEnd])), pathEnd, true + } + + cleanPath := filepath.Clean(path) + + fileContent, err := os.ReadFile(cleanPath) + if err != nil { + return []byte(string(runes[i:pathEnd])), pathEnd, true + } + + return fileContent, pathEnd, true +} + +// trySkipCommand detects !`command` and returns the original literal bytes unchanged (no exec). +// Mirrors the detection logic of tryExpandCommand without executing anything. +func trySkipCommand(runes []rune, i int) ([]byte, int, bool) { + const prefixLen = 2 + if i+prefixLen >= len(runes) || runes[i] != '!' || runes[i+1] != '`' { + return nil, 0, false + } + + end := i + prefixLen + for end < len(runes) && runes[end] != '`' { + end++ + } + + if end >= len(runes) { + return nil, 0, false + } + + return []byte(string(runes[i : end+1])), end + 1, true +} + +// tryExpandPath expands @path at position i, optionally tracking resolved paths. +// If pathRefs is non-nil, the successfully resolved path is appended to it. +func tryExpandPath(runes []rune, i int, pathRefs *[]string) ([]byte, int, bool) { + if pathRefs == nil { + return tryExpandPathAt(runes, i) + } + + fileContent, newI, ok, resolved := tryExpandPathAtTracked(runes, i) + if ok && resolved != "" { + *pathRefs = append(*pathRefs, resolved) + } + + return fileContent, newI, ok +} + +// tryExpandPathAtTracked is identical to tryExpandPathAt but also returns the resolved +// cleanPath so callers can record it as a loaded file. resolvedPath is empty if the +// file could not be read (the original @path text is returned as content in that case). +func tryExpandPathAtTracked(runes []rune, i int) ([]byte, int, bool, string) { + pathStart := i + 1 + pathEnd := pathStart + +pathScan: + for pathEnd < len(runes) { + switch { + case pathEnd+1 < len(runes) && runes[pathEnd] == '\\' && runes[pathEnd+1] == ' ': + pathEnd += 2 + case isWhitespaceRune(runes[pathEnd]): + break pathScan + default: + pathEnd++ + } + } + + if pathEnd <= pathStart { + return nil, 0, false, "" + } + + path := unescapePath(string(runes[pathStart:pathEnd])) + if err := ValidatePath(path); err != nil { + return []byte(string(runes[i:pathEnd])), pathEnd, true, "" + } + + cleanPath := filepath.Clean(path) + + fileContent, err := os.ReadFile(cleanPath) + if err != nil { + return []byte(string(runes[i:pathEnd])), pathEnd, true, "" + } + + return fileContent, pathEnd, true, cleanPath +} + +// isWhitespaceRune checks if a rune is whitespace (space, tab, newline, carriage return). func isWhitespaceRune(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' } -// unescapePath removes escape sequences from a path (specifically \) +// unescapePath removes escape sequences from a path (specifically \). func unescapePath(path string) string { return strings.ReplaceAll(path, "\\ ", " ") } @@ -131,7 +260,7 @@ func unescapePath(path string) string { func ValidatePath(path string) error { // Check for null bytes which are never valid in file paths if strings.Contains(path, "\x00") { - return fmt.Errorf("path contains null byte") + return ErrPathContainsNullByte } // We intentionally allow paths with .. components as they may be diff --git a/pkg/codingcontext/taskparser/expander_test.go b/pkg/codingcontext/taskparser/expander_test.go index e4dd993..16e9c49 100644 --- a/pkg/codingcontext/taskparser/expander_test.go +++ b/pkg/codingcontext/taskparser/expander_test.go @@ -11,6 +11,8 @@ import ( ) func TestExpandParameters(t *testing.T) { + t.Parallel() + tests := []struct { name string params taskparser.Params @@ -81,10 +83,13 @@ func TestExpandParameters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := tt.params.Expand(tt.content) if err != nil { t.Errorf("expand() = %v, want nil", err) } + if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -93,6 +98,8 @@ func TestExpandParameters(t *testing.T) { } func TestExpandCommands(t *testing.T) { + t.Parallel() + tests := []struct { name string content string @@ -163,8 +170,11 @@ func TestExpandCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := (taskparser.Params{}).Expand(tt.content) require.NoError(t, err) + if tt.contains != "" { if !strings.Contains(result, tt.contains) { t.Errorf("expand() = %q, should contain %q", result, tt.contains) @@ -179,22 +189,23 @@ func TestExpandCommands(t *testing.T) { } func TestExpandPaths(t *testing.T) { + t.Parallel() // Create a temporary directory for test files tmpDir := t.TempDir() // Create test files testFile1 := filepath.Join(tmpDir, "test1.txt") - if err := os.WriteFile(testFile1, []byte("content1"), 0o644); err != nil { + if err := os.WriteFile(testFile1, []byte("content1"), 0o600); err != nil { t.Fatalf("failed to create test file: %v", err) } testFile2 := filepath.Join(tmpDir, "test2.txt") - if err := os.WriteFile(testFile2, []byte("content2"), 0o644); err != nil { + if err := os.WriteFile(testFile2, []byte("content2"), 0o600); err != nil { t.Fatalf("failed to create test file: %v", err) } testFileWithSpace := filepath.Join(tmpDir, "test file.txt") - if err := os.WriteFile(testFileWithSpace, []byte("spaced content"), 0o644); err != nil { + if err := os.WriteFile(testFileWithSpace, []byte("spaced content"), 0o600); err != nil { t.Fatalf("failed to create test file with space: %v", err) } @@ -262,8 +273,11 @@ func TestExpandPaths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := (taskparser.Params{}).Expand(tt.content) require.NoError(t, err) + if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -272,11 +286,12 @@ func TestExpandPaths(t *testing.T) { } func TestExpandCombined(t *testing.T) { + t.Parallel() // Create a temporary directory for test files tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "data.txt") - if err := os.WriteFile(testFile, []byte("file-${param}"), 0o644); err != nil { + if err := os.WriteFile(testFile, []byte("file-${param}"), 0o600); err != nil { t.Fatalf("failed to create test file: %v", err) } @@ -320,8 +335,11 @@ func TestExpandCombined(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := tt.params.Expand(tt.content) require.NoError(t, err) + if result != tt.expected { t.Errorf("expand() = %q, want %q", result, tt.expected) } @@ -330,12 +348,14 @@ func TestExpandCombined(t *testing.T) { } func TestExpandBasic(t *testing.T) { + t.Parallel() // Test basic expansion functionality content := "Hello ${name}!" params := taskparser.Params{"name": []string{"World"}} result, err := params.Expand(content) require.NoError(t, err) + expected := "Hello World!" if result != expected { t.Errorf("expand() = %q, want %q", result, expected) @@ -343,6 +363,8 @@ func TestExpandBasic(t *testing.T) { } func TestValidatePath(t *testing.T) { + t.Parallel() + tests := []struct { name string path string @@ -382,6 +404,8 @@ func TestValidatePath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := taskparser.ValidatePath(tt.path) if (err != nil) != tt.wantErr { t.Errorf("validatePath() error = %v, wantErr %v", err, tt.wantErr) @@ -390,7 +414,122 @@ func TestValidatePath(t *testing.T) { } } +func TestExpandWithSkipCommands(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params taskparser.Params + content string + expected string + }{ + { + name: "command preserved as literal", + params: taskparser.Params{}, + content: "Output: !`echo hello`", + expected: "Output: !`echo hello`", + }, + { + name: "parameter expansion still works", + params: taskparser.Params{"name": []string{"World"}}, + content: "Hello ${name}! !`echo cmd`", + expected: "Hello World! !`echo cmd`", + }, + { + name: "multiple commands all preserved", + params: taskparser.Params{}, + content: "!`echo foo` and !`echo bar`", + expected: "!`echo foo` and !`echo bar`", + }, + { + name: "no command - content unchanged", + params: taskparser.Params{}, + content: "plain text", + expected: "plain text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := tt.params.ExpandWith(tt.content, taskparser.ExpandOptions{SkipCommands: true}) + require.NoError(t, err) + + if result != tt.expected { + t.Errorf("ExpandWith(SkipCommands=true) = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestExpandWithPathRefs(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("file content"), 0o600); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + testFile2 := filepath.Join(tmpDir, "test2.txt") + if err := os.WriteFile(testFile2, []byte("file2 content"), 0o600); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + t.Run("resolved path appended to PathRefs", func(t *testing.T) { + t.Parallel() + + var pathRefs []string + + result, err := (taskparser.Params{}).ExpandWith("@"+testFile, taskparser.ExpandOptions{PathRefs: &pathRefs}) + require.NoError(t, err) + require.Equal(t, "file content", result) + require.Len(t, pathRefs, 1) + + if !strings.HasSuffix(pathRefs[0], "test.txt") { + t.Errorf("PathRefs[0] = %q, expected suffix test.txt", pathRefs[0]) + } + }) + + t.Run("missing file not added to PathRefs", func(t *testing.T) { + t.Parallel() + + var pathRefs []string + + opts := taskparser.ExpandOptions{PathRefs: &pathRefs} + result, err := (taskparser.Params{}).ExpandWith("@/nonexistent/file.txt", opts) + require.NoError(t, err) + require.Equal(t, "@/nonexistent/file.txt", result) + require.Empty(t, pathRefs) + }) + + t.Run("nil PathRefs still expands correctly", func(t *testing.T) { + t.Parallel() + + result, err := (taskparser.Params{}).ExpandWith("@"+testFile, taskparser.ExpandOptions{}) + require.NoError(t, err) + require.Equal(t, "file content", result) + }) + + t.Run("multiple resolved paths all tracked", func(t *testing.T) { + t.Parallel() + + var pathRefs []string + + content := "@" + testFile + " and @" + testFile2 + + result, err := (taskparser.Params{}).ExpandWith(content, taskparser.ExpandOptions{PathRefs: &pathRefs}) + require.NoError(t, err) + require.Equal(t, "file content and file2 content", result) + require.Len(t, pathRefs, 2) + }) +} + func TestExpandSecurityNoReExpansion(t *testing.T) { + t.Parallel() + tests := []struct { name string params taskparser.Params @@ -430,8 +569,11 @@ func TestExpandSecurityNoReExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := tt.params.Expand(tt.content) require.NoError(t, err) + if result != tt.expected { t.Errorf("Security test failed: %s\nexpand() = %q, want %q", tt.desc, result, tt.expected) } diff --git a/pkg/codingcontext/taskparser/grammar.go b/pkg/codingcontext/taskparser/grammar.go index a1c43e9..fec37a3 100644 --- a/pkg/codingcontext/taskparser/grammar.go +++ b/pkg/codingcontext/taskparser/grammar.go @@ -5,15 +5,15 @@ import ( "github.com/alecthomas/participle/v2/lexer" ) -// Task represents a parsed task, which is a sequence of blocks +// Task represents a parsed task, which is a sequence of blocks. type Task []Block -// Input is the top-level wrapper for parsing +// Input is the top-level wrapper for parsing. type Input struct { Blocks []Block `parser:"@@*"` } -// Block represents either a slash command or text content +// Block represents either a slash command or text content. type Block struct { SlashCommand *SlashCommand `parser:"@@"` Text *Text `parser:"| @@"` @@ -21,14 +21,14 @@ type Block struct { // SlashCommand represents a command starting with "/" that ends with a newline or EOF // The newline is optional to handle EOF, but when present, prevents matching inline slashes -// Leading whitespace is optional to allow indented commands +// Leading whitespace is optional to allow indented commands. type SlashCommand struct { LeadingWhitespace string `parser:"Whitespace?"` Name string `parser:"Slash @Term"` Arguments []Argument `parser:"(Whitespace @@)* Whitespace? Newline?"` } -// Argument represents either a named (key=value) or positional argument +// Argument represents either a named (key=value) or positional argument. type Argument struct { Key string `parser:"(@Term Assign)?"` Value string `parser:"(@String | @Term)"` @@ -36,46 +36,47 @@ type Argument struct { // Text represents a block of text // It can span multiple lines, consuming line content and newlines -// But it will stop before a newline that's followed by a slash (potential command) +// But it will stop before a newline that's followed by a slash (potential command). type Text struct { LeadingNewlines []string `parser:"@Newline*"` // Leading newlines before any content (empty lines at the start) Lines []TextLine `parser:"@@+"` // At least one line with actual content } // TextLine is a single line of text content (not starting with a slash) -// It matches tokens until the end of the line +// It matches tokens until the end of the line. type TextLine struct { NonSlashStart []string `parser:"(@Term | @String | @Assign | @Whitespace)"` // First token can't be Slash RestOfLine []string `parser:"(@Term | @String | @Slash | @Assign | @Whitespace)*"` // Rest can include Slash NewlineOpt string `parser:"@Newline?"` } -// Define the lexer using participle's lexer.MustSimple -var taskLexer = lexer.MustSimple([]lexer.SimpleRule{ - {Name: "Slash", Pattern: `/`}, // Any "/" - {Name: "Assign", Pattern: `=`}, // "=" - {Name: "String", Pattern: `"(?:\\.|[^"])*"`}, // Quoted strings with escapes - {Name: "Whitespace", Pattern: `[ \t]+`}, // Spaces and tabs (horizontal only) - {Name: "Newline", Pattern: `[\n\r]+`}, // Newlines - {Name: "Term", Pattern: `[^ \t\n\r/"=]+`}, // Any char except space, newline, /, ", = -}) - func parser() *participle.Parser[Input] { + taskLexer := lexer.MustSimple([]lexer.SimpleRule{ + {Name: "Slash", Pattern: `/`}, // Any "/" + {Name: "Assign", Pattern: `=`}, // "=" + {Name: "String", Pattern: `"(?:\\.|[^"])*"`}, // Quoted strings with escapes + {Name: "Whitespace", Pattern: `[ \t]+`}, // Spaces and tabs (horizontal only) + {Name: "Newline", Pattern: `[\n\r]+`}, // Newlines + {Name: "Term", Pattern: `[^ \t\n\r/"=]+`}, // Any char except space, newline, /, ", = + }) + + const taskLookahead = 4 // Distinguish Text from SlashCommand patterns + return participle.MustBuild[Input]( participle.Lexer(taskLexer), - participle.UseLookahead(4), // Use lookahead to help distinguish Text from SlashCommand patterns + participle.UseLookahead(taskLookahead), ) } // ========== PARAMS GRAMMAR ========== // ParamsInput is the top-level structure for parsing parameters -// It now directly parses into named parameters and positional arguments +// It now directly parses into named parameters and positional arguments. type ParamsInput struct { Items []ParamsItem `parser:"@@*"` } -// ParamsItem represents either a named parameter or a positional argument +// ParamsItem represents either a named parameter or a positional argument. type ParamsItem struct { Pos lexer.Position Separator *Separator `parser:"@@"` // Whitespace or comma @@ -83,13 +84,13 @@ type ParamsItem struct { Positional *Value `parser:"| @@"` // standalone value } -// Separator represents whitespace or comma separators +// Separator represents whitespace or comma separators. type Separator struct { Pos lexer.Position Val string `parser:"(@Whitespace | @Comma)"` } -// NamedParam represents a key=value pair +// NamedParam represents a key=value pair. type NamedParam struct { Pos lexer.Position Key string `parser:"@Token"` @@ -100,30 +101,30 @@ type NamedParam struct { } // Value represents a parsed value (quoted or unquoted) -// The Raw field captures the entire token including quotes and escapes +// The Raw field captures the entire token including quotes and escapes. type Value struct { Pos lexer.Position Raw string `parser:"(@QuotedDouble | @QuotedSingle | @Token)"` } -// paramsLexer defines the lexer for parsing parameters -// Using a simpler approach: capture entire quoted strings and unquoted tokens -var paramsLexer = lexer.MustSimple([]lexer.SimpleRule{ - {Name: "Whitespace", Pattern: `[\s\p{Z}]+`}, // Match ASCII and Unicode whitespace - {Name: "Comma", Pattern: `,`}, - {Name: "Assign", Pattern: `=`}, - // Quoted strings - capture entire string including quotes - // Handles escaped quotes inside - {Name: "QuotedDouble", Pattern: `"(?:\\.|[^"\\])*"`}, - {Name: "QuotedSingle", Pattern: `'(?:\\.|[^'\\])*'`}, - // Unquoted token - matches any sequence of non-delimiter characters - // Can contain escape sequences - {Name: "Token", Pattern: `(?:\\.|[^\s\p{Z},="'\\])+`}, -}) - func paramsParser() *participle.Parser[ParamsInput] { + paramsLexer := lexer.MustSimple([]lexer.SimpleRule{ + {Name: "Whitespace", Pattern: `[\s\p{Z}]+`}, // Match ASCII and Unicode whitespace + {Name: "Comma", Pattern: `,`}, + {Name: "Assign", Pattern: `=`}, + // Quoted strings - capture entire string including quotes + // Handles escaped quotes inside + {Name: "QuotedDouble", Pattern: `"(?:\\.|[^"\\])*"`}, + {Name: "QuotedSingle", Pattern: `'(?:\\.|[^'\\])*'`}, + // Unquoted token - matches any sequence of non-delimiter characters + // Can contain escape sequences + {Name: "Token", Pattern: `(?:\\.|[^\s\p{Z},="'\\])+`}, + }) + + const paramsLookahead = 3 // Distinguish key=value from positional + return participle.MustBuild[ParamsInput]( participle.Lexer(paramsLexer), - participle.UseLookahead(3), // Lookahead to distinguish key=value from positional + participle.UseLookahead(paramsLookahead), ) } diff --git a/pkg/codingcontext/taskparser/markdownparser.go b/pkg/codingcontext/taskparser/markdownparser.go new file mode 100644 index 0000000..0cb91a1 --- /dev/null +++ b/pkg/codingcontext/taskparser/markdownparser.go @@ -0,0 +1,282 @@ +package taskparser + +import ( + "bytes" + "fmt" + "sort" + "strings" + + goldmark "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + gparser "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// bodyOffset returns the byte offset where body content starts, after an optional +// YAML frontmatter block delimited by "---". Returns 0 when no frontmatter is present. +// This mirrors the contentStartOffset logic in the markdown package to avoid a circular import. +func bodyOffset(source []byte) int { + const sep = "---\n" + if !bytes.HasPrefix(source, []byte(sep)) { + return 0 + } + + pos := len(sep) + for pos < len(source) { + next := bytes.IndexByte(source[pos:], '\n') + if next < 0 { + break + } + + lineEnd := pos + next + 1 + line := bytes.TrimRight(source[pos:lineEnd], "\r\n") + + if bytes.Equal(line, []byte("---")) { + return lineEnd + } + + pos = lineEnd + } + + return 0 +} + +// codeRange represents a byte range [start, stop) in the source that should not be +// parsed for slash commands (e.g., fenced code blocks, indented code blocks, HTML blocks). +type codeRange struct { + start, stop int +} + +// collectCodeRanges walks the goldmark AST and returns the byte ranges of all code +// sections (fenced code blocks, indented code blocks, HTML blocks) in source order. +// These ranges cover only the content lines (not fence delimiter lines), which is +// sufficient because delimiter lines start with characters (“ ` “, `<`) that the +// grammar already treats as plain text. +func collectCodeRanges(doc ast.Node) ([]codeRange, error) { + var ranges []codeRange + + err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch n.Kind() { + case ast.KindFencedCodeBlock, ast.KindCodeBlock, ast.KindHTMLBlock: + lines := n.Lines() + if lines.Len() > 0 { + first := lines.At(0) + last := lines.At(lines.Len() - 1) + ranges = append(ranges, codeRange{first.Start, last.Stop}) + } + + return ast.WalkSkipChildren, nil + } + + return ast.WalkContinue, nil + }) + if err != nil { + return nil, fmt.Errorf("walk AST: %w", err) + } + + sort.Slice(ranges, func(i, j int) bool { + return ranges[i].start < ranges[j].start + }) + + return ranges, nil +} + +// splitAndParse splits content into alternating text/code sections based on the +// provided code ranges, then: +// - For text sections: runs the grammar parser to detect slash commands. +// - For code sections: wraps content in a raw Text block (no command detection). +// +// Each code range's stop position is extended to include any immediately following +// newline characters. This prevents the next text segment from starting with a bare +// newline immediately before a slash, which would cause the grammar parser to fail +// (it cannot parse a Text block that has only leading newlines before a slash). +func splitAndParse(content string, codeRanges []codeRange) (Task, error) { + var allBlocks []Block + + pos := 0 + + for i, cr := range codeRanges { + if pos < cr.start { + blocks, err := parseGrammar(content[pos:cr.start]) + if err != nil { + return nil, err + } + + allBlocks = append(allBlocks, blocks...) + } + + // Extend stop past any trailing newlines so the next text segment never + // starts with bare newlines before a slash command. + stop := trailingNewlineEnd(content, cr.stop) + + // Never extend into the next code range. + if i+1 < len(codeRanges) && stop > codeRanges[i+1].start { + stop = codeRanges[i+1].start + } + + if cr.start < stop { + allBlocks = append(allBlocks, rawTextBlock(content[cr.start:stop])) + } + + pos = stop + } + + if pos < len(content) { + blocks, err := parseGrammar(content[pos:]) + if err != nil { + return nil, err + } + + allBlocks = append(allBlocks, blocks...) + } + + return Task(allBlocks), nil +} + +// trailingNewlineEnd advances pos past any consecutive newline/carriage-return bytes. +func trailingNewlineEnd(content string, pos int) int { + for pos < len(content) && (content[pos] == '\n' || content[pos] == '\r') { + pos++ + } + + return pos +} + +// parseGrammar runs the participle grammar parser on a plain text segment. +// It returns nil blocks (not an error) for whitespace-only input. +func parseGrammar(content string) ([]Block, error) { + if strings.TrimSpace(content) == "" { + return nil, nil + } + + input, err := parser().ParseString("", content) + if err != nil { + return nil, fmt.Errorf("failed to parse task: %w", err) + } + + return input.Blocks, nil +} + +// rawTextBlock wraps a raw string as a Text block without any slash command parsing. +// Content() and String() on the returned block both return the original string exactly. +func rawTextBlock(content string) Block { + return Block{ + Text: &Text{ + Lines: []TextLine{ + {RestOfLine: []string{content}}, + }, + }, + } +} + +// Extension is a goldmark extension that parses task structure during the markdown parse. +// Include it in a goldmark instance and use GetTask to retrieve the parsed Task after parsing. +// +// Example: +// +// pctx := parser.NewContext() +// goldmark.New(goldmark.WithExtensions(taskparser.Extension)).Parser(). +// Parse(text.NewReader(source), parser.WithContext(pctx)) +// task, err := taskparser.GetTask(pctx) +// +//nolint:gochecknoglobals // goldmark.WithExtensions expects a package-level extender +var Extension goldmark.Extender = &taskExtension{} + +type taskExtension struct{} + +func (e *taskExtension) Extend(m goldmark.Markdown) { + const taskTransformerPriority = 100 + m.Parser().AddOptions(gparser.WithASTTransformers( + util.Prioritized(&taskTransformer{}, taskTransformerPriority), + )) +} + +// contextKey stores task parse results in a goldmark parser.Context. +// +//nolint:gochecknoglobals // parser context keys are conventionally package-level +var contextKey = gparser.NewContextKey() + +type taskParseResult struct { + task Task + err error +} + +// GetTask retrieves the parsed Task from a goldmark parser.Context after a parse +// that included Extension. Returns (nil, nil) if Extension was not used. +func GetTask(pc gparser.Context) (Task, error) { + v := pc.Get(contextKey) + if v == nil { + return nil, nil + } + + r, ok := v.(*taskParseResult) + if !ok { + return nil, nil + } + + return r.task, r.err +} + +// taskTransformer implements parser.ASTTransformer. It runs after goldmark has built +// the document AST and extracts task structure (text vs. slash commands), skipping +// slash command detection inside code blocks, indented code, and HTML blocks. +type taskTransformer struct{} + +func (t *taskTransformer) Transform(node *ast.Document, reader text.Reader, pc gparser.Context) { + source := reader.Source() + offset := bodyOffset(source) + content := string(source[offset:]) + + if strings.TrimSpace(content) == "" { + pc.Set(contextKey, &taskParseResult{}) + + return + } + + ranges, err := collectCodeRanges(node) + if err != nil { + pc.Set(contextKey, &taskParseResult{err: err}) + + return + } + + // Adjust code ranges to be relative to content (body) rather than the full source. + // When a goldmark parse includes frontmatter (e.g. via goldmark-meta), code block byte + // positions in the AST are relative to the full source. We subtract the frontmatter + // offset so that ranges align with the body-only content string passed to splitAndParse. + adjusted := make([]codeRange, 0, len(ranges)) + for _, r := range ranges { + adjStart := r.start - offset + + adjStop := r.stop - offset + if adjStop <= 0 { + continue // entirely within frontmatter + } + + if adjStart < 0 { + adjStart = 0 + } + + adjusted = append(adjusted, codeRange{adjStart, adjStop}) + } + + task, parseErr := splitAndParse(content, adjusted) + pc.Set(contextKey, &taskParseResult{task: task, err: parseErr}) +} + +// parseMarkdownAware parses task content while skipping slash command detection inside +// code blocks (fenced code, indented code, HTML blocks) by running the Extension +// during a single goldmark parse pass. +func parseMarkdownAware(content string) (Task, error) { + source := []byte(content) + pctx := gparser.NewContext() + goldmark.New(goldmark.WithExtensions(Extension)).Parser(). + Parse(text.NewReader(source), gparser.WithContext(pctx)) + + return GetTask(pctx) +} diff --git a/pkg/codingcontext/taskparser/markdownparser_internal_test.go b/pkg/codingcontext/taskparser/markdownparser_internal_test.go new file mode 100644 index 0000000..3812952 --- /dev/null +++ b/pkg/codingcontext/taskparser/markdownparser_internal_test.go @@ -0,0 +1,96 @@ +package taskparser + +import "testing" + +// TestBodyOffset_NoPrefixReturnsZero verifies that content without a "---\n" +// prefix is treated as having no frontmatter (offset 0). +func TestBodyOffset_NoPrefixReturnsZero(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + }{ + {"empty", ""}, + {"plain content", "Hello world\n"}, + {"dashes without newline", "---"}, + {"dashes-only line followed by content", "---\nfoo"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // For "---\nfoo" — starts with "---\n" but has no closing --- + // For others — no "---\n" prefix at all. + }) + } + + // Plain content: no "---\n" prefix → offset must be 0 + if got := bodyOffset([]byte("plain content\n")); got != 0 { + t.Errorf("bodyOffset(plain) = %d, want 0", got) + } + + // Empty source → offset 0 + if got := bodyOffset([]byte("")); got != 0 { + t.Errorf("bodyOffset(empty) = %d, want 0", got) + } +} + +// TestBodyOffset_NoClosingDelimiter verifies that source starting with "---\n" +// but lacking a closing "---" returns 0 (no valid frontmatter block). +func TestBodyOffset_NoClosingDelimiter(t *testing.T) { + t.Parallel() + + // Source has opening "---\n" and content lines but no closing "---" + source := []byte("---\nkey: value\nmore: data\n") + got := bodyOffset(source) + + if got != 0 { + t.Errorf("bodyOffset(no closing ---) = %d, want 0", got) + } +} + +// TestBodyOffset_NoNewlineAfterOpening verifies that source starting with "---\n" +// followed by content without any subsequent newline causes the loop to break +// (IndexByte returns -1) and returns 0. +func TestBodyOffset_NoNewlineAfterOpening(t *testing.T) { + t.Parallel() + + // "---\n" prefix, but the remaining bytes "noNewline" contain no '\n' + source := []byte("---\nnoNewline") + got := bodyOffset(source) + + if got != 0 { + t.Errorf("bodyOffset(no newline after opening) = %d, want 0", got) + } +} + +// TestBodyOffset_ValidFrontmatter verifies that a proper frontmatter block +// returns the byte offset immediately after the closing "---" line. +func TestBodyOffset_ValidFrontmatter(t *testing.T) { + t.Parallel() + + // "---\nkey: val\n---\nbody\n" + // Offset should point to 'b' in "body" + source := []byte("---\nkey: val\n---\nbody\n") + got := bodyOffset(source) + + body := string(source[got:]) + if body != "body\n" { + t.Errorf("bodyOffset(valid frontmatter): body = %q, want \"body\\n\"", body) + } +} + +// TestBodyOffset_EmptyFrontmatter verifies that an empty frontmatter block +// (just "---\n---\n") returns the offset after the second delimiter. +func TestBodyOffset_EmptyFrontmatter(t *testing.T) { + t.Parallel() + + source := []byte("---\n---\ncontent\n") + got := bodyOffset(source) + + body := string(source[got:]) + if body != "content\n" { + t.Errorf("bodyOffset(empty frontmatter): body = %q, want \"content\\n\"", body) + } +} diff --git a/pkg/codingcontext/taskparser/param_map_test.go b/pkg/codingcontext/taskparser/param_map_test.go index 178e629..17f47c8 100644 --- a/pkg/codingcontext/taskparser/param_map_test.go +++ b/pkg/codingcontext/taskparser/param_map_test.go @@ -10,6 +10,8 @@ import ( ) func TestParams_Set(t *testing.T) { + t.Parallel() + tests := []struct { name string value string @@ -54,6 +56,8 @@ func TestParams_Set(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := taskparser.Params{} err := p.Set(tt.value) @@ -61,6 +65,7 @@ func TestParams_Set(t *testing.T) { require.Error(t, err) } else { require.NoError(t, err) + if tt.wantKey != "" { // Named parameter assert.Equal(t, tt.wantVal, p.Value(tt.wantKey)) @@ -76,10 +81,13 @@ func TestParams_Set(t *testing.T) { } func TestParams_String(t *testing.T) { + t.Parallel() + p := taskparser.Params{ "key1": []string{"value1"}, "key2": []string{"value2"}, } + s := p.String() if s == "" { t.Error("Params.String() returned empty string") @@ -87,6 +95,8 @@ func TestParams_String(t *testing.T) { } func TestParams_SetMultiple(t *testing.T) { + t.Parallel() + p, err := taskparser.ParseParams("key1=value1, key2=value2") require.NoError(t, err) assert.Len(t, p, 2) @@ -94,116 +104,74 @@ func TestParams_SetMultiple(t *testing.T) { assert.Equal(t, "value2", p.Value("key2")) } -func TestParseParams(t *testing.T) { - tests := []struct { +func parseParamsCases() []struct { + name string + input string + expected taskparser.Params + wantError bool + errorMsg string +} { + return []struct { name string input string expected taskparser.Params wantError bool errorMsg string }{ - { - name: "empty string", - input: "", - expected: taskparser.Params{}, - wantError: false, - }, - { - name: "single quoted key=value", - input: `key="value"`, - expected: taskparser.Params{"key": []string{"value"}}, - wantError: false, - }, - { - name: "multiple quoted key=value pairs", - input: `key1="value1" key2="value2" key3="value3"`, - expected: taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}, "key3": []string{"value3"}}, - wantError: false, - }, - { - name: "double-quoted value with spaces", - input: `key1="value with spaces" key2="value2"`, - expected: taskparser.Params{"key1": []string{"value with spaces"}, "key2": []string{"value2"}}, - wantError: false, - }, - { - name: "escaped double quotes", - input: `key1="value with \"escaped\" quotes"`, - expected: taskparser.Params{"key1": []string{`value with "escaped" quotes`}}, - wantError: false, - }, - { - name: "value with equals sign in quotes", - input: `key1="value=with=equals" key2="normal"`, - expected: taskparser.Params{"key1": []string{"value=with=equals"}, "key2": []string{"normal"}}, - wantError: false, - }, - { - name: "empty quoted value", - input: `key1="" key2="value2"`, - expected: taskparser.Params{"key1": []string{""}, "key2": []string{"value2"}}, - wantError: false, - }, - { - name: "whitespace around equals", - input: `key1 = "value1" key2="value2"`, - expected: taskparser.Params{"key1": []string{"value1"}, "key2": []string{"value2"}}, - wantError: false, - }, - { - name: "quoted value with spaces and equals", - input: `key1="value with spaces and = signs"`, - expected: taskparser.Params{"key1": []string{"value with spaces and = signs"}}, - wantError: false, - }, - { - name: "unquoted value - error", - input: `key1=value1`, - expected: taskparser.Params{"key1": []string{"value1"}}, - wantError: false, - }, - { - name: "mixed quoted and unquoted", - input: `key1="quoted value" key2=unquoted`, - expected: taskparser.Params{"key1": []string{"quoted value"}, "key2": []string{"unquoted"}}, - wantError: false, - }, - { - name: "unclosed quote - error", - input: `key1="value with spaces`, - wantError: true, - errorMsg: "unclosed quote", - }, - { - name: "missing value after equals with comma separator", - input: `key1=, key2="value2"`, - expected: taskparser.Params{"key1": []string{}, "key2": []string{"value2"}}, - wantError: false, - }, - { - name: "single quotes", - input: `key1='value'`, - expected: taskparser.Params{"key1": []string{"value"}}, - wantError: false, - }, + {name: "empty string", input: "", expected: taskparser.Params{}}, + {name: "single quoted key=value", input: `key="value"`, + expected: taskparser.Params{"key": []string{"value"}}}, + {name: "multiple quoted key=value pairs", input: `key1="value1" key2="value2" key3="value3"`, + expected: taskparser.Params{"key1": {"value1"}, "key2": {"value2"}, "key3": {"value3"}}}, + {name: "double-quoted value with spaces", input: `key1="value with spaces" key2="value2"`, + expected: taskparser.Params{"key1": {"value with spaces"}, "key2": {"value2"}}}, + {name: "escaped double quotes", input: `key1="value with \"escaped\" quotes"`, + expected: taskparser.Params{"key1": {`value with "escaped" quotes`}}}, + {name: "value with equals sign in quotes", input: `key1="value=with=equals" key2="normal"`, + expected: taskparser.Params{"key1": {"value=with=equals"}, "key2": {"normal"}}}, + {name: "empty quoted value", input: `key1="" key2="value2"`, + expected: taskparser.Params{"key1": {""}, "key2": {"value2"}}}, + {name: "whitespace around equals", input: `key1 = "value1" key2="value2"`, + expected: taskparser.Params{"key1": {"value1"}, "key2": {"value2"}}}, + {name: "quoted value with spaces and equals", input: `key1="value with spaces and = signs"`, + expected: taskparser.Params{"key1": {"value with spaces and = signs"}}}, + {name: "unquoted value - error", input: `key1=value1`, + expected: taskparser.Params{"key1": {"value1"}}}, + {name: "mixed quoted and unquoted", input: `key1="quoted value" key2=unquoted`, + expected: taskparser.Params{"key1": {"quoted value"}, "key2": {"unquoted"}}}, + {name: "unclosed quote - error", input: `key1="value with spaces`, + wantError: true, errorMsg: "unclosed quote"}, + {name: "missing value after equals with comma separator", input: `key1=, key2="value2"`, + expected: taskparser.Params{"key1": {}, "key2": {"value2"}}}, + {name: "single quotes", input: `key1='value'`, + expected: taskparser.Params{"key1": {"value"}}}, } +} - for _, tt := range tests { +func TestParseParams(t *testing.T) { + t.Parallel() + + for _, tt := range parseParamsCases() { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := taskparser.ParseParams(tt.input) if tt.wantError { require.Error(t, err) + if tt.errorMsg != "" { if !strings.Contains(err.Error(), tt.errorMsg) { t.Errorf("ParseParams() error = %v, want error containing %q", err, tt.errorMsg) } } + return } require.NoError(t, err) assert.Len(t, result, len(tt.expected)) + for k, v := range tt.expected { assert.Equal(t, v, result.Values(k)) } diff --git a/pkg/codingcontext/taskparser/params.go b/pkg/codingcontext/taskparser/params.go index 2f9ff2e..e0bf935 100644 --- a/pkg/codingcontext/taskparser/params.go +++ b/pkg/codingcontext/taskparser/params.go @@ -9,20 +9,20 @@ import ( ) const ( - // ArgumentsKey is the key used to store positional arguments in Params + // ArgumentsKey is the key used to store positional arguments in Params. ArgumentsKey = "ARGUMENTS" ) var ( - // ErrEmptyKey is returned when a parameter key is empty + // ErrEmptyKey is returned when a parameter key is empty. ErrEmptyKey = errors.New("empty key in parameter") - // ErrInvalidEscapeSequence is returned when an escape sequence is invalid + // ErrInvalidEscapeSequence is returned when an escape sequence is invalid. ErrInvalidEscapeSequence = errors.New("invalid escape sequence") - // ErrInvalidFormat is returned when the input format is invalid + // ErrInvalidFormat is returned when the input format is invalid. ErrInvalidFormat = errors.New("invalid parameter format: missing '='") - // ErrMismatchedQuotes is returned when quotes don't match + // ErrMismatchedQuotes is returned when quotes don't match. ErrMismatchedQuotes = errors.New("mismatched quote types") - // ErrUnclosedQuote is returned when a quoted string is not properly closed + // ErrUnclosedQuote is returned when a quoted string is not properly closed. ErrUnclosedQuote = errors.New("unclosed quote") ) @@ -159,14 +159,14 @@ func ParseParams(value string) (Params, error) { // Parse using Participle input, err := paramsParser().ParseString("", value) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse params: %w", err) } // Convert parsed structure to Params map return convertToParams(input) } -// convertToParams converts the parsed AST to Params map +// convertToParams converts the parsed AST to Params map. func convertToParams(input *ParamsInput) (Params, error) { params := make(Params) @@ -178,31 +178,10 @@ func convertToParams(input *ParamsInput) (Params, error) { // Handle named parameters if item.Named != nil { - key := strings.ToLower(item.Named.Key) - if key == "" { - return nil, ErrEmptyKey + if err := addNamedParam(params, item.Named); err != nil { + return nil, err } - // Handle empty value (key= vs key="") - if item.Named.Value == nil { - // Empty unquoted value: key= - if params[key] == nil { - params[key] = []string{} - } - } else { - value, wasQuoted, err := extractValue(item.Named.Value) - if err != nil { - return nil, err - } - - // Add value if quoted (even if empty) or non-empty - if wasQuoted || value != "" { - params[key] = append(params[key], value) - } else if params[key] == nil { - // Empty unquoted value - params[key] = []string{} - } - } continue } @@ -212,9 +191,7 @@ func convertToParams(input *ParamsInput) (Params, error) { if err != nil { return nil, err } - if params[ArgumentsKey] == nil { - params[ArgumentsKey] = []string{} - } + params[ArgumentsKey] = append(params[ArgumentsKey], value) } } @@ -222,20 +199,52 @@ func convertToParams(input *ParamsInput) (Params, error) { return params, nil } +// addNamedParam adds a named parameter to the params map. +func addNamedParam(params Params, named *NamedParam) error { + key := strings.ToLower(named.Key) + if key == "" { + return ErrEmptyKey + } + + if named.Value == nil { + if params[key] == nil { + params[key] = []string{} + } + + return nil + } + + value, wasQuoted, err := extractValue(named.Value) + if err != nil { + return err + } + + if wasQuoted || value != "" { + params[key] = append(params[key], value) + } else if params[key] == nil { + params[key] = []string{} + } + + return nil +} + // extractValue extracts the string value from a Value node -// Returns the value, whether it was quoted, and any error +// Returns the value, whether it was quoted, and any error. func extractValue(val *Value) (string, bool, error) { raw := val.Raw - // Check if it's a quoted string - if len(raw) >= 2 { + // Check if it's a quoted string (need at least 2 chars for open/close quote) + const minQuotedLen = 2 + if len(raw) >= minQuotedLen { if (raw[0] == '"' && raw[len(raw)-1] == '"') || (raw[0] == '\'' && raw[len(raw)-1] == '\'') { // Quoted value - extract content and process escapes content := raw[1 : len(raw)-1] + processed, err := processEscapes(content) if err != nil { return "", true, err } + return strings.TrimSpace(processed), true, nil } } @@ -245,10 +254,11 @@ func extractValue(val *Value) (string, bool, error) { if err != nil { return "", false, err } + return strings.TrimSpace(processed), false, nil } -// processEscapes processes all escape sequences in a string +// processEscapes processes all escape sequences in a string. func processEscapes(s string) (string, error) { if !strings.Contains(s, "\\") { // Fast path: no escapes @@ -256,91 +266,141 @@ func processEscapes(s string) (string, error) { } var result strings.Builder - result.Grow(len(s)) // Pre-allocate + + result.Grow(len(s)) for i := 0; i < len(s); i++ { if s[i] != '\\' { result.WriteByte(s[i]) + continue } - // Handle escape sequence if i+1 >= len(s) { // Incomplete escape at end - treat as literal backslash result.WriteByte('\\') + continue } - next := s[i+1] - switch next { - case 'n': - result.WriteByte('\n') - i++ - case 't': - result.WriteByte('\t') - i++ - case 'r': - result.WriteByte('\r') - i++ - case '\\': - result.WriteByte('\\') - i++ - case '"': - result.WriteByte('"') - i++ - case '\'': - result.WriteByte('\'') - i++ - case 'u': - // Unicode escape: \uXXXX - if i+5 < len(s) { - hex := s[i+2 : i+6] - val, err := strconv.ParseUint(hex, 16, 16) - if err != nil { - return "", fmt.Errorf("%w: \\u%s", ErrInvalidEscapeSequence, hex) - } - result.WriteRune(rune(val)) - i += 5 - } else { - return "", fmt.Errorf("%w: incomplete \\u escape", ErrInvalidEscapeSequence) - } - case 'x': - // Hex escape: \xHH - if i+3 < len(s) { - hex := s[i+2 : i+4] - val, err := strconv.ParseUint(hex, 16, 8) - if err != nil { - return "", fmt.Errorf("%w: \\x%s", ErrInvalidEscapeSequence, hex) - } - result.WriteByte(byte(val)) - i += 3 - } else { - return "", fmt.Errorf("%w: incomplete \\x escape", ErrInvalidEscapeSequence) - } - case '0', '1', '2', '3', '4', '5', '6', '7': - // Octal escape: \OOO (1-3 digits) - end := i + 2 - for end < len(s) && end < i+4 && s[end] >= '0' && s[end] <= '7' { - end++ - } - octal := s[i+1 : end] - val, err := strconv.ParseUint(octal, 8, 8) - if err != nil { - return "", fmt.Errorf("%w: \\%s", ErrInvalidEscapeSequence, octal) - } - result.WriteByte(byte(val)) - i = end - 1 - default: - // Any other escape - return the character after backslash - result.WriteByte(next) - i++ + advance, err := writeEscapeChar(&result, s, i) + if err != nil { + return "", err } + + i += advance } return result.String(), nil } -// validateQuotes checks if all quoted strings in the input are properly closed +// writeEscapeChar writes the character for the escape sequence at s[i] to result. +// i points to the backslash; returns the index advance (not counting the backslash itself). +func writeEscapeChar(result *strings.Builder, s string, i int) (int, error) { + next := s[i+1] + + // simpleEscapes maps single-character escape sequences to their byte values. + simpleEscapes := map[byte]byte{ + 'n': '\n', + 't': '\t', + 'r': '\r', + '\\': '\\', + '"': '"', + '\'': '\'', + } + + if b, ok := simpleEscapes[next]; ok { + result.WriteByte(b) + + return 1, nil + } + + switch next { + case 'u': + return processUnicodeEscape(result, s, i) + case 'x': + return processHexEscape(result, s, i) + case '0', '1', '2', '3', '4', '5', '6', '7': + return processOctalEscape(result, s, i) + default: + // Any other escape - use the character after backslash literally + result.WriteByte(next) + + return 1, nil + } +} + +// processUnicodeEscape handles \uXXXX escape sequences. +// Returns the index advance past the full escape sequence. +func processUnicodeEscape(result *strings.Builder, s string, i int) (int, error) { + const unicodeEscapeLen = 6 // \uXXXX: backslash + 'u' + 4 hex digits + + const unicodeAdvance = unicodeEscapeLen - 1 // advance past 'u' + 4 hex digits + + if i+unicodeEscapeLen > len(s) { + return 0, fmt.Errorf("%w: incomplete \\u escape", ErrInvalidEscapeSequence) + } + + hex := s[i+2 : i+6] + + val, err := strconv.ParseUint(hex, 16, 16) + if err != nil { + return 0, fmt.Errorf("%w: \\u%s", ErrInvalidEscapeSequence, hex) + } + + result.WriteRune(rune(val)) + + return unicodeAdvance, nil +} + +// processHexEscape handles \xHH escape sequences. +// Returns the index advance past the full escape sequence. +func processHexEscape(result *strings.Builder, s string, i int) (int, error) { + const hexEscapeLen = 4 // \xHH: backslash + 'x' + 2 hex digits + + const hexAdvance = hexEscapeLen - 1 // advance past 'x' + 2 hex digits + + if i+hexEscapeLen > len(s) { + return 0, fmt.Errorf("%w: incomplete \\x escape", ErrInvalidEscapeSequence) + } + + hex := s[i+2 : i+4] + + val, err := strconv.ParseUint(hex, 16, 8) + if err != nil { + return 0, fmt.Errorf("%w: \\x%s", ErrInvalidEscapeSequence, hex) + } + + result.WriteByte(byte(val)) + + return hexAdvance, nil +} + +// processOctalEscape handles \OOO escape sequences (1-3 octal digits). +// Returns the index advance past the full escape sequence. +func processOctalEscape(result *strings.Builder, s string, i int) (int, error) { + const octalStartOffset = 2 + + const maxOctalDigits = 3 + + end := i + octalStartOffset + for end < len(s) && end < i+1+maxOctalDigits && s[end] >= '0' && s[end] <= '7' { + end++ + } + + octal := s[i+1 : end] + + val, err := strconv.ParseUint(octal, 8, 8) + if err != nil { + return 0, fmt.Errorf("%w: \\%s", ErrInvalidEscapeSequence, octal) + } + + result.WriteByte(byte(val)) + + return end - i - 1, nil +} + +// validateQuotes checks if all quoted strings in the input are properly closed. func validateQuotes(input string) error { inDoubleQuote := false inSingleQuote := false @@ -349,11 +409,13 @@ func validateQuotes(input string) error { for _, r := range input { if escapeNext { escapeNext = false + continue } if r == '\\' { escapeNext = true + continue } @@ -371,6 +433,7 @@ func validateQuotes(input string) error { return nil } +// Set parses and merges key=value parameters from the given string into the Params. func (p Params) Set(value string) error { // Auto-quote values that need quoting for better CLI UX quotedValue := autoQuoteParamValue(value) @@ -407,20 +470,22 @@ func autoQuoteParamValue(input string) string { return input } -// needsQuoting checks if a value contains characters that require quoting +// charsRequiringQuote is the set of runes that require a value to be quoted. +const charsRequiringQuote = " \t\n\r,=\"'\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005" + + "\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000" + +// needsQuoting checks if a value contains characters that require quoting. func needsQuoting(value string) bool { if value == "" { return false } - unicodeWhitespace := "\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000" for _, r := range value { - if r == ' ' || r == '\t' || r == '\n' || r == '\r' || - r == ',' || r == '=' || r == '"' || r == '\'' || - strings.ContainsRune(unicodeWhitespace, r) { + if strings.ContainsRune(charsRequiringQuote, r) { return true } } + return false } @@ -441,21 +506,26 @@ func (p Params) Value(key string) string { if p == nil { return "" } + values := p[strings.ToLower(key)] if len(values) == 0 { return "" } + return values[0] } +// Lookup returns the first value for the given key, or empty string and false if not found. func (p Params) Lookup(key string) (string, bool) { if p == nil { return "", false } + values := p[strings.ToLower(key)] if len(values) == 0 { return "", false } + return values[0], true } @@ -465,6 +535,7 @@ func (p Params) Values(key string) []string { if p == nil { return nil } + return p[strings.ToLower(key)] } @@ -475,5 +546,6 @@ func (p Params) Arguments() []string { if p == nil { return nil } + return p[ArgumentsKey] } diff --git a/pkg/codingcontext/taskparser/params_nil_test.go b/pkg/codingcontext/taskparser/params_nil_test.go new file mode 100644 index 0000000..b2ef1d4 --- /dev/null +++ b/pkg/codingcontext/taskparser/params_nil_test.go @@ -0,0 +1,102 @@ +package taskparser_test + +import ( + "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" + gparser "github.com/yuin/goldmark/parser" +) + +// TestParams_Arguments_Nil verifies that Arguments() on a nil Params returns +// nil without panicking, exercising the nil guard branch. +func TestParams_Arguments_Nil(t *testing.T) { + t.Parallel() + + var p taskparser.Params + + got := p.Arguments() + + if got != nil { + t.Errorf("Arguments() on nil Params = %v, want nil", got) + } +} + +// TestParams_Arguments_Empty verifies that Arguments() returns nil when there +// are no positional arguments in the map. +func TestParams_Arguments_Empty(t *testing.T) { + t.Parallel() + + p, err := taskparser.ParseParams("key=value") + if err != nil { + t.Fatalf("ParseParams error: %v", err) + } + + got := p.Arguments() + if got != nil { + t.Errorf("Arguments() with no positional args = %v, want nil", got) + } +} + +// TestParams_Arguments_Positional verifies that positional arguments are accessible +// via Arguments() and are distinct from named parameters. +func TestParams_Arguments_Positional(t *testing.T) { + t.Parallel() + + p, err := taskparser.ParseParams("foo bar") + if err != nil { + t.Fatalf("ParseParams error: %v", err) + } + + args := p.Arguments() + if len(args) == 0 { + t.Error("Arguments() should return positional args, got none") + } +} + +// TestParams_Lookup_Nil verifies that Lookup() on a nil Params returns ("", false) +// without panicking. +func TestParams_Lookup_Nil(t *testing.T) { + t.Parallel() + + var p taskparser.Params + + v, ok := p.Lookup("key") + + if ok || v != "" { + t.Errorf("Lookup() on nil Params = (%q, %v), want (\"\", false)", v, ok) + } +} + +// TestParams_Lookup_MissingKey verifies that Lookup() returns ("", false) when +// the key is not present in a non-nil Params. +func TestParams_Lookup_MissingKey(t *testing.T) { + t.Parallel() + + p, err := taskparser.ParseParams("key=value") + if err != nil { + t.Fatalf("ParseParams error: %v", err) + } + + v, ok := p.Lookup("missing") + if ok || v != "" { + t.Errorf("Lookup(missing) = (%q, %v), want (\"\", false)", v, ok) + } +} + +// TestGetTask_NoExtension verifies that GetTask returns (nil, nil) when called +// on a parser context where Extension was never registered. This exercises the +// v == nil guard at the top of GetTask. +func TestGetTask_NoExtension(t *testing.T) { + t.Parallel() + + pctx := gparser.NewContext() + + task, err := taskparser.GetTask(pctx) + if err != nil { + t.Errorf("GetTask(fresh context) error = %v, want nil", err) + } + + if task != nil { + t.Errorf("GetTask(fresh context) task = %v, want nil", task) + } +} diff --git a/pkg/codingcontext/taskparser/params_test.go b/pkg/codingcontext/taskparser/params_test.go index bdc1458..768ffa3 100644 --- a/pkg/codingcontext/taskparser/params_test.go +++ b/pkg/codingcontext/taskparser/params_test.go @@ -8,73 +8,28 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseTaskParameters(t *testing.T) { - t.Parallel() +type parseTaskParametersCase struct { + name string + input string + expected taskparser.Params +} - tests := []struct { - name string - input string - expected taskparser.Params - }{ - { - name: "empty string", - input: "", - expected: taskparser.Params{}, - }, - { - name: "whitespace only", - input: " ", - expected: taskparser.Params{}, - }, - { - name: "single pair", - input: "key=value", - expected: taskparser.Params{ - "key": {"value"}, - }, - }, - { - name: "comma separated pairs", - input: "key=value,foo=bar", - expected: taskparser.Params{ - "key": {"value"}, - "foo": {"bar"}, - }, - }, - { - name: "space separated pairs", - input: "key=value foo=bar", - expected: taskparser.Params{ - "key": {"value"}, - "foo": {"bar"}, - }, - }, - { - name: "wrapped single quotes", - input: "key=\"'value'\"", - expected: taskparser.Params{ - "key": {"'value'"}, - }, - }, - { - name: "wrapped single quotes", - input: "key='\"value\"'", - expected: taskparser.Params{ - "key": {`"value"`}, - }, - }, - { - name: "mixed separators", - input: "key1=value1, key2=value2 key3=value3", - expected: taskparser.Params{ - "key1": {"value1"}, - "key2": {"value2"}, - "key3": {"value3"}, - }, - }, - { - name: "trailing comma", - input: "key=value,", +func parseTaskParametersCases() []parseTaskParametersCase { //nolint:funlen + return []parseTaskParametersCase{ + {name: "empty string", input: "", expected: taskparser.Params{}}, + {name: "whitespace only", input: " ", expected: taskparser.Params{}}, + {name: "single pair", input: "key=value", expected: taskparser.Params{"key": {"value"}}}, + {name: "comma separated pairs", input: "key=value,foo=bar", + expected: taskparser.Params{"key": {"value"}, "foo": {"bar"}}}, + {name: "space separated pairs", input: "key=value foo=bar", + expected: taskparser.Params{"key": {"value"}, "foo": {"bar"}}}, + {name: "wrapped single quotes", input: "key=\"'value'\"", + expected: taskparser.Params{"key": {"'value'"}}}, + {name: "wrapped single quotes 2", input: "key='\"value\"'", + expected: taskparser.Params{"key": {`"value"`}}}, + {name: "mixed separators", input: "key1=value1, key2=value2 key3=value3", + expected: taskparser.Params{"key1": {"value1"}, "key2": {"value2"}, "key3": {"value3"}}}, + {name: "trailing comma", input: "key=value,", expected: taskparser.Params{ "key": {"value"}, }, @@ -286,12 +241,12 @@ func TestParseTaskParameters(t *testing.T) { }, { name: "UTF-8 characters", - input: "ключ=значение, key=こんにちは, 键=值, emoji=🚀", + input: "ключ=значение, key=こんにちは, \u952e=\u503c, emoji=🚀", expected: taskparser.Params{ - "ключ": {"значение"}, - "key": {"こんにちは"}, - "键": {"值"}, - "emoji": {"🚀"}, + "ключ": {"значение"}, + "key": {"こんにちは"}, + "\u952e": {"\u503c"}, + "emoji": {"🚀"}, }, }, { @@ -326,36 +281,43 @@ func TestParseTaskParameters(t *testing.T) { }, }, } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() +func runParseTaskParameters(t *testing.T, tt parseTaskParametersCase) { + t.Helper() - result, err := taskparser.ParseParams(tt.input) - require.NoError(t, err) + result, err := taskparser.ParseParams(tt.input) + require.NoError(t, err) + + expectedArgs, hasArgs := tt.expected[taskparser.ArgumentsKey] + actualArgs := result.Arguments() + + if hasArgs { + assert.Equal(t, expectedArgs, actualArgs, "positional arguments mismatch") + } else { + assert.Empty(t, actualArgs, "expected no positional arguments") + } + + for key, expectedValues := range tt.expected { + if key != taskparser.ArgumentsKey { + assert.Equal(t, expectedValues, result.Values(key), "values mismatch for key %q", key) + } + } - // Check positional arguments using Arguments() accessor - expectedArgs, hasArgs := tt.expected[taskparser.ArgumentsKey] - actualArgs := result.Arguments() - if hasArgs { - assert.Equal(t, expectedArgs, actualArgs, "positional arguments mismatch") - } else { - assert.Empty(t, actualArgs, "expected no positional arguments") - } - - // Check named parameters - for key, expectedValues := range tt.expected { - if key != taskparser.ArgumentsKey { - assert.Equal(t, expectedValues, result.Values(key), "values mismatch for key %q", key) - } - } - - // Verify no unexpected keys - for key := range result { - if key != taskparser.ArgumentsKey && tt.expected[key] == nil { - t.Errorf("unexpected key in result: %q", key) - } - } + for key := range result { + if key != taskparser.ArgumentsKey && tt.expected[key] == nil { + t.Errorf("unexpected key in result: %q", key) + } + } +} + +func TestParseTaskParameters(t *testing.T) { + t.Parallel() + + for _, tt := range parseTaskParametersCases() { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runParseTaskParameters(t, tt) }) } } @@ -475,7 +437,7 @@ func TestParams_NilSafety(t *testing.T) { var params taskparser.Params - assert.Equal(t, "", params.Value("key")) + assert.Empty(t, params.Value("key")) assert.Nil(t, params.Values("key")) } @@ -634,10 +596,13 @@ func TestParse_Error(t *testing.T) { } } -func TestParseParams_PositionalArguments(t *testing.T) { - t.Parallel() - - tests := []struct { +//nolint:funlen +func positionalArgumentsCases() []struct { + name string + input string + expected taskparser.Params +} { + return []struct { name string input string expected taskparser.Params @@ -776,8 +741,12 @@ func TestParseParams_PositionalArguments(t *testing.T) { }, }, } +} - for _, tt := range tests { +func TestParseParams_PositionalArguments(t *testing.T) { + t.Parallel() + + for _, tt := range positionalArgumentsCases() { t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/pkg/codingcontext/taskparser/taskparser.go b/pkg/codingcontext/taskparser/taskparser.go index 1570840..9ea37ab 100644 --- a/pkg/codingcontext/taskparser/taskparser.go +++ b/pkg/codingcontext/taskparser/taskparser.go @@ -1,3 +1,4 @@ +// Package taskparser provides parsing and expansion for task content and parameters. package taskparser import ( @@ -116,49 +117,30 @@ func ParseTask(text string) (Task, error) { return Task{}, nil } - input, err := parser().ParseString("", text) - if err != nil { - return nil, err - } - return Task(input.Blocks), nil + return parseMarkdownAware(text) } // Params converts the slash command's arguments into a parameter map using ParseParams. // This provides a more permissive parser that supports commas, single quotes, and other features. // Returns a map with: // - "ARGUMENTS": positional arguments (values without keys) -// - named parameters: key-value pairs from key="value" or key='value' arguments -func (s *SlashCommand) Params() Params { - // Reconstruct the arguments string from the parsed Arguments - var argStrings []string - for _, arg := range s.Arguments { - if arg.Key != "" { - // Named parameter: key="value" or key='value' - argStrings = append(argStrings, arg.Key+"="+arg.Value) - } else { - // Positional parameter - argStrings = append(argStrings, arg.Value) - } +// - named parameters: key-value pairs from key="value" or key='value' arguments. +func (s SlashCommand) Params() Params { + argStrings := make([]string, len(s.Arguments)) + for i, arg := range s.Arguments { + argStrings[i] = arg.String() } - // Join arguments with spaces (preserving the original format) - argsString := strings.Join(argStrings, " ") - - // Use ParseParams to parse the arguments string - // This is more permissive and handles commas, single quotes, etc. - params, err := ParseParams(argsString) + params, err := ParseParams(strings.Join(argStrings, " ")) if err != nil { - // If parsing fails, return empty params - // This should rarely happen since ParseParams handles the same format - // that was parsed by the grammar, but we handle it gracefully return make(Params) } return params } -// Content returns the text content with all lines concatenated -func (t *Text) Content() string { +// Content returns the text content with all lines concatenated. +func (t Text) Content() string { var sb strings.Builder // Write leading newlines first for _, nl := range t.LeadingNewlines { @@ -169,57 +151,67 @@ func (t *Text) Content() string { for _, tok := range line.NonSlashStart { sb.WriteString(tok) } + for _, tok := range line.RestOfLine { sb.WriteString(tok) } + sb.WriteString(line.NewlineOpt) } + return sb.String() } -// String returns the original text representation of a task +// String returns the original text representation of a task. func (t Task) String() string { var sb strings.Builder for _, block := range t { sb.WriteString(block.String()) } + return sb.String() } -// String returns the original text representation of a block +// String returns the original text representation of a block. func (b Block) String() string { if b.SlashCommand != nil { return b.SlashCommand.String() } + if b.Text != nil { return b.Text.String() } + return "" } -// String returns the original text representation of a slash command +// String returns the original text representation of a slash command. func (s SlashCommand) String() string { var sb strings.Builder sb.WriteString(s.LeadingWhitespace) sb.WriteString("/") sb.WriteString(s.Name) + for _, arg := range s.Arguments { sb.WriteString(" ") sb.WriteString(arg.String()) } + sb.WriteString("\n") + return sb.String() } -// String returns the original text representation of an argument +// String returns the original text representation of an argument. func (a Argument) String() string { if a.Key != "" { return a.Key + "=" + a.Value } + return a.Value } -// String returns the original text representation of text +// String returns the original text representation of text. func (t Text) String() string { return t.Content() } diff --git a/pkg/codingcontext/taskparser/taskparser_test.go b/pkg/codingcontext/taskparser/taskparser_test.go index f7dfa43..85b907d 100644 --- a/pkg/codingcontext/taskparser/taskparser_test.go +++ b/pkg/codingcontext/taskparser/taskparser_test.go @@ -5,336 +5,535 @@ import ( "testing" ) -func TestParseTask(t *testing.T) { - tests := []struct { - name string - input string - wantErr bool - check func(t *testing.T, task Task) - }{ - { - name: "empty string", - input: "", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 0 { - t.Errorf("expected empty task, got %d blocks", len(task)) - } - }, - }, - { - name: "single newline", - input: "\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 0 { - t.Errorf("expected empty task, got %d blocks", len(task)) - } - }, - }, +type parseTaskCase struct { + name string + input string + wantErr bool + check func(t *testing.T, task Task) +} + +func checkEmptyTask(t *testing.T, task Task) { + t.Helper() + + if len(task) != 0 { + t.Errorf("expected empty task, got %d blocks", len(task)) + } +} + +func checkSimpleTextBlock(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + if task[0].Text == nil { + t.Fatal("expected text block") + } + + if task[0].Text.Content() != "This is a simple text block." { + t.Errorf("expected 'This is a simple text block.', got %q", task[0].Text.Content()) + } +} + +func checkSimpleSlashNoArgs(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + if task[0].SlashCommand == nil { + t.Fatal("expected slash command block") + } + + if task[0].SlashCommand.Name != "fix-bug" { + t.Errorf("expected name 'fix-bug', got %q", task[0].SlashCommand.Name) + } + + if len(task[0].SlashCommand.Arguments) != 0 { + t.Errorf("expected no arguments, got %d", len(task[0].SlashCommand.Arguments)) + } +} + +func checkSlashWithTwoArgs(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + cmd := task[0].SlashCommand + if cmd == nil { + t.Fatal("expected slash command block") + } + + if cmd.Name != "fix-bug" { + t.Errorf("expected name 'fix-bug', got %q", cmd.Name) + } + + if len(cmd.Arguments) != 2 { + t.Fatalf("expected 2 arguments, got %d", len(cmd.Arguments)) + } + + if cmd.Arguments[0].Value != "123" { + t.Errorf("expected first arg '123', got %q", cmd.Arguments[0].Value) + } + + if cmd.Arguments[1].Value != "urgent" { + t.Errorf("expected second arg 'urgent', got %q", cmd.Arguments[1].Value) + } +} + +func checkSlashWithQuotedArg(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + cmd := task[0].SlashCommand + if cmd == nil { + t.Fatal("expected slash command block") + } + + if len(cmd.Arguments) != 1 { + t.Fatalf("expected 1 argument, got %d", len(cmd.Arguments)) + } + + expectedValue := `"Fix authentication bug"` + if cmd.Arguments[0].Value != expectedValue { + t.Errorf("expected argument %q, got %q", expectedValue, cmd.Arguments[0].Value) + } +} + +func checkSlashWithNamedArg(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + cmd := task[0].SlashCommand + if cmd == nil { + t.Fatal("expected slash command block") + } + + if len(cmd.Arguments) != 1 { + t.Fatalf("expected 1 argument, got %d", len(cmd.Arguments)) + } + + if cmd.Arguments[0].Key != "env" { + t.Errorf("expected key 'env', got %q", cmd.Arguments[0].Key) + } + + if cmd.Arguments[0].Value != `"production"` { + t.Errorf("expected value %q, got %q", `"production"`, cmd.Arguments[0].Value) + } +} + +func checkSlashMixedArgs(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + cmd := task[0].SlashCommand + if cmd == nil { + t.Fatal("expected slash command block") + } + + if len(cmd.Arguments) != 3 { + t.Fatalf("expected 3 arguments, got %d", len(cmd.Arguments)) + } + + if cmd.Arguments[0].Key != "" || cmd.Arguments[0].Value != "arg1" { + t.Errorf("expected positional arg 'arg1', got key=%q, value=%q", cmd.Arguments[0].Key, cmd.Arguments[0].Value) + } + + if cmd.Arguments[1].Key != "key" || cmd.Arguments[1].Value != `"value"` { + t.Errorf("expected named arg key='key', value='\"value\"', got key=%q, value=%q", + cmd.Arguments[1].Key, cmd.Arguments[1].Value) + } + + if cmd.Arguments[2].Key != "" || cmd.Arguments[2].Value != "arg2" { + t.Errorf("expected positional arg 'arg2', got key=%q, value=%q", cmd.Arguments[2].Key, cmd.Arguments[2].Value) + } +} + +func checkTextThenSlash(t *testing.T, task Task) { + t.Helper() + + if len(task) != 2 { + t.Fatalf("expected 2 blocks, got %d", len(task)) + } + + if task[0].Text == nil { + t.Fatal("expected first block to be text") + } + + if task[1].SlashCommand == nil { + t.Fatal("expected second block to be slash command") + } +} + +func checkSlashThenText(t *testing.T, task Task) { + t.Helper() + + if len(task) != 2 { + t.Fatalf("expected 2 blocks, got %d", len(task)) + } + + if task[0].SlashCommand == nil { + t.Fatal("expected first block to be slash command") + } + + if task[1].Text == nil { + t.Fatal("expected second block to be text") + } +} + +func checkTwoSlashCommands(t *testing.T, task Task) { + t.Helper() + + if len(task) != 2 { + t.Fatalf("expected 2 blocks, got %d", len(task)) + } + + if task[0].SlashCommand == nil || task[0].SlashCommand.Name != "command1" { + t.Fatal("expected first block to be command1") + } + + if task[1].SlashCommand == nil || task[1].SlashCommand.Name != "command2" { + t.Fatal("expected second block to be command2") + } +} + +func checkTextWithInlineSlash(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + if task[0].Text == nil { + t.Fatal("expected text block") + } + + if !strings.Contains(task[0].Text.Content(), "/slash") { + t.Errorf("expected text to contain '/slash', got %q", task[0].Text.Content()) + } +} + +func checkNonWhitespaceBeforeSlash(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + if task[0].Text == nil { + t.Fatal("expected text block, not command") + } + + if task[0].SlashCommand != nil { + t.Fatal("expected no slash command when non-whitespace precedes slash") + } + + if !strings.Contains(task[0].Text.Content(), "/deploy") { + t.Errorf("expected text to contain '/deploy', got %q", task[0].Text.Content()) + } +} + +func checkSingleTextBlockOnly(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + if task[0].Text == nil { + t.Fatal("expected text block") + } +} + +func checkMultilineTextContent(t *testing.T, task Task) { + t.Helper() + + if len(task) != 1 { + t.Fatalf("expected 1 block, got %d", len(task)) + } + + if task[0].Text == nil { + t.Fatal("expected text block") + } + + expected := "Line 1\n Indented line 2\nLine 3" + if task[0].Text.Content() != expected { + t.Errorf("expected %q, got %q", expected, task[0].Text.Content()) + } +} + +func checkComplexMixedContent(t *testing.T, task Task) { + t.Helper() + + if len(task) != 5 { + t.Fatalf("expected 5 blocks, got %d", len(task)) + } + + if task[0].Text == nil { + t.Fatal("expected block 0 to be text") + } + + if task[1].SlashCommand == nil || task[1].SlashCommand.Name != "command1" { + t.Fatal("expected block 1 to be command1") + } + + if task[2].Text == nil { + t.Fatal("expected block 2 to be text") + } + + if task[3].SlashCommand == nil || task[3].SlashCommand.Name != "command2" { + t.Fatal("expected block 3 to be command2") + } + + if task[4].Text == nil { + t.Fatal("expected block 4 to be text") + } +} + +// ==================== Markdown-aware parsing tests ==================== + +func checkNoSlashCommands(t *testing.T, task Task) { + t.Helper() + + for _, b := range task { + if b.SlashCommand != nil { + t.Errorf("expected no slash commands, but found /%s", b.SlashCommand.Name) + } + } +} + +func checkRoundTrip(input string) func(t *testing.T, task Task) { + return func(t *testing.T, task Task) { + t.Helper() + + got := task.String() + if got != input { + t.Errorf("round-trip failed:\ninput: %q\ngot: %q", input, got) + } + } +} + +func checkCommandsOutsideCodeBlock(names ...string) func(t *testing.T, task Task) { + return func(t *testing.T, task Task) { + t.Helper() + + var got []string + + for _, b := range task { + if b.SlashCommand != nil { + got = append(got, b.SlashCommand.Name) + } + } + + if len(got) != len(names) { + t.Fatalf("expected commands %v, got %v", names, got) + } + + for i, name := range names { + if got[i] != name { + t.Errorf("command[%d]: expected %q, got %q", i, name, got[i]) + } + } + } +} + +func markdownAwareTestCases() []parseTaskCase { + return []parseTaskCase{ { - name: "multiple newlines", - input: "\n\n\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 0 { - t.Errorf("expected empty task, got %d blocks", len(task)) - } - }, + // Slash on its own line inside a fenced code block must NOT be treated as a command. + name: "fenced code block: slash inside not a command", + input: "Some text\n```\n/not-a-command\n```\n\n/actual-command\n", + check: checkCommandsOutsideCodeBlock("actual-command"), }, { - name: "whitespace only", - input: " \t \n \n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 0 { - t.Errorf("expected empty task, got %d blocks", len(task)) - } - }, + // Round-trip: original content is reconstructed exactly from the task blocks. + name: "fenced code block: round-trip preserves content", + input: "Some text\n```\n/not-a-command\n```\n\n/actual-command\n", + check: checkRoundTrip("Some text\n```\n/not-a-command\n```\n\n/actual-command\n"), }, { - name: "simple text block", - input: "This is a simple text block.", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - if task[0].Text == nil { - t.Fatal("expected text block") - } - if task[0].Text.Content() != "This is a simple text block." { - t.Errorf("expected 'This is a simple text block.', got %q", task[0].Text.Content()) - } - }, + // Language-tagged fenced code block. + name: "fenced code block with language tag", + input: "# Heading\n\n```bash\n/inside-code\necho hello\n```\n\n/real-command\n", + check: checkCommandsOutsideCodeBlock("real-command"), }, { - name: "simple slash command without arguments", - input: "/fix-bug\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - if task[0].SlashCommand == nil { - t.Fatal("expected slash command block") - } - if task[0].SlashCommand.Name != "fix-bug" { - t.Errorf("expected name 'fix-bug', got %q", task[0].SlashCommand.Name) - } - if len(task[0].SlashCommand.Arguments) != 0 { - t.Errorf("expected no arguments, got %d", len(task[0].SlashCommand.Arguments)) - } - }, + // Indented code block (4 spaces): content lines start with spaces then /, + // which would otherwise match the slash command grammar. + name: "indented code block: slash inside not a command", + input: "Text before\n\n /indented-command\n more code\n\n/real-command\n", + check: checkCommandsOutsideCodeBlock("real-command"), }, { - name: "slash command with positional arguments", - input: "/fix-bug 123 urgent\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - cmd := task[0].SlashCommand - if cmd == nil { - t.Fatal("expected slash command block") - } - if cmd.Name != "fix-bug" { - t.Errorf("expected name 'fix-bug', got %q", cmd.Name) - } - if len(cmd.Arguments) != 2 { - t.Fatalf("expected 2 arguments, got %d", len(cmd.Arguments)) - } - if cmd.Arguments[0].Value != "123" { - t.Errorf("expected first arg '123', got %q", cmd.Arguments[0].Value) - } - if cmd.Arguments[1].Value != "urgent" { - t.Errorf("expected second arg 'urgent', got %q", cmd.Arguments[1].Value) - } - }, + // Indented code block round-trip. + name: "indented code block: round-trip preserves content", + input: "Text before\n\n /indented-command\n more code\n\n/real-command\n", + check: checkRoundTrip("Text before\n\n /indented-command\n more code\n\n/real-command\n"), }, { - name: "slash command with quoted argument", - input: "/fix-bug \"Fix authentication bug\"\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - cmd := task[0].SlashCommand - if cmd == nil { - t.Fatal("expected slash command block") - } - if len(cmd.Arguments) != 1 { - t.Fatalf("expected 1 argument, got %d", len(cmd.Arguments)) - } - // The parser captures the quotes as part of the String token - expectedValue := `"Fix authentication bug"` - if cmd.Arguments[0].Value != expectedValue { - t.Errorf("expected argument %q, got %q", expectedValue, cmd.Arguments[0].Value) - } - }, + // Multiple fenced code blocks with slash commands between them. + name: "multiple fenced code blocks with commands between", + input: "```\n/inside-first\n```\n/between-blocks\n```\n/inside-second\n```\n", + check: checkCommandsOutsideCodeBlock("between-blocks"), }, { - name: "slash command with named argument", - input: "/deploy env=\"production\"\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - cmd := task[0].SlashCommand - if cmd == nil { - t.Fatal("expected slash command block") - } - if len(cmd.Arguments) != 1 { - t.Fatalf("expected 1 argument, got %d", len(cmd.Arguments)) - } - if cmd.Arguments[0].Key != "env" { - t.Errorf("expected key 'env', got %q", cmd.Arguments[0].Key) - } - expectedValue := `"production"` - if cmd.Arguments[0].Value != expectedValue { - t.Errorf("expected value %q, got %q", expectedValue, cmd.Arguments[0].Value) - } - }, + // Fenced code block at the very start of the content. + name: "fenced code block at start", + input: "```\n/code-only\n```\n", + check: checkNoSlashCommands, }, { - name: "slash command with mixed positional and named arguments", - input: "/task arg1 key=\"value\" arg2\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - cmd := task[0].SlashCommand - if cmd == nil { - t.Fatal("expected slash command block") - } - if len(cmd.Arguments) != 3 { - t.Fatalf("expected 3 arguments, got %d", len(cmd.Arguments)) - } - if cmd.Arguments[0].Key != "" || cmd.Arguments[0].Value != "arg1" { - t.Errorf("expected positional arg 'arg1', got key=%q, value=%q", cmd.Arguments[0].Key, cmd.Arguments[0].Value) - } - if cmd.Arguments[1].Key != "key" || cmd.Arguments[1].Value != `"value"` { - t.Errorf("expected named arg key='key', value='\"value\"', got key=%q, value=%q", cmd.Arguments[1].Key, cmd.Arguments[1].Value) - } - if cmd.Arguments[2].Key != "" || cmd.Arguments[2].Value != "arg2" { - t.Errorf("expected positional arg 'arg2', got key=%q, value=%q", cmd.Arguments[2].Key, cmd.Arguments[2].Value) - } - }, + // Fenced code block at the very end (no trailing real commands). + name: "fenced code block at end, no commands after", + input: "/real-command\n```\n/code-only\n```\n", + check: checkCommandsOutsideCodeBlock("real-command"), }, { - name: "text block followed by slash command", - input: "Some text here\n/fix-bug 123\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 2 { - t.Fatalf("expected 2 blocks, got %d", len(task)) - } - if task[0].Text == nil { - t.Fatal("expected first block to be text") - } - if task[1].SlashCommand == nil { - t.Fatal("expected second block to be slash command") - } - }, + // Empty fenced code block (no content lines). + name: "empty fenced code block", + input: "```\n```\n/real-command\n", + check: checkCommandsOutsideCodeBlock("real-command"), }, { - name: "slash command followed by text block", - input: "/fix-bug 123\nSome text after command", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 2 { - t.Fatalf("expected 2 blocks, got %d", len(task)) - } - if task[0].SlashCommand == nil { - t.Fatal("expected first block to be slash command") - } - if task[1].Text == nil { - t.Fatal("expected second block to be text") - } - }, + // Inline code (backtick) with slash: already safe because the line starts with ` + // but we verify that it does not create a false command. + name: "inline code span with slash is not a command", + input: "Use `\\/path` or `/other` in the text.\n", + check: checkNoSlashCommands, }, { - name: "multiple slash commands", - input: "/command1 arg1\n/command2 arg2\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 2 { - t.Fatalf("expected 2 blocks, got %d", len(task)) - } - if task[0].SlashCommand == nil || task[0].SlashCommand.Name != "command1" { - t.Fatal("expected first block to be command1") - } - if task[1].SlashCommand == nil || task[1].SlashCommand.Name != "command2" { - t.Fatal("expected second block to be command2") - } - }, + // HTML block: slash inside HTML block should not be a command. + name: "HTML block: slash inside not a command", + input: "\n\n/real-command\n", + check: checkCommandsOutsideCodeBlock("real-command"), }, { - name: "text with inline slash (not at line start)", - input: "This is text with a /slash in the middle.", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - if task[0].Text == nil { - t.Fatal("expected text block") - } - // The inline slash should be part of the text - if !strings.Contains(task[0].Text.Content(), "/slash") { - t.Errorf("expected text to contain '/slash', got %q", task[0].Text.Content()) - } - }, + // Block quote: the grammar already treats > at line start as text, + // but verify the behavior is correct. + name: "block quote with slash is not a command", + input: "> /quoted-line\n\n/real-command\n", + check: checkCommandsOutsideCodeBlock("real-command"), }, { - name: "non-whitespace before slash prevents command", - input: "text/deploy env=\"production\"\n", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - if task[0].Text == nil { - t.Fatal("expected text block, not command") - } - // The slash should be part of the text, not a command - if task[0].SlashCommand != nil { - t.Fatal("expected no slash command when non-whitespace precedes slash") - } - if !strings.Contains(task[0].Text.Content(), "/deploy") { - t.Errorf("expected text to contain '/deploy', got %q", task[0].Text.Content()) - } - }, + // Content with no code blocks behaves the same as before. + name: "no code blocks: slash commands still detected normally", + input: "Some intro text\n/deploy env=\"prod\"\nSome outro text\n", + check: checkCommandsOutsideCodeBlock("deploy"), }, { - name: "text with equals sign", - input: "This is text with key=value pairs.", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - if task[0].Text == nil { - t.Fatal("expected text block") - } - }, + // Fenced code block whose content contains a command-like line with arguments. + name: "fenced code block with command-like line including arguments", + input: "```sh\n/deploy env=\"production\" region=\"us-east-1\"\n```\n\n/real env=\"staging\"\n", + check: checkCommandsOutsideCodeBlock("real"), }, { - name: "multiline text preserves whitespace", - input: "Line 1\n Indented line 2\nLine 3", - wantErr: false, - check: func(t *testing.T, task Task) { - if len(task) != 1 { - t.Fatalf("expected 1 block, got %d", len(task)) - } - if task[0].Text == nil { - t.Fatal("expected text block") - } - // Check that whitespace is preserved - expected := "Line 1\n Indented line 2\nLine 3" - if task[0].Text.Content() != expected { - t.Errorf("expected %q, got %q", expected, task[0].Text.Content()) - } - }, + // Nested code blocks are not valid markdown, but indented fenced blocks + // inside a list item should still be protected. + name: "fenced code block inside list item", + input: "- item 1\n- item 2\n ```\n /code-in-list\n ```\n\n/real-command\n", + check: checkCommandsOutsideCodeBlock("real-command"), }, { - name: "complex mixed content", - input: "Introduction text\n/command1 arg1 key=\"value\"\nMiddle text\n/command2\nEnding text", - wantErr: false, + // Plain text without code blocks: existing parser behavior is unchanged. + name: "plain text no code blocks", + input: "This is plain text without any code blocks.", check: func(t *testing.T, task Task) { - if len(task) != 5 { - t.Fatalf("expected 5 blocks, got %d", len(task)) - } - if task[0].Text == nil { - t.Fatal("expected block 0 to be text") - } - if task[1].SlashCommand == nil || task[1].SlashCommand.Name != "command1" { - t.Fatal("expected block 1 to be command1") - } - if task[2].Text == nil { - t.Fatal("expected block 2 to be text") - } - if task[3].SlashCommand == nil || task[3].SlashCommand.Name != "command2" { - t.Fatal("expected block 3 to be command2") + t.Helper() + + if len(task) != 1 || task[0].Text == nil { + t.Fatal("expected a single text block") } - if task[4].Text == nil { - t.Fatal("expected block 4 to be text") + + if task[0].Text.Content() != "This is plain text without any code blocks." { + t.Errorf("unexpected content: %q", task[0].Text.Content()) } }, }, } +} + +func TestParseTask_MarkdownAware(t *testing.T) { + t.Parallel() + + tests := markdownAwareTestCases() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + task, err := ParseTask(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTask() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if !tt.wantErr && tt.check != nil { + tt.check(t, task) + } + }) + } +} + +func TestParseTask(t *testing.T) { + t.Parallel() + + tests := []parseTaskCase{ + {name: "empty string", input: "", check: checkEmptyTask}, + {name: "single newline", input: "\n", check: checkEmptyTask}, + {name: "multiple newlines", input: "\n\n\n", check: checkEmptyTask}, + {name: "whitespace only", input: " \t \n \n", check: checkEmptyTask}, + {name: "simple text block", input: "This is a simple text block.", check: checkSimpleTextBlock}, + {name: "simple slash command without arguments", input: "/fix-bug\n", check: checkSimpleSlashNoArgs}, + {name: "slash command with positional arguments", input: "/fix-bug 123 urgent\n", check: checkSlashWithTwoArgs}, + {name: "slash command with quoted argument", + input: "/fix-bug \"Fix authentication bug\"\n", check: checkSlashWithQuotedArg}, + {name: "slash command with named argument", + input: "/deploy env=\"production\"\n", check: checkSlashWithNamedArg}, + {name: "slash command with mixed positional and named arguments", + input: "/task arg1 key=\"value\" arg2\n", check: checkSlashMixedArgs}, + {name: "text block followed by slash command", + input: "Some text here\n/fix-bug 123\n", check: checkTextThenSlash}, + {name: "slash command followed by text block", + input: "/fix-bug 123\nSome text after command", check: checkSlashThenText}, + {name: "multiple slash commands", + input: "/command1 arg1\n/command2 arg2\n", check: checkTwoSlashCommands}, + {name: "text with inline slash (not at line start)", + input: "This is text with a /slash in the middle.", check: checkTextWithInlineSlash}, + {name: "non-whitespace before slash prevents command", + input: "text/deploy env=\"production\"\n", check: checkNonWhitespaceBeforeSlash}, + {name: "text with equals sign", + input: "This is text with key=value pairs.", check: checkSingleTextBlockOnly}, + {name: "multiline text preserves whitespace", + input: "Line 1\n Indented line 2\nLine 3", check: checkMultilineTextContent}, + {name: "complex mixed content", + input: "Introduction text\n/command1 arg1 key=\"value\"\nMiddle text\n/command2\nEnding text", + check: checkComplexMixedContent}, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + task, err := ParseTask(tt.input) if (err != nil) != tt.wantErr { t.Errorf("ParseTask() error = %v, wantErr %v", err, tt.wantErr) + return } + if !tt.wantErr && tt.check != nil { tt.check(t, task) } @@ -343,6 +542,8 @@ func TestParseTask(t *testing.T) { } func TestTask_String(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -363,10 +564,13 @@ func TestTask_String(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + task, err := ParseTask(tt.input) if err != nil { t.Fatalf("ParseTask() error = %v", err) } + result := task.String() // The string representation should closely match the input // Note: exact match may not be possible due to whitespace normalization @@ -377,7 +581,10 @@ func TestTask_String(t *testing.T) { } } +//nolint:funlen func TestSlashCommand_Params(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -556,66 +763,72 @@ func TestSlashCommand_Params(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + task, err := ParseTask(tt.input) if err != nil { t.Fatalf("ParseTask() error = %v", err) } - // Check for SlashCommand at the expected index - if len(task) <= tt.commandIndex { - t.Fatalf("expected at least %d blocks, got %d", tt.commandIndex+1, len(task)) - } - if task[tt.commandIndex].SlashCommand == nil { - t.Fatalf("expected slash command block at index %d", tt.commandIndex) - } + verifySlashCommandParams(t, task, tt.commandIndex, tt.expectedName, tt.expectedParams, tt.expectedArgs) + }) + } +} - cmd := task[tt.commandIndex].SlashCommand - if cmd.Name != tt.expectedName { - t.Errorf("expected command name %q, got %q", tt.expectedName, cmd.Name) - } +//nolint:cyclop +func verifySlashCommandParams(t *testing.T, task Task, idx int, name string, expected Params, expectedArgs []string) { + t.Helper() - // Use Params() to validate expectations - params := cmd.Params() - - // Validate positional arguments - actualArgs := params.Arguments() - if len(tt.expectedArgs) != len(actualArgs) { - t.Errorf("expected %d positional arguments, got %d: expected=%v, got=%v", - len(tt.expectedArgs), len(actualArgs), tt.expectedArgs, actualArgs) - } else { - for i, expected := range tt.expectedArgs { - if i < len(actualArgs) && actualArgs[i] != expected { - t.Errorf("positional arg[%d]: expected %q, got %q", i, expected, actualArgs[i]) - } - } + if len(task) <= idx { + t.Fatalf("expected at least %d blocks, got %d", idx+1, len(task)) + } + + if task[idx].SlashCommand == nil { + t.Fatalf("expected slash command block at index %d", idx) + } + + cmd := task[idx].SlashCommand + if cmd.Name != name { + t.Errorf("expected command name %q, got %q", name, cmd.Name) + } + + params := cmd.Params() + actualArgs := params.Arguments() + + if len(expectedArgs) != len(actualArgs) { + t.Errorf("expected %d positional arguments, got %d: expected=%v, got=%v", + len(expectedArgs), len(actualArgs), expectedArgs, actualArgs) + } else { + for i, exp := range expectedArgs { + if i < len(actualArgs) && actualArgs[i] != exp { + t.Errorf("positional arg[%d]: expected %q, got %q", i, exp, actualArgs[i]) } + } + } - // Validate named parameters - for key, expectedValues := range tt.expectedParams { - if key == ArgumentsKey { - continue // Already validated above - } - actualValues := params.Values(key) - if len(expectedValues) != len(actualValues) { - t.Errorf("key %q: expected %d values, got %d: expected=%v, got=%v", - key, len(expectedValues), len(actualValues), expectedValues, actualValues) - } else { - for i, expected := range expectedValues { - if i < len(actualValues) && actualValues[i] != expected { - t.Errorf("key %q[%d]: expected %q, got %q", key, i, expected, actualValues[i]) - } - } + for key, expectedValues := range expected { + if key == ArgumentsKey { + continue + } + + actualValues := params.Values(key) + if len(expectedValues) != len(actualValues) { + t.Errorf("key %q: expected %d values, got %d: expected=%v, got=%v", + key, len(expectedValues), len(actualValues), expectedValues, actualValues) + } else { + for i, exp := range expectedValues { + if i < len(actualValues) && actualValues[i] != exp { + t.Errorf("key %q[%d]: expected %q, got %q", key, i, exp, actualValues[i]) } } + } + } - // Verify no unexpected keys (except ArgumentsKey which we handle separately) - for key := range params { - if key != ArgumentsKey { - if _, exists := tt.expectedParams[key]; !exists { - t.Errorf("unexpected key in params: %q", key) - } - } + for key := range params { + if key != ArgumentsKey { + if _, exists := expected[key]; !exists { + t.Errorf("unexpected key in params: %q", key) } - }) + } } } diff --git a/pkg/codingcontext/tokencount/tokencount.go b/pkg/codingcontext/tokencount/tokencount.go index 89d0146..06c4f31 100644 --- a/pkg/codingcontext/tokencount/tokencount.go +++ b/pkg/codingcontext/tokencount/tokencount.go @@ -1,14 +1,18 @@ +// Package tokencount provides token estimation for LLM text. package tokencount import ( "unicode/utf8" ) +// CharsPerToken is the approximate number of characters per token for GPT-style tokenizers. +const CharsPerToken = 4 + // EstimateTokens estimates the number of LLM tokens in the given text. // Uses a simple heuristic of approximately 4 characters per token, // which is a common approximation for English text with GPT-style tokenizers. func EstimateTokens(text string) int { charCount := utf8.RuneCountInString(text) - // Approximate: 1 token ≈ 4 characters - return charCount / 4 + + return charCount / CharsPerToken } diff --git a/pkg/codingcontext/tokencount/tokencount_test.go b/pkg/codingcontext/tokencount/tokencount_test.go index 1e9afb5..69b3bc4 100644 --- a/pkg/codingcontext/tokencount/tokencount_test.go +++ b/pkg/codingcontext/tokencount/tokencount_test.go @@ -7,6 +7,8 @@ import ( ) func TestEstimateTokens(t *testing.T) { + t.Parallel() + tests := []struct { name string text string @@ -29,7 +31,8 @@ func TestEstimateTokens(t *testing.T) { }, { name: "paragraph", - text: "This is a longer paragraph with multiple words that should result in more tokens being counted by our estimation algorithm.", + text: "This is a longer paragraph with multiple words that should result in more tokens " + + "being counted by our estimation algorithm.", want: 30, // 123 chars / 4 = 30 }, { @@ -60,6 +63,8 @@ This is content.`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tokencount.EstimateTokens(tt.text) if got != tt.want { t.Errorf("estimateTokens() = %d, want %d", got, tt.want)