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
14 changes: 0 additions & 14 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,20 +216,6 @@ func runBuild(cmd *cobra.Command, args []string) error {
return nil
}

// exitError pairs an error with a specific exit code for the CLI.
type exitError struct {
code int
err error
}

func (e *exitError) Error() string {
return e.err.Error()
}

func (e *exitError) Unwrap() error {
return e.err
}

// guidelinesConfigHasNonDefaults returns true if the guidelines config has any
// explicitly set value (Enabled pointer, section title, order list, or exclude list).
func guidelinesConfigHasNonDefaults(cfg config.GuidelinesConfig) bool {
Expand Down
119 changes: 119 additions & 0 deletions cmd/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/armstrongl/rulebound/internal/mdgen"
"github.com/spf13/cobra"
)

// Generate flags.
var (
generateOutput string
generateForce bool
)

// generateCmd defines the `rulebound generate` sub-command.
var generateCmd = &cobra.Command{
Use: "generate <file.md>",
Short: "Generate a Vale YAML rule file from a structured Markdown file",
Long: `generate reads a structured Markdown file with YAML frontmatter and vale-*
fenced code blocks, and emits a Vale-compatible YAML rule file.

The input .md file uses frontmatter for rule metadata and vale-swap,
vale-tokens, or vale-exceptions fenced blocks for rule data.

Supported rule types: substitution, existence, occurrence, capitalization.`,
Args: cobra.ExactArgs(1),
RunE: runGenerate,
}

func init() {
generateCmd.Flags().StringVarP(&generateOutput, "output", "o", "", "Output path (default: input stem + .yml); use '-' for stdout")
generateCmd.Flags().BoolVar(&generateForce, "force", false, "Overwrite output file if it already exists")
}

func runGenerate(cmd *cobra.Command, args []string) error {
inputPath := args[0]

// Validate input is a regular file.
info, err := os.Stat(inputPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("input file does not exist: %s", inputPath)
}
return fmt.Errorf("checking input file: %w", err)
}
if info.IsDir() {
return fmt.Errorf("input path is a directory, expected a file: %s", inputPath)
}

// Read input.
data, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("reading input file: %w", err)
}

// Parse.
src, warnings, err := mdgen.ParseMarkdown(data)
if err != nil {
return &exitError{code: ExitGeneral, err: fmt.Errorf("parsing %s: %w", inputPath, err)}
}

for _, w := range warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", w.Message)
}

// Emit YAML.
yamlBytes, emitWarnings, err := mdgen.EmitYAML(src)
if err != nil {
return &exitError{code: ExitGeneral, err: fmt.Errorf("generating YAML: %w", err)}
}

for _, w := range emitWarnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", w.Message)
}

// Resolve output path.
outputPath := generateOutput
if outputPath == "" {
// Default: input stem + .yml in the same directory.
ext := filepath.Ext(inputPath)
outputPath = strings.TrimSuffix(inputPath, ext) + ".yml"
}

// Write output.
if outputPath == "-" {
// stdout
_, err = os.Stdout.Write(yamlBytes)
if err != nil {
return fmt.Errorf("writing to stdout: %w", err)
}
return nil
}

// Check if output file exists.
if !generateForce {
if _, err := os.Stat(outputPath); err == nil {
return &exitError{
code: ExitGeneral,
err: fmt.Errorf("output file already exists: %s (use --force to overwrite)", outputPath),
}
}
}

if err := os.WriteFile(outputPath, yamlBytes, 0644); err != nil {
return fmt.Errorf("writing output file: %w", err)
}

if Verbose {
fmt.Printf("Input: %s\n", inputPath)
fmt.Printf("Extends: %s\n", src.Extends)
}
fmt.Printf("Generated: %s\n", outputPath)

return nil
}
172 changes: 172 additions & 0 deletions cmd/generate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package cmd

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

// resetGenerateFlags resets package-level flag variables to their defaults.
// Cobra flag values persist between rootCmd.Execute() calls in the same process.
func resetGenerateFlags(t *testing.T) {
t.Helper()
generateOutput = ""
generateForce = false
}

// writeTestMD writes a minimal substitution .md to the given directory.
func writeTestMD(t *testing.T, dir, name string) string {
t.Helper()
content := `---
extends: substitution
message: "Prefer '%s' over '%s'."
level: warning
---

# Test

` + "```vale-swap\nleverage: use\n```\n"

path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("writing test md: %v", err)
}
return path
}

func TestGenerate_DefaultOutput(t *testing.T) {
resetGenerateFlags(t)
dir := t.TempDir()
mdPath := writeTestMD(t, dir, "Jargon.md")

rootCmd.SetArgs([]string{"generate", mdPath})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

ymlPath := filepath.Join(dir, "Jargon.yml")
data, err := os.ReadFile(ymlPath)
if err != nil {
t.Fatalf("reading output: %v", err)
}
if !strings.Contains(string(data), "extends: substitution") {
t.Errorf("output should contain extends: substitution")
}
if !strings.Contains(string(data), "leverage: use") {
t.Errorf("output should contain swap entry")
}
}

func TestGenerate_ExplicitOutput(t *testing.T) {
resetGenerateFlags(t)
dir := t.TempDir()
mdPath := writeTestMD(t, dir, "Test.md")
outPath := filepath.Join(dir, "custom.yml")

rootCmd.SetArgs([]string{"generate", mdPath, "--output", outPath})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

if _, err := os.Stat(outPath); err != nil {
t.Errorf("output file not created at %s", outPath)
}
}

func TestGenerate_Stdout(t *testing.T) {
resetGenerateFlags(t)
dir := t.TempDir()
mdPath := writeTestMD(t, dir, "Test.md")

// Capture stdout.
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

rootCmd.SetArgs([]string{"generate", mdPath, "--output", "-"})
err := rootCmd.Execute()

w.Close()
os.Stdout = old

if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()

if !strings.Contains(output, "extends: substitution") {
t.Errorf("stdout should contain YAML output, got: %s", output)
}
}

func TestGenerate_ForceOverwrite(t *testing.T) {
resetGenerateFlags(t)
dir := t.TempDir()
mdPath := writeTestMD(t, dir, "Test.md")
ymlPath := filepath.Join(dir, "Test.yml")

// Create existing file.
if err := os.WriteFile(ymlPath, []byte("old content"), 0644); err != nil {
t.Fatal(err)
}

// Without --force should fail.
rootCmd.SetArgs([]string{"generate", mdPath})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error when output exists without --force")
}

// With --force should succeed.
rootCmd.SetArgs([]string{"generate", mdPath, "--force"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error with --force: %v", err)
}

data, _ := os.ReadFile(ymlPath)
if strings.Contains(string(data), "old content") {
t.Error("file should have been overwritten")
}
}

func TestGenerate_FileNotExist(t *testing.T) {
resetGenerateFlags(t)
rootCmd.SetArgs([]string{"generate", "/nonexistent/file.md"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}

func TestGenerate_InputIsDirectory(t *testing.T) {
resetGenerateFlags(t)
dir := t.TempDir()

rootCmd.SetArgs([]string{"generate", dir})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error when input is a directory")
}
if !strings.Contains(err.Error(), "directory") {
t.Errorf("error should mention directory: %v", err)
}
}

func TestGenerate_MissingRequiredField(t *testing.T) {
resetGenerateFlags(t)
dir := t.TempDir()
content := "---\nextends: substitution\n---\n\n# No message\n\n```vale-swap\nfoo: bar\n```\n"
mdPath := filepath.Join(dir, "bad.md")
os.WriteFile(mdPath, []byte(content), 0644)

rootCmd.SetArgs([]string{"generate", mdPath})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for missing message")
}
}
16 changes: 16 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ Usage example:
SilenceErrors: true,
}

// exitError pairs an error with a specific exit code for the CLI.
type exitError struct {
code int
err error
}

func (e *exitError) Error() string {
return e.err.Error()
}

func (e *exitError) Unwrap() error {
return e.err
}

// Execute runs the root command and exits on failure.
// An *exitError carries a specific exit code; other errors exit with ExitGeneral.
func Execute() {
Expand All @@ -56,4 +70,6 @@ func init() {

// Add sub-commands.
rootCmd.AddCommand(buildCmd)
rootCmd.AddCommand(generateCmd)
rootCmd.AddCommand(validateCmd)
}
Loading
Loading