Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions pkg/codingcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions pkg/codingcontext/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions pkg/codingcontext/markdown/frontmatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
43 changes: 43 additions & 0 deletions pkg/codingcontext/markdown/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
158 changes: 158 additions & 0 deletions pkg/codingcontext/markdown/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}