diff --git a/pkg/codingcontext/context.go b/pkg/codingcontext/context.go index 35c9704..698d7cf 100644 --- a/pkg/codingcontext/context.go +++ b/pkg/codingcontext/context.go @@ -115,14 +115,48 @@ func (cc *Context) findTask(taskName string) error { cc.includes.SetValue("task_name", taskName) taskFound := false - err := cc.visitMarkdownFiles(taskSearchPaths, func(path string, _ *markdown.BaseFrontMatter) error { - baseName := filepath.Base(path) - ext := filepath.Ext(baseName) - if strings.TrimSuffix(baseName, ext) != taskName { + expectedID := fmt.Sprintf("tasks/%s", taskName) + + err := cc.visitMarkdownFiles(taskSearchPaths, func(path string, fm *markdown.BaseFrontMatter) error { + // Check if this task matches by ID field or filename + // ID can be: + // - Full format: "tasks/fix-bug" + // - Just the name: "fix-bug" (matches the basename part) + // - Or match by filename for backward compatibility + matched := false + + // First, try ID-based matching + if fm.ID == expectedID { + // Exact ID match + matched = true + } else if fm.ID == taskName { + // Plain name match (for custom IDs without tasks/ prefix) + matched = true + } else if strings.HasPrefix(fm.ID, "tasks/") { + // Extract basename from ID and compare + idBasename := strings.TrimPrefix(fm.ID, "tasks/") + if idBasename == taskName { + matched = true + } + } + + // Fall back to filename matching if ID doesn't match + // This handles cases where files don't have explicit IDs + if !matched { + baseName := filepath.Base(path) + ext := filepath.Ext(baseName) + if strings.TrimSuffix(baseName, ext) == taskName { + matched = true + } + } + + if !matched { return nil } taskFound = true + + // Parse the full task frontmatter and content var frontMatter markdown.TaskFrontMatter md, err := markdown.ParseMarkdownFile(path, &frontMatter) if err != nil { diff --git a/pkg/codingcontext/context_test.go b/pkg/codingcontext/context_test.go index e11025d..c6112d8 100644 --- a/pkg/codingcontext/context_test.go +++ b/pkg/codingcontext/context_test.go @@ -325,6 +325,38 @@ func TestContext_Run_Basic(t *testing.T) { wantErr: true, errContains: "task not found", }, + { + name: "task found by custom ID in frontmatter", + setup: func(t *testing.T, dir string) { + createTask(t, dir, "actual-filename", "id: tasks/custom-task-id\nagent: cursor", "Task content with custom ID") + }, + taskName: "custom-task-id", + wantErr: false, + check: func(t *testing.T, result *Result) { + if !strings.Contains(result.Task.Content, "Task content with custom ID") { + t.Errorf("expected task content, got %q", result.Task.Content) + } + if result.Task.FrontMatter.ID != "tasks/custom-task-id" { + t.Errorf("expected task ID 'tasks/custom-task-id', got %q", result.Task.FrontMatter.ID) + } + }, + }, + { + name: "task ID defaults to TYPE/basename when not specified", + setup: func(t *testing.T, dir string) { + createTask(t, dir, "my-task-file", "agent: cursor", "Task content without ID field") + }, + taskName: "my-task-file", + wantErr: false, + check: func(t *testing.T, result *Result) { + if !strings.Contains(result.Task.Content, "Task content without ID field") { + t.Errorf("expected task content, got %q", result.Task.Content) + } + if result.Task.FrontMatter.ID != "tasks/my-task-file" { + t.Errorf("expected task ID to default to 'tasks/my-task-file', got %q", result.Task.FrontMatter.ID) + } + }, + }, { name: "task with selectors sets includes", setup: func(t *testing.T, dir string) { diff --git a/pkg/codingcontext/markdown/frontmatter.go b/pkg/codingcontext/markdown/frontmatter.go index bb9f2ad..9c4a935 100644 --- a/pkg/codingcontext/markdown/frontmatter.go +++ b/pkg/codingcontext/markdown/frontmatter.go @@ -9,6 +9,11 @@ import ( // BaseFrontMatter represents parsed YAML frontmatter from markdown files type BaseFrontMatter struct { + // ID is a standard identifier field for all file types + // Format: TYPE/basename (e.g., tasks/fix-bug, rules/go-style) + // Defaults to TYPE/basename based on file type and filename without extension if not specified + ID string `yaml:"id,omitempty" json:"id,omitempty"` + Content map[string]any `json:"-" yaml:",inline"` } diff --git a/pkg/codingcontext/markdown/markdown.go b/pkg/codingcontext/markdown/markdown.go index f9c2a7c..809b268 100644 --- a/pkg/codingcontext/markdown/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -5,6 +5,8 @@ import ( "bytes" "fmt" "os" + "path/filepath" + "strings" yaml "github.com/goccy/go-yaml" "github.com/kitproj/coding-context-cli/pkg/codingcontext/tokencount" @@ -78,9 +80,50 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) } } + // Default the ID field to URN format based on file type and filename if not specified + setDefaultID(frontMatter, path) + return Markdown[T]{ FrontMatter: *frontMatter, Content: content.String(), Tokens: tokencount.EstimateTokens(content.String()), }, nil } + +// setDefaultID sets the ID field to path-like format if not already set +// Format: TYPE/basename where TYPE is tasks, rules, commands, etc. (plural) +func setDefaultID(frontMatter any, path string) { + basename := getBasename(path) + + switch fm := frontMatter.(type) { + case *TaskFrontMatter: + if fm.ID == "" { + fm.ID = fmt.Sprintf("tasks/%s", basename) + } + case *RuleFrontMatter: + if fm.ID == "" { + fm.ID = fmt.Sprintf("rules/%s", basename) + } + case *CommandFrontMatter: + if fm.ID == "" { + fm.ID = fmt.Sprintf("commands/%s", basename) + } + case *SkillFrontMatter: + // Skills have their own Name field, but we still set ID for consistency + if fm.ID == "" { + fm.ID = fmt.Sprintf("skills/%s", basename) + } + case *BaseFrontMatter: + if fm.ID == "" { + // For generic BaseFrontMatter, use a generic type + fm.ID = fmt.Sprintf("files/%s", basename) + } + } +} + +// getBasename extracts the filename without extension +func getBasename(path string) string { + baseName := filepath.Base(path) + ext := filepath.Ext(baseName) + return strings.TrimSuffix(baseName, ext) +} diff --git a/pkg/codingcontext/markdown/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go index 86f4db1..4a91535 100644 --- a/pkg/codingcontext/markdown/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -272,3 +272,161 @@ This task has no frontmatter. }) } } + +// TestParseMarkdownFile_IDFieldDefaulting tests that the ID field is defaulted to TYPE/basename format +func TestParseMarkdownFile_IDFieldDefaulting(t *testing.T) { + tests := []struct { + name string + filename string + content string + wantID string + frontmatterType string // "task", "rule", "command" + }{ + { + name: "task with explicit ID field", + filename: "my-task.md", + content: `--- +id: tasks/custom-task-id +agent: cursor +--- +# My Task Content +`, + wantID: "tasks/custom-task-id", + frontmatterType: "task", + }, + { + name: "task without ID field - defaults to TYPE/basename", + filename: "fix-bug.md", + content: `--- +agent: cursor +--- +# Fix Bug Task +`, + wantID: "tasks/fix-bug", + frontmatterType: "task", + }, + { + name: "task without frontmatter - defaults to TYPE/basename", + filename: "deploy-app.md", + content: `# Deploy Application + +This task has no frontmatter. +`, + wantID: "tasks/deploy-app", + frontmatterType: "task", + }, + { + name: "rule with explicit ID field", + filename: "go-style.md", + content: `--- +id: rules/go-coding-standards +languages: + - go +--- +# Go Coding Standards +`, + wantID: "rules/go-coding-standards", + frontmatterType: "rule", + }, + { + name: "rule without ID field - defaults to TYPE/basename", + filename: "testing-guidelines.md", + content: `--- +languages: + - go +--- +# Testing Guidelines +`, + wantID: "rules/testing-guidelines", + frontmatterType: "rule", + }, + { + name: "command with explicit ID field", + filename: "setup-db.md", + content: `--- +id: commands/database-setup +--- +# Setup Database +`, + wantID: "commands/database-setup", + frontmatterType: "command", + }, + { + name: "command without ID field - defaults to TYPE/basename", + filename: "run-tests.md", + content: `--- +expand: true +--- +# Run Tests +`, + wantID: "commands/run-tests", + frontmatterType: "command", + }, + { + name: "file with .mdc extension", + filename: "my-rule.mdc", + content: `--- +languages: + - go +--- +# My Rule +`, + wantID: "rules/my-rule", + frontmatterType: "rule", + }, + { + name: "task with custom ID without prefix", + filename: "my-task.md", + content: `--- +id: custom-id-without-prefix +--- +# My Task +`, + wantID: "custom-id-without-prefix", + frontmatterType: "task", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary file with the specified filename + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, tt.filename) + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + // Parse based on frontmatter type + var gotID string + switch tt.frontmatterType { + case "task": + var fm TaskFrontMatter + md, err := ParseMarkdownFile(tmpFile, &fm) + if err != nil { + t.Fatalf("ParseMarkdownFile() error = %v", err) + } + gotID = md.FrontMatter.ID + case "rule": + var fm RuleFrontMatter + md, err := ParseMarkdownFile(tmpFile, &fm) + if err != nil { + t.Fatalf("ParseMarkdownFile() error = %v", err) + } + gotID = md.FrontMatter.ID + case "command": + var fm CommandFrontMatter + md, err := ParseMarkdownFile(tmpFile, &fm) + if err != nil { + t.Fatalf("ParseMarkdownFile() error = %v", err) + } + gotID = md.FrontMatter.ID + default: + t.Fatalf("unknown frontmatter type: %s", tt.frontmatterType) + } + + if gotID != tt.wantID { + t.Errorf("ID = %q, want %q", gotID, tt.wantID) + } + }) + } +}