Skip to content

Commit 79f0fab

Browse files
committed
validate rules.json by json schema
1 parent 6a65090 commit 79f0fab

3 files changed

Lines changed: 122 additions & 201 deletions

File tree

cmd/publish.go

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"rules-cli/internal/auth"
1111
"rules-cli/internal/registry"
1212
"rules-cli/internal/ruleset"
13+
"rules-cli/internal/validation"
1314

1415
"github.com/fatih/color"
1516
"github.com/spf13/cobra"
@@ -40,12 +41,6 @@ Examples:
4041

4142
// runPublishCommand implements the main logic for the publish command
4243
func runPublishCommand(cmd *cobra.Command, args []string) error {
43-
// Ensure the user is authenticated
44-
authenticated, err := auth.EnsureAuthenticated(true)
45-
if err != nil || !authenticated {
46-
return fmt.Errorf("authentication required to publish rules")
47-
}
48-
4944
// Validate the visibility
5045
if visibility != "public" && visibility != "private" {
5146
return fmt.Errorf("visibility must be either 'public' or 'private'")
@@ -57,6 +52,28 @@ func runPublishCommand(cmd *cobra.Command, args []string) error {
5752
rulesPath = args[0]
5853
}
5954

55+
// Determine the rules.json file path
56+
var rulesJSONPath string
57+
if rulesPath != "" {
58+
// If a path was specified, look for rules.json in that directory
59+
stat, err := os.Stat(rulesPath)
60+
if err == nil && stat.IsDir() {
61+
rulesJSONPath = filepath.Join(rulesPath, "rules.json")
62+
} else {
63+
rulesJSONPath = rulesPath
64+
}
65+
} else {
66+
// Look in current directory for rules.json
67+
rulesJSONPath = "rules.json"
68+
}
69+
70+
// Validate the rules.json file against the schema FIRST
71+
color.Cyan("Validating rules.json against schema...")
72+
if err := validation.ValidateRulesJSONFromFile(rulesJSONPath); err != nil {
73+
return fmt.Errorf("schema validation failed: %w", err)
74+
}
75+
color.Green("✓ rules.json is valid")
76+
6077
// Load ruleset from the specified path or current directory
6178
rs, err := ruleset.LoadRuleSetFromPath(rulesPath)
6279
if err != nil {
@@ -68,6 +85,12 @@ func runPublishCommand(cmd *cobra.Command, args []string) error {
6885
return fmt.Errorf("rules.json must have a 'name' field")
6986
}
7087

88+
// NOW ensure the user is authenticated (after validation passes)
89+
authenticated, err := auth.EnsureAuthenticated(true)
90+
if err != nil || !authenticated {
91+
return fmt.Errorf("authentication required to publish rules")
92+
}
93+
7194
// Create registry client and get user info
7295
authConfig := auth.LoadAuthConfig()
7396
client := registry.NewClient(cfg.RegistryURL)
@@ -155,17 +178,17 @@ func isValidSlug(slug string) bool {
155178
if slug == "" {
156179
return false
157180
}
158-
181+
159182
// Check if slug contains only valid characters
160183
for _, char := range slug {
161-
if !((char >= 'a' && char <= 'z') ||
162-
(char >= 'A' && char <= 'Z') ||
163-
(char >= '0' && char <= '9') ||
164-
char == '-' || char == '_') {
184+
if !((char >= 'a' && char <= 'z') ||
185+
(char >= 'A' && char <= 'Z') ||
186+
(char >= '0' && char <= '9') ||
187+
char == '-' || char == '_') {
165188
return false
166189
}
167190
}
168-
191+
169192
return true
170193
}
171194

@@ -174,4 +197,4 @@ func init() {
174197

175198
// Add flags
176199
publishCmd.Flags().StringVar(&visibility, "visibility", "public", "Set the visibility of the rule to 'public' or 'private'")
177-
}
200+
}

internal/validation/schema.go

Lines changed: 36 additions & 188 deletions
Original file line numberDiff line numberDiff line change
@@ -1,221 +1,69 @@
11
package validation
22

33
import (
4-
_ "embed"
4+
"embed"
55
"encoding/json"
66
"fmt"
77
"os"
8-
"strings"
98

109
"github.com/xeipuuv/gojsonschema"
11-
"gopkg.in/yaml.v3"
1210
)
1311

14-
// RuleFrontmatterSchema contains the embedded JSON schema for rule frontmatter
15-
//
16-
//go:embed rule_frontmatter_schema.json
17-
var RuleFrontmatterSchema string
12+
//go:embed schema/rules-schema.json
13+
var schemaFS embed.FS
1814

19-
// RuleFrontmatter represents the structure of rule frontmatter
20-
type RuleFrontmatter struct {
21-
AlwaysApply bool `yaml:"alwaysApply,omitempty" json:"alwaysApply,omitempty"`
22-
Description string `yaml:"description,omitempty" json:"description,omitempty"`
23-
Globs string `yaml:"globs,omitempty" json:"globs,omitempty"`
24-
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
25-
}
26-
27-
// ValidationError represents a validation error with context
28-
type ValidationError struct {
29-
Field string `json:"field"`
30-
Message string `json:"message"`
31-
Value interface{} `json:"value,omitempty"`
32-
Type string `json:"type,omitempty"`
33-
SchemaPath string `json:"schemaPath,omitempty"`
34-
Context string `json:"context,omitempty"`
35-
}
36-
37-
// ValidationResult contains the validation results
38-
type ValidationResult struct {
39-
Valid bool `json:"valid"`
40-
Errors []ValidationError `json:"errors,omitempty"`
41-
}
42-
43-
var (
44-
// compiledSchema holds the compiled JSON schema for reuse
45-
compiledSchema *gojsonschema.Schema
46-
)
47-
48-
// init compiles the schema once at startup
49-
func init() {
50-
schemaLoader := gojsonschema.NewStringLoader(RuleFrontmatterSchema)
51-
schema, err := gojsonschema.NewSchema(schemaLoader)
52-
if err != nil {
53-
panic(fmt.Errorf("failed to compile frontmatter schema: %w", err))
54-
}
55-
compiledSchema = schema
56-
}
57-
58-
// ValidateRuleFile validates a markdown rule file's frontmatter against the schema
59-
func ValidateRuleFile(filePath string) (*ValidationResult, error) {
60-
// Read the file
61-
content, err := os.ReadFile(filePath)
62-
if err != nil {
63-
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
64-
}
65-
66-
// Parse frontmatter
67-
frontmatter, err := extractFrontmatter(content)
15+
// ValidateRulesJSON validates a rules.json file against the JSON schema
16+
func ValidateRulesJSON(rulesData []byte) error {
17+
// Load the schema from embedded file
18+
schemaBytes, err := schemaFS.ReadFile("schema/rules-schema.json")
6819
if err != nil {
69-
return &ValidationResult{
70-
Valid: false,
71-
Errors: []ValidationError{
72-
{
73-
Field: "frontmatter",
74-
Message: fmt.Sprintf("Failed to parse frontmatter: %v", err),
75-
},
76-
},
77-
}, nil
20+
return fmt.Errorf("failed to load embedded schema: %w", err)
7821
}
7922

80-
// If no frontmatter found, consider it valid (rules without frontmatter are allowed)
81-
if frontmatter == nil {
82-
return &ValidationResult{Valid: true}, nil
83-
}
23+
// Create schema loader
24+
schemaLoader := gojsonschema.NewBytesLoader(schemaBytes)
8425

85-
return ValidateFrontmatter(frontmatter)
86-
}
26+
// Create document loader with the rules data
27+
documentLoader := gojsonschema.NewBytesLoader(rulesData)
8728

88-
// ValidateFrontmatter validates frontmatter data against the JSON schema
89-
func ValidateFrontmatter(frontmatter map[string]interface{}) (*ValidationResult, error) {
90-
// Create a document loader from the frontmatter data
91-
documentLoader := gojsonschema.NewGoLoader(frontmatter)
92-
93-
// Validate against the compiled schema
94-
result, err := compiledSchema.Validate(documentLoader)
29+
// Validate
30+
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
9531
if err != nil {
96-
return nil, fmt.Errorf("validation failed: %w", err)
97-
}
98-
99-
validationResult := &ValidationResult{
100-
Valid: result.Valid(),
101-
Errors: make([]ValidationError, 0),
32+
return fmt.Errorf("failed to validate JSON schema: %w", err)
10233
}
10334

104-
// Convert validation errors to our format
10535
if !result.Valid() {
106-
for _, err := range result.Errors() {
107-
validationErr := ValidationError{
108-
Field: extractFieldName(err.Field()),
109-
Message: err.Description(),
110-
Type: err.Type(),
111-
Context: err.Context().String(),
112-
Value: err.Value(),
113-
}
114-
115-
// Safely extract schema path from details
116-
if details := err.Details(); details != nil {
117-
if property, ok := details["property"]; ok && property != nil {
118-
validationErr.SchemaPath = fmt.Sprintf("%v", property)
119-
}
36+
var errorMsg string
37+
for i, validationError := range result.Errors() {
38+
if i > 0 {
39+
errorMsg += "\n"
12040
}
121-
122-
validationResult.Errors = append(validationResult.Errors, validationErr)
41+
errorMsg += fmt.Sprintf(" - %s", validationError.String())
12342
}
43+
return fmt.Errorf("rules.json validation failed:\n%s", errorMsg)
12444
}
12545

126-
return validationResult, nil
127-
}
128-
129-
// extractFieldName cleans up the field name for better display
130-
func extractFieldName(field string) string {
131-
// Remove (root). prefix and return clean field name
132-
cleanField := strings.TrimPrefix(field, "(root).")
133-
if cleanField == "(root)" {
134-
return "root"
135-
}
136-
return cleanField
46+
return nil
13747
}
13848

139-
// extractFrontmatter extracts YAML frontmatter from markdown content
140-
func extractFrontmatter(content []byte) (map[string]interface{}, error) {
141-
lines := string(content)
142-
143-
// Check if content starts with frontmatter delimiter
144-
if len(lines) < 4 || lines[:4] != "---\n" {
145-
return nil, nil // No frontmatter
146-
}
147-
148-
// Find the end of frontmatter
149-
endDelimiter := "\n---\n"
150-
endIndex := strings.Index(lines[4:], endDelimiter)
151-
if endIndex == -1 {
152-
return nil, fmt.Errorf("unterminated frontmatter: missing closing ---")
153-
}
154-
155-
// Extract frontmatter content
156-
frontmatterContent := lines[4 : 4+endIndex]
157-
if strings.TrimSpace(frontmatterContent) == "" {
158-
return nil, nil // Empty frontmatter
159-
}
160-
161-
// Parse YAML
162-
var frontmatter map[string]interface{}
163-
if err := yaml.Unmarshal([]byte(frontmatterContent), &frontmatter); err != nil {
164-
return nil, fmt.Errorf("invalid YAML frontmatter: %w", err)
165-
}
166-
167-
return frontmatter, nil
168-
}
169-
170-
// GetSchema returns the JSON schema as a string for external use
171-
func GetSchema() string {
172-
return RuleFrontmatterSchema
173-
}
174-
175-
// ValidateJSON validates JSON data directly against the schema
176-
func ValidateJSON(data []byte) (*ValidationResult, error) {
177-
var jsonData interface{}
178-
if err := json.Unmarshal(data, &jsonData); err != nil {
179-
return &ValidationResult{
180-
Valid: false,
181-
Errors: []ValidationError{
182-
{
183-
Field: "json",
184-
Message: fmt.Sprintf("Invalid JSON: %v", err),
185-
},
186-
},
187-
}, nil
188-
}
189-
190-
if jsonMap, ok := jsonData.(map[string]interface{}); ok {
191-
return ValidateFrontmatter(jsonMap)
49+
// ValidateRulesJSONFromFile validates a rules.json file from disk
50+
func ValidateRulesJSONFromFile(filePath string) error {
51+
// Read the file content
52+
rulesData, err := os.ReadFile(filePath)
53+
if err != nil {
54+
return fmt.Errorf("failed to read rules file: %w", err)
19255
}
19356

194-
return &ValidationResult{
195-
Valid: false,
196-
Errors: []ValidationError{
197-
{
198-
Field: "root",
199-
Message: "JSON data must be an object",
200-
},
201-
},
202-
}, nil
57+
return ValidateRulesJSON(rulesData)
20358
}
20459

205-
// ValidateYAML validates YAML data directly against the schema
206-
func ValidateYAML(data []byte) (*ValidationResult, error) {
207-
var yamlData map[string]interface{}
208-
if err := yaml.Unmarshal(data, &yamlData); err != nil {
209-
return &ValidationResult{
210-
Valid: false,
211-
Errors: []ValidationError{
212-
{
213-
Field: "yaml",
214-
Message: fmt.Sprintf("Invalid YAML: %v", err),
215-
},
216-
},
217-
}, nil
60+
// ValidateRulesObject validates a rules object directly
61+
func ValidateRulesObject(rules interface{}) error {
62+
// Convert the rules object to JSON bytes
63+
rulesBytes, err := json.Marshal(rules)
64+
if err != nil {
65+
return fmt.Errorf("failed to marshal rules object: %w", err)
21866
}
21967

220-
return ValidateFrontmatter(yamlData)
68+
return ValidateRulesJSON(rulesBytes)
22169
}

0 commit comments

Comments
 (0)