Skip to content
Open
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@ node_modules

sandbox/
test-sandbox/

# Project documentation and task files
docs/compile-command-design.md
tasks/prd-compile-command.md
tasks/tasks-prd-compile-command.md
create-prd.md
generate-tasks.md
process-task-list.md
159 changes: 159 additions & 0 deletions cmd/arm/compile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package main

import (
"context"
"fmt"
"strings"

"github.com/jomadu/ai-rules-manager/internal/arm"
"github.com/jomadu/ai-rules-manager/internal/urf"
"github.com/spf13/cobra"
)

func newCompileCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "compile [file...]",
Short: "Compile URF files to target formats",
Long: `Compile Universal Rule Format (URF) files to specific AI tool formats.

Supports compilation to various targets:
- cursor: Cursor-compatible .mdc files with YAML frontmatter
- amazonq: Amazon Q compatible .md files
- copilot: GitHub Copilot .instructions.md files
- markdown: Generic markdown files

Examples:
arm compile rules.yaml --target cursor
arm compile rules.yaml --target cursor,amazonq,copilot --output ./compiled
arm compile ./rules/ --target cursor --recursive --output .cursor/rules
arm compile rules.yaml --target cursor --dry-run --verbose
arm compile *.yaml --validate-only`,
RunE: runCompile,
Args: cobra.MinimumNArgs(1),
}

// Core flags
cmd.Flags().StringP("target", "t", "", "Target format(s) - comma-separated (cursor, amazonq, markdown, copilot) [REQUIRED]")
cmd.Flags().StringP("output", "o", ".", "Output directory (defaults to current directory)")
cmd.Flags().StringP("namespace", "n", "", "Namespace for compiled rules (defaults to filename)")
cmd.Flags().BoolP("force", "f", false, "Overwrite existing files")
cmd.Flags().BoolP("recursive", "r", false, "Recursively find URF files in directories")

// Processing flags
cmd.Flags().Bool("dry-run", false, "Show what would be compiled without writing files")
cmd.Flags().BoolP("verbose", "v", false, "Show detailed compilation information")
cmd.Flags().Bool("validate-only", false, "Validate URF syntax without compilation")
cmd.Flags().Bool("fail-fast", false, "Stop compilation on first error")

// Filtering flags (reuse existing ARM patterns)
cmd.Flags().StringSlice("include", nil, "Include patterns for file filtering")
cmd.Flags().StringSlice("exclude", nil, "Exclude patterns for file filtering")

// Mark target as required
_ = cmd.MarkFlagRequired("target")

return cmd
}

func runCompile(cmd *cobra.Command, args []string) error {
// Parse and validate flags
targetStr, _ := cmd.Flags().GetString("target")
outputDir, _ := cmd.Flags().GetString("output")
namespace, _ := cmd.Flags().GetString("namespace")
force, _ := cmd.Flags().GetBool("force")
recursive, _ := cmd.Flags().GetBool("recursive")
dryRun, _ := cmd.Flags().GetBool("dry-run")
verbose, _ := cmd.Flags().GetBool("verbose")
validateOnly, _ := cmd.Flags().GetBool("validate-only")
failFast, _ := cmd.Flags().GetBool("fail-fast")
include, _ := cmd.Flags().GetStringSlice("include")
exclude, _ := cmd.Flags().GetStringSlice("exclude")

// Parse and validate targets (comma-separated)
targets, err := parseTargets(targetStr)
if err != nil {
return fmt.Errorf("invalid target specification: %w", err)
}

// Apply default include patterns for YAML files
include = GetDefaultIncludePatterns(include)

// Validate conflicting flags
if validateOnly && (dryRun || force) {
return fmt.Errorf("--validate-only cannot be used with --dry-run or --force")
}

// Create service and compile request
service := arm.NewArmService()

request := &arm.CompileRequest{
Files: args,
Targets: targets,
OutputDir: outputDir,
Namespace: namespace,
Force: force,
Recursive: recursive,
DryRun: dryRun,
Verbose: verbose,
ValidateOnly: validateOnly,
FailFast: failFast,
Include: include,
Exclude: exclude,
}

// Execute compilation
ctx := context.Background()
result, err := service.CompileFiles(ctx, request)
if err != nil {
return fmt.Errorf("compilation failed: %w", err)
}

// Display results using output formatter
formatter := NewCompileOutputFormatter(verbose, dryRun)

if validateOnly {
formatter.DisplayValidationResults(result)
return nil
}

if dryRun {
formatter.DisplayDryRunPlan(result)
return nil
}

return formatter.DisplayResults(result)
}

// parseTargets parses comma-separated target string and validates each target
func parseTargets(targetStr string) ([]urf.CompileTarget, error) {
if targetStr == "" {
return nil, fmt.Errorf("target is required")
}

targetParts := strings.Split(targetStr, ",")
targets := make([]urf.CompileTarget, 0, len(targetParts))
seen := make(map[string]bool)

for _, part := range targetParts {
target := strings.TrimSpace(part)
if target == "" {
continue
}

// Check for duplicates
if seen[target] {
return nil, fmt.Errorf("duplicate target: %s", target)
}
seen[target] = true

// Add target (validation will happen when creating compiler)
compileTarget := urf.CompileTarget(target)
targets = append(targets, compileTarget)
}

if len(targets) == 0 {
return nil, fmt.Errorf("no valid targets specified")
}

return targets, nil
}
162 changes: 162 additions & 0 deletions cmd/arm/compile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package main

import (
"testing"

"github.com/jomadu/ai-rules-manager/internal/arm"
"github.com/jomadu/ai-rules-manager/internal/urf"
)

func TestParseTargets(t *testing.T) {
tests := []struct {
name string
targetStr string
expected []urf.CompileTarget
expectError bool
}{
{
name: "single target",
targetStr: "cursor",
expected: []urf.CompileTarget{urf.TargetCursor},
expectError: false,
},
{
name: "multiple targets",
targetStr: "cursor,amazonq,markdown",
expected: []urf.CompileTarget{urf.TargetCursor, urf.TargetAmazonQ, urf.TargetMarkdown},
expectError: false,
},
{
name: "targets with spaces",
targetStr: "cursor, amazonq , markdown",
expected: []urf.CompileTarget{urf.TargetCursor, urf.TargetAmazonQ, urf.TargetMarkdown},
expectError: false,
},
{
name: "empty target string",
targetStr: "",
expected: nil,
expectError: true,
},
{
name: "duplicate targets",
targetStr: "cursor,cursor,amazonq",
expected: nil,
expectError: true,
},
{
name: "empty target in list",
targetStr: "cursor,,amazonq",
expected: []urf.CompileTarget{urf.TargetCursor, urf.TargetAmazonQ},
expectError: false,
},
{
name: "all supported targets",
targetStr: "cursor,amazonq,markdown,copilot",
expected: []urf.CompileTarget{urf.TargetCursor, urf.TargetAmazonQ, urf.TargetMarkdown, urf.TargetCopilot},
expectError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseTargets(tt.targetStr)

if tt.expectError {
if err == nil {
t.Errorf("Expected error for input %q, but got none", tt.targetStr)
}
return
}

if err != nil {
t.Errorf("Unexpected error for input %q: %v", tt.targetStr, err)
return
}

if len(result) != len(tt.expected) {
t.Errorf("Expected %d targets, got %d", len(tt.expected), len(result))
return
}

for i, expected := range tt.expected {
if result[i] != expected {
t.Errorf("Expected target %s at position %d, got %s", expected, i, result[i])
}
}
})
}
}

func TestDisplayCompileResults(t *testing.T) {
tests := []struct {
name string
result *arm.CompileResult
verbose bool
dryRun bool
expectError bool
}{
{
name: "nil result",
result: nil,
verbose: false,
dryRun: false,
expectError: true,
},
{
name: "empty result",
result: &arm.CompileResult{
CompiledFiles: make([]arm.CompiledFile, 0),
Skipped: make([]arm.SkippedFile, 0),
Errors: make([]arm.CompileError, 0),
Stats: arm.CompileStats{
FilesProcessed: 0,
FilesCompiled: 0,
FilesSkipped: 0,
RulesGenerated: 0,
Errors: 0,
TargetStats: make(map[string]int),
},
},
verbose: false,
dryRun: false,
expectError: false,
},
{
name: "result with errors",
result: &arm.CompileResult{
CompiledFiles: make([]arm.CompiledFile, 0),
Skipped: make([]arm.SkippedFile, 0),
Errors: []arm.CompileError{
{FilePath: "test.yaml", Error: "test error"},
},
Stats: arm.CompileStats{
FilesProcessed: 1,
FilesCompiled: 0,
FilesSkipped: 0,
RulesGenerated: 0,
Errors: 1,
TargetStats: make(map[string]int),
},
},
verbose: false,
dryRun: false,
expectError: true, // Should return error for exit code handling
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
formatter := NewCompileOutputFormatter(tt.verbose, tt.dryRun)
err := formatter.DisplayResults(tt.result)

if tt.expectError && err == nil {
t.Error("Expected error but got none")
}

if !tt.expectError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/arm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ func init() {
rootCmd.AddCommand(newInfoCmd())
rootCmd.AddCommand(newConfigCmd())
rootCmd.AddCommand(newCacheCmd())
rootCmd.AddCommand(newCompileCmd())
rootCmd.AddCommand(newVersionCmd())
}
Loading
Loading