From 5a751d0242b61436a3e3e7f1fe2d78d77381d526 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 03:56:18 +0000 Subject: [PATCH 1/6] Initial plan From 54248eacd0641a5722e9ff2fe39a18668a8992db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 04:02:13 +0000 Subject: [PATCH 2/6] Add selector flag (-s) for filtering memories based on frontmatter Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- integration_test.go | 150 ++++++++++++++++++++++++++++++ main.go | 16 +++- selector_map.go | 78 ++++++++++++++++ selector_map_test.go | 215 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 selector_map.go create mode 100644 selector_map_test.go diff --git a/integration_test.go b/integration_test.go index ac610411..2baab18b 100644 --- a/integration_test.go +++ b/integration_test.go @@ -234,3 +234,153 @@ func TestMultipleBootstrapFiles(t *testing.T) { t.Errorf("expected 2 bootstrap files, got %d", len(files)) } } + +func TestSelectorFiltering(t *testing.T) { + // Build the binary + binaryPath := filepath.Join(t.TempDir(), "coding-agent-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() + contextDir := filepath.Join(tmpDir, ".coding-agent-context") + memoriesDir := filepath.Join(contextDir, "memories") + promptsDir := filepath.Join(contextDir, "prompts") + outputDir := filepath.Join(tmpDir, "output") + + if err := os.MkdirAll(memoriesDir, 0755); err != nil { + t.Fatalf("failed to create memories dir: %v", err) + } + if err := os.MkdirAll(promptsDir, 0755); err != nil { + t.Fatalf("failed to create prompts dir: %v", err) + } + + // Create memory files with different frontmatter + if err := os.WriteFile(filepath.Join(memoriesDir, "prod.md"), []byte("---\nenv: production\nlanguage: go\n---\n# Production\nProd content\n"), 0644); err != nil { + t.Fatalf("failed to write memory file: %v", err) + } + if err := os.WriteFile(filepath.Join(memoriesDir, "dev.md"), []byte("---\nenv: development\nlanguage: python\n---\n# Development\nDev content\n"), 0644); err != nil { + t.Fatalf("failed to write memory file: %v", err) + } + if err := os.WriteFile(filepath.Join(memoriesDir, "test.md"), []byte("---\nenv: test\nlanguage: go\n---\n# Test\nTest content\n"), 0644); err != nil { + t.Fatalf("failed to write memory file: %v", err) + } + + // Create a prompt file + if err := os.WriteFile(filepath.Join(promptsDir, "test-task.md"), []byte("---\n---\n# Test Task\n"), 0644); err != nil { + t.Fatalf("failed to write prompt file: %v", err) + } + + // Test 1: Filter by env=production + cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-s", "env=production", "test-task") + cmd.Dir = tmpDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to run binary: %v\n%s", err, output) + } + + promptOutput := filepath.Join(outputDir, "prompt.md") + content, err := os.ReadFile(promptOutput) + if err != nil { + t.Fatalf("failed to read prompt output: %v", err) + } + contentStr := string(content) + if !contains(contentStr, "Prod content") { + t.Errorf("Expected production content in output") + } + if contains(contentStr, "Dev content") { + t.Errorf("Did not expect development content in output") + } + if contains(contentStr, "Test content") { + t.Errorf("Did not expect test content in output") + } + + // Clean output for next test + os.RemoveAll(outputDir) + + // Test 2: Filter by language=go (should include prod and test) + cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-s", "language=go", "test-task") + cmd.Dir = tmpDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to run binary: %v\n%s", err, output) + } + + content, err = os.ReadFile(promptOutput) + if err != nil { + t.Fatalf("failed to read prompt output: %v", err) + } + contentStr = string(content) + if !contains(contentStr, "Prod content") { + t.Errorf("Expected production content in output") + } + if contains(contentStr, "Dev content") { + t.Errorf("Did not expect development content in output") + } + if !contains(contentStr, "Test content") { + t.Errorf("Expected test content in output") + } + + // Clean output for next test + os.RemoveAll(outputDir) + + // Test 3: Filter by env!=production (should include dev and test) + cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-s", "env!=production", "test-task") + cmd.Dir = tmpDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to run binary: %v\n%s", err, output) + } + + content, err = os.ReadFile(promptOutput) + if err != nil { + t.Fatalf("failed to read prompt output: %v", err) + } + contentStr = string(content) + if contains(contentStr, "Prod content") { + t.Errorf("Did not expect production content in output") + } + if !contains(contentStr, "Dev content") { + t.Errorf("Expected development content in output") + } + if !contains(contentStr, "Test content") { + t.Errorf("Expected test content in output") + } + + // Clean output for next test + os.RemoveAll(outputDir) + + // Test 4: Multiple selectors env=production language=go (should include only prod) + cmd = exec.Command(binaryPath, "-d", contextDir, "-o", outputDir, "-s", "env=production", "-s", "language=go", "test-task") + cmd.Dir = tmpDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to run binary: %v\n%s", err, output) + } + + content, err = os.ReadFile(promptOutput) + if err != nil { + t.Fatalf("failed to read prompt output: %v", err) + } + contentStr = string(content) + if !contains(contentStr, "Prod content") { + t.Errorf("Expected production content in output") + } + if contains(contentStr, "Dev content") { + t.Errorf("Did not expect development content in output") + } + if contains(contentStr, "Test content") { + t.Errorf("Did not expect test content in output") + } +} + +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsSubstring(s, substr))) +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/main.go b/main.go index 0cf071be..23f66a17 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ var ( dirs stringSlice outputDir = "." params = make(paramMap) + selectors selectorMap ) func main() { @@ -37,6 +38,7 @@ func main() { flag.Var(&dirs, "d", "Directory to include in the context. Can be specified multiple times.") flag.StringVar(&outputDir, "o", ".", "Directory to write the context files to.") flag.Var(¶ms, "p", "Parameter to substitute in the prompt. Can be specified multiple times as key=value.") + flag.Var(&selectors, "s", "Selector to filter memories. Can be specified multiple times as key=value or key!=value.") flag.Usage = func() { w := flag.CommandLine.Output() @@ -97,13 +99,21 @@ func run(args []string) error { return nil } - slog.Info("Including memory file", "path", path) - - content, err := parseMarkdownFile(path, &struct{}{}) + // Parse frontmatter to check selectors + var frontmatter map[string]string + content, err := parseMarkdownFile(path, &frontmatter) if err != nil { return fmt.Errorf("failed to parse markdown file: %w", err) } + // Check if file matches selectors + if !selectors.matches(frontmatter) { + slog.Info("Excluding memory file (does not match selectors)", "path", path) + return nil + } + + slog.Info("Including memory file", "path", path) + // Check for a bootstrap file named -bootstrap // For example, setup.md -> setup-bootstrap baseNameWithoutExt := strings.TrimSuffix(path, ".md") diff --git a/selector_map.go b/selector_map.go new file mode 100644 index 00000000..9e756458 --- /dev/null +++ b/selector_map.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "strings" +) + +type selectorType int + +const ( + selectorEquals selectorType = iota + selectorNotEquals +) + +type selector struct { + key string + value string + op selectorType +} + +type selectorMap []selector + +func (s *selectorMap) String() string { + return fmt.Sprint(*s) +} + +func (s *selectorMap) Set(value string) error { + // Check for != operator first (it's longer) + if strings.Contains(value, "!=") { + kv := strings.SplitN(value, "!=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid selector format: %s", value) + } + *s = append(*s, selector{ + key: strings.TrimSpace(kv[0]), + value: strings.TrimSpace(kv[1]), + op: selectorNotEquals, + }) + return nil + } + + // Check for = operator + if strings.Contains(value, "=") { + kv := strings.SplitN(value, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid selector format: %s", value) + } + *s = append(*s, selector{ + key: strings.TrimSpace(kv[0]), + value: strings.TrimSpace(kv[1]), + op: selectorEquals, + }) + return nil + } + + return fmt.Errorf("invalid selector format: %s (must contain = or !=)", value) +} + +// matches returns true if the frontmatter matches all selectors +func (s *selectorMap) matches(frontmatter map[string]string) bool { + for _, sel := range *s { + fmValue, exists := frontmatter[sel.key] + + switch sel.op { + case selectorEquals: + // For equals, the key must exist and match the value + if !exists || fmValue != sel.value { + return false + } + case selectorNotEquals: + // For not equals, if key exists it must not match the value + if exists && fmValue == sel.value { + return false + } + } + } + return true +} diff --git a/selector_map_test.go b/selector_map_test.go new file mode 100644 index 00000000..cc540f8f --- /dev/null +++ b/selector_map_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "testing" +) + +func TestSelectorMap_Set(t *testing.T) { + tests := []struct { + name string + value string + wantKey string + wantVal string + wantOp selectorType + wantErr bool + }{ + { + name: "valid equals selector", + value: "env=production", + wantKey: "env", + wantVal: "production", + wantOp: selectorEquals, + wantErr: false, + }, + { + name: "valid not equals selector", + value: "env!=test", + wantKey: "env", + wantVal: "test", + wantOp: selectorNotEquals, + wantErr: false, + }, + { + name: "equals with spaces", + value: "env = production", + wantKey: "env", + wantVal: "production", + wantOp: selectorEquals, + wantErr: false, + }, + { + name: "not equals with spaces", + value: "env != test", + wantKey: "env", + wantVal: "test", + wantOp: selectorNotEquals, + wantErr: false, + }, + { + name: "invalid format - no operator", + value: "env", + wantErr: true, + }, + { + name: "invalid format - empty", + value: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var s selectorMap + err := s.Set(tt.value) + + if (err != nil) != tt.wantErr { + t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + if len(s) != 1 { + t.Errorf("Set() resulted in %d selectors, want 1", len(s)) + return + } + if s[0].key != tt.wantKey { + t.Errorf("Set() key = %q, want %q", s[0].key, tt.wantKey) + } + if s[0].value != tt.wantVal { + t.Errorf("Set() value = %q, want %q", s[0].value, tt.wantVal) + } + if s[0].op != tt.wantOp { + t.Errorf("Set() op = %v, want %v", s[0].op, tt.wantOp) + } + } + }) + } +} + +func TestSelectorMap_SetMultiple(t *testing.T) { + var s selectorMap + if err := s.Set("env=production"); err != nil { + t.Fatalf("Set() error = %v", err) + } + if err := s.Set("language!=python"); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(s) != 2 { + t.Errorf("Set() resulted in %d selectors, want 2", len(s)) + } +} + +func TestSelectorMap_Matches(t *testing.T) { + tests := []struct { + name string + selectors []string + frontmatter map[string]string + wantMatch bool + }{ + { + name: "single equals - match", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: true, + }, + { + name: "single equals - no match", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"env": "development"}, + wantMatch: false, + }, + { + name: "single equals - key missing", + selectors: []string{"env=production"}, + frontmatter: map[string]string{"language": "go"}, + wantMatch: false, + }, + { + name: "single not equals - match (different value)", + selectors: []string{"env!=production"}, + frontmatter: map[string]string{"env": "development"}, + wantMatch: true, + }, + { + name: "single not equals - no match (same value)", + selectors: []string{"env!=production"}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: false, + }, + { + name: "single not equals - match (key missing)", + selectors: []string{"env!=production"}, + frontmatter: map[string]string{"language": "go"}, + wantMatch: true, + }, + { + name: "multiple selectors - all match", + selectors: []string{"env=production", "language=go"}, + frontmatter: map[string]string{"env": "production", "language": "go"}, + wantMatch: true, + }, + { + name: "multiple selectors - one doesn't match", + selectors: []string{"env=production", "language=go"}, + frontmatter: map[string]string{"env": "production", "language": "python"}, + wantMatch: false, + }, + { + name: "mixed operators - all match", + selectors: []string{"env=production", "language!=python"}, + frontmatter: map[string]string{"env": "production", "language": "go"}, + wantMatch: true, + }, + { + name: "mixed operators - one doesn't match", + selectors: []string{"env=production", "language!=python"}, + frontmatter: map[string]string{"env": "production", "language": "python"}, + wantMatch: false, + }, + { + name: "empty selectors - always match", + selectors: []string{}, + frontmatter: map[string]string{"env": "production"}, + wantMatch: true, + }, + { + name: "empty frontmatter - equals doesn't match", + selectors: []string{"env=production"}, + frontmatter: map[string]string{}, + wantMatch: false, + }, + { + name: "empty frontmatter - not equals matches", + selectors: []string{"env!=production"}, + frontmatter: map[string]string{}, + wantMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var s selectorMap + for _, sel := range tt.selectors { + if err := s.Set(sel); err != nil { + t.Fatalf("Set() error = %v", err) + } + } + + if got := s.matches(tt.frontmatter); got != tt.wantMatch { + t.Errorf("matches() = %v, want %v", got, tt.wantMatch) + } + }) + } +} + +func TestSelectorMap_String(t *testing.T) { + var s selectorMap + s.Set("env=production") + s.Set("language!=python") + + str := s.String() + if str == "" { + t.Error("String() returned empty string") + } +} From dc9d16e729c3a30368003a23ee1d36c6a561644f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 04:04:28 +0000 Subject: [PATCH 3/6] Refactor integration tests to use strings.Contains from standard library Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- integration_test.go | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/integration_test.go b/integration_test.go index 2baab18b..8eef0c49 100644 --- a/integration_test.go +++ b/integration_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -286,13 +287,13 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to read prompt output: %v", err) } contentStr := string(content) - if !contains(contentStr, "Prod content") { + if !strings.Contains(contentStr, "Prod content") { t.Errorf("Expected production content in output") } - if contains(contentStr, "Dev content") { + if strings.Contains(contentStr, "Dev content") { t.Errorf("Did not expect development content in output") } - if contains(contentStr, "Test content") { + if strings.Contains(contentStr, "Test content") { t.Errorf("Did not expect test content in output") } @@ -311,13 +312,13 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to read prompt output: %v", err) } contentStr = string(content) - if !contains(contentStr, "Prod content") { + if !strings.Contains(contentStr, "Prod content") { t.Errorf("Expected production content in output") } - if contains(contentStr, "Dev content") { + if strings.Contains(contentStr, "Dev content") { t.Errorf("Did not expect development content in output") } - if !contains(contentStr, "Test content") { + if !strings.Contains(contentStr, "Test content") { t.Errorf("Expected test content in output") } @@ -336,13 +337,13 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to read prompt output: %v", err) } contentStr = string(content) - if contains(contentStr, "Prod content") { + if strings.Contains(contentStr, "Prod content") { t.Errorf("Did not expect production content in output") } - if !contains(contentStr, "Dev content") { + if !strings.Contains(contentStr, "Dev content") { t.Errorf("Expected development content in output") } - if !contains(contentStr, "Test content") { + if !strings.Contains(contentStr, "Test content") { t.Errorf("Expected test content in output") } @@ -361,26 +362,13 @@ func TestSelectorFiltering(t *testing.T) { t.Fatalf("failed to read prompt output: %v", err) } contentStr = string(content) - if !contains(contentStr, "Prod content") { + if !strings.Contains(contentStr, "Prod content") { t.Errorf("Expected production content in output") } - if contains(contentStr, "Dev content") { + if strings.Contains(contentStr, "Dev content") { t.Errorf("Did not expect development content in output") } - if contains(contentStr, "Test content") { + if strings.Contains(contentStr, "Test content") { t.Errorf("Did not expect test content in output") } } - -func contains(s, substr string) bool { - return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsSubstring(s, substr))) -} - -func containsSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} From 80255a7fb4eac5707d3d2bffa1a26b9187c3f1bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 04:07:17 +0000 Subject: [PATCH 4/6] Update README with selector flag documentation and examples Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index c1a16228..e06ae47d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Options: Default: .coding-agent-context, ~/.config/coding-agent-context, /var/local/coding-agent-context -o Output directory for generated files (default: .) -p Template parameter for prompt substitution (can be used multiple times) + -s Selector to filter memories (can be used multiple times) + Format: key=value (include only) or key!=value (exclude) ``` **Example:** @@ -36,6 +38,18 @@ Options: coding-agent-context -p feature="Authentication" -p language=Go add-feature ``` +**Example with selectors:** +```bash +# Include only production memories +coding-agent-context -s env=production deploy + +# Exclude test memories +coding-agent-context -s env!=test deploy + +# Combine multiple selectors (AND logic) +coding-agent-context -s env=production -s language=go deploy +``` + ## Quick Start 1. Create a context directory structure: @@ -114,6 +128,8 @@ Markdown files included in every generated context. Bootstrap scripts can be pro **Example** (`.coding-agent-context/memories/setup.md`): ```markdown --- +env: development +language: go --- # Development Setup @@ -129,6 +145,46 @@ npm install For each memory file `.md`, you can optionally create a corresponding `-bootstrap` file that will be executed during setup. +## Filtering Memories with Selectors + +Use the `-s` flag to filter which memory files are included based on their frontmatter metadata. + +### Selector Syntax + +- **`key=value`** - Include only memories where the frontmatter key matches the value +- **`key!=value`** - Exclude memories where the frontmatter key matches the value +- Multiple selectors use AND logic (all must match) + +### Examples + +**Include only production memories:** +```bash +coding-agent-context -s env=production deploy +``` + +**Exclude test environment:** +```bash +coding-agent-context -s env!=test deploy +``` + +**Combine multiple criteria:** +```bash +# Only production Go backend memories +coding-agent-context -s env=production -s language=go -s tier=backend deploy +``` + +### How It Works + +When you run with selectors, the tool logs which files are included or excluded: + +``` +INFO Including memory file path=.coding-agent-context/memories/production.md +INFO Excluding memory file (does not match selectors) path=.coding-agent-context/memories/development.md +``` + +If no selectors are specified, all memory files are included. + + ## Output Files - **`prompt.md`** - Combined output with all memories and the task prompt From 54c322ce012041d839c7e795e3680d58bf20b239 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 04:26:23 +0000 Subject: [PATCH 5/6] Refactor selectors to use -s (include) and -S (exclude) flags instead of != syntax Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- README.md | 34 +++++++---- integration_test.go | 55 +++++++++++++++-- main.go | 20 ++++--- selector_map.go | 64 ++++++++------------ selector_map_test.go | 137 +++++++++++++++++++++++++------------------ 5 files changed, 188 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index e06ae47d..d69d10ef 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ Options: Default: .coding-agent-context, ~/.config/coding-agent-context, /var/local/coding-agent-context -o Output directory for generated files (default: .) -p Template parameter for prompt substitution (can be used multiple times) - -s Selector to filter memories (can be used multiple times) - Format: key=value (include only) or key!=value (exclude) + -s Include memories with matching frontmatter (can be used multiple times) + -S Exclude memories with matching frontmatter (can be used multiple times) ``` **Example:** @@ -44,10 +44,10 @@ coding-agent-context -p feature="Authentication" -p language=Go add-feature coding-agent-context -s env=production deploy # Exclude test memories -coding-agent-context -s env!=test deploy +coding-agent-context -S env=test deploy -# Combine multiple selectors (AND logic) -coding-agent-context -s env=production -s language=go deploy +# Combine include and exclude selectors +coding-agent-context -s env=production -S language=python deploy ``` ## Quick Start @@ -147,13 +147,14 @@ For each memory file `.md`, you can optionally create a corresponding ` Date: Fri, 31 Oct 2025 04:46:30 +0000 Subject: [PATCH 6/6] Refactor selectorMap to reuse paramMap with custom Set method for trimming Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- main.go | 8 ++++---- selector_map.go | 45 +++++++++++++++++++------------------------- selector_map_test.go | 17 +++++++---------- 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/main.go b/main.go index 21831368..5b0700f0 100644 --- a/main.go +++ b/main.go @@ -16,11 +16,11 @@ import ( var bootstrap string var ( - dirs stringSlice + dirs stringSlice outputDir = "." - params = make(paramMap) - includes selectorMap - excludes selectorMap + params = make(paramMap) + includes = make(selectorMap) + excludes = make(selectorMap) ) func main() { diff --git a/selector_map.go b/selector_map.go index 230936cc..1baf2c86 100644 --- a/selector_map.go +++ b/selector_map.go @@ -5,41 +5,34 @@ import ( "strings" ) -type selector struct { - key string - value string -} - -type selectorMap []selector +// selectorMap reuses paramMap for parsing key=value pairs +type selectorMap paramMap func (s *selectorMap) String() string { - return fmt.Sprint(*s) + return (*paramMap)(s).String() } func (s *selectorMap) Set(value string) error { - // Parse key=value format - if strings.Contains(value, "=") { - kv := strings.SplitN(value, "=", 2) - if len(kv) != 2 { - return fmt.Errorf("invalid selector format: %s", value) - } - *s = append(*s, selector{ - key: strings.TrimSpace(kv[0]), - value: strings.TrimSpace(kv[1]), - }) - return nil + // Parse key=value format with trimming + kv := strings.SplitN(value, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid selector format: %s", value) } - - return fmt.Errorf("invalid selector format: %s (must contain =)", value) + if *s == nil { + *s = make(selectorMap) + } + // Trim spaces from both key and value for selectors + (*s)[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + return nil } // matchesIncludes returns true if the frontmatter matches all include selectors // If a key doesn't exist in frontmatter, it's allowed func (includes *selectorMap) matchesIncludes(frontmatter map[string]string) bool { - for _, sel := range *includes { - fmValue, exists := frontmatter[sel.key] + for key, value := range *includes { + fmValue, exists := frontmatter[key] // If key exists, it must match the value - if exists && fmValue != sel.value { + if exists && fmValue != value { return false } // If key doesn't exist, allow it @@ -50,10 +43,10 @@ func (includes *selectorMap) matchesIncludes(frontmatter map[string]string) bool // matchesExcludes returns true if the frontmatter doesn't match any exclude selectors // If a key doesn't exist in frontmatter, it's allowed func (excludes *selectorMap) matchesExcludes(frontmatter map[string]string) bool { - for _, sel := range *excludes { - fmValue, exists := frontmatter[sel.key] + for key, value := range *excludes { + fmValue, exists := frontmatter[key] // If key exists and matches the value, exclude it - if exists && fmValue == sel.value { + if exists && fmValue == value { return false } // If key doesn't exist, allow it diff --git a/selector_map_test.go b/selector_map_test.go index 4b6be2b8..9b964fca 100644 --- a/selector_map_test.go +++ b/selector_map_test.go @@ -40,7 +40,7 @@ func TestSelectorMap_Set(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var s selectorMap + s := make(selectorMap) err := s.Set(tt.value) if (err != nil) != tt.wantErr { @@ -53,11 +53,8 @@ func TestSelectorMap_Set(t *testing.T) { t.Errorf("Set() resulted in %d selectors, want 1", len(s)) return } - if s[0].key != tt.wantKey { - t.Errorf("Set() key = %q, want %q", s[0].key, tt.wantKey) - } - if s[0].value != tt.wantVal { - t.Errorf("Set() value = %q, want %q", s[0].value, tt.wantVal) + if s[tt.wantKey] != tt.wantVal { + t.Errorf("Set() s[%q] = %q, want %q", tt.wantKey, s[tt.wantKey], tt.wantVal) } } }) @@ -65,7 +62,7 @@ func TestSelectorMap_Set(t *testing.T) { } func TestSelectorMap_SetMultiple(t *testing.T) { - var s selectorMap + s := make(selectorMap) if err := s.Set("env=production"); err != nil { t.Fatalf("Set() error = %v", err) } @@ -137,7 +134,7 @@ func TestSelectorMap_MatchesIncludes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var s selectorMap + s := make(selectorMap) for _, sel := range tt.selectors { if err := s.Set(sel); err != nil { t.Fatalf("Set() error = %v", err) @@ -210,7 +207,7 @@ func TestSelectorMap_MatchesExcludes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var s selectorMap + s := make(selectorMap) for _, sel := range tt.selectors { if err := s.Set(sel); err != nil { t.Fatalf("Set() error = %v", err) @@ -225,7 +222,7 @@ func TestSelectorMap_MatchesExcludes(t *testing.T) { } func TestSelectorMap_String(t *testing.T) { - var s selectorMap + s := make(selectorMap) s.Set("env=production") s.Set("language=go")