From 0b81465f7e57072ab6b1403cf604b6f5ba44dcb3 Mon Sep 17 00:00:00 2001 From: mpowers5 Date: Wed, 12 Nov 2025 15:40:53 +0000 Subject: [PATCH 1/6] Switch from go.yaml.in/yaml to github.com/goccy/go-yaml. Previous library is archived and is no longer maintained. --- go.mod | 2 +- go.sum | 6 ++---- markdown.go | 9 +-------- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index d9002b5..88b04f1 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/kitproj/coding-context-cli go 1.24.4 require ( + github.com/goccy/go-yaml v1.18.0 github.com/hashicorp/go-getter/v2 v2.2.3 - gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index eab3b09..ac7f68c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +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= @@ -22,7 +24,3 @@ github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdI github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/ulikunitz/xz v0.5.8 h1:ERv8V6GKqVi23rgu5cj9pVfVzJbOqAY2Ntl88O6c2nQ= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/markdown.go b/markdown.go index 43e96d0..9993998 100644 --- a/markdown.go +++ b/markdown.go @@ -6,18 +6,11 @@ import ( "fmt" "os" - "gopkg.in/yaml.v3" + yaml "github.com/goccy/go-yaml" ) // parseMarkdownFile parses the file into frontmatter and content func parseMarkdownFile(path string, frontmatter any) (string, error) { - content, _, err := parseMarkdownFileWithRawFrontmatter(path, frontmatter) - return content, err -} - -// parseMarkdownFileWithRawFrontmatter parses the file and returns content and raw frontmatter text -func parseMarkdownFileWithRawFrontmatter(path string, frontmatter any) (string, string, error) { - fh, err := os.Open(path) if err != nil { return "", "", fmt.Errorf("failed to open file: %w", err) From c87575653eb11b6d0939c0bc009a6647abe8ae7d Mon Sep 17 00:00:00 2001 From: mpowers5 Date: Wed, 12 Nov 2025 19:09:57 +0000 Subject: [PATCH 2/6] Refactor of main.go Changes: * Moved static path lists to paths.go * Removed global state for testability. * Split run() into multiple functions for readability / maintainability. * Added tests. --- main.go | 457 ++++++++++++------------- main_test.go | 949 +++++++++++++++++++++++++++++++++++++++++++++++++++ paths.go | 77 +++++ remote.go | 20 ++ 4 files changed, 1258 insertions(+), 245 deletions(-) create mode 100644 main_test.go create mode 100644 paths.go diff --git a/main.go b/main.go index a332805..0602733 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( _ "embed" "flag" "fmt" + "io" "os" "os/exec" "os/signal" @@ -13,97 +14,109 @@ import ( "syscall" ) -var ( - workDir string - resume bool - printTaskFrontmatter bool - params = make(paramMap) - includes = make(selectorMap) - remotePaths []string -) +type codingContext struct { + workDir string + resume bool + params paramMap + includes selectorMap + remotePaths []string + + downloadedDirs []string + matchingTaskFile string + totalTokens int + output io.Writer + logOut io.Writer + cmdRunner func(cmd *exec.Cmd) error +} func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - flag.StringVar(&workDir, "C", ".", "Change to directory before doing anything.") - flag.BoolVar(&resume, "r", false, "Resume mode: skip outputting rules and select task with 'resume: true' in frontmatter.") - flag.BoolVar(&printTaskFrontmatter, "t", false, "Print task frontmatter at the beginning of output.") - 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.") + cc := &codingContext{ + params: make(paramMap), + includes: make(selectorMap), + output: os.Stdout, + logOut: flag.CommandLine.Output(), + cmdRunner: func(cmd *exec.Cmd) error { + return cmd.Run() + }, + } + + flag.StringVar(&cc.workDir, "C", ".", "Change to directory before doing anything.") + flag.BoolVar(&cc.resume, "r", false, "Resume mode: skip outputting rules and select task with 'resume: true' in frontmatter.") + flag.Var(&cc.params, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") + flag.Var(&cc.includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") flag.Func("d", "Remote directory containing rules and tasks. Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, etc.).", func(s string) error { - remotePaths = append(remotePaths, s) + cc.remotePaths = append(cc.remotePaths, s) return nil }) flag.Usage = func() { - w := flag.CommandLine.Output() - fmt.Fprintf(w, "Usage:") - fmt.Fprintln(w) - fmt.Fprintln(w, " coding-context [options] ") - fmt.Fprintln(w) - fmt.Fprintln(w, "Options:") + fmt.Fprintf(cc.logOut, "Usage:") + fmt.Fprintln(cc.logOut) + fmt.Fprintln(cc.logOut, " coding-context [options] ") + fmt.Fprintln(cc.logOut) + fmt.Fprintln(cc.logOut, "Options:") flag.PrintDefaults() } flag.Parse() - if err := run(ctx, flag.Args()); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + if err := cc.run(ctx, flag.Args()); err != nil { + fmt.Fprintf(cc.logOut, "Error: %v\n", err) flag.Usage() os.Exit(1) } } -func run(ctx context.Context, args []string) error { +func (cc *codingContext) run(ctx context.Context, args []string) error { if len(args) != 1 { return fmt.Errorf("invalid usage") } - if err := os.Chdir(workDir); err != nil { - return fmt.Errorf("failed to chdir to %s: %w", workDir, err) + if err := os.Chdir(cc.workDir); err != nil { + return fmt.Errorf("failed to chdir to %s: %w", cc.workDir, err) } - // Download remote directories if specified - var downloadedDirs []string - defer func() { - // Clean up downloaded directories on exit - for _, dir := range downloadedDirs { - cleanupRemoteDirectory(dir) - } - }() - - for _, remotePath := range remotePaths { - fmt.Fprintf(os.Stderr, "⪢ Downloading remote directory: %s\n", remotePath) - localPath, err := downloadRemoteDirectory(ctx, remotePath) - if err != nil { - return fmt.Errorf("failed to download remote directory %s: %w", remotePath, err) - } - downloadedDirs = append(downloadedDirs, localPath) - fmt.Fprintf(os.Stderr, "⪢ Downloaded to: %s\n", localPath) + if err := cc.downloadRemoteDirectories(ctx); err != nil { + return fmt.Errorf("failed to download remote directories: %w", err) } + defer cc.cleanupDownloadedDirectories() // Add task name to includes so rules can be filtered by task taskName := args[0] - includes["task_name"] = taskName - includes["resume"] = fmt.Sprint(resume) + cc.includes["task_name"] = taskName + cc.includes["resume"] = fmt.Sprint(cc.resume) homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get user home directory: %w", err) } - // find the task prompt by searching for a file with matching task_name in frontmatter - taskSearchDirs := []string{ - filepath.Join(".agents", "tasks"), - filepath.Join(homeDir, ".agents", "tasks"), + if err := cc.findTaskFile(homeDir, taskName); err != nil { + return fmt.Errorf("failed to find task file: %w", err) } + if err := cc.findExecuteRuleFiles(ctx, homeDir); err != nil { + return fmt.Errorf("failed to find and execute rule files: %w", err) + } + + if err := cc.writeTaskFileContent(); err != nil { + return fmt.Errorf("failed to write task file content: %w", err) + } + + return nil +} + +func (cc *codingContext) findTaskFile(homeDir string, taskName string) error { + // find the task prompt by searching for a file with matching task_name in frontmatter + taskSearchDirs := allTaskSearchPaths(homeDir) + // Add downloaded remote directories to task search paths - for _, dir := range downloadedDirs { - taskSearchDirs = append(taskSearchDirs, filepath.Join(dir, ".agents", "tasks")) + for _, dir := range cc.downloadedDirs { + taskSearchDirs = append(taskSearchDirs, downloadedTaskSearchPaths(dir)...) } - var matchingTaskFile string for _, dir := range taskSearchDirs { if _, err := os.Stat(dir); os.IsNotExist(err) { continue @@ -111,215 +124,170 @@ func run(ctx context.Context, args []string) error { return fmt.Errorf("failed to stat task dir %s: %w", dir, err) } - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } + if err := filepath.Walk(dir, cc.taskFileWalker(taskName)); err != nil { + return err + } + } - // Only process .md files as task files - if filepath.Ext(path) != ".md" { - return nil - } - - // Parse frontmatter to check task_name - var frontmatter frontMatter - _, err = parseMarkdownFile(path, &frontmatter) - if err != nil { - return fmt.Errorf("failed to parse task file %s: %w", path, err) - } - - // Check if task_name is present in frontmatter - if _, hasTaskName := frontmatter["task_name"]; !hasTaskName { - return fmt.Errorf("task file %s is missing required 'task_name' field in frontmatter", path) - } - - // Check if file matches include selectors (task_name is already in includes) - if !includes.matchesIncludes(frontmatter) { - return nil - } - - // If we already found a matching task, error on duplicate - if matchingTaskFile != "" { - return fmt.Errorf("multiple task files found with task_name=%s: %s and %s", taskName, matchingTaskFile, path) - } - - matchingTaskFile = path + if cc.matchingTaskFile == "" { + return fmt.Errorf("no task file found with task_name=%s matching selectors in frontmatter (searched in %v)", taskName, taskSearchDirs) + } - return nil - }) + return nil +} + +func (cc *codingContext) taskFileWalker(taskName string) func(path string, info os.FileInfo, err error) error { + return func(path string, info os.FileInfo, err error) error { if err != nil { + // Skip errors return err + } else if info.IsDir() { + // Skip directories + return nil + } else if filepath.Ext(path) != ".md" { + // Only process .md files as task files + return nil } - } - if matchingTaskFile == "" { - return fmt.Errorf("no task file found with task_name=%s matching selectors in frontmatter (searched in %v)", taskName, taskSearchDirs) + // Parse frontmatter to check task_name + var frontmatter frontMatter + + if _, err = parseMarkdownFile(path, &frontmatter); err != nil { + return fmt.Errorf("failed to parse task file %s: %w", path, err) + } + + // Check if task_name is present in frontmatter + if _, hasTaskName := frontmatter["task_name"]; !hasTaskName { + return fmt.Errorf("task file %s is missing required 'task_name' field in frontmatter", path) + } + + // Check if file matches include selectors (task_name is already in includes) + if !cc.includes.matchesIncludes(frontmatter) { + return nil + } + + // If we already found a matching task, error on duplicate + if cc.matchingTaskFile != "" { + return fmt.Errorf("multiple task files found with task_name=%s: %s and %s", taskName, cc.matchingTaskFile, path) + } + + cc.matchingTaskFile = path + + return nil } +} - taskPromptPath := matchingTaskFile +func (cc *codingContext) findExecuteRuleFiles(ctx context.Context, homeDir string) error { + // Skip rule file discovery in resume mode. + if cc.resume { + return nil + } - // Track total tokens - var totalTokens int + // Build the list of rule locations (local and remote) + rulePaths := allRulePaths(homeDir) - // Parse task file to get frontmatter and content - var taskFrontmatter frontMatter - taskContent, taskRawFrontmatter, err := parseMarkdownFileWithRawFrontmatter(taskPromptPath, &taskFrontmatter) - if err != nil { - return fmt.Errorf("failed to parse prompt file %s: %w", taskPromptPath, err) + // Append remote directories to rule paths + for _, dir := range cc.downloadedDirs { + rulePaths = append(rulePaths, downloadedRulePaths(dir)...) } - // Print task frontmatter at the beginning if requested - if printTaskFrontmatter { - fmt.Println("---") - fmt.Print(taskRawFrontmatter) - fmt.Println("---") - fmt.Println() + for _, rule := range rulePaths { + // Skip if the path doesn't exist + if _, err := os.Stat(rule); os.IsNotExist(err) { + continue + } else if err != nil { + return fmt.Errorf("failed to stat rule path %s: %w", rule, err) + } + + if err := filepath.Walk(rule, cc.ruleFileWalker(ctx)); err != nil { + return fmt.Errorf("failed to walk rule dir: %w", err) + } } - // Skip rules processing in resume mode - if !resume { - // Build the list of rule locations (local and remote) - rulePaths := []string{ - "CLAUDE.local.md", - - ".agents/rules", - ".cursor/rules", - ".augment/rules", - ".windsurf/rules", - ".opencode/agent", - ".opencode/command", - - ".github/copilot-instructions.md", - ".gemini/styleguide.md", - ".github/agents", - ".augment/guidelines.md", - - "AGENTS.md", - "CLAUDE.md", - "GEMINI.md", - - ".cursorrules", - ".windsurfrules", - - // ancestors - "../AGENTS.md", - "../CLAUDE.md", - "../GEMINI.md", - - "../../AGENTS.md", - "../../CLAUDE.md", - "../../GEMINI.md", - - // user - filepath.Join(homeDir, ".agents", "rules"), - filepath.Join(homeDir, ".claude", "CLAUDE.md"), - filepath.Join(homeDir, ".codex", "AGENTS.md"), - filepath.Join(homeDir, ".gemini", "GEMINI.md"), - filepath.Join(homeDir, ".opencode", "rules"), + return nil +} + +func (cc *codingContext) ruleFileWalker(ctx context.Context) func(path string, info os.FileInfo, err error) error { + return func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } else if info.IsDir() { + return nil } - // Append remote directories to rule paths - for _, dir := range downloadedDirs { - rulePaths = append(rulePaths, - filepath.Join(dir, ".agents", "rules"), - filepath.Join(dir, ".cursor", "rules"), - filepath.Join(dir, ".augment", "rules"), - filepath.Join(dir, ".windsurf", "rules"), - filepath.Join(dir, ".opencode", "agent"), - filepath.Join(dir, ".opencode", "command"), - filepath.Join(dir, ".github", "copilot-instructions.md"), - filepath.Join(dir, ".gemini", "styleguide.md"), - filepath.Join(dir, ".github", "agents"), - filepath.Join(dir, ".augment", "guidelines.md"), - filepath.Join(dir, "AGENTS.md"), - filepath.Join(dir, "CLAUDE.md"), - filepath.Join(dir, "GEMINI.md"), - filepath.Join(dir, ".cursorrules"), - filepath.Join(dir, ".windsurfrules"), - ) + // Only process .md and .mdc files as rule files + ext := filepath.Ext(path) + if ext != ".md" && ext != ".mdc" { + return nil } - for _, rule := range rulePaths { - - // Skip if the path doesn't exist - if _, err := os.Stat(rule); os.IsNotExist(err) { - continue - } else if err != nil { - return fmt.Errorf("failed to stat rule path %s: %w", rule, err) - } - - err := filepath.Walk(rule, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - - // Only process .md and .mdc files as rule files - ext := filepath.Ext(path) - if ext != ".md" && ext != ".mdc" { - return nil - } - - // Parse frontmatter to check selectors - var frontmatter frontMatter - content, err := parseMarkdownFile(path, &frontmatter) - if err != nil { - return fmt.Errorf("failed to parse markdown file: %w", err) - } - - // Check if file matches include selectors. - // Note: Files with duplicate basenames will both be included. - if !includes.matchesIncludes(frontmatter) { - fmt.Fprintf(os.Stderr, "⪢ Excluding rule file (does not match include selectors): %s\n", path) - return nil - } - - // Check for a bootstrap file named -bootstrap - // For example, setup.md -> setup-bootstrap, setup.mdc -> setup-bootstrap - baseNameWithoutExt := strings.TrimSuffix(path, ext) - bootstrapFilePath := baseNameWithoutExt + "-bootstrap" - - if _, err := os.Stat(bootstrapFilePath); err == nil { - // Bootstrap file exists, make it executable and run it before printing content - if err := os.Chmod(bootstrapFilePath, 0755); err != nil { - return fmt.Errorf("failed to chmod bootstrap file %s: %w", bootstrapFilePath, err) - } - - fmt.Fprintf(os.Stderr, "⪢ Running bootstrap script: %s\n", bootstrapFilePath) - - cmd := exec.CommandContext(ctx, bootstrapFilePath) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to run bootstrap script: %w", err) - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to stat bootstrap file %s: %w", bootstrapFilePath, err) - } - - // Estimate tokens for this file - tokens := estimateTokens(content) - totalTokens += tokens - fmt.Fprintf(os.Stderr, "⪢ Including rule file: %s (~%d tokens)\n", path, tokens) - fmt.Println(content) - - return nil - - }) - if err != nil { - return fmt.Errorf("failed to walk rule dir: %w", err) - } + // Parse frontmatter to check selectors + var frontmatter frontMatter + content, err := parseMarkdownFile(path, &frontmatter) + if err != nil { + return fmt.Errorf("failed to parse markdown file: %w", err) + } + + if err := cc.runBootstrapScript(ctx, path, ext); err != nil { + return fmt.Errorf("failed to run bootstrap script: %w", err) + } + + // Check if file matches include selectors. + // Note: Files with duplicate basenames will both be included. + if !cc.includes.matchesIncludes(frontmatter) { + fmt.Fprintf(cc.logOut, "⪢ Excluding rule file (does not match include selectors): %s\n", path) + return nil } - } // end of if !resume - expanded := os.Expand(taskContent, func(key string) string { - if val, ok := params[key]; ok { + // Estimate tokens for this file + tokens := estimateTokens(content) + cc.totalTokens += tokens + fmt.Fprintf(cc.logOut, "⪢ Including rule file: %s (~%d tokens)\n", path, tokens) + fmt.Fprintln(cc.output, content) + + return nil + } +} + +func (cc *codingContext) runBootstrapScript(ctx context.Context, path, ext string) error { + // Check for a bootstrap file named -bootstrap + // For example, setup.md -> setup-bootstrap, setup.mdc -> setup-bootstrap + baseNameWithoutExt := strings.TrimSuffix(path, ext) + 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 { + return fmt.Errorf("failed to chmod bootstrap file %s: %w", bootstrapFilePath, err) + } + + fmt.Fprintf(cc.logOut, "⪢ Running bootstrap script: %s\n", bootstrapFilePath) + + cmd := exec.CommandContext(ctx, bootstrapFilePath) + cmd.Stdout = cc.logOut + cmd.Stderr = cc.logOut + + if err := cc.cmdRunner(cmd); err != nil { + return fmt.Errorf("failed to run bootstrap script: %w", err) + } + + return nil +} + +func (cc *codingContext) writeTaskFileContent() error { + content, err := parseMarkdownFile(cc.matchingTaskFile, &struct{}{}) + if err != nil { + return fmt.Errorf("failed to parse prompt file %s: %w", cc.matchingTaskFile, err) + } + + expanded := os.Expand(content, func(key string) string { + if val, ok := cc.params[key]; ok { return val } // this might not exist, in that case, return the original text @@ -328,13 +296,12 @@ func run(ctx context.Context, args []string) error { // Estimate tokens for this file tokens := estimateTokens(expanded) - totalTokens += tokens - fmt.Fprintf(os.Stderr, "⪢ Including task file: %s (~%d tokens)\n", taskPromptPath, tokens) - - fmt.Println(expanded) + cc.totalTokens += tokens + fmt.Fprintf(cc.logOut, "⪢ Including task file: %s (~%d tokens)\n", cc.matchingTaskFile, tokens) + fmt.Fprintln(cc.output, expanded) // Print total token count - fmt.Fprintf(os.Stderr, "⪢ Total estimated tokens: %d\n", totalTokens) + fmt.Fprintf(cc.logOut, "⪢ Total estimated tokens: %d\n", cc.totalTokens) return nil } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..904ece8 --- /dev/null +++ b/main_test.go @@ -0,0 +1,949 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Helper to create a markdown file with frontmatter +func createMarkdownFile(t *testing.T, path string, frontmatter string, content string) { + t.Helper() + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("failed to create directory %s: %v", dir, err) + } + + var file string + if frontmatter != "" { + file = fmt.Sprintf("---\n%s\n---\n%s", frontmatter, content) + } else { + file = content + } + + if err := os.WriteFile(path, []byte(file), 0o644); err != nil { + t.Fatalf("failed to write file %s: %v", path, err) + } +} + +func TestRun(t *testing.T) { + tests := []struct { + name string + args []string + workDir string + resume bool + params paramMap + includes selectorMap + setupFiles func(t *testing.T, tmpDir string) + wantErr bool + errContains string + }{ + { + name: "no arguments", + args: []string{}, + wantErr: true, + errContains: "invalid usage", + }, + { + name: "too many arguments", + args: []string{"task1", "task2"}, + wantErr: true, + errContains: "invalid usage", + }, + { + name: "task not found", + args: []string{"nonexistent"}, + wantErr: true, + errContains: "no task file found", + }, + { + name: "successful task execution", + args: []string{"test_task"}, + setupFiles: func(t *testing.T, tmpDir string) { + // Create task file + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "test.md"), + "task_name: test_task", + "# Test Task\nThis is a test task.") + }, + wantErr: false, + }, + { + name: "task with parameters", + args: []string{"param_task"}, + params: paramMap{ + "name": "value", + }, + setupFiles: func(t *testing.T, tmpDir string) { + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "param.md"), + "task_name: param_task", + "# Test ${name}") + }, + wantErr: false, + }, + { + name: "resume mode skips rules", + args: []string{"resume_task"}, + resume: true, + setupFiles: func(t *testing.T, tmpDir string) { + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "resume.md"), + "task_name: resume_task\nresume: true", + "# Resume Task") + + // Create a rule file that should be skipped + createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), + "", + "# Rule that should be skipped") + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + // Setup test files + if tt.setupFiles != nil { + tt.setupFiles(t, tmpDir) + } + + // Change to temp dir + oldDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldDir) + + var output, logOut bytes.Buffer + cc := &codingContext{ + workDir: tmpDir, + resume: tt.resume, + params: tt.params, + includes: tt.includes, + output: &output, + logOut: &logOut, + cmdRunner: func(cmd *exec.Cmd) error { + return nil // Mock command runner + }, + } + + if cc.params == nil { + cc.params = make(paramMap) + } + if cc.includes == nil { + cc.includes = make(selectorMap) + } + + err = cc.run(context.Background(), tt.args) + + if tt.wantErr { + if err == nil { + t.Errorf("run() expected error, got nil") + } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("run() error = %v, should contain %q", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("run() unexpected error: %v\nLog output:\n%s", err, logOut.String()) + } + } + }) + } +} + +func TestFindTaskFile(t *testing.T) { + tests := []struct { + name string + taskName string + includes selectorMap + setupFiles func(t *testing.T, tmpDir string) + downloadedDirs []string // Directories to add to downloadedDirs + wantErr bool + errContains string + }{ + { + name: "task file not found", + taskName: "missing", + setupFiles: func(t *testing.T, tmpDir string) { + // No files created + }, + wantErr: true, + errContains: "no task file found", + }, + { + name: "task file found", + taskName: "my_task", + setupFiles: func(t *testing.T, tmpDir string) { + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "task.md"), + "task_name: my_task", + "# My Task") + }, + wantErr: false, + }, + { + name: "multiple task files with same name", + taskName: "duplicate", + setupFiles: func(t *testing.T, tmpDir string) { + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "task1.md"), + "task_name: duplicate", + "# Task 1") + createMarkdownFile(t, filepath.Join(taskDir, "task2.md"), + "task_name: duplicate", + "# Task 2") + }, + wantErr: true, + errContains: "multiple task files found", + }, + { + name: "task with matching selector", + taskName: "filtered_task", + includes: selectorMap{ + "env": "prod", + }, + setupFiles: func(t *testing.T, tmpDir string) { + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "task.md"), + "task_name: filtered_task\nenv: prod", + "# Filtered Task") + }, + wantErr: false, + }, + { + name: "task with non-matching selector", + taskName: "filtered_task", + includes: selectorMap{ + "env": "dev", + }, + setupFiles: func(t *testing.T, tmpDir string) { + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "task.md"), + "task_name: filtered_task\nenv: prod", + "# Filtered Task") + }, + wantErr: true, + errContains: "no task file found", + }, + { + name: "task missing task_name field", + taskName: "my_task", + setupFiles: func(t *testing.T, tmpDir string) { + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "task.md"), + "env: prod", + "# Task without name") + }, + wantErr: true, + errContains: "missing required 'task_name' field", + }, + { + name: "task file found in downloaded directory", + taskName: "downloaded_task", + setupFiles: func(t *testing.T, tmpDir string) { + // Create task file in downloaded directory + downloadedDir := filepath.Join(tmpDir, "downloaded") + taskDir := filepath.Join(downloadedDir, ".agents", "tasks") + createMarkdownFile(t, filepath.Join(taskDir, "task.md"), + "task_name: downloaded_task", + "# Downloaded Task") + }, + downloadedDirs: []string{"downloaded"}, // Relative path, will be joined with tmpDir + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tt.setupFiles(t, tmpDir) + + cc := &codingContext{ + includes: tt.includes, + } + if cc.includes == nil { + cc.includes = make(selectorMap) + } + cc.includes["task_name"] = tt.taskName + + // Set downloadedDirs if specified in test case + if len(tt.downloadedDirs) > 0 { + cc.downloadedDirs = make([]string, len(tt.downloadedDirs)) + for i, dir := range tt.downloadedDirs { + cc.downloadedDirs[i] = filepath.Join(tmpDir, dir) + } + } + + err := cc.findTaskFile(tmpDir, tt.taskName) + + if tt.wantErr { + if err == nil { + t.Errorf("findTaskFile() expected error, got nil") + } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("findTaskFile() error = %v, should contain %q", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("findTaskFile() unexpected error: %v", err) + } + if cc.matchingTaskFile == "" { + t.Errorf("findTaskFile() did not set matchingTaskFile") + } + } + }) + } +} + +func TestFindExecuteRuleFiles(t *testing.T) { + tests := []struct { + name string + resume bool + includes selectorMap + setupFiles func(t *testing.T, tmpDir string) + downloadedDirs []string // Directories to add to downloadedDirs + wantTokens int + wantMinTokens bool // Check that tokens > 0 + expectInOutput string + expectNotInOutput string + }{ + { + name: "resume mode skips rules", + resume: true, + setupFiles: func(t *testing.T, tmpDir string) { + createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), + "", + "# Rule File") + }, + wantTokens: 0, + }, + { + name: "include rule file", + resume: false, + setupFiles: func(t *testing.T, tmpDir string) { + createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), + "", + "# Rule File\nThis is a rule.") + }, + wantMinTokens: true, + expectInOutput: "# Rule File", + }, + { + name: "exclude rule with non-matching selector", + resume: false, + includes: selectorMap{ + "env": "prod", + }, + setupFiles: func(t *testing.T, tmpDir string) { + createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), + "env: dev", + "# Dev Rule") + }, + expectNotInOutput: "# Dev Rule", + }, + { + name: "include rule with matching selector", + resume: false, + includes: selectorMap{ + "env": "prod", + }, + setupFiles: func(t *testing.T, tmpDir string) { + createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), + "env: prod", + "# Prod Rule") + }, + wantMinTokens: true, + expectInOutput: "# Prod Rule", + }, + { + name: "include multiple rules", + resume: false, + setupFiles: func(t *testing.T, tmpDir string) { + createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), + "", + "# Rule 1") + createMarkdownFile(t, filepath.Join(tmpDir, "AGENTS.md"), + "", + "# Rule 2") + }, + wantMinTokens: true, + expectInOutput: "# Rule 1", + }, + { + name: "include .mdc files", + resume: false, + setupFiles: func(t *testing.T, tmpDir string) { + // .mdc files need to be in a rules directory + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + createMarkdownFile(t, filepath.Join(rulesDir, "rule.mdc"), + "", + "# MDC Rule") + }, + wantMinTokens: true, + expectInOutput: "# MDC Rule", + }, + { + name: "include rules from downloaded directories", + resume: false, + setupFiles: func(t *testing.T, tmpDir string) { + // Create a downloaded directory with rules + downloadedDir := filepath.Join(tmpDir, "downloaded") + createMarkdownFile(t, filepath.Join(downloadedDir, "CLAUDE.md"), + "", + "# Downloaded Rule") + // Also create a rule in a subdirectory + rulesDir := filepath.Join(downloadedDir, ".agents", "rules") + createMarkdownFile(t, filepath.Join(rulesDir, "remote.md"), + "", + "# Remote Rule") + }, + downloadedDirs: []string{"downloaded"}, // Relative path, will be joined with tmpDir + wantMinTokens: true, + expectInOutput: "Downloaded Rule", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tt.setupFiles(t, tmpDir) + + // Change to temp dir + oldDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + var output, logOut bytes.Buffer + cc := &codingContext{ + resume: tt.resume, + includes: tt.includes, + output: &output, + logOut: &logOut, + cmdRunner: func(cmd *exec.Cmd) error { + return nil // Mock command runner + }, + } + if cc.includes == nil { + cc.includes = make(selectorMap) + } + + // Set downloadedDirs if specified in test case + if len(tt.downloadedDirs) > 0 { + cc.downloadedDirs = make([]string, len(tt.downloadedDirs)) + for i, dir := range tt.downloadedDirs { + cc.downloadedDirs[i] = filepath.Join(tmpDir, dir) + } + } + + err = cc.findExecuteRuleFiles(context.Background(), tmpDir) + if err != nil { + t.Errorf("findExecuteRuleFiles() unexpected error: %v", err) + } + + if tt.wantMinTokens && cc.totalTokens <= 0 { + t.Errorf("findExecuteRuleFiles() expected tokens > 0, got %d", cc.totalTokens) + } + if !tt.wantMinTokens && tt.wantTokens != cc.totalTokens { + t.Errorf("findExecuteRuleFiles() expected %d tokens, got %d", tt.wantTokens, cc.totalTokens) + } + + outputStr := output.String() + if tt.expectInOutput != "" && !strings.Contains(outputStr, tt.expectInOutput) { + t.Errorf("findExecuteRuleFiles() output should contain %q, got:\n%s", tt.expectInOutput, outputStr) + } + if tt.expectNotInOutput != "" && strings.Contains(outputStr, tt.expectNotInOutput) { + t.Errorf("findExecuteRuleFiles() output should not contain %q, got:\n%s", tt.expectNotInOutput, outputStr) + } + }) + } +} + +func TestRunBootstrapScript(t *testing.T) { + tests := []struct { + name string + mdFile string + ext string + setupFiles func(t *testing.T, tmpDir string, mdFile string) string // returns bootstrap path + wantErr bool + expectRun bool + mockRunError error + }{ + { + name: "no bootstrap file", + mdFile: "test.md", + ext: ".md", + setupFiles: func(t *testing.T, tmpDir string, mdFile string) string { + // Don't create bootstrap file + return "" + }, + wantErr: false, + expectRun: false, + }, + { + name: "bootstrap file exists and runs", + mdFile: "test.md", + ext: ".md", + setupFiles: func(t *testing.T, tmpDir string, mdFile string) string { + bootstrapPath := filepath.Join(tmpDir, "test-bootstrap") + if err := os.WriteFile(bootstrapPath, []byte("#!/bin/sh\necho 'bootstrap'"), 0o644); err != nil { + t.Fatalf("failed to create bootstrap file: %v", err) + } + return bootstrapPath + }, + wantErr: false, + expectRun: true, + }, + { + name: "bootstrap file with .mdc extension", + mdFile: "test.mdc", + ext: ".mdc", + setupFiles: func(t *testing.T, tmpDir string, mdFile string) string { + bootstrapPath := filepath.Join(tmpDir, "test-bootstrap") + if err := os.WriteFile(bootstrapPath, []byte("#!/bin/sh\necho 'bootstrap'"), 0o644); err != nil { + t.Fatalf("failed to create bootstrap file: %v", err) + } + return bootstrapPath + }, + wantErr: false, + expectRun: true, + }, + { + name: "bootstrap file fails", + mdFile: "test.md", + ext: ".md", + setupFiles: func(t *testing.T, tmpDir string, mdFile string) string { + bootstrapPath := filepath.Join(tmpDir, "test-bootstrap") + if err := os.WriteFile(bootstrapPath, []byte("#!/bin/sh\nexit 1"), 0o644); err != nil { + t.Fatalf("failed to create bootstrap file: %v", err) + } + return bootstrapPath + }, + wantErr: true, + expectRun: true, + mockRunError: fmt.Errorf("exit status 1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + mdPath := filepath.Join(tmpDir, tt.mdFile) + bootstrapPath := tt.setupFiles(t, tmpDir, tt.mdFile) + + var logOut bytes.Buffer + cmdRan := false + cc := &codingContext{ + logOut: &logOut, + cmdRunner: func(cmd *exec.Cmd) error { + cmdRan = true + if tt.mockRunError != nil { + return tt.mockRunError + } + return nil + }, + } + + err := cc.runBootstrapScript(context.Background(), mdPath, tt.ext) + + if tt.wantErr { + if err == nil { + t.Errorf("runBootstrapScript() expected error, got nil") + } + } else { + if err != nil { + t.Errorf("runBootstrapScript() unexpected error: %v", err) + } + } + + if tt.expectRun && !cmdRan { + t.Errorf("runBootstrapScript() expected command to run, but it didn't") + } + if !tt.expectRun && cmdRan { + t.Errorf("runBootstrapScript() expected command not to run, but it did") + } + + // Check that bootstrap file was made executable if it existed + if bootstrapPath != "" { + info, err := os.Stat(bootstrapPath) + if err == nil && tt.expectRun { + mode := info.Mode() + if mode&0o100 == 0 { + t.Errorf("runBootstrapScript() bootstrap file should be executable") + } + } + } + }) + } +} + +func TestWriteTaskFileContent(t *testing.T) { + tests := []struct { + name string + taskFile string + params paramMap + setupFiles func(t *testing.T, tmpDir string) string // returns task file path + expectInOutput string + wantErr bool + }{ + { + name: "simple task", + taskFile: "task.md", + params: paramMap{}, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test", + "# Simple Task\nContent here.") + return taskPath + }, + expectInOutput: "# Simple Task", + wantErr: false, + }, + { + name: "task with parameter substitution", + taskFile: "task.md", + params: paramMap{ + "name": "Alice", + "value": "123", + }, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test", + "Hello ${name}, your value is ${value}.") + return taskPath + }, + expectInOutput: "Hello Alice, your value is 123.", + wantErr: false, + }, + { + name: "task with missing parameter", + taskFile: "task.md", + params: paramMap{}, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test", + "Hello ${missing}.") + return taskPath + }, + expectInOutput: "Hello ${missing}.", + wantErr: false, + }, + { + name: "task with partial parameter substitution", + taskFile: "task.md", + params: paramMap{ + "name": "Bob", + }, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test", + "Hello ${name}, your value is ${missing}.") + return taskPath + }, + expectInOutput: "Hello Bob, your value is ${missing}.", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + taskPath := tt.setupFiles(t, tmpDir) + + var output, logOut bytes.Buffer + cc := &codingContext{ + matchingTaskFile: taskPath, + params: tt.params, + output: &output, + logOut: &logOut, + } + + err := cc.writeTaskFileContent() + + if tt.wantErr { + if err == nil { + t.Errorf("writeTaskFileContent() expected error, got nil") + } + } else { + if err != nil { + t.Errorf("writeTaskFileContent() unexpected error: %v", err) + } + } + + if tt.expectInOutput != "" { + outputStr := output.String() + if !strings.Contains(outputStr, tt.expectInOutput) { + t.Errorf("writeTaskFileContent() output should contain %q, got:\n%s", tt.expectInOutput, outputStr) + } + } + + if !tt.wantErr && cc.totalTokens <= 0 { + t.Errorf("writeTaskFileContent() expected tokens > 0, got %d", cc.totalTokens) + } + }) + } +} + +func TestTaskFileWalker(t *testing.T) { + tests := []struct { + name string + taskName string + includes selectorMap + fileInfo fileInfoMock + filePath string + fileContent string // frontmatter + content + existingMatch string // existing matchingTaskFile + expectMatch bool + wantErr bool + errContains string + }{ + { + name: "skip directories", + taskName: "test", + fileInfo: fileInfoMock{isDir: true, name: "somedir"}, + filePath: "/test/somedir", + wantErr: false, + }, + { + name: "skip non-markdown files", + taskName: "test", + fileInfo: fileInfoMock{isDir: false, name: "file.txt"}, + filePath: "/test/file.txt", + wantErr: false, + }, + { + name: "matching task file", + taskName: "my_task", + fileInfo: fileInfoMock{isDir: false, name: "task.md"}, + filePath: "task.md", + fileContent: "---\ntask_name: my_task\n---\n# Task", + expectMatch: true, + wantErr: false, + }, + { + name: "non-matching task name", + taskName: "other_task", + fileInfo: fileInfoMock{isDir: false, name: "task.md"}, + filePath: "task.md", + fileContent: "---\ntask_name: my_task\n---\n# Task", + expectMatch: false, + wantErr: false, + }, + { + name: "duplicate task file", + taskName: "my_task", + fileInfo: fileInfoMock{isDir: false, name: "task2.md"}, + filePath: "task2.md", + fileContent: "---\ntask_name: my_task\n---\n# Task", + existingMatch: "task1.md", + wantErr: true, + errContains: "multiple task files found", + }, + { + name: "task missing task_name", + taskName: "test", + fileInfo: fileInfoMock{isDir: false, name: "task.md"}, + filePath: "task.md", + fileContent: "---\nother: value\n---\n# Task", + wantErr: true, + errContains: "missing required 'task_name' field", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + // Create the file if content is provided + if tt.fileContent != "" { + fullPath := filepath.Join(tmpDir, tt.filePath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + if err := os.WriteFile(fullPath, []byte(tt.fileContent), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + tt.filePath = fullPath + } + + cc := &codingContext{ + includes: tt.includes, + matchingTaskFile: tt.existingMatch, + } + if cc.includes == nil { + cc.includes = make(selectorMap) + } + cc.includes["task_name"] = tt.taskName + + walker := cc.taskFileWalker(tt.taskName) + err := walker(tt.filePath, &tt.fileInfo, nil) + + if tt.wantErr { + if err == nil { + t.Errorf("taskFileWalker() expected error, got nil") + } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("taskFileWalker() error = %v, should contain %q", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("taskFileWalker() unexpected error: %v", err) + } + } + + if tt.expectMatch && cc.matchingTaskFile == "" { + t.Errorf("taskFileWalker() expected to set matchingTaskFile, but it's empty") + } + if !tt.expectMatch && tt.existingMatch == "" && cc.matchingTaskFile != "" { + t.Errorf("taskFileWalker() expected no match, but matchingTaskFile = %s", cc.matchingTaskFile) + } + }) + } +} + +func TestRuleFileWalker(t *testing.T) { + tests := []struct { + name string + includes selectorMap + fileInfo fileInfoMock + filePath string + fileContent string + expectInOutput bool + expectExcludeLog bool + wantErr bool + }{ + { + name: "skip directories", + fileInfo: fileInfoMock{isDir: true, name: "somedir"}, + filePath: "/test/somedir", + wantErr: false, + }, + { + name: "skip non-markdown files", + fileInfo: fileInfoMock{isDir: false, name: "file.txt"}, + filePath: "/test/file.txt", + wantErr: false, + }, + { + name: "include rule file", + fileInfo: fileInfoMock{isDir: false, name: "rule.md"}, + filePath: "rule.md", + fileContent: "---\n---\n# Rule Content", + expectInOutput: true, + wantErr: false, + }, + { + name: "include mdc file", + fileInfo: fileInfoMock{isDir: false, name: "rule.mdc"}, + filePath: "rule.mdc", + fileContent: "---\n---\n# MDC Rule", + expectInOutput: true, + wantErr: false, + }, + { + name: "exclude rule with non-matching selector", + includes: selectorMap{"env": "prod"}, + fileInfo: fileInfoMock{isDir: false, name: "rule.md"}, + filePath: "rule.md", + fileContent: "---\nenv: dev\n---\n# Dev Rule", + expectInOutput: false, + expectExcludeLog: true, + wantErr: false, + }, + { + name: "include rule with matching selector", + includes: selectorMap{"env": "prod"}, + fileInfo: fileInfoMock{isDir: false, name: "rule.md"}, + filePath: "rule.md", + fileContent: "---\nenv: prod\n---\n# Prod Rule", + expectInOutput: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + // Create the file if content is provided + if tt.fileContent != "" { + fullPath := filepath.Join(tmpDir, tt.filePath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + if err := os.WriteFile(fullPath, []byte(tt.fileContent), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + tt.filePath = fullPath + } + + var output, logOut bytes.Buffer + cc := &codingContext{ + includes: tt.includes, + output: &output, + logOut: &logOut, + cmdRunner: func(cmd *exec.Cmd) error { + return nil // Mock command runner + }, + } + if cc.includes == nil { + cc.includes = make(selectorMap) + } + + walker := cc.ruleFileWalker(context.Background()) + err := walker(tt.filePath, &tt.fileInfo, nil) + + if tt.wantErr { + if err == nil { + t.Errorf("ruleFileWalker() expected error, got nil") + } + } else { + if err != nil { + t.Errorf("ruleFileWalker() unexpected error: %v", err) + } + } + + outputStr := output.String() + logStr := logOut.String() + + if tt.expectInOutput && !strings.Contains(outputStr, "Rule") { + t.Errorf("ruleFileWalker() expected output to contain rule content, got:\n%s", outputStr) + } + if !tt.expectInOutput && strings.Contains(outputStr, "Rule") { + t.Errorf("ruleFileWalker() expected output not to contain rule content, got:\n%s", outputStr) + } + + if tt.expectExcludeLog && !strings.Contains(logStr, "Excluding") { + t.Errorf("ruleFileWalker() expected log to contain 'Excluding', got:\n%s", logStr) + } + }) + } +} + +// Mock fileInfo for testing +type fileInfoMock struct { + name string + isDir bool +} + +func (f *fileInfoMock) Name() string { return f.name } +func (f *fileInfoMock) Size() int64 { return 0 } +func (f *fileInfoMock) Mode() os.FileMode { return 0o644 } +func (f *fileInfoMock) ModTime() time.Time { return time.Time{} } +func (f *fileInfoMock) IsDir() bool { return f.isDir } +func (f *fileInfoMock) Sys() interface{} { return nil } diff --git a/paths.go b/paths.go new file mode 100644 index 0000000..ed6d813 --- /dev/null +++ b/paths.go @@ -0,0 +1,77 @@ +package main + +import "path/filepath" + +func allTaskSearchPaths(homeDir string) []string { + return []string{ + filepath.Join(".agents", "tasks"), + filepath.Join(homeDir, ".agents", "tasks"), + } +} + +func allRulePaths(homeDir string) []string { + return []string{ + "CLAUDE.local.md", + + ".agents/rules", + ".cursor/rules", + ".augment/rules", + ".windsurf/rules", + ".opencode/agent", + ".opencode/command", + + ".github/copilot-instructions.md", + ".gemini/styleguide.md", + ".github/agents", + ".augment/guidelines.md", + + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + + ".cursorrules", + ".windsurfrules", + + // ancestors + "../AGENTS.md", + "../CLAUDE.md", + "../GEMINI.md", + + "../../AGENTS.md", + "../../CLAUDE.md", + "../../GEMINI.md", + + // user + filepath.Join(homeDir, ".agents", "rules"), + filepath.Join(homeDir, ".claude", "CLAUDE.md"), + filepath.Join(homeDir, ".codex", "AGENTS.md"), + filepath.Join(homeDir, ".gemini", "GEMINI.md"), + filepath.Join(homeDir, ".opencode", "rules"), + } +} + +func downloadedRulePaths(dir string) []string { + return []string{ + filepath.Join(dir, ".agents", "rules"), + filepath.Join(dir, ".cursor", "rules"), + filepath.Join(dir, ".augment", "rules"), + filepath.Join(dir, ".windsurf", "rules"), + filepath.Join(dir, ".opencode", "agent"), + filepath.Join(dir, ".opencode", "command"), + filepath.Join(dir, ".github", "copilot-instructions.md"), + filepath.Join(dir, ".gemini", "styleguide.md"), + filepath.Join(dir, ".github", "agents"), + filepath.Join(dir, ".augment", "guidelines.md"), + filepath.Join(dir, "AGENTS.md"), + filepath.Join(dir, "CLAUDE.md"), + filepath.Join(dir, "GEMINI.md"), + filepath.Join(dir, ".cursorrules"), + filepath.Join(dir, ".windsurfrules"), + } +} + +func downloadedTaskSearchPaths(dir string) []string { + return []string{ + filepath.Join(dir, ".agents", "tasks"), + } +} diff --git a/remote.go b/remote.go index 6df9e8e..e19b82b 100644 --- a/remote.go +++ b/remote.go @@ -9,6 +9,26 @@ import ( getter "github.com/hashicorp/go-getter/v2" ) +func (cc *codingContext) downloadRemoteDirectories(ctx context.Context) error { + for _, remotePath := range cc.remotePaths { + fmt.Fprintf(os.Stderr, "⪢ Downloading remote directory: %s\n", remotePath) + localPath, err := downloadRemoteDirectory(ctx, remotePath) + if err != nil { + return fmt.Errorf("failed to download remote directory %s: %w", remotePath, err) + } + cc.downloadedDirs = append(cc.downloadedDirs, localPath) + fmt.Fprintf(os.Stderr, "⪢ Downloaded to: %s\n", localPath) + } + + return nil +} + +func (cc *codingContext) cleanupDownloadedDirectories() { + for _, dir := range cc.downloadedDirs { + cleanupRemoteDirectory(dir) + } +} + // downloadRemoteDirectory downloads a remote directory using go-getter // and returns the local path where it was downloaded func downloadRemoteDirectory(ctx context.Context, src string) (string, error) { From bba0ca00d24864d47d6d4204d956b97dd8af795f Mon Sep 17 00:00:00 2001 From: mpowers5 Date: Wed, 12 Nov 2025 19:43:58 +0000 Subject: [PATCH 3/6] feat: Adds flag for emitting task frontmatter. --- main.go | 27 ++++++++++++++++++++------ main_test.go | 55 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/main.go b/main.go index 0602733..a03e682 100644 --- a/main.go +++ b/main.go @@ -12,14 +12,17 @@ import ( "path/filepath" "strings" "syscall" + + yaml "github.com/goccy/go-yaml" ) type codingContext struct { - workDir string - resume bool - params paramMap - includes selectorMap - remotePaths []string + workDir string + resume bool + params paramMap + includes selectorMap + remotePaths []string + emitTaskFrontmatter bool downloadedDirs []string matchingTaskFile string @@ -45,6 +48,7 @@ func main() { flag.StringVar(&cc.workDir, "C", ".", "Change to directory before doing anything.") flag.BoolVar(&cc.resume, "r", false, "Resume mode: skip outputting rules and select task with 'resume: true' in frontmatter.") + flag.BoolVar(&cc.emitTaskFrontmatter, "t", false, "Print task frontmatter at the beginning of output.") flag.Var(&cc.params, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") flag.Var(&cc.includes, "s", "Include rules with matching frontmatter. Can be specified multiple times as key=value.") flag.Func("d", "Remote directory containing rules and tasks. Can be specified multiple times. Supports various protocols via go-getter (http://, https://, git::, s3::, etc.).", func(s string) error { @@ -281,7 +285,9 @@ func (cc *codingContext) runBootstrapScript(ctx context.Context, path, ext strin } func (cc *codingContext) writeTaskFileContent() error { - content, err := parseMarkdownFile(cc.matchingTaskFile, &struct{}{}) + taskMatter := make(map[string]any) + + content, err := parseMarkdownFile(cc.matchingTaskFile, &taskMatter) if err != nil { return fmt.Errorf("failed to parse prompt file %s: %w", cc.matchingTaskFile, err) } @@ -298,6 +304,15 @@ func (cc *codingContext) writeTaskFileContent() error { tokens := estimateTokens(expanded) cc.totalTokens += tokens fmt.Fprintf(cc.logOut, "⪢ Including task file: %s (~%d tokens)\n", cc.matchingTaskFile, tokens) + + if cc.emitTaskFrontmatter { + fmt.Fprintln(cc.output, "---") + if err := yaml.NewEncoder(cc.output).Encode(taskMatter); err != nil { + return fmt.Errorf("failed to encode task matter: %w", err) + } + fmt.Fprintln(cc.output, "---") + } + fmt.Fprintln(cc.output, expanded) // Print total token count diff --git a/main_test.go b/main_test.go index 904ece8..364aab6 100644 --- a/main_test.go +++ b/main_test.go @@ -591,12 +591,13 @@ func TestRunBootstrapScript(t *testing.T) { func TestWriteTaskFileContent(t *testing.T) { tests := []struct { - name string - taskFile string - params paramMap - setupFiles func(t *testing.T, tmpDir string) string // returns task file path - expectInOutput string - wantErr bool + name string + taskFile string + params paramMap + emitTaskFrontmatter bool + setupFiles func(t *testing.T, tmpDir string) string // returns task file path + expectInOutput string + wantErr bool }{ { name: "simple task", @@ -659,6 +660,21 @@ func TestWriteTaskFileContent(t *testing.T) { expectInOutput: "Hello Bob, your value is ${missing}.", wantErr: false, }, + { + name: "task with frontmatter emission enabled", + taskFile: "task.md", + params: paramMap{}, + emitTaskFrontmatter: true, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test_task\nenv: production\nversion: 1.0", + "# Task with Frontmatter\nThis task has frontmatter.") + return taskPath + }, + expectInOutput: "task_name: test_task", + wantErr: false, + }, } for _, tt := range tests { @@ -668,10 +684,11 @@ func TestWriteTaskFileContent(t *testing.T) { var output, logOut bytes.Buffer cc := &codingContext{ - matchingTaskFile: taskPath, - params: tt.params, - output: &output, - logOut: &logOut, + matchingTaskFile: taskPath, + params: tt.params, + emitTaskFrontmatter: tt.emitTaskFrontmatter, + output: &output, + logOut: &logOut, } err := cc.writeTaskFileContent() @@ -686,13 +703,29 @@ func TestWriteTaskFileContent(t *testing.T) { } } + outputStr := output.String() if tt.expectInOutput != "" { - outputStr := output.String() if !strings.Contains(outputStr, tt.expectInOutput) { t.Errorf("writeTaskFileContent() output should contain %q, got:\n%s", tt.expectInOutput, outputStr) } } + // Additional checks for frontmatter emission + if tt.emitTaskFrontmatter { + // Verify frontmatter delimiters are present + if !strings.Contains(outputStr, "---") { + t.Errorf("writeTaskFileContent() with emitTaskFrontmatter=true should contain '---' delimiters, got:\n%s", outputStr) + } + // Verify YAML frontmatter structure + if !strings.Contains(outputStr, "task_name:") { + t.Errorf("writeTaskFileContent() with emitTaskFrontmatter=true should contain 'task_name:' field, got:\n%s", outputStr) + } + // Verify task content is still present + if !strings.Contains(outputStr, "# Task with Frontmatter") { + t.Errorf("writeTaskFileContent() should contain task content, got:\n%s", outputStr) + } + } + if !tt.wantErr && cc.totalTokens <= 0 { t.Errorf("writeTaskFileContent() expected tokens > 0, got %d", cc.totalTokens) } From 45a9fd7c48074ed0e01828865381de21edcab28b Mon Sep 17 00:00:00 2001 From: mpowers5 Date: Wed, 12 Nov 2025 20:00:29 +0000 Subject: [PATCH 4/6] feat: Add selectors to frontmatter for tasks. For Example: ```markdown --- task_name: my_task selectors: language: go env: prod --- ``` Would be the same as: ``` -s language=go -s env=prod ``` --- main.go | 101 ++++++++++-- main_test.go | 380 +++++++++++++++++++++++++++++++++++++++++-- markdown.go | 16 +- selector_map.go | 48 +++++- selector_map_test.go | 115 +++++++++---- 5 files changed, 580 insertions(+), 80 deletions(-) diff --git a/main.go b/main.go index a03e682..a951307 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,8 @@ type codingContext struct { downloadedDirs []string matchingTaskFile string + taskFrontmatter frontMatter // Parsed task frontmatter + taskContent string // Parsed task content (before parameter expansion) totalTokens int output io.Writer logOut io.Writer @@ -89,8 +91,8 @@ func (cc *codingContext) run(ctx context.Context, args []string) error { // Add task name to includes so rules can be filtered by task taskName := args[0] - cc.includes["task_name"] = taskName - cc.includes["resume"] = fmt.Sprint(cc.resume) + cc.includes["task_name"] = []string{taskName} + cc.includes["resume"] = []string{fmt.Sprint(cc.resume)} homeDir, err := os.UserHomeDir() if err != nil { @@ -101,12 +103,21 @@ func (cc *codingContext) run(ctx context.Context, args []string) error { return fmt.Errorf("failed to find task file: %w", err) } + // Parse task file early to extract selector labels for filtering rules and tools + if err := cc.parseTaskFile(); err != nil { + return fmt.Errorf("failed to parse task file: %w", err) + } + + if err := cc.printTaskFrontmatter(); err != nil { + return fmt.Errorf("failed to emit task frontmatter: %w", err) + } + if err := cc.findExecuteRuleFiles(ctx, homeDir); err != nil { return fmt.Errorf("failed to find and execute rule files: %w", err) } - if err := cc.writeTaskFileContent(); err != nil { - return fmt.Errorf("failed to write task file content: %w", err) + if err := cc.emitTaskFileContent(); err != nil { + return fmt.Errorf("failed to emit task file content: %w", err) } return nil @@ -284,15 +295,79 @@ func (cc *codingContext) runBootstrapScript(ctx context.Context, path, ext strin return nil } -func (cc *codingContext) writeTaskFileContent() error { - taskMatter := make(map[string]any) +// parseTaskFile parses the task file and extracts selector labels from frontmatter. +// The selectors are added to cc.includes for filtering rules and tools. +// The parsed frontmatter and content are stored in cc.taskFrontmatter and cc.taskContent. +func (cc *codingContext) parseTaskFile() error { + cc.taskFrontmatter = make(frontMatter) - content, err := parseMarkdownFile(cc.matchingTaskFile, &taskMatter) + content, err := parseMarkdownFile(cc.matchingTaskFile, &cc.taskFrontmatter) if err != nil { - return fmt.Errorf("failed to parse prompt file %s: %w", cc.matchingTaskFile, err) + return fmt.Errorf("failed to parse task file %s: %w", cc.matchingTaskFile, err) + } + + cc.taskContent = content + + // Extract selector labels from frontmatter + // Look for a "selectors" field that contains a map of key-value pairs + // Values can be strings or arrays (for OR logic) + if selectorsRaw, ok := cc.taskFrontmatter["selectors"]; ok { + selectorsMap, ok := selectorsRaw.(map[string]any) + if !ok { + // Try to handle it as a map[interface{}]interface{} (common YAML unmarshal result) + if selectorsMapAny, ok := selectorsRaw.(map[any]any); ok { + selectorsMap = make(map[string]any) + for k, v := range selectorsMapAny { + selectorsMap[fmt.Sprint(k)] = v + } + } else { + return fmt.Errorf("task file %s has invalid 'selectors' field: expected map, got %T", cc.matchingTaskFile, selectorsRaw) + } + } + + // Add selectors to includes + // Convert all values to []string slices for OR logic + for key, value := range selectorsMap { + var strSlice []string + switch v := value.(type) { + case []any: + // Convert []any to []string + strSlice = make([]string, len(v)) + for i, item := range v { + strSlice[i] = fmt.Sprint(item) + } + case string: + // Convert string to single-element slice + strSlice = []string{v} + default: + return fmt.Errorf("task file %s has invalid selector value for key %q: expected string or array, got %T", cc.matchingTaskFile, key, value) + } + cc.includes[key] = strSlice + } + } + + return nil +} + +// emitTaskFrontmatterOnly emits only the task frontmatter to the output. +// This is used to print frontmatter before rules when -t flag is set. +func (cc *codingContext) printTaskFrontmatter() error { + if !cc.emitTaskFrontmatter { + return nil } - expanded := os.Expand(content, func(key string) string { + fmt.Fprintln(cc.output, "---") + if err := yaml.NewEncoder(cc.output).Encode(cc.taskFrontmatter); err != nil { + return fmt.Errorf("failed to encode task frontmatter: %w", err) + } + fmt.Fprintln(cc.output, "---") + return nil +} + +// emitTaskFileContent emits the parsed task content to the output. +// It expands parameters, estimates tokens, and optionally includes frontmatter. +func (cc *codingContext) emitTaskFileContent() error { + expanded := os.Expand(cc.taskContent, func(key string) string { if val, ok := cc.params[key]; ok { return val } @@ -305,14 +380,6 @@ func (cc *codingContext) writeTaskFileContent() error { cc.totalTokens += tokens fmt.Fprintf(cc.logOut, "⪢ Including task file: %s (~%d tokens)\n", cc.matchingTaskFile, tokens) - if cc.emitTaskFrontmatter { - fmt.Fprintln(cc.output, "---") - if err := yaml.NewEncoder(cc.output).Encode(taskMatter); err != nil { - return fmt.Errorf("failed to encode task matter: %w", err) - } - fmt.Fprintln(cc.output, "---") - } - fmt.Fprintln(cc.output, expanded) // Print total token count diff --git a/main_test.go b/main_test.go index 364aab6..6b22eeb 100644 --- a/main_test.go +++ b/main_test.go @@ -209,7 +209,7 @@ func TestFindTaskFile(t *testing.T) { name: "task with matching selector", taskName: "filtered_task", includes: selectorMap{ - "env": "prod", + "env": []string{"prod"}, }, setupFiles: func(t *testing.T, tmpDir string) { taskDir := filepath.Join(tmpDir, ".agents", "tasks") @@ -223,7 +223,7 @@ func TestFindTaskFile(t *testing.T) { name: "task with non-matching selector", taskName: "filtered_task", includes: selectorMap{ - "env": "dev", + "env": []string{"dev"}, }, setupFiles: func(t *testing.T, tmpDir string) { taskDir := filepath.Join(tmpDir, ".agents", "tasks") @@ -273,7 +273,7 @@ func TestFindTaskFile(t *testing.T) { if cc.includes == nil { cc.includes = make(selectorMap) } - cc.includes["task_name"] = tt.taskName + cc.includes["task_name"] = []string{tt.taskName} // Set downloadedDirs if specified in test case if len(tt.downloadedDirs) > 0 { @@ -340,7 +340,7 @@ func TestFindExecuteRuleFiles(t *testing.T) { name: "exclude rule with non-matching selector", resume: false, includes: selectorMap{ - "env": "prod", + "env": []string{"prod"}, }, setupFiles: func(t *testing.T, tmpDir string) { createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), @@ -353,7 +353,7 @@ func TestFindExecuteRuleFiles(t *testing.T) { name: "include rule with matching selector", resume: false, includes: selectorMap{ - "env": "prod", + "env": []string{"prod"}, }, setupFiles: func(t *testing.T, tmpDir string) { createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), @@ -689,24 +689,42 @@ func TestWriteTaskFileContent(t *testing.T) { emitTaskFrontmatter: tt.emitTaskFrontmatter, output: &output, logOut: &logOut, + includes: make(selectorMap), } - err := cc.writeTaskFileContent() + // Parse task file first + if err := cc.parseTaskFile(); err != nil { + if !tt.wantErr { + t.Errorf("parseTaskFile() unexpected error: %v", err) + } + return + } + + // Print frontmatter if enabled (matches main flow behavior) + if err := cc.printTaskFrontmatter(); err != nil { + if !tt.wantErr { + t.Errorf("printTaskFrontmatter() unexpected error: %v", err) + } + return + } + + // Then emit the content + err := cc.emitTaskFileContent() if tt.wantErr { if err == nil { - t.Errorf("writeTaskFileContent() expected error, got nil") + t.Errorf("emitTaskFileContent() expected error, got nil") } } else { if err != nil { - t.Errorf("writeTaskFileContent() unexpected error: %v", err) + t.Errorf("emitTaskFileContent() unexpected error: %v", err) } } outputStr := output.String() if tt.expectInOutput != "" { if !strings.Contains(outputStr, tt.expectInOutput) { - t.Errorf("writeTaskFileContent() output should contain %q, got:\n%s", tt.expectInOutput, outputStr) + t.Errorf("emitTaskFileContent() output should contain %q, got:\n%s", tt.expectInOutput, outputStr) } } @@ -714,20 +732,348 @@ func TestWriteTaskFileContent(t *testing.T) { if tt.emitTaskFrontmatter { // Verify frontmatter delimiters are present if !strings.Contains(outputStr, "---") { - t.Errorf("writeTaskFileContent() with emitTaskFrontmatter=true should contain '---' delimiters, got:\n%s", outputStr) + t.Errorf("emitTaskFileContent() with emitTaskFrontmatter=true should contain '---' delimiters, got:\n%s", outputStr) } // Verify YAML frontmatter structure if !strings.Contains(outputStr, "task_name:") { - t.Errorf("writeTaskFileContent() with emitTaskFrontmatter=true should contain 'task_name:' field, got:\n%s", outputStr) + t.Errorf("emitTaskFileContent() with emitTaskFrontmatter=true should contain 'task_name:' field, got:\n%s", outputStr) } // Verify task content is still present if !strings.Contains(outputStr, "# Task with Frontmatter") { - t.Errorf("writeTaskFileContent() should contain task content, got:\n%s", outputStr) + t.Errorf("emitTaskFileContent() should contain task content, got:\n%s", outputStr) } } if !tt.wantErr && cc.totalTokens <= 0 { - t.Errorf("writeTaskFileContent() expected tokens > 0, got %d", cc.totalTokens) + t.Errorf("emitTaskFileContent() expected tokens > 0, got %d", cc.totalTokens) + } + }) + } +} + +func TestParseTaskFile(t *testing.T) { + tests := []struct { + name string + taskFile string + setupFiles func(t *testing.T, tmpDir string) string // returns task file path + initialIncludes selectorMap + expectedIncludes selectorMap // expected includes after parsing + wantErr bool + errContains string + }{ + { + name: "task without selectors field", + taskFile: "task.md", + initialIncludes: make(selectorMap), + expectedIncludes: make(selectorMap), + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test", + "# Simple Task") + return taskPath + }, + wantErr: false, + }, + { + name: "task with selectors field", + taskFile: "task.md", + initialIncludes: make(selectorMap), + expectedIncludes: selectorMap{ + "language": []string{"Go"}, + "env": []string{"prod"}, + }, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test\nselectors:\n language: Go\n env: prod", + "# Task with Selectors") + return taskPath + }, + wantErr: false, + }, + { + name: "task with selectors merges with existing includes", + taskFile: "task.md", + initialIncludes: selectorMap{"existing": []string{"value"}}, + expectedIncludes: selectorMap{ + "existing": []string{"value"}, + "language": []string{"Python"}, + }, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test\nselectors:\n language: Python", + "# Task with Selectors") + return taskPath + }, + wantErr: false, + }, + { + name: "task with array selector values", + taskFile: "task.md", + initialIncludes: make(selectorMap), + expectedIncludes: selectorMap{ + "rule_name": []string{"rule1", "rule2"}, + }, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test\nselectors:\n rule_name:\n - rule1\n - rule2", + "# Task with Array Selectors") + return taskPath + }, + wantErr: false, + }, + { + name: "task with invalid selectors field type", + taskFile: "task.md", + initialIncludes: make(selectorMap), + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test\nselectors: invalid", + "# Task with Invalid Selectors") + return taskPath + }, + wantErr: true, + errContains: "invalid 'selectors' field", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + taskPath := tt.setupFiles(t, tmpDir) + + cc := &codingContext{ + matchingTaskFile: taskPath, + includes: tt.initialIncludes, + } + if cc.includes == nil { + cc.includes = make(selectorMap) + } + + err := cc.parseTaskFile() + + if tt.wantErr { + if err == nil { + t.Errorf("parseTaskFile() expected error, got nil") + } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("parseTaskFile() error = %v, should contain %q", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("parseTaskFile() unexpected error: %v", err) + } + + // Verify selectors were extracted correctly + for key, expectedValue := range tt.expectedIncludes { + if actualValue, ok := cc.includes[key]; !ok { + t.Errorf("parseTaskFile() expected includes[%q] = %v, but key not found", key, expectedValue) + } else { + // Compare []string slices + if len(actualValue) != len(expectedValue) { + t.Errorf("parseTaskFile() includes[%q] array length = %d, want %d", key, len(actualValue), len(expectedValue)) + } else { + for i, expectedVal := range expectedValue { + if actualValue[i] != expectedVal { + t.Errorf("parseTaskFile() includes[%q][%d] = %q, want %q", key, i, actualValue[i], expectedVal) + } + } + } + } + } + + // Verify all includes match expected (including initial includes) + if len(cc.includes) != len(tt.expectedIncludes) { + t.Errorf("parseTaskFile() includes length = %d, want %d. Includes: %v", len(cc.includes), len(tt.expectedIncludes), cc.includes) + } + + // Verify task content was stored + if cc.taskContent == "" { + t.Errorf("parseTaskFile() expected taskContent to be set, got empty string") + } + + // Verify task frontmatter was stored + if cc.taskFrontmatter == nil { + t.Errorf("parseTaskFile() expected taskFrontmatter to be set, got nil") + } + } + }) + } +} + +func TestTaskSelectorsFilterRulesByRuleName(t *testing.T) { + tests := []struct { + name string + taskSelectors string // YAML frontmatter for task selectors field + setupRules func(t *testing.T, tmpDir string) + expectInOutput []string // Rule content that should be present + expectNotInOutput []string // Rule content that should NOT be present + wantErr bool + }{ + { + name: "single rule_name selector filters to one rule", + taskSelectors: "selectors:\n rule_name: rule1", + setupRules: func(t *testing.T, tmpDir string) { + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + createMarkdownFile(t, filepath.Join(rulesDir, "rule1.md"), + "rule_name: rule1", + "# Rule 1 Content\nThis is rule 1.") + createMarkdownFile(t, filepath.Join(rulesDir, "rule2.md"), + "rule_name: rule2", + "# Rule 2 Content\nThis is rule 2.") + createMarkdownFile(t, filepath.Join(rulesDir, "rule3.md"), + "rule_name: rule3", + "# Rule 3 Content\nThis is rule 3.") + }, + expectInOutput: []string{"# Rule 1 Content", "This is rule 1."}, + expectNotInOutput: []string{"# Rule 2 Content", "# Rule 3 Content", "This is rule 2.", "This is rule 3."}, + wantErr: false, + }, + { + name: "array selector matches multiple rules", + taskSelectors: "selectors:\n rule_name:\n - rule1\n - rule2", + setupRules: func(t *testing.T, tmpDir string) { + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + createMarkdownFile(t, filepath.Join(rulesDir, "rule1.md"), + "rule_name: rule1", + "# Rule 1 Content\nThis is rule 1.") + createMarkdownFile(t, filepath.Join(rulesDir, "rule2.md"), + "rule_name: rule2", + "# Rule 2 Content\nThis is rule 2.") + createMarkdownFile(t, filepath.Join(rulesDir, "rule3.md"), + "rule_name: rule3", + "# Rule 3 Content\nThis is rule 3.") + }, + expectInOutput: []string{"# Rule 1 Content", "# Rule 2 Content", "This is rule 1.", "This is rule 2."}, + expectNotInOutput: []string{"# Rule 3 Content", "This is rule 3."}, + wantErr: false, + }, + { + name: "combined selectors use AND logic", + taskSelectors: "selectors:\n rule_name: rule1\n env: prod", + setupRules: func(t *testing.T, tmpDir string) { + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + createMarkdownFile(t, filepath.Join(rulesDir, "rule1.md"), + "rule_name: rule1\nenv: prod", + "# Rule 1 Content\nThis is rule 1.") + createMarkdownFile(t, filepath.Join(rulesDir, "rule2.md"), + "rule_name: rule2\nenv: prod", + "# Rule 2 Content\nThis is rule 2.") + createMarkdownFile(t, filepath.Join(rulesDir, "rule1-dev.md"), + "rule_name: rule1\nenv: dev", + "# Rule 1 Dev Content\nThis is rule 1 dev.") + }, + expectInOutput: []string{"# Rule 1 Content", "This is rule 1."}, + expectNotInOutput: []string{"# Rule 2 Content", "# Rule 1 Dev Content", "This is rule 2.", "This is rule 1 dev."}, + wantErr: false, + }, + { + name: "no selectors includes all rules", + taskSelectors: "", + setupRules: func(t *testing.T, tmpDir string) { + rulesDir := filepath.Join(tmpDir, ".agents", "rules") + createMarkdownFile(t, filepath.Join(rulesDir, "rule1.md"), + "rule_name: rule1", + "# Rule 1 Content\nThis is rule 1.") + createMarkdownFile(t, filepath.Join(rulesDir, "rule2.md"), + "rule_name: rule2", + "# Rule 2 Content\nThis is rule 2.") + }, + expectInOutput: []string{"# Rule 1 Content", "# Rule 2 Content", "This is rule 1.", "This is rule 2."}, + expectNotInOutput: []string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + // Setup rule files + tt.setupRules(t, tmpDir) + + // Setup task file + taskDir := filepath.Join(tmpDir, ".agents", "tasks") + taskPath := filepath.Join(taskDir, "test-task.md") + var taskFrontmatter string + if tt.taskSelectors != "" { + taskFrontmatter = fmt.Sprintf("task_name: test-task\n%s", tt.taskSelectors) + } else { + taskFrontmatter = "task_name: test-task" + } + createMarkdownFile(t, taskPath, taskFrontmatter, "# Test Task\nThis is a test task.") + + // Change to temp dir + oldDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldDir) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + var output, logOut bytes.Buffer + cc := &codingContext{ + workDir: tmpDir, + includes: make(selectorMap), + output: &output, + logOut: &logOut, + cmdRunner: func(cmd *exec.Cmd) error { + return nil // Mock command runner + }, + } + + // Set up task name in includes (as done in run()) + cc.includes["task_name"] = []string{"test-task"} + cc.includes["resume"] = []string{"false"} + + // Find and parse task file + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get user home directory: %v", err) + } + + if err := cc.findTaskFile(homeDir, "test-task"); err != nil { + if !tt.wantErr { + t.Fatalf("findTaskFile() unexpected error: %v", err) + } + return + } + + // Parse task file to extract selectors + if err := cc.parseTaskFile(); err != nil { + if !tt.wantErr { + t.Fatalf("parseTaskFile() unexpected error: %v", err) + } + return + } + + // Find and execute rule files + if err := cc.findExecuteRuleFiles(context.Background(), homeDir); err != nil { + if !tt.wantErr { + t.Fatalf("findExecuteRuleFiles() unexpected error: %v", err) + } + return + } + + outputStr := output.String() + + // Verify expected content is present + for _, expected := range tt.expectInOutput { + if !strings.Contains(outputStr, expected) { + t.Errorf("TestTaskSelectorsFilterRulesByRuleName() output should contain %q, got:\n%s", expected, outputStr) + } + } + + // Verify unexpected content is NOT present + for _, unexpected := range tt.expectNotInOutput { + if strings.Contains(outputStr, unexpected) { + t.Errorf("TestTaskSelectorsFilterRulesByRuleName() output should NOT contain %q, got:\n%s", unexpected, outputStr) + } } }) } @@ -822,7 +1168,7 @@ func TestTaskFileWalker(t *testing.T) { if cc.includes == nil { cc.includes = make(selectorMap) } - cc.includes["task_name"] = tt.taskName + cc.includes["task_name"] = []string{tt.taskName} walker := cc.taskFileWalker(tt.taskName) err := walker(tt.filePath, &tt.fileInfo, nil) @@ -890,7 +1236,7 @@ func TestRuleFileWalker(t *testing.T) { }, { name: "exclude rule with non-matching selector", - includes: selectorMap{"env": "prod"}, + includes: selectorMap{"env": []string{"prod"}}, fileInfo: fileInfoMock{isDir: false, name: "rule.md"}, filePath: "rule.md", fileContent: "---\nenv: dev\n---\n# Dev Rule", @@ -900,7 +1246,7 @@ func TestRuleFileWalker(t *testing.T) { }, { name: "include rule with matching selector", - includes: selectorMap{"env": "prod"}, + includes: selectorMap{"env": []string{"prod"}}, fileInfo: fileInfoMock{isDir: false, name: "rule.md"}, filePath: "rule.md", fileContent: "---\nenv: prod\n---\n# Prod Rule", @@ -979,4 +1325,4 @@ func (f *fileInfoMock) Size() int64 { return 0 } func (f *fileInfoMock) Mode() os.FileMode { return 0o644 } func (f *fileInfoMock) ModTime() time.Time { return time.Time{} } func (f *fileInfoMock) IsDir() bool { return f.isDir } -func (f *fileInfoMock) Sys() interface{} { return nil } +func (f *fileInfoMock) Sys() any { return nil } diff --git a/markdown.go b/markdown.go index 9993998..079f60c 100644 --- a/markdown.go +++ b/markdown.go @@ -13,7 +13,7 @@ import ( func parseMarkdownFile(path string, frontmatter any) (string, error) { fh, err := os.Open(path) if err != nil { - return "", "", fmt.Errorf("failed to open file: %w", err) + return "", fmt.Errorf("failed to open file: %w", err) } defer fh.Close() @@ -35,7 +35,7 @@ func parseMarkdownFile(path string, frontmatter any) (string, error) { } else { state = 2 // No frontmatter, start scanning content if _, err := content.WriteString(line + "\n"); err != nil { - return "", "", fmt.Errorf("failed to write content: %w", err) + return "", fmt.Errorf("failed to write content: %w", err) } } case 1: // Scanning frontmatter @@ -43,28 +43,26 @@ func parseMarkdownFile(path string, frontmatter any) (string, error) { state = 2 // End of frontmatter, start scanning content } else { if _, err := frontMatterBytes.WriteString(line + "\n"); err != nil { - return "", "", fmt.Errorf("failed to write frontmatter: %w", err) + return "", fmt.Errorf("failed to write frontmatter: %w", err) } } case 2: // Scanning content if _, err := content.WriteString(line + "\n"); err != nil { - return "", "", fmt.Errorf("failed to write content: %w", err) + return "", fmt.Errorf("failed to write content: %w", err) } } } if err := s.Err(); err != nil { - return "", "", fmt.Errorf("failed to scan file: %w", err) + return "", fmt.Errorf("failed to scan file: %w", err) } // Parse frontmatter if we collected any - var rawFrontmatter string if frontMatterBytes.Len() > 0 { - rawFrontmatter = frontMatterBytes.String() if err := yaml.Unmarshal(frontMatterBytes.Bytes(), frontmatter); err != nil { - return "", "", fmt.Errorf("failed to unmarshal frontmatter: %w", err) + return "", fmt.Errorf("failed to unmarshal frontmatter: %w", err) } } - return content.String(), rawFrontmatter, nil + return content.String(), nil } diff --git a/selector_map.go b/selector_map.go index 9e6ca2c..aa9aabc 100644 --- a/selector_map.go +++ b/selector_map.go @@ -2,14 +2,27 @@ package main import ( "fmt" + "slices" "strings" ) -// selectorMap reuses paramMap for parsing key=value pairs -type selectorMap paramMap +// selectorMap stores selector key-value pairs where values are always string slices +// Multiple values for the same key use OR logic (match any value in the slice) +type selectorMap map[string][]string func (s *selectorMap) String() string { - return (*paramMap)(s).String() + if *s == nil { + return "{}" + } + var parts []string + for k, v := range *s { + if len(v) == 1 { + parts = append(parts, fmt.Sprintf("%s=%s", k, v[0])) + } else { + parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + } + } + return fmt.Sprintf("{%s}", strings.Join(parts, ", ")) } func (s *selectorMap) Set(value string) error { @@ -21,21 +34,38 @@ func (s *selectorMap) Set(value string) error { if *s == nil { *s = make(selectorMap) } - // Trim spaces from both key and value for selectors - (*s)[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + key := strings.TrimSpace(kv[0]) + newValue := strings.TrimSpace(kv[1]) + + // If key already exists, append to slice for OR logic + if existingValues, exists := (*s)[key]; exists { + // Check if new value is already in the slice + if !slices.Contains(existingValues, newValue) { + (*s)[key] = append(existingValues, newValue) + } + } else { + // Key doesn't exist, store as single-element slice + (*s)[key] = []string{newValue} + } return nil } // matchesIncludes returns true if the frontmatter matches all include selectors // If a key doesn't exist in frontmatter, it's allowed +// Multiple values for the same key use OR logic (matches if frontmatter value is in the slice) func (includes *selectorMap) matchesIncludes(frontmatter frontMatter) bool { - for key, value := range *includes { + for key, values := range *includes { fmValue, exists := frontmatter[key] - // If key exists, it must match the value - if exists && fmt.Sprint(fmValue) != value { + if !exists { + // If key doesn't exist in frontmatter, allow it + continue + } + + // Check if frontmatter value matches any element in the slice (OR logic) + fmStr := fmt.Sprint(fmValue) + if !slices.Contains(values, fmStr) { return false } - // If key doesn't exist, allow it } return true } diff --git a/selector_map_test.go b/selector_map_test.go index af6b046..fb8af1e 100644 --- a/selector_map_test.go +++ b/selector_map_test.go @@ -53,8 +53,8 @@ func TestSelectorMap_Set(t *testing.T) { t.Errorf("Set() resulted in %d selectors, want 1", len(s)) return } - if s[tt.wantKey] != tt.wantVal { - t.Errorf("Set() s[%q] = %q, want %q", tt.wantKey, s[tt.wantKey], tt.wantVal) + if len(s[tt.wantKey]) != 1 || s[tt.wantKey][0] != tt.wantVal { + t.Errorf("Set() s[%q] = %v, want [%q]", tt.wantKey, s[tt.wantKey], tt.wantVal) } } }) @@ -77,83 +77,137 @@ func TestSelectorMap_SetMultiple(t *testing.T) { func TestSelectorMap_MatchesIncludes(t *testing.T) { tests := []struct { - name string - selectors []string - frontmatter frontMatter - wantMatch bool + name string + selectors []string + setupSelectors func(s selectorMap) // Optional function to set up array selectors directly + frontmatter frontMatter + wantMatch bool }{ { - name: "single include - match", + name: "single selector - match", selectors: []string{"env=production"}, frontmatter: frontMatter{"env": "production"}, wantMatch: true, }, { - name: "single include - no match", + name: "single selector - no match", selectors: []string{"env=production"}, frontmatter: frontMatter{"env": "development"}, wantMatch: false, }, { - name: "single include - key missing (allowed)", + name: "single selector - key missing (allowed)", selectors: []string{"env=production"}, frontmatter: frontMatter{"language": "go"}, wantMatch: true, }, { - name: "multiple includes - all match", + name: "multiple selectors - all match", selectors: []string{"env=production", "language=go"}, frontmatter: frontMatter{"env": "production", "language": "go"}, wantMatch: true, }, { - name: "multiple includes - one doesn't match", + name: "multiple selectors - one doesn't match", selectors: []string{"env=production", "language=go"}, frontmatter: frontMatter{"env": "production", "language": "python"}, wantMatch: false, }, { - name: "multiple includes - one key missing (allowed)", + name: "multiple selectors - one key missing (allowed)", selectors: []string{"env=production", "language=go"}, frontmatter: frontMatter{"env": "production"}, wantMatch: true, }, { - name: "empty includes - always match", + name: "empty selectors - always match", selectors: []string{}, frontmatter: frontMatter{"env": "production"}, wantMatch: true, }, { - name: "empty frontmatter - key missing (allowed)", - selectors: []string{"env=production"}, - frontmatter: frontMatter{}, + name: "boolean value conversion - match", + selectors: []string{"is_active=true"}, + frontmatter: frontMatter{"is_active": true}, wantMatch: true, }, { - name: "task_name include - match", - selectors: []string{"task_name=deploy"}, - frontmatter: frontMatter{"task_name": "deploy"}, + name: "array selector - match", + selectors: []string{}, + frontmatter: frontMatter{"rule_name": "rule2"}, wantMatch: true, + setupSelectors: func(s selectorMap) { + s["rule_name"] = []string{"rule1", "rule2", "rule3"} + }, }, { - name: "task_name include - no match", - selectors: []string{"task_name=deploy"}, - frontmatter: frontMatter{"task_name": "test"}, + name: "array selector - no match", + selectors: []string{}, + frontmatter: frontMatter{"rule_name": "rule4"}, wantMatch: false, + setupSelectors: func(s selectorMap) { + s["rule_name"] = []string{"rule1", "rule2", "rule3"} + }, }, { - name: "task_name include - key missing (allowed)", - selectors: []string{"task_name=deploy"}, - frontmatter: frontMatter{"env": "production"}, + name: "array selector - key missing (allowed)", + selectors: []string{}, + frontmatter: frontMatter{"env": "prod"}, wantMatch: true, + setupSelectors: func(s selectorMap) { + s["rule_name"] = []string{"rule1", "rule2"} + }, }, { - name: "include a boolean value - match", - selectors: []string{"is_active=true"}, - frontmatter: frontMatter{"is_active": true}, + name: "mixed selectors - array and string both match", + selectors: []string{"env=prod"}, + frontmatter: frontMatter{"env": "prod", "rule_name": "rule1"}, + wantMatch: true, + setupSelectors: func(s selectorMap) { + s["rule_name"] = []string{"rule1", "rule2"} + }, + }, + { + name: "mixed selectors - string doesn't match", + selectors: []string{"env=dev"}, + frontmatter: frontMatter{"env": "prod", "rule_name": "rule1"}, + wantMatch: false, + setupSelectors: func(s selectorMap) { + s["rule_name"] = []string{"rule1", "rule2"} + }, + }, + { + name: "multiple array selectors - both match", + selectors: []string{}, + frontmatter: frontMatter{"rule_name": "rule1", "language": "go"}, + wantMatch: true, + setupSelectors: func(s selectorMap) { + s["rule_name"] = []string{"rule1", "rule2"} + s["language"] = []string{"go", "python"} + }, + }, + { + name: "multiple array selectors - one doesn't match", + selectors: []string{}, + frontmatter: frontMatter{"rule_name": "rule1", "language": "java"}, + wantMatch: false, + setupSelectors: func(s selectorMap) { + s["rule_name"] = []string{"rule1", "rule2"} + s["language"] = []string{"go", "python"} + }, + }, + { + name: "OR logic - same key multiple values matches", + selectors: []string{"env=prod", "env=dev"}, + frontmatter: frontMatter{"env": "dev"}, wantMatch: true, }, + { + name: "OR logic - same key multiple values no match", + selectors: []string{"env=prod", "env=dev"}, + frontmatter: frontMatter{"env": "staging"}, + wantMatch: false, + }, } for _, tt := range tests { @@ -165,6 +219,11 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { } } + // Set up array selectors if provided + if tt.setupSelectors != nil { + tt.setupSelectors(s) + } + if got := s.matchesIncludes(tt.frontmatter); got != tt.wantMatch { t.Errorf("matchesIncludes() = %v, want %v", got, tt.wantMatch) } From 7f0040da93a3d2980b4ac8860417261cb1d04d57 Mon Sep 17 00:00:00 2001 From: mpowers5 Date: Wed, 12 Nov 2025 22:17:02 +0000 Subject: [PATCH 5/6] fix:convert selectorMap to map --- main.go | 19 ++++++-------- main_test.go | 42 +++++++++++++++--------------- selector_map.go | 61 +++++++++++++++++++++++++++++--------------- selector_map_test.go | 33 ++++++++++++++++-------- 4 files changed, 92 insertions(+), 63 deletions(-) diff --git a/main.go b/main.go index a951307..24b9f57 100644 --- a/main.go +++ b/main.go @@ -91,8 +91,8 @@ func (cc *codingContext) run(ctx context.Context, args []string) error { // Add task name to includes so rules can be filtered by task taskName := args[0] - cc.includes["task_name"] = []string{taskName} - cc.includes["resume"] = []string{fmt.Sprint(cc.resume)} + cc.includes.SetValue("task_name", taskName) + cc.includes.SetValue("resume", fmt.Sprint(cc.resume)) homeDir, err := os.UserHomeDir() if err != nil { @@ -326,23 +326,20 @@ func (cc *codingContext) parseTaskFile() error { } // Add selectors to includes - // Convert all values to []string slices for OR logic + // Convert all values to map[string]bool for OR logic for key, value := range selectorsMap { - var strSlice []string switch v := value.(type) { case []any: - // Convert []any to []string - strSlice = make([]string, len(v)) - for i, item := range v { - strSlice[i] = fmt.Sprint(item) + // Convert []any to map[string]bool + for _, item := range v { + cc.includes.SetValue(key, fmt.Sprint(item)) } case string: - // Convert string to single-element slice - strSlice = []string{v} + // Convert string to single value in map + cc.includes.SetValue(key, v) default: return fmt.Errorf("task file %s has invalid selector value for key %q: expected string or array, got %T", cc.matchingTaskFile, key, value) } - cc.includes[key] = strSlice } } diff --git a/main_test.go b/main_test.go index 6b22eeb..abc5404 100644 --- a/main_test.go +++ b/main_test.go @@ -209,7 +209,7 @@ func TestFindTaskFile(t *testing.T) { name: "task with matching selector", taskName: "filtered_task", includes: selectorMap{ - "env": []string{"prod"}, + "env": map[string]bool{"prod": true}, }, setupFiles: func(t *testing.T, tmpDir string) { taskDir := filepath.Join(tmpDir, ".agents", "tasks") @@ -223,7 +223,7 @@ func TestFindTaskFile(t *testing.T) { name: "task with non-matching selector", taskName: "filtered_task", includes: selectorMap{ - "env": []string{"dev"}, + "env": map[string]bool{"dev": true}, }, setupFiles: func(t *testing.T, tmpDir string) { taskDir := filepath.Join(tmpDir, ".agents", "tasks") @@ -273,7 +273,7 @@ func TestFindTaskFile(t *testing.T) { if cc.includes == nil { cc.includes = make(selectorMap) } - cc.includes["task_name"] = []string{tt.taskName} + cc.includes.SetValue("task_name", tt.taskName) // Set downloadedDirs if specified in test case if len(tt.downloadedDirs) > 0 { @@ -340,7 +340,7 @@ func TestFindExecuteRuleFiles(t *testing.T) { name: "exclude rule with non-matching selector", resume: false, includes: selectorMap{ - "env": []string{"prod"}, + "env": map[string]bool{"prod": true}, }, setupFiles: func(t *testing.T, tmpDir string) { createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), @@ -353,7 +353,7 @@ func TestFindExecuteRuleFiles(t *testing.T) { name: "include rule with matching selector", resume: false, includes: selectorMap{ - "env": []string{"prod"}, + "env": map[string]bool{"prod": true}, }, setupFiles: func(t *testing.T, tmpDir string) { createMarkdownFile(t, filepath.Join(tmpDir, "CLAUDE.md"), @@ -780,8 +780,8 @@ func TestParseTaskFile(t *testing.T) { taskFile: "task.md", initialIncludes: make(selectorMap), expectedIncludes: selectorMap{ - "language": []string{"Go"}, - "env": []string{"prod"}, + "language": map[string]bool{"Go": true}, + "env": map[string]bool{"prod": true}, }, setupFiles: func(t *testing.T, tmpDir string) string { taskPath := filepath.Join(tmpDir, "task.md") @@ -795,10 +795,10 @@ func TestParseTaskFile(t *testing.T) { { name: "task with selectors merges with existing includes", taskFile: "task.md", - initialIncludes: selectorMap{"existing": []string{"value"}}, + initialIncludes: selectorMap{"existing": map[string]bool{"value": true}}, expectedIncludes: selectorMap{ - "existing": []string{"value"}, - "language": []string{"Python"}, + "existing": map[string]bool{"value": true}, + "language": map[string]bool{"Python": true}, }, setupFiles: func(t *testing.T, tmpDir string) string { taskPath := filepath.Join(tmpDir, "task.md") @@ -814,7 +814,7 @@ func TestParseTaskFile(t *testing.T) { taskFile: "task.md", initialIncludes: make(selectorMap), expectedIncludes: selectorMap{ - "rule_name": []string{"rule1", "rule2"}, + "rule_name": map[string]bool{"rule1": true, "rule2": true}, }, setupFiles: func(t *testing.T, tmpDir string) string { taskPath := filepath.Join(tmpDir, "task.md") @@ -872,13 +872,13 @@ func TestParseTaskFile(t *testing.T) { if actualValue, ok := cc.includes[key]; !ok { t.Errorf("parseTaskFile() expected includes[%q] = %v, but key not found", key, expectedValue) } else { - // Compare []string slices + // Compare map[string]bool structures if len(actualValue) != len(expectedValue) { - t.Errorf("parseTaskFile() includes[%q] array length = %d, want %d", key, len(actualValue), len(expectedValue)) + t.Errorf("parseTaskFile() includes[%q] map length = %d, want %d", key, len(actualValue), len(expectedValue)) } else { - for i, expectedVal := range expectedValue { - if actualValue[i] != expectedVal { - t.Errorf("parseTaskFile() includes[%q][%d] = %q, want %q", key, i, actualValue[i], expectedVal) + for expectedVal := range expectedValue { + if !actualValue[expectedVal] { + t.Errorf("parseTaskFile() includes[%q] does not contain value %q", key, expectedVal) } } } @@ -1028,8 +1028,8 @@ func TestTaskSelectorsFilterRulesByRuleName(t *testing.T) { } // Set up task name in includes (as done in run()) - cc.includes["task_name"] = []string{"test-task"} - cc.includes["resume"] = []string{"false"} + cc.includes.SetValue("task_name", "test-task") + cc.includes.SetValue("resume", "false") // Find and parse task file homeDir, err := os.UserHomeDir() @@ -1168,7 +1168,7 @@ func TestTaskFileWalker(t *testing.T) { if cc.includes == nil { cc.includes = make(selectorMap) } - cc.includes["task_name"] = []string{tt.taskName} + cc.includes.SetValue("task_name", tt.taskName) walker := cc.taskFileWalker(tt.taskName) err := walker(tt.filePath, &tt.fileInfo, nil) @@ -1236,7 +1236,7 @@ func TestRuleFileWalker(t *testing.T) { }, { name: "exclude rule with non-matching selector", - includes: selectorMap{"env": []string{"prod"}}, + includes: selectorMap{"env": map[string]bool{"prod": true}}, fileInfo: fileInfoMock{isDir: false, name: "rule.md"}, filePath: "rule.md", fileContent: "---\nenv: dev\n---\n# Dev Rule", @@ -1246,7 +1246,7 @@ func TestRuleFileWalker(t *testing.T) { }, { name: "include rule with matching selector", - includes: selectorMap{"env": []string{"prod"}}, + includes: selectorMap{"env": map[string]bool{"prod": true}}, fileInfo: fileInfoMock{isDir: false, name: "rule.md"}, filePath: "rule.md", fileContent: "---\nenv: prod\n---\n# Prod Rule", diff --git a/selector_map.go b/selector_map.go index aa9aabc..63f246d 100644 --- a/selector_map.go +++ b/selector_map.go @@ -2,13 +2,13 @@ package main import ( "fmt" - "slices" "strings" ) -// selectorMap stores selector key-value pairs where values are always string slices -// Multiple values for the same key use OR logic (match any value in the slice) -type selectorMap map[string][]string +// selectorMap 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 +type selectorMap map[string]map[string]bool func (s *selectorMap) String() string { if *s == nil { @@ -16,10 +16,14 @@ func (s *selectorMap) String() string { } var parts []string for k, v := range *s { - if len(v) == 1 { - parts = append(parts, fmt.Sprintf("%s=%s", k, v[0])) + 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, v)) + parts = append(parts, fmt.Sprintf("%s=%v", k, values)) } } return fmt.Sprintf("{%s}", strings.Join(parts, ", ")) @@ -37,22 +41,39 @@ func (s *selectorMap) Set(value string) error { key := strings.TrimSpace(kv[0]) newValue := strings.TrimSpace(kv[1]) - // If key already exists, append to slice for OR logic - if existingValues, exists := (*s)[key]; exists { - // Check if new value is already in the slice - if !slices.Contains(existingValues, newValue) { - (*s)[key] = append(existingValues, newValue) - } - } else { - // Key doesn't exist, store as single-element slice - (*s)[key] = []string{newValue} - } + s.SetValue(key, newValue) return nil } +// SetValue sets a value in the inner map for the given key. +// If the key doesn't exist, it creates a new inner map. +// Each value can be represented exactly once per key. +func (s *selectorMap) SetValue(key, value string) { + if *s == nil { + *s = make(selectorMap) + } + if (*s)[key] == nil { + (*s)[key] = make(map[string]bool) + } + (*s)[key][value] = true +} + +// GetValue returns true if the given value exists in the inner map for the given key. +// Returns false if the key doesn't exist or the value is not present. +func (s *selectorMap) GetValue(key, value string) bool { + if *s == nil { + return false + } + innerMap, exists := (*s)[key] + if !exists { + return false + } + return innerMap[value] +} + // matchesIncludes returns true if the frontmatter matches all include selectors // If a key doesn't exist in frontmatter, it's allowed -// Multiple values for the same key use OR logic (matches if frontmatter value is in the slice) +// Multiple values for the same key use OR logic (matches if frontmatter value is in the inner map) func (includes *selectorMap) matchesIncludes(frontmatter frontMatter) bool { for key, values := range *includes { fmValue, exists := frontmatter[key] @@ -61,9 +82,9 @@ func (includes *selectorMap) matchesIncludes(frontmatter frontMatter) bool { continue } - // Check if frontmatter value matches any element in the slice (OR logic) + // Check if frontmatter value matches any element in the inner map (OR logic) fmStr := fmt.Sprint(fmValue) - if !slices.Contains(values, fmStr) { + if !values[fmStr] { return false } } diff --git a/selector_map_test.go b/selector_map_test.go index fb8af1e..c666144 100644 --- a/selector_map_test.go +++ b/selector_map_test.go @@ -53,8 +53,8 @@ func TestSelectorMap_Set(t *testing.T) { t.Errorf("Set() resulted in %d selectors, want 1", len(s)) return } - if len(s[tt.wantKey]) != 1 || s[tt.wantKey][0] != tt.wantVal { - t.Errorf("Set() s[%q] = %v, want [%q]", tt.wantKey, s[tt.wantKey], tt.wantVal) + if !s.GetValue(tt.wantKey, tt.wantVal) { + t.Errorf("Set() s[%q] does not contain value %q", tt.wantKey, tt.wantVal) } } }) @@ -137,7 +137,9 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { frontmatter: frontMatter{"rule_name": "rule2"}, wantMatch: true, setupSelectors: func(s selectorMap) { - s["rule_name"] = []string{"rule1", "rule2", "rule3"} + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") + s.SetValue("rule_name", "rule3") }, }, { @@ -146,7 +148,9 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { frontmatter: frontMatter{"rule_name": "rule4"}, wantMatch: false, setupSelectors: func(s selectorMap) { - s["rule_name"] = []string{"rule1", "rule2", "rule3"} + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") + s.SetValue("rule_name", "rule3") }, }, { @@ -155,7 +159,8 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { frontmatter: frontMatter{"env": "prod"}, wantMatch: true, setupSelectors: func(s selectorMap) { - s["rule_name"] = []string{"rule1", "rule2"} + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") }, }, { @@ -164,7 +169,8 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { frontmatter: frontMatter{"env": "prod", "rule_name": "rule1"}, wantMatch: true, setupSelectors: func(s selectorMap) { - s["rule_name"] = []string{"rule1", "rule2"} + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") }, }, { @@ -173,7 +179,8 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { frontmatter: frontMatter{"env": "prod", "rule_name": "rule1"}, wantMatch: false, setupSelectors: func(s selectorMap) { - s["rule_name"] = []string{"rule1", "rule2"} + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") }, }, { @@ -182,8 +189,10 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { frontmatter: frontMatter{"rule_name": "rule1", "language": "go"}, wantMatch: true, setupSelectors: func(s selectorMap) { - s["rule_name"] = []string{"rule1", "rule2"} - s["language"] = []string{"go", "python"} + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") + s.SetValue("language", "go") + s.SetValue("language", "python") }, }, { @@ -192,8 +201,10 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { frontmatter: frontMatter{"rule_name": "rule1", "language": "java"}, wantMatch: false, setupSelectors: func(s selectorMap) { - s["rule_name"] = []string{"rule1", "rule2"} - s["language"] = []string{"go", "python"} + s.SetValue("rule_name", "rule1") + s.SetValue("rule_name", "rule2") + s.SetValue("language", "go") + s.SetValue("language", "python") }, }, { From 197a2cf9ec45e811ac6d01e8bbb5b1b7a7eb52a1 Mon Sep 17 00:00:00 2001 From: mpowers5 Date: Thu, 13 Nov 2025 14:11:35 +0000 Subject: [PATCH 6/6] Address feedback, simplify and speed up integration tests. --- integration_test.go | 815 ++++++++++++------------------------------- main.go | 21 +- main_test.go | 90 ++++- remote.go | 20 +- selector_map.go | 11 +- selector_map_test.go | 12 + 6 files changed, 348 insertions(+), 621 deletions(-) diff --git a/integration_test.go b/integration_test.go index 6a103bc..b6ccfea 100644 --- a/integration_test.go +++ b/integration_test.go @@ -8,71 +8,103 @@ import ( "testing" ) -func TestBootstrapFromFile(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } +// testDirs holds the directory structure for a test +type testDirs struct { + tmpDir string + rulesDir string + tasksDir string +} - // Create a temporary directory structure +// setupTestDirs creates the standard directory structure for tests +func setupTestDirs(t *testing.T) testDirs { tmpDir := t.TempDir() rulesDir := filepath.Join(tmpDir, ".agents", "rules") tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(rulesDir, 0755); err != nil { + if err := os.MkdirAll(rulesDir, 0o755); err != nil { t.Fatalf("failed to create rules dir: %v", err) } - if err := os.MkdirAll(tasksDir, 0755); err != nil { + if err := os.MkdirAll(tasksDir, 0o755); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } + return testDirs{ + tmpDir: tmpDir, + rulesDir: rulesDir, + tasksDir: tasksDir, + } +} + +// 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 { + output, err := runToolWithError(args...) + if err != nil { + t.Fatalf("failed to run tool: %v\n%s", err, output) + } + return string(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) { + // Get the current working directory to use as the source path for go run + wd, err := os.Getwd() + if err != nil { + return "", err + } + cmd := exec.Command("go", append([]string{"run", wd}, args...)...) + 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) { + taskFile := filepath.Join(tasksDir, taskName+".md") + taskContent := `--- +task_name: ` + taskName + ` +--- +# Test Task + +Please help with this task. +` + if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { + t.Fatalf("failed to write task file: %v", err) + } +} + +func TestBootstrapFromFile(t *testing.T) { + dirs := setupTestDirs(t) + // Create a rule file - ruleFile := filepath.Join(rulesDir, "setup.md") + ruleFile := filepath.Join(dirs.rulesDir, "setup.md") ruleContent := `--- --- # Development Setup This is a setup guide. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); 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(rulesDir, "setup-bootstrap") + bootstrapFile := filepath.Join(dirs.rulesDir, "setup-bootstrap") bootstrapContent := `#!/bin/bash echo "Running bootstrap" ` - if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0755); err != nil { + if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o755); err != nil { t.Fatalf("failed to write bootstrap file: %v", err) } - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task + createStandardTask(t, dirs.tasksDir, "test-task") -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } - - // Run the binary - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program + output := runTool(t, "-C", dirs.tmpDir, "test-task") // Check that bootstrap output appears before rule content - outputStr := string(output) - bootstrapIdx := strings.Index(outputStr, "Running bootstrap") - setupIdx := strings.Index(outputStr, "# Development Setup") + bootstrapIdx := strings.Index(output, "Running bootstrap") + setupIdx := strings.Index(output, "# Development Setup") if bootstrapIdx == -1 { t.Errorf("bootstrap output not found in stdout") @@ -85,195 +117,112 @@ Please help with this task. } // Check that task content is present - if !strings.Contains(outputStr, "# Test Task") { + if !strings.Contains(output, "# Test Task") { t.Errorf("task content not found in stdout") } } func TestBootstrapFileNotRequired(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - rulesDir := filepath.Join(tmpDir, ".agents", "rules") - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(rulesDir, 0755); err != nil { - t.Fatalf("failed to create rules dir: %v", err) - } - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } + dirs := setupTestDirs(t) // Create a rule file WITHOUT a bootstrap - ruleFile := filepath.Join(rulesDir, "info.md") + ruleFile := filepath.Join(dirs.rulesDir, "info.md") ruleContent := `--- --- # Project Info General information about the project. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { t.Fatalf("failed to write rule file: %v", err) } - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task - -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } + createStandardTask(t, dirs.tasksDir, "test-task") - // Run the binary - should succeed without a bootstrap file - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program - should succeed without a bootstrap file + output := runTool(t, "-C", dirs.tmpDir, "test-task") // Check that rule content is present - outputStr := string(output) - if !strings.Contains(outputStr, "# Project Info") { + if !strings.Contains(output, "# Project Info") { t.Errorf("rule content not found in stdout") } // Check that task content is present - if !strings.Contains(outputStr, "# Test Task") { + if !strings.Contains(output, "# Test Task") { t.Errorf("task content not found in stdout") } } func TestMultipleBootstrapFiles(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - rulesDir := filepath.Join(tmpDir, ".agents", "rules") - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(rulesDir, 0755); err != nil { - t.Fatalf("failed to create rules dir: %v", err) - } - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } + dirs := setupTestDirs(t) // Create first rule file with bootstrap - ruleFile1 := filepath.Join(rulesDir, "setup.md") + ruleFile1 := filepath.Join(dirs.rulesDir, "setup.md") ruleContent1 := `--- --- # Setup Setup instructions. ` - if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0644); err != nil { + if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0o644); err != nil { t.Fatalf("failed to write rule file 1: %v", err) } - bootstrapFile1 := filepath.Join(rulesDir, "setup-bootstrap") + bootstrapFile1 := filepath.Join(dirs.rulesDir, "setup-bootstrap") bootstrapContent1 := `#!/bin/bash echo "Running setup bootstrap" ` - if err := os.WriteFile(bootstrapFile1, []byte(bootstrapContent1), 0755); err != nil { + if err := os.WriteFile(bootstrapFile1, []byte(bootstrapContent1), 0o755); err != nil { t.Fatalf("failed to write bootstrap file 1: %v", err) } // Create second rule file with bootstrap - ruleFile2 := filepath.Join(rulesDir, "deploy.md") + ruleFile2 := filepath.Join(dirs.rulesDir, "deploy.md") ruleContent2 := `--- --- # Deploy Deployment instructions. ` - if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0644); err != nil { + if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0o644); err != nil { t.Fatalf("failed to write rule file 2: %v", err) } - bootstrapFile2 := filepath.Join(rulesDir, "deploy-bootstrap") + bootstrapFile2 := filepath.Join(dirs.rulesDir, "deploy-bootstrap") bootstrapContent2 := `#!/bin/bash echo "Running deploy bootstrap" ` - if err := os.WriteFile(bootstrapFile2, []byte(bootstrapContent2), 0755); err != nil { + if err := os.WriteFile(bootstrapFile2, []byte(bootstrapContent2), 0o755); err != nil { t.Fatalf("failed to write bootstrap file 2: %v", err) } - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task + createStandardTask(t, dirs.tasksDir, "test-task") -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } - - // Run the binary - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program + output := runTool(t, "-C", dirs.tmpDir, "test-task") // Check that both bootstrap scripts ran - outputStr := string(output) - if !strings.Contains(outputStr, "Running setup bootstrap") { + if !strings.Contains(output, "Running setup bootstrap") { t.Errorf("setup bootstrap output not found in stdout") } - if !strings.Contains(outputStr, "Running deploy bootstrap") { + if !strings.Contains(output, "Running deploy bootstrap") { t.Errorf("deploy bootstrap output not found in stdout") } // Check that both rule contents are present - if !strings.Contains(outputStr, "# Setup") { + if !strings.Contains(output, "# Setup") { t.Errorf("setup rule content not found in stdout") } - if !strings.Contains(outputStr, "# Deploy") { + if !strings.Contains(output, "# Deploy") { t.Errorf("deploy rule content not found in stdout") } } func TestSelectorFiltering(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - rulesDir := filepath.Join(tmpDir, ".agents", "rules") - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(rulesDir, 0755); err != nil { - t.Fatalf("failed to create rules dir: %v", err) - } - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } + dirs := setupTestDirs(t) // Create rule files with different selectors - ruleFile1 := filepath.Join(rulesDir, "python.md") + ruleFile1 := filepath.Join(dirs.rulesDir, "python.md") ruleContent1 := `--- language: python --- @@ -281,11 +230,11 @@ language: python Python specific guidelines. ` - if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0644); err != nil { + if err := os.WriteFile(ruleFile1, []byte(ruleContent1), 0o644); err != nil { t.Fatalf("failed to write python rule file: %v", err) } - ruleFile2 := filepath.Join(rulesDir, "golang.md") + ruleFile2 := filepath.Join(dirs.rulesDir, "golang.md") ruleContent2 := `--- language: go --- @@ -293,53 +242,29 @@ language: go Go specific guidelines. ` - if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0644); err != nil { + if err := os.WriteFile(ruleFile2, []byte(ruleContent2), 0o644); err != nil { t.Fatalf("failed to write go rule file: %v", err) } - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task + createStandardTask(t, dirs.tasksDir, "test-task") -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } - - // Run the binary with selector filtering for Python - cmd = exec.Command(binaryPath, "-C", tmpDir, "-s", "language=python", "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program with selector filtering for Python + output := runTool(t, "-C", dirs.tmpDir, "-s", "language=python", "test-task") // Check that only Python guidelines are included - outputStr := string(output) - if !strings.Contains(outputStr, "# Python Guidelines") { + if !strings.Contains(output, "# Python Guidelines") { t.Errorf("Python guidelines not found in stdout") } - if strings.Contains(outputStr, "# Go Guidelines") { + if strings.Contains(output, "# Go Guidelines") { t.Errorf("Go guidelines should not be in stdout when filtering for Python") } } func TestTemplateExpansionWithOsExpand(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0755); err != nil { + if err := os.MkdirAll(tasksDir, 0o755); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } @@ -352,238 +277,106 @@ task_name: test-task Please work on ${component} and fix ${issue}. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { t.Fatalf("failed to write task file: %v", err) } - // Run the binary with parameters - cmd = exec.Command(binaryPath, "-C", tmpDir, "-p", "component=auth", "-p", "issue=login bug", "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program with parameters + output := runTool(t, "-C", tmpDir, "-p", "component=auth", "-p", "issue=login bug", "test-task") // Check that template variables were expanded - outputStr := string(output) - if !strings.Contains(outputStr, "Please work on auth and fix login bug.") { - t.Errorf("template variables were not expanded correctly. Output:\n%s", outputStr) - } -} - -func TestWorkDirOption(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } - - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task - -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } - - // Run the binary with -C flag (from a different directory) - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-task") - cmd.Dir = "/" // Start from root directory - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } - - // Check that task content is present - outputStr := string(output) - if !strings.Contains(outputStr, "# Test Task") { - t.Errorf("task content not found in stdout") + if !strings.Contains(output, "Please work on auth and fix login bug.") { + t.Errorf("template variables were not expanded correctly. Output:\n%s", output) } } func TestMdcFileSupport(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - rulesDir := filepath.Join(tmpDir, ".agents", "rules") - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(rulesDir, 0755); err != nil { - t.Fatalf("failed to create rules dir: %v", err) - } - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } + dirs := setupTestDirs(t) // Create a .mdc rule file - ruleFile := filepath.Join(rulesDir, "custom.mdc") + ruleFile := filepath.Join(dirs.rulesDir, "custom.mdc") ruleContent := `--- --- # Custom Rules This is a .mdc file. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { t.Fatalf("failed to write .mdc rule file: %v", err) } - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task + createStandardTask(t, dirs.tasksDir, "test-task") -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } - - // Run the binary - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program + output := runTool(t, "-C", dirs.tmpDir, "test-task") // Check that .mdc file content is present - outputStr := string(output) - if !strings.Contains(outputStr, "# Custom Rules") { + if !strings.Contains(output, "# Custom Rules") { t.Errorf(".mdc file content not found in stdout") } } func TestMdcFileWithBootstrap(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - rulesDir := filepath.Join(tmpDir, ".agents", "rules") - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(rulesDir, 0755); err != nil { - t.Fatalf("failed to create rules dir: %v", err) - } - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } + dirs := setupTestDirs(t) // Create a .mdc rule file - ruleFile := filepath.Join(rulesDir, "custom.mdc") + ruleFile := filepath.Join(dirs.rulesDir, "custom.mdc") ruleContent := `--- --- # Custom Rules This is a .mdc file with bootstrap. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); 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(rulesDir, "custom-bootstrap") + bootstrapFile := filepath.Join(dirs.rulesDir, "custom-bootstrap") bootstrapContent := `#!/bin/bash echo "Running custom bootstrap" ` - if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0755); err != nil { + if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o755); err != nil { t.Fatalf("failed to write bootstrap file: %v", err) } - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task - -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } + createStandardTask(t, dirs.tasksDir, "test-task") - // Run the binary - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program + output := runTool(t, "-C", dirs.tmpDir, "test-task") // Check that bootstrap ran and content is present - outputStr := string(output) - if !strings.Contains(outputStr, "Running custom bootstrap") { + if !strings.Contains(output, "Running custom bootstrap") { t.Errorf("custom bootstrap output not found in stdout") } - if !strings.Contains(outputStr, "# Custom Rules") { + if !strings.Contains(output, "# Custom Rules") { t.Errorf(".mdc file content not found in stdout") } } func TestBootstrapWithoutExecutePermission(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - rulesDir := filepath.Join(tmpDir, ".agents", "rules") - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(rulesDir, 0755); err != nil { - t.Fatalf("failed to create rules dir: %v", err) - } - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } + dirs := setupTestDirs(t) // Create a rule file - ruleFile := filepath.Join(rulesDir, "setup.md") + ruleFile := filepath.Join(dirs.rulesDir, "setup.md") ruleContent := `--- --- # Development Setup This is a setup guide. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a bootstrap file WITHOUT execute permission (0644 instead of 0755) // 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(rulesDir, "setup-bootstrap") + bootstrapFile := filepath.Join(dirs.rulesDir, "setup-bootstrap") bootstrapContent := `#!/bin/bash echo "Bootstrap executed successfully" ` - if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0644); err != nil { + if err := os.WriteFile(bootstrapFile, []byte(bootstrapContent), 0o644); err != nil { t.Fatalf("failed to write bootstrap file: %v", err) } @@ -592,43 +385,27 @@ echo "Bootstrap executed successfully" if err != nil { t.Fatalf("failed to stat bootstrap file: %v", err) } - if fileInfo.Mode()&0111 != 0 { + if fileInfo.Mode()&0o111 != 0 { t.Fatalf("bootstrap file should not be executable initially, but has mode: %v", fileInfo.Mode()) } - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task - -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } + createStandardTask(t, dirs.tasksDir, "test-task") - // Run the binary - this should chmod +x the bootstrap file before running it - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program - this should chmod +x the bootstrap file before running it + output := runTool(t, "-C", dirs.tmpDir, "test-task") // Check that bootstrap output appears (proving it ran successfully) - outputStr := string(output) - if !strings.Contains(outputStr, "Bootstrap executed successfully") { + if !strings.Contains(output, "Bootstrap executed successfully") { t.Errorf("bootstrap output not found in stdout, meaning it didn't run successfully") } // Check that rule content is present - if !strings.Contains(outputStr, "# Development Setup") { + if !strings.Contains(output, "# Development Setup") { t.Errorf("rule content not found in stdout") } // Check that task content is present - if !strings.Contains(outputStr, "# Test Task") { + if !strings.Contains(output, "# Test Task") { t.Errorf("task content not found in stdout") } @@ -637,32 +414,24 @@ Please help with this task. if err != nil { t.Fatalf("failed to stat bootstrap file after run: %v", err) } - if fileInfo.Mode()&0111 == 0 { + 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) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure tmpDir := t.TempDir() openCodeAgentDir := filepath.Join(tmpDir, ".opencode", "agent") openCodeCommandDir := filepath.Join(tmpDir, ".opencode", "command") tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(openCodeAgentDir, 0755); err != nil { + if err := os.MkdirAll(openCodeAgentDir, 0o755); err != nil { t.Fatalf("failed to create opencode agent dir: %v", err) } - if err := os.MkdirAll(openCodeCommandDir, 0755); err != nil { + if err := os.MkdirAll(openCodeCommandDir, 0o755); err != nil { t.Fatalf("failed to create opencode command dir: %v", err) } - if err := os.MkdirAll(tasksDir, 0755); err != nil { + if err := os.MkdirAll(tasksDir, 0o755); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } @@ -672,7 +441,7 @@ func TestOpenCodeRulesSupport(t *testing.T) { This agent helps with documentation. ` - if err := os.WriteFile(agentFile, []byte(agentContent), 0644); err != nil { + if err := os.WriteFile(agentFile, []byte(agentContent), 0o644); err != nil { t.Fatalf("failed to write agent file: %v", err) } @@ -682,7 +451,7 @@ This agent helps with documentation. This command helps create commits. ` - if err := os.WriteFile(commandFile, []byte(commandContent), 0644); err != nil { + if err := os.WriteFile(commandFile, []byte(commandContent), 0o644); err != nil { t.Fatalf("failed to write command file: %v", err) } @@ -695,48 +464,34 @@ task_name: test-opencode This is a test task. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { t.Fatalf("failed to write task file: %v", err) } - // Run the binary - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-opencode") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } - - outputStr := string(output) + // Run the program + output := runTool(t, "-C", tmpDir, "test-opencode") // Check that agent rule content is present - if !strings.Contains(outputStr, "# Documentation Agent") { + if !strings.Contains(output, "# Documentation Agent") { t.Errorf("OpenCode agent rule content not found in stdout") } // Check that command rule content is present - if !strings.Contains(outputStr, "# Commit Command") { + if !strings.Contains(output, "# Commit Command") { t.Errorf("OpenCode command rule content not found in stdout") } // Check that task content is present - if !strings.Contains(outputStr, "# Test OpenCode Task") { + if !strings.Contains(output, "# Test OpenCode Task") { t.Errorf("task content not found in stdout") } } func TestTaskSelectionByFrontmatter(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0755); err != nil { + if err := os.MkdirAll(tasksDir, 0o755); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } @@ -750,37 +505,24 @@ task_name: my-special-task This task has a different filename than task_name. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { t.Fatalf("failed to write task file: %v", err) } - // Run the binary with task name matching the task_name frontmatter, not filename - cmd = exec.Command(binaryPath, "-C", tmpDir, "my-special-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + // Run the program with task name matching the task_name frontmatter, not filename + output := runTool(t, "-C", tmpDir, "my-special-task") // Check that task content is present - outputStr := string(output) - if !strings.Contains(outputStr, "# My Special Task") { + if !strings.Contains(output, "# My Special Task") { t.Errorf("task content not found in stdout") } } func TestTaskMissingTaskNameError(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0755); err != nil { + if err := os.MkdirAll(tasksDir, 0o755); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } @@ -793,37 +535,27 @@ description: A task without task_name This task is missing task_name in frontmatter. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { t.Fatalf("failed to write task file: %v", err) } - // Run the binary - should fail with an error - cmd = exec.Command(binaryPath, "-C", tmpDir, "bad-task") - output, err := cmd.CombinedOutput() + // Run the program - should fail with an error + output, err := runToolWithError("-C", tmpDir, "bad-task") if err == nil { - t.Fatalf("expected binary to fail, but it succeeded") + t.Fatalf("expected program to fail, but it succeeded") } // Check that error message mentions missing task_name - outputStr := string(output) - if !strings.Contains(outputStr, "missing required 'task_name' field in frontmatter") { - t.Errorf("expected error about missing task_name, got: %s", outputStr) + if !strings.Contains(output, "missing required 'task_name' field in frontmatter") { + t.Errorf("expected error about missing task_name, got: %s", output) } } func TestMultipleTasksWithSameNameError(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0755); err != nil { + if err := os.MkdirAll(tasksDir, 0o755); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } @@ -836,7 +568,7 @@ task_name: duplicate-task This is the first file. ` - if err := os.WriteFile(taskFile1, []byte(taskContent1), 0644); err != nil { + if err := os.WriteFile(taskFile1, []byte(taskContent1), 0o644); err != nil { t.Fatalf("failed to write task file 1: %v", err) } @@ -848,37 +580,27 @@ task_name: duplicate-task This is the second file. ` - if err := os.WriteFile(taskFile2, []byte(taskContent2), 0644); err != nil { + if err := os.WriteFile(taskFile2, []byte(taskContent2), 0o644); err != nil { t.Fatalf("failed to write task file 2: %v", err) } - // Run the binary - should fail with an error about duplicate task names - cmd = exec.Command(binaryPath, "-C", tmpDir, "duplicate-task") - output, err := cmd.CombinedOutput() + // Run the program - should fail with an error about duplicate task names + output, err := runToolWithError("-C", tmpDir, "duplicate-task") if err == nil { - t.Fatalf("expected binary to fail with duplicate task names, but it succeeded") + t.Fatalf("expected program to fail with duplicate task names, but it succeeded") } // Check that error message mentions multiple task files - outputStr := string(output) - if !strings.Contains(outputStr, "multiple task files found") { - t.Errorf("expected error about multiple task files, got: %s", outputStr) + if !strings.Contains(output, "multiple task files found") { + t.Errorf("expected error about multiple task files, got: %s", output) } } func TestTaskSelectionWithSelectors(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0755); err != nil { + if err := os.MkdirAll(tasksDir, 0o755); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } @@ -892,7 +614,7 @@ environment: staging Deploy to the staging environment. ` - if err := os.WriteFile(taskFile1, []byte(taskContent1), 0644); err != nil { + if err := os.WriteFile(taskFile1, []byte(taskContent1), 0o644); err != nil { t.Fatalf("failed to write staging task file: %v", err) } @@ -905,77 +627,50 @@ environment: production Deploy to the production environment. ` - if err := os.WriteFile(taskFile2, []byte(taskContent2), 0644); err != nil { + if err := os.WriteFile(taskFile2, []byte(taskContent2), 0o644); err != nil { t.Fatalf("failed to write production task file: %v", err) } - // Run the binary with selector for staging - cmd = exec.Command(binaryPath, "-C", tmpDir, "-s", "environment=staging", "deploy") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary for staging: %v\n%s", err, output) - } + // Run the program with selector for staging + output := runTool(t, "-C", tmpDir, "-s", "environment=staging", "deploy") // Check that staging task content is present - outputStr := string(output) - if !strings.Contains(outputStr, "# Deploy to Staging") { + if !strings.Contains(output, "# Deploy to Staging") { t.Errorf("staging task content not found in stdout") } - if strings.Contains(outputStr, "# Deploy to Production") { + if strings.Contains(output, "# Deploy to Production") { t.Errorf("production task content should not be in stdout when selecting staging") } - // Run the binary with selector for production - cmd = exec.Command(binaryPath, "-C", tmpDir, "-s", "environment=production", "deploy") - output, err = cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary for production: %v\n%s", err, output) - } + // Run the program with selector for production + output = runTool(t, "-C", tmpDir, "-s", "environment=production", "deploy") // Check that production task content is present - outputStr = string(output) - if !strings.Contains(outputStr, "# Deploy to Production") { + if !strings.Contains(output, "# Deploy to Production") { t.Errorf("production task content not found in stdout") } - if strings.Contains(outputStr, "# Deploy to Staging") { + if strings.Contains(output, "# Deploy to Staging") { t.Errorf("staging task content should not be in stdout when selecting production") } } func TestResumeMode(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - rulesDir := filepath.Join(tmpDir, ".agents", "rules") - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(rulesDir, 0755); err != nil { - t.Fatalf("failed to create rules dir: %v", err) - } - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } + dirs := setupTestDirs(t) // Create a rule file that should be included in normal mode - ruleFile := filepath.Join(rulesDir, "coding-standards.md") + 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), 0644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a normal task file (with resume: false) - normalTaskFile := filepath.Join(tasksDir, "fix-bug-initial.md") + normalTaskFile := filepath.Join(dirs.tasksDir, "fix-bug-initial.md") normalTaskContent := `--- task_name: fix-bug resume: false @@ -984,12 +679,12 @@ resume: false This is the initial task prompt for fixing a bug. ` - if err := os.WriteFile(normalTaskFile, []byte(normalTaskContent), 0644); err != nil { + if err := os.WriteFile(normalTaskFile, []byte(normalTaskContent), 0o644); err != nil { t.Fatalf("failed to write normal task file: %v", err) } // Create a resume task file (with resume: true) - resumeTaskFile := filepath.Join(tasksDir, "fix-bug-resume.md") + resumeTaskFile := filepath.Join(dirs.tasksDir, "fix-bug-resume.md") resumeTaskContent := `--- task_name: fix-bug resume: true @@ -998,67 +693,48 @@ resume: true This is the resume task prompt for continuing the bug fix. ` - if err := os.WriteFile(resumeTaskFile, []byte(resumeTaskContent), 0644); err != nil { + if err := os.WriteFile(resumeTaskFile, []byte(resumeTaskContent), 0o644); err != nil { t.Fatalf("failed to write resume task file: %v", err) } // Test 1: Run in normal mode (with -s resume=false to select non-resume task) - cmd = exec.Command(binaryPath, "-C", tmpDir, "-s", "resume=false", "fix-bug") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary in normal mode: %v\n%s", err, output) - } - - outputStr := string(output) + output := runTool(t, "-C", dirs.tmpDir, "-s", "resume=false", "fix-bug") // In normal mode, rules should be included - if !strings.Contains(outputStr, "# Coding Standards") { + if !strings.Contains(output, "# Coding Standards") { t.Errorf("normal mode: rule content not found in stdout") } // In normal mode, should use the normal task (not resume task) - if !strings.Contains(outputStr, "# Fix Bug (Initial)") { + if !strings.Contains(output, "# Fix Bug (Initial)") { t.Errorf("normal mode: normal task content not found in stdout") } - if strings.Contains(outputStr, "# Fix Bug (Resume)") { + if strings.Contains(output, "# Fix Bug (Resume)") { t.Errorf("normal mode: resume task content should not be in stdout") } // Test 2: Run in resume mode (with -r flag) - cmd = exec.Command(binaryPath, "-C", tmpDir, "-r", "fix-bug") - output, err = cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary in resume mode: %v\n%s", err, output) - } - - outputStr = string(output) + output = runTool(t, "-C", dirs.tmpDir, "-r", "fix-bug") // In resume mode, rules should NOT be included - if strings.Contains(outputStr, "# Coding Standards") { + if strings.Contains(output, "# Coding Standards") { t.Errorf("resume mode: rule content should not be in stdout") } // In resume mode, should use the resume task - if !strings.Contains(outputStr, "# Fix Bug (Resume)") { + if !strings.Contains(output, "# Fix Bug (Resume)") { t.Errorf("resume mode: resume task content not found in stdout") } - if strings.Contains(outputStr, "# Fix Bug (Initial)") { + if strings.Contains(output, "# Fix Bug (Initial)") { t.Errorf("resume mode: normal task content should not be in stdout") } } func TestRemoteRuleFromHTTP(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - // Create a remote directory structure to serve remoteDir := t.TempDir() rulesDir := filepath.Join(remoteDir, ".agents", "rules") - if err := os.MkdirAll(rulesDir, 0755); err != nil { + if err := os.MkdirAll(rulesDir, 0o755); err != nil { t.Fatalf("failed to create remote rules dir: %v", err) } @@ -1070,7 +746,7 @@ func TestRemoteRuleFromHTTP(t *testing.T) { This is a rule loaded from a remote directory. ` - if err := os.WriteFile(remoteRuleFile, []byte(remoteRuleContent), 0644); err != nil { + if err := os.WriteFile(remoteRuleFile, []byte(remoteRuleContent), 0o644); err != nil { t.Fatalf("failed to write remote rule file: %v", err) } @@ -1078,68 +754,35 @@ This is a rule loaded from a remote directory. tmpDir := t.TempDir() tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - if err := os.MkdirAll(tasksDir, 0755); err != nil { + if err := os.MkdirAll(tasksDir, 0o755); err != nil { t.Fatalf("failed to create tasks dir: %v", err) } - // Create a task file - taskFile := filepath.Join(tasksDir, "test-task.md") - taskContent := `--- -task_name: test-task ---- -# Test Task - -Please help with this task. -` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { - t.Fatalf("failed to write task file: %v", err) - } + createStandardTask(t, tasksDir, "test-task") - // Run the binary with remote directory (using file:// URL) + // Run the program with remote directory (using file:// URL) remoteURL := "file://" + remoteDir - cmd = exec.Command(binaryPath, "-C", tmpDir, "-d", remoteURL, "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary: %v\n%s", err, output) - } + output := runTool(t, "-C", tmpDir, "-d", remoteURL, "test-task") // Check that remote rule content is present - outputStr := string(output) - if !strings.Contains(outputStr, "# Remote Rule") { + if !strings.Contains(output, "# Remote Rule") { t.Errorf("remote rule content not found in stdout") } - if !strings.Contains(outputStr, "This is a rule loaded from a remote directory") { + if !strings.Contains(output, "This is a rule loaded from a remote directory") { t.Errorf("remote rule description not found in stdout") } // Check that task content is present - if !strings.Contains(outputStr, "# Test Task") { + if !strings.Contains(output, "# Test Task") { t.Errorf("task content not found in stdout") } } func TestPrintTaskFrontmatter(t *testing.T) { - // Build the binary - binaryPath := filepath.Join(t.TempDir(), "coding-context") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to build binary: %v\n%s", err, output) - } - - // Create a temporary directory structure - tmpDir := t.TempDir() - rulesDir := filepath.Join(tmpDir, ".agents", "rules") - tasksDir := filepath.Join(tmpDir, ".agents", "tasks") - - if err := os.MkdirAll(rulesDir, 0755); err != nil { - t.Fatalf("failed to create rules dir: %v", err) - } - if err := os.MkdirAll(tasksDir, 0755); err != nil { - t.Fatalf("failed to create tasks dir: %v", err) - } + dirs := setupTestDirs(t) // Create a rule file - ruleFile := filepath.Join(rulesDir, "test-rule.md") + ruleFile := filepath.Join(dirs.rulesDir, "test-rule.md") ruleContent := `--- language: go --- @@ -1147,12 +790,12 @@ language: go This is a test rule. ` - if err := os.WriteFile(ruleFile, []byte(ruleContent), 0644); err != nil { + if err := os.WriteFile(ruleFile, []byte(ruleContent), 0o644); err != nil { t.Fatalf("failed to write rule file: %v", err) } // Create a task file with frontmatter - taskFile := filepath.Join(tasksDir, "test-task.md") + taskFile := filepath.Join(dirs.tasksDir, "test-task.md") taskContent := `--- task_name: test-task author: tester @@ -1162,41 +805,31 @@ version: 1.0 This is a test task. ` - if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil { + if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil { t.Fatalf("failed to write task file: %v", err) } // Test without -t flag (should not print frontmatter) - cmd = exec.Command(binaryPath, "-C", tmpDir, "test-task") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary without -t: %v\n%s", err, output) - } + output := runTool(t, "-C", dirs.tmpDir, "test-task") - outputStr := string(output) // Should not contain frontmatter delimiters in the main output - lines := strings.Split(outputStr, "\n") + lines := strings.Split(output, "\n") if len(lines) > 0 && lines[0] == "---" { t.Errorf("frontmatter should not be printed without -t flag") } // Task content should be present - if !strings.Contains(outputStr, "# Test Task") { + if !strings.Contains(output, "# Test Task") { t.Errorf("task content not found in output without -t flag") } // Rule content should be present - if !strings.Contains(outputStr, "# Test Rule") { + if !strings.Contains(output, "# Test Rule") { t.Errorf("rule content not found in output without -t flag") } // Test with -t flag (should print frontmatter) - cmd = exec.Command(binaryPath, "-C", tmpDir, "-t", "test-task") - output, err = cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to run binary with -t: %v\n%s", err, output) - } + output = runTool(t, "-C", dirs.tmpDir, "-t", "test-task") - outputStr = string(output) - lines = strings.Split(outputStr, "\n") + lines = strings.Split(output, "\n") // First line should be frontmatter delimiter if lines[0] != "---" { @@ -1204,13 +837,13 @@ This is a test task. } // Should contain task frontmatter fields - if !strings.Contains(outputStr, "task_name: test-task") { + if !strings.Contains(output, "task_name: test-task") { t.Errorf("task frontmatter field 'task_name' not found in output") } - if !strings.Contains(outputStr, "author: tester") { + if !strings.Contains(output, "author: tester") { t.Errorf("task frontmatter field 'author' not found in output") } - if !strings.Contains(outputStr, "version: 1.0") { + if !strings.Contains(output, "version: 1.0") { t.Errorf("task frontmatter field 'version' not found in output") } @@ -1227,19 +860,19 @@ This is a test task. } // Rule content should appear after frontmatter - if !strings.Contains(outputStr, "# Test Rule") { + if !strings.Contains(output, "# Test Rule") { t.Errorf("rule content not found in output with -t flag") } // Task content should appear after rules - if !strings.Contains(outputStr, "# Test Task") { + if !strings.Contains(output, "# Test Task") { t.Errorf("task content not found in output with -t flag") } // Verify order: frontmatter should come before rules, rules before task content - frontmatterIdx := strings.Index(outputStr, "task_name: test-task") - ruleIdx := strings.Index(outputStr, "# Test Rule") - taskIdx := strings.Index(outputStr, "# Test Task") + frontmatterIdx := strings.Index(output, "task_name: test-task") + ruleIdx := strings.Index(output, "# Test Rule") + taskIdx := strings.Index(output, "# Test Task") if frontmatterIdx == -1 || ruleIdx == -1 || taskIdx == -1 { t.Fatalf("could not find all required sections in output") @@ -1253,7 +886,7 @@ This is a test task. } // Rule frontmatter should NOT be printed - if strings.Contains(outputStr, "language: go") { + if strings.Contains(output, "language: go") { t.Errorf("rule frontmatter should not be printed in output") } } diff --git a/main.go b/main.go index 24b9f57..e58ac4f 100644 --- a/main.go +++ b/main.go @@ -243,17 +243,17 @@ func (cc *codingContext) ruleFileWalker(ctx context.Context) func(path string, i return fmt.Errorf("failed to parse markdown file: %w", err) } - if err := cc.runBootstrapScript(ctx, path, ext); err != nil { - return fmt.Errorf("failed to run bootstrap script: %w", err) - } - - // Check if file matches include selectors. + // Check if file matches include selectors BEFORE running bootstrap script. // Note: Files with duplicate basenames will both be included. if !cc.includes.matchesIncludes(frontmatter) { fmt.Fprintf(cc.logOut, "⪢ Excluding rule file (does not match include selectors): %s\n", path) return nil } + if err := cc.runBootstrapScript(ctx, path, ext); err != nil { + return fmt.Errorf("failed to run bootstrap script (path: %s): %w", path, err) + } + // Estimate tokens for this file tokens := estimateTokens(content) cc.totalTokens += tokens @@ -289,7 +289,7 @@ func (cc *codingContext) runBootstrapScript(ctx context.Context, path, ext strin cmd.Stderr = cc.logOut if err := cc.cmdRunner(cmd); err != nil { - return fmt.Errorf("failed to run bootstrap script: %w", err) + return err } return nil @@ -337,6 +337,9 @@ func (cc *codingContext) parseTaskFile() error { case string: // Convert string to single value in map cc.includes.SetValue(key, v) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: + // Convert scalar numeric or boolean to string + cc.includes.SetValue(key, fmt.Sprint(v)) default: return fmt.Errorf("task file %s has invalid selector value for key %q: expected string or array, got %T", cc.matchingTaskFile, key, value) } @@ -346,8 +349,8 @@ func (cc *codingContext) parseTaskFile() error { return nil } -// emitTaskFrontmatterOnly emits only the task frontmatter to the output. -// This is used to print frontmatter before rules when -t flag is set. +// printTaskFrontmatter emits only the task frontmatter to the output and +// only if emitTaskFrontmatter is true. func (cc *codingContext) printTaskFrontmatter() error { if !cc.emitTaskFrontmatter { return nil @@ -362,7 +365,7 @@ func (cc *codingContext) printTaskFrontmatter() error { } // emitTaskFileContent emits the parsed task content to the output. -// It expands parameters, estimates tokens, and optionally includes frontmatter. +// It expands parameters and estimates tokens. func (cc *codingContext) emitTaskFileContent() error { expanded := os.Expand(cc.taskContent, func(key string) string { if val, ok := cc.params[key]; ok { diff --git a/main_test.go b/main_test.go index abc5404..3b5080f 100644 --- a/main_test.go +++ b/main_test.go @@ -305,15 +305,17 @@ func TestFindTaskFile(t *testing.T) { func TestFindExecuteRuleFiles(t *testing.T) { tests := []struct { - name string - resume bool - includes selectorMap - setupFiles func(t *testing.T, tmpDir string) - downloadedDirs []string // Directories to add to downloadedDirs - wantTokens int - wantMinTokens bool // Check that tokens > 0 - expectInOutput string - expectNotInOutput string + name string + resume bool + includes selectorMap + setupFiles func(t *testing.T, tmpDir string) + downloadedDirs []string // Directories to add to downloadedDirs + wantTokens int + wantMinTokens bool // Check that tokens > 0 + expectInOutput string + expectNotInOutput string + expectBootstrapRun bool // Whether bootstrap script should run + bootstrapPath string // Path to bootstrap script to check }{ { name: "resume mode skips rules", @@ -409,6 +411,29 @@ func TestFindExecuteRuleFiles(t *testing.T) { wantMinTokens: true, expectInOutput: "Downloaded Rule", }, + { + name: "bootstrap script should not run on excluded files", + resume: false, + includes: selectorMap{ + "env": map[string]bool{"prod": true}, + }, + setupFiles: func(t *testing.T, tmpDir string) { + // Create an excluded rule file (env: dev doesn't match env: prod) + rulePath := filepath.Join(tmpDir, "CLAUDE.md") + createMarkdownFile(t, rulePath, + "env: dev", + "# Dev Rule") + // Create a bootstrap script for this rule + bootstrapPath := filepath.Join(tmpDir, "CLAUDE-bootstrap") + if err := os.WriteFile(bootstrapPath, []byte("#!/bin/sh\necho 'bootstrap ran' >&2"), 0o644); err != nil { + t.Fatalf("failed to create bootstrap file: %v", err) + } + }, + wantTokens: 0, + expectNotInOutput: "# Dev Rule", + expectBootstrapRun: false, + bootstrapPath: "CLAUDE-bootstrap", + }, } for _, tt := range tests { @@ -427,12 +452,17 @@ func TestFindExecuteRuleFiles(t *testing.T) { } var output, logOut bytes.Buffer + bootstrapRan := false cc := &codingContext{ resume: tt.resume, includes: tt.includes, output: &output, logOut: &logOut, cmdRunner: func(cmd *exec.Cmd) error { + // Track if bootstrap script was executed + if cmd.Path != "" { + bootstrapRan = true + } return nil // Mock command runner }, } @@ -467,6 +497,16 @@ func TestFindExecuteRuleFiles(t *testing.T) { if tt.expectNotInOutput != "" && strings.Contains(outputStr, tt.expectNotInOutput) { t.Errorf("findExecuteRuleFiles() output should not contain %q, got:\n%s", tt.expectNotInOutput, outputStr) } + + // Check bootstrap script execution + if tt.bootstrapPath != "" { + if tt.expectBootstrapRun && !bootstrapRan { + t.Errorf("findExecuteRuleFiles() expected bootstrap script %q to run, but it didn't", tt.bootstrapPath) + } + if !tt.expectBootstrapRun && bootstrapRan { + t.Errorf("findExecuteRuleFiles() expected bootstrap script %q NOT to run, but it did", tt.bootstrapPath) + } + } }) } } @@ -825,6 +865,38 @@ func TestParseTaskFile(t *testing.T) { }, wantErr: false, }, + { + name: "selectors from -s flag and task file are additive", + taskFile: "task.md", + initialIncludes: selectorMap{"var": map[string]bool{"arg1": true}}, + expectedIncludes: selectorMap{ + "var": map[string]bool{"arg1": true, "arg2": true}, + }, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test\nselectors:\n var: arg2", + "# Task with Additive Selectors") + return taskPath + }, + wantErr: false, + }, + { + name: "task with integer selector value", + taskFile: "task.md", + initialIncludes: make(selectorMap), + expectedIncludes: selectorMap{ + "version": map[string]bool{"42": true}, + }, + setupFiles: func(t *testing.T, tmpDir string) string { + taskPath := filepath.Join(tmpDir, "task.md") + createMarkdownFile(t, taskPath, + "task_name: test\nselectors:\n version: 42", + "# Task with Integer Selector") + return taskPath + }, + wantErr: false, + }, { name: "task with invalid selectors field type", taskFile: "task.md", diff --git a/remote.go b/remote.go index e19b82b..f8f821c 100644 --- a/remote.go +++ b/remote.go @@ -11,13 +11,13 @@ import ( func (cc *codingContext) downloadRemoteDirectories(ctx context.Context) error { for _, remotePath := range cc.remotePaths { - fmt.Fprintf(os.Stderr, "⪢ Downloading remote directory: %s\n", remotePath) + fmt.Fprintf(cc.logOut, "⪢ Downloading remote directory: %s\n", remotePath) localPath, err := downloadRemoteDirectory(ctx, remotePath) if err != nil { return fmt.Errorf("failed to download remote directory %s: %w", remotePath, err) } cc.downloadedDirs = append(cc.downloadedDirs, localPath) - fmt.Fprintf(os.Stderr, "⪢ Downloaded to: %s\n", localPath) + fmt.Fprintf(cc.logOut, "⪢ Downloaded to: %s\n", localPath) } return nil @@ -25,7 +25,13 @@ func (cc *codingContext) downloadRemoteDirectories(ctx context.Context) error { func (cc *codingContext) cleanupDownloadedDirectories() { for _, dir := range cc.downloadedDirs { - cleanupRemoteDirectory(dir) + if dir == "" { + continue + } + + if err := os.RemoveAll(dir); err != nil { + fmt.Fprintf(cc.logOut, "⪢ Error cleaning up downloaded directory %s: %v\n", dir, err) + } } } @@ -50,11 +56,3 @@ func downloadRemoteDirectory(ctx context.Context, src string) (string, error) { return tmpDir, nil } - -// cleanupRemoteDirectory removes a downloaded remote directory -func cleanupRemoteDirectory(path string) error { - if path == "" { - return nil - } - return os.RemoveAll(path) -} diff --git a/selector_map.go b/selector_map.go index 63f246d..4a594ee 100644 --- a/selector_map.go +++ b/selector_map.go @@ -41,7 +41,16 @@ func (s *selectorMap) Set(value string) error { key := strings.TrimSpace(kv[0]) newValue := strings.TrimSpace(kv[1]) - s.SetValue(key, newValue) + // If value is empty, set the key to an empty map only if it's currently unset + if newValue == "" { + if _, exists := (*s)[key]; !exists { + (*s)[key] = make(map[string]bool) + } + return nil + } else { + s.SetValue(key, newValue) + } + return nil } diff --git a/selector_map_test.go b/selector_map_test.go index c666144..3ce6ac8 100644 --- a/selector_map_test.go +++ b/selector_map_test.go @@ -219,6 +219,18 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { frontmatter: frontMatter{"env": "staging"}, wantMatch: false, }, + { + name: "empty value selector - key exists in frontmatter (no match)", + selectors: []string{"env="}, + frontmatter: frontMatter{"env": "production"}, + wantMatch: false, + }, + { + name: "empty value selector - key missing in frontmatter (match)", + selectors: []string{"env="}, + frontmatter: frontMatter{"language": "go"}, + wantMatch: true, + }, } for _, tt := range tests {