diff --git a/internal/cmd/category.go b/internal/cmd/category.go index 332904d..140960e 100644 --- a/internal/cmd/category.go +++ b/internal/cmd/category.go @@ -1,30 +1,141 @@ package cmd import ( + "encoding/json" "fmt" + "os" + "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" + "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/spf13/cobra" ) +// CategoryItem represents a category for batch operations. +type CategoryItem struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// CategoryEditItem represents a category edit for batch operations. +type CategoryEditItem struct { + Name string `json:"name"` + NewName string `json:"new_name,omitempty"` + Description string `json:"description,omitempty"` +} + var categoryCmd = &cobra.Command{ Use: "category", - Short: "List all available convention categories", + Short: "Manage convention categories", + Long: `Manage convention categories in user-policy.json. + +Categories help organize rules by concern area (e.g., security, style, performance). + +Available subcommands: + list - List all categories + add - Add a new category + edit - Edit an existing category + remove - Remove a category`, +} + +var categoryListCmd = &cobra.Command{ + Use: "list", + Short: "List all categories", Long: `List all convention categories with their descriptions. Categories are defined in user-policy.json and can be customized by the user. Run 'sym init' to create default categories (security, style, documentation, -error_handling, architecture, performance, testing). +error_handling, architecture, performance, testing).`, + RunE: runCategoryList, +} + +var categoryAddCmd = &cobra.Command{ + Use: "add [name] [description]", + Short: "Add a new category", + Long: `Add a new convention category. -You can add, remove, or modify categories directly in user-policy.json.`, - RunE: runCategory, +Single mode: + sym category add accessibility "Accessibility rules (WCAG, ARIA, etc.)" + +Batch mode (JSON file): + sym category add -f categories.json + + categories.json format: + [ + {"name": "security", "description": "Security rules"}, + {"name": "performance", "description": "Performance rules"} + ]`, + Args: cobra.MaximumNArgs(2), + RunE: runCategoryAdd, +} + +var categoryEditCmd = &cobra.Command{ + Use: "edit [name]", + Short: "Edit an existing category", + Long: `Edit an existing category's name or description. + +Single mode: + sym category edit security --description "Updated security rules" + sym category edit old-name --name new-name + sym category edit security --name sec --description "Security conventions" + +Batch mode (JSON file): + sym category edit -f edits.json + + edits.json format: + [ + {"name": "security", "new_name": "sec"}, + {"name": "performance", "description": "New description"} + ]`, + Args: cobra.MaximumNArgs(1), + RunE: runCategoryEdit, +} + +var categoryRemoveCmd = &cobra.Command{ + Use: "remove [names...]", + Short: "Remove a category", + Long: `Remove categories from user-policy.json. + +Note: Categories that are referenced by rules cannot be removed. +You must first remove or update the rules that reference the category. + +Single mode: + sym category remove deprecated-category + +Batch mode (multiple args): + sym category remove cat1 cat2 cat3 + +Batch mode (JSON file): + sym category remove -f names.json + + names.json format: + ["cat1", "cat2", "cat3"]`, + Args: cobra.ArbitraryArgs, + RunE: runCategoryRemove, } func init() { rootCmd.AddCommand(categoryCmd) + + // Add subcommands + categoryCmd.AddCommand(categoryListCmd) + categoryCmd.AddCommand(categoryAddCmd) + categoryCmd.AddCommand(categoryEditCmd) + categoryCmd.AddCommand(categoryRemoveCmd) + + // Add command flags + categoryAddCmd.Flags().StringP("file", "f", "", "JSON file with categories to add") + + // Edit command flags + categoryEditCmd.Flags().String("name", "", "New category name") + categoryEditCmd.Flags().String("description", "", "New category description") + categoryEditCmd.Flags().StringP("file", "f", "", "JSON file with category edits") + + // Remove command flags + categoryRemoveCmd.Flags().StringP("file", "f", "", "JSON file with category names to remove") } -func runCategory(cmd *cobra.Command, args []string) error { +func runCategoryList(cmd *cobra.Command, args []string) error { // Load categories from user-policy.json userPolicy, err := roles.LoadUserPolicyFromRepo() if err != nil { @@ -50,3 +161,309 @@ func runCategory(cmd *cobra.Command, args []string) error { return nil } + +func runCategoryAdd(cmd *cobra.Command, args []string) error { + fileFlag, _ := cmd.Flags().GetString("file") + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + return fmt.Errorf("failed to load policy: %w", err) + } + + // Build existing names map + existingNames := make(map[string]bool) + for _, cat := range userPolicy.Category { + existingNames[cat.Name] = true + } + + var categories []CategoryItem + + if fileFlag != "" { + // Batch mode: load from JSON file + data, err := os.ReadFile(fileFlag) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + if err := json.Unmarshal(data, &categories); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + if len(categories) == 0 { + return fmt.Errorf("no categories found in file") + } + } else { + // Single mode: use args + if len(args) != 2 { + return fmt.Errorf("usage: sym category add or sym category add -f ") + } + categories = []CategoryItem{{Name: args[0], Description: args[1]}} + } + + var succeeded []string + var failed []string + + // Process each category + for _, cat := range categories { + if cat.Name == "" { + failed = append(failed, "(empty): name is required") + continue + } + if cat.Description == "" { + failed = append(failed, fmt.Sprintf("%s: description is required", cat.Name)) + continue + } + if existingNames[cat.Name] { + failed = append(failed, fmt.Sprintf("%s: already exists", cat.Name)) + continue + } + + userPolicy.Category = append(userPolicy.Category, schema.CategoryDef{ + Name: cat.Name, + Description: cat.Description, + }) + existingNames[cat.Name] = true + succeeded = append(succeeded, cat.Name) + } + + // Save policy if any succeeded + if len(succeeded) > 0 { + if err := policy.SavePolicy(userPolicy, ""); err != nil { + return fmt.Errorf("failed to save policy: %w", err) + } + } + + // Print results + printBatchResult("Added", succeeded, failed) + return nil +} + +func runCategoryEdit(cmd *cobra.Command, args []string) error { + fileFlag, _ := cmd.Flags().GetString("file") + newName, _ := cmd.Flags().GetString("name") + newDescription, _ := cmd.Flags().GetString("description") + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + return fmt.Errorf("failed to load policy: %w", err) + } + + // Build category index map + categoryIndex := make(map[string]int) + for i, cat := range userPolicy.Category { + categoryIndex[cat.Name] = i + } + + var edits []CategoryEditItem + + if fileFlag != "" { + // Batch mode: load from JSON file + data, err := os.ReadFile(fileFlag) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + if err := json.Unmarshal(data, &edits); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + if len(edits) == 0 { + return fmt.Errorf("no edits found in file") + } + } else { + // Single mode: use args + if len(args) != 1 { + return fmt.Errorf("usage: sym category edit [--name ] [--description ] or sym category edit -f ") + } + if newName == "" && newDescription == "" { + return fmt.Errorf("at least one of --name or --description must be provided") + } + edits = []CategoryEditItem{{Name: args[0], NewName: newName, Description: newDescription}} + } + + var succeeded []string + var failed []string + + // Process each edit + for _, edit := range edits { + if edit.Name == "" { + failed = append(failed, "(empty): name is required") + continue + } + if edit.NewName == "" && edit.Description == "" { + failed = append(failed, fmt.Sprintf("%s: at least one of new_name or description required", edit.Name)) + continue + } + + idx, exists := categoryIndex[edit.Name] + if !exists { + failed = append(failed, fmt.Sprintf("%s: not found", edit.Name)) + continue + } + + rulesUpdated := 0 + resultText := edit.Name + + // If renaming + if edit.NewName != "" && edit.NewName != edit.Name { + if _, dupExists := categoryIndex[edit.NewName]; dupExists { + failed = append(failed, fmt.Sprintf("%s: '%s' already exists", edit.Name, edit.NewName)) + continue + } + + // Update rule references + for i := range userPolicy.Rules { + if userPolicy.Rules[i].Category == edit.Name { + userPolicy.Rules[i].Category = edit.NewName + rulesUpdated++ + } + } + + delete(categoryIndex, edit.Name) + categoryIndex[edit.NewName] = idx + userPolicy.Category[idx].Name = edit.NewName + resultText = fmt.Sprintf("%s → %s", edit.Name, edit.NewName) + } + + // Update description + if edit.Description != "" { + userPolicy.Category[idx].Description = edit.Description + if edit.NewName == "" || edit.NewName == edit.Name { + resultText = fmt.Sprintf("%s (description updated)", edit.Name) + } + } + + if rulesUpdated > 0 { + resultText = fmt.Sprintf("%s (%d rules updated)", resultText, rulesUpdated) + } + + succeeded = append(succeeded, resultText) + } + + // Save policy if any succeeded + if len(succeeded) > 0 { + if err := policy.SavePolicy(userPolicy, ""); err != nil { + return fmt.Errorf("failed to save policy: %w", err) + } + } + + // Print results + printBatchResult("Updated", succeeded, failed) + return nil +} + +func runCategoryRemove(cmd *cobra.Command, args []string) error { + fileFlag, _ := cmd.Flags().GetString("file") + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + return fmt.Errorf("failed to load policy: %w", err) + } + + // Build category index map and rule count map + categoryIndex := make(map[string]int) + for i, cat := range userPolicy.Category { + categoryIndex[cat.Name] = i + } + + ruleCount := make(map[string]int) + for _, rule := range userPolicy.Rules { + ruleCount[rule.Category]++ + } + + var names []string + + if fileFlag != "" { + // Batch mode: load from JSON file + data, err := os.ReadFile(fileFlag) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + if err := json.Unmarshal(data, &names); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + if len(names) == 0 { + return fmt.Errorf("no category names found in file") + } + } else if len(args) > 0 { + // Single or batch mode: use args + names = args + } else { + return fmt.Errorf("usage: sym category remove [names...] or sym category remove -f ") + } + + var succeeded []string + var failed []string + toRemove := make(map[int]bool) + + // Process each name + for _, name := range names { + if name == "" { + failed = append(failed, "(empty): name is required") + continue + } + + idx, exists := categoryIndex[name] + if !exists { + failed = append(failed, fmt.Sprintf("%s: not found", name)) + continue + } + + if count := ruleCount[name]; count > 0 { + failed = append(failed, fmt.Sprintf("%s: used by %d rule(s)", name, count)) + continue + } + + toRemove[idx] = true + succeeded = append(succeeded, name) + } + + // Remove categories + if len(toRemove) > 0 { + newCategories := make([]schema.CategoryDef, 0, len(userPolicy.Category)-len(toRemove)) + for i, cat := range userPolicy.Category { + if !toRemove[i] { + newCategories = append(newCategories, cat) + } + } + userPolicy.Category = newCategories + + if err := policy.SavePolicy(userPolicy, ""); err != nil { + return fmt.Errorf("failed to save policy: %w", err) + } + } + + // Print results + printBatchResult("Removed", succeeded, failed) + return nil +} + +// printBatchResult prints the result of a batch operation. +func printBatchResult(action string, succeeded, failed []string) { + if len(failed) == 0 && len(succeeded) > 0 { + if len(succeeded) == 1 { + printDone(fmt.Sprintf("%s category: %s", action, succeeded[0])) + } else { + printDone(fmt.Sprintf("%s %d categories:", action, len(succeeded))) + for _, name := range succeeded { + fmt.Printf(" • %s\n", name) + } + } + } else if len(succeeded) == 0 && len(failed) > 0 { + printWarn(fmt.Sprintf("Failed to %s any categories:", action)) + for _, f := range failed { + fmt.Printf(" ✗ %s\n", f) + } + } else if len(succeeded) > 0 && len(failed) > 0 { + printWarn("Batch operation completed with errors:") + fmt.Printf(" ✓ %s (%d):\n", action, len(succeeded)) + for _, name := range succeeded { + fmt.Printf(" • %s\n", name) + } + fmt.Printf(" ✗ Failed (%d):\n", len(failed)) + for _, f := range failed { + fmt.Printf(" • %s\n", f) + } + } else { + printWarn("No categories to process") + } +} diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 9b213b0..402c974 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -301,6 +301,12 @@ func (c *Converter) routeRulesWithLLM(ctx context.Context, userPolicy *schema.Us } sem := make(chan struct{}, maxConcurrent) + // Build category name -> description map + categoryMap := make(map[string]string) + for _, cat := range userPolicy.Category { + categoryMap[cat.Name] = cat.Description + } + // Process rules in parallel with concurrency limit for _, rule := range userPolicy.Rules { // Get languages for this rule @@ -322,7 +328,7 @@ func (c *Converter) routeRulesWithLLM(ctx context.Context, userPolicy *schema.Us } wg.Add(1) - go func(r schema.UserRule, linters []string) { + go func(r schema.UserRule, linters []string, catMap map[string]string) { defer wg.Done() // Acquire semaphore with context check @@ -334,7 +340,7 @@ func (c *Converter) routeRulesWithLLM(ctx context.Context, userPolicy *schema.Us defer func() { <-sem }() // Ask LLM which linters are appropriate for this rule - selectedLinters := c.selectLintersForRule(ctx, r, linters) + selectedLinters := c.selectLintersForRule(ctx, r, linters, catMap) // Send result with context check to prevent deadlock if len(selectedLinters) == 0 { @@ -351,7 +357,7 @@ func (c *Converter) routeRulesWithLLM(ctx context.Context, userPolicy *schema.Us return } } - }(rule, availableLinters) + }(rule, availableLinters, categoryMap) } // Close results channel after all goroutines complete @@ -398,7 +404,7 @@ func (c *Converter) getAvailableLinters(languages []string) []string { } // selectLintersForRule uses LLM to determine which linters are appropriate for a rule -func (c *Converter) selectLintersForRule(ctx context.Context, rule schema.UserRule, availableLinters []string) []string { +func (c *Converter) selectLintersForRule(ctx context.Context, rule schema.UserRule, availableLinters []string, categoryMap map[string]string) []string { // Build linter descriptions dynamically from registry linterDescriptions := c.buildLinterDescriptions(availableLinters) @@ -465,7 +471,11 @@ Input: "Imports from large packages must be specific" Output: [] Reason: Requires knowing which packages are "large"`, linterDescriptions, routingHints, availableLinters) - userPrompt := fmt.Sprintf("Rule: %s\nCategory: %s", rule.Say, rule.Category) + categoryInfo := rule.Category + if desc, ok := categoryMap[rule.Category]; ok && desc != "" { + categoryInfo = fmt.Sprintf("%s (%s)", rule.Category, desc) + } + userPrompt := fmt.Sprintf("Rule: %s\nCategory: %s", rule.Say, categoryInfo) // Call LLM prompt := systemPrompt + "\n\n" + userPrompt diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 7fe4149..9515e30 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -202,6 +202,40 @@ type ListCategoryInput struct { // No parameters - returns all categories } +// CategoryItem represents a single category for batch operations. +type CategoryItem struct { + Name string `json:"name" jsonschema:"Category name"` + Description string `json:"description" jsonschema:"Category description"` +} + +// CategoryEditItem represents a single category edit for batch operations. +type CategoryEditItem struct { + Name string `json:"name" jsonschema:"Current category name"` + NewName string `json:"new_name,omitempty" jsonschema:"New name (optional)"` + Description string `json:"description,omitempty" jsonschema:"New description (optional)"` +} + +// FailedItem represents a failed operation in batch processing. +type FailedItem struct { + Name string `json:"name"` + Reason string `json:"reason"` +} + +// AddCategoryInput represents the input schema for the add_category tool (batch mode). +type AddCategoryInput struct { + Categories []CategoryItem `json:"categories" jsonschema:"Array of categories to add"` +} + +// EditCategoryInput represents the input schema for the edit_category tool (batch mode). +type EditCategoryInput struct { + Edits []CategoryEditItem `json:"edits" jsonschema:"Array of category edits"` +} + +// RemoveCategoryInput represents the input schema for the remove_category tool (batch mode). +type RemoveCategoryInput struct { + Names []string `json:"names" jsonschema:"Array of category names to remove"` +} + // runStdioWithSDK runs a spec-compliant MCP server over stdio using the official go-sdk. func (s *Server) runStdioWithSDK(ctx context.Context) error { server := sdkmcp.NewServer(&sdkmcp.Implementation{ @@ -253,6 +287,42 @@ func (s *Server) runStdioWithSDK(ctx context.Context) error { return nil, result.(map[string]any), nil }) + // Tool: add_category (batch mode) + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "add_category", + Description: "Add convention categories. Pass array of {name, description} objects in 'categories' field.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input AddCategoryInput) (*sdkmcp.CallToolResult, map[string]any, error) { + result, rpcErr := s.handleAddCategory(input) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + return nil, result.(map[string]any), nil + }) + + // Tool: edit_category (batch mode) + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "edit_category", + Description: "Edit convention categories. Pass array of {name, new_name?, description?} objects in 'edits' field.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input EditCategoryInput) (*sdkmcp.CallToolResult, map[string]any, error) { + result, rpcErr := s.handleEditCategory(input) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + return nil, result.(map[string]any), nil + }) + + // Tool: remove_category (batch mode) + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "remove_category", + Description: "Remove convention categories. Pass array of category names in 'names' field.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input RemoveCategoryInput) (*sdkmcp.CallToolResult, map[string]any, error) { + result, rpcErr := s.handleRemoveCategory(input) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + return nil, result.(map[string]any), nil + }) + // Run the server over stdio until the client disconnects return server.Run(ctx, &sdkmcp.StdioTransport{}) } @@ -855,3 +925,257 @@ func (s *Server) getCategory() []schema.CategoryDef { } return nil } + +// handleAddCategory handles adding categories (batch mode). +func (s *Server) handleAddCategory(input AddCategoryInput) (interface{}, *RPCError) { + // Validate input + if len(input.Categories) == 0 { + return nil, &RPCError{Code: -32602, Message: "At least one category is required in 'categories' array"} + } + + // Build existing names map + existingNames := make(map[string]bool) + for _, cat := range s.userPolicy.Category { + existingNames[cat.Name] = true + } + + var succeeded []string + var failed []FailedItem + + // Process each category + for _, cat := range input.Categories { + // Validate + if cat.Name == "" { + failed = append(failed, FailedItem{Name: "(empty)", Reason: "Category name is required"}) + continue + } + if cat.Description == "" { + failed = append(failed, FailedItem{Name: cat.Name, Reason: "Category description is required"}) + continue + } + + // Check for duplicate + if existingNames[cat.Name] { + failed = append(failed, FailedItem{Name: cat.Name, Reason: fmt.Sprintf("Category '%s' already exists", cat.Name)}) + continue + } + + // Add category + s.userPolicy.Category = append(s.userPolicy.Category, schema.CategoryDef{ + Name: cat.Name, + Description: cat.Description, + }) + existingNames[cat.Name] = true + succeeded = append(succeeded, cat.Name) + } + + // Save policy if any succeeded + if len(succeeded) > 0 { + if err := s.saveUserPolicy(); err != nil { + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Failed to save policy: %v", err)} + } + } + + // Build response + return s.buildBatchResponse("Added", succeeded, failed), nil +} + +// handleEditCategory handles editing categories (batch mode). +func (s *Server) handleEditCategory(input EditCategoryInput) (interface{}, *RPCError) { + // Validate input + if len(input.Edits) == 0 { + return nil, &RPCError{Code: -32602, Message: "At least one edit is required in 'edits' array"} + } + + // Build category index map + categoryIndex := make(map[string]int) + for i, cat := range s.userPolicy.Category { + categoryIndex[cat.Name] = i + } + + var succeeded []string + var failed []FailedItem + totalRulesUpdated := 0 + + // Process each edit + for _, edit := range input.Edits { + // Validate + if edit.Name == "" { + failed = append(failed, FailedItem{Name: "(empty)", Reason: "Category name is required"}) + continue + } + if edit.NewName == "" && edit.Description == "" { + failed = append(failed, FailedItem{Name: edit.Name, Reason: "At least one of new_name or description must be provided"}) + continue + } + + // Find category + idx, exists := categoryIndex[edit.Name] + if !exists { + failed = append(failed, FailedItem{Name: edit.Name, Reason: fmt.Sprintf("Category '%s' not found", edit.Name)}) + continue + } + + rulesUpdated := 0 + resultText := edit.Name + + // If renaming + if edit.NewName != "" && edit.NewName != edit.Name { + // Check for duplicate + if _, dupExists := categoryIndex[edit.NewName]; dupExists { + failed = append(failed, FailedItem{Name: edit.Name, Reason: fmt.Sprintf("Category '%s' already exists", edit.NewName)}) + continue + } + + // Update rule references + for i := range s.userPolicy.Rules { + if s.userPolicy.Rules[i].Category == edit.Name { + s.userPolicy.Rules[i].Category = edit.NewName + rulesUpdated++ + } + } + + // Update index map + delete(categoryIndex, edit.Name) + categoryIndex[edit.NewName] = idx + + s.userPolicy.Category[idx].Name = edit.NewName + resultText = fmt.Sprintf("%s → %s", edit.Name, edit.NewName) + } + + // Update description if provided + if edit.Description != "" { + s.userPolicy.Category[idx].Description = edit.Description + if edit.NewName == "" || edit.NewName == edit.Name { + resultText = fmt.Sprintf("%s (description updated)", edit.Name) + } + } + + if rulesUpdated > 0 { + resultText = fmt.Sprintf("%s (%d rules updated)", resultText, rulesUpdated) + totalRulesUpdated += rulesUpdated + } + + succeeded = append(succeeded, resultText) + } + + // Save policy if any succeeded + if len(succeeded) > 0 { + if err := s.saveUserPolicy(); err != nil { + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Failed to save policy: %v", err)} + } + } + + // Build response + return s.buildBatchResponse("Updated", succeeded, failed), nil +} + +// handleRemoveCategory handles removing categories (batch mode). +func (s *Server) handleRemoveCategory(input RemoveCategoryInput) (interface{}, *RPCError) { + // Validate input + if len(input.Names) == 0 { + return nil, &RPCError{Code: -32602, Message: "At least one category name is required in 'names' array"} + } + + // Build category index map and rule count map + categoryIndex := make(map[string]int) + for i, cat := range s.userPolicy.Category { + categoryIndex[cat.Name] = i + } + + ruleCount := make(map[string]int) + for _, rule := range s.userPolicy.Rules { + ruleCount[rule.Category]++ + } + + var succeeded []string + var failed []FailedItem + toRemove := make(map[int]bool) // indices to remove + + // Process each name + for _, name := range input.Names { + // Validate + if name == "" { + failed = append(failed, FailedItem{Name: "(empty)", Reason: "Category name is required"}) + continue + } + + // Find category + idx, exists := categoryIndex[name] + if !exists { + failed = append(failed, FailedItem{Name: name, Reason: fmt.Sprintf("Category '%s' not found", name)}) + continue + } + + // Check if rules reference this category + if count := ruleCount[name]; count > 0 { + failed = append(failed, FailedItem{Name: name, Reason: fmt.Sprintf("Category is used by %d rule(s)", count)}) + continue + } + + toRemove[idx] = true + succeeded = append(succeeded, name) + } + + // Remove categories (in reverse order to preserve indices) + if len(toRemove) > 0 { + newCategories := make([]schema.CategoryDef, 0, len(s.userPolicy.Category)-len(toRemove)) + for i, cat := range s.userPolicy.Category { + if !toRemove[i] { + newCategories = append(newCategories, cat) + } + } + s.userPolicy.Category = newCategories + + if err := s.saveUserPolicy(); err != nil { + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Failed to save policy: %v", err)} + } + } + + // Build response + return s.buildBatchResponse("Removed", succeeded, failed), nil +} + +// buildBatchResponse builds a standardized batch operation response. +func (s *Server) buildBatchResponse(action string, succeeded []string, failed []FailedItem) map[string]interface{} { + var textContent string + + if len(failed) == 0 && len(succeeded) > 0 { + // All succeeded + textContent = fmt.Sprintf("%s %d category(ies) successfully:\n", action, len(succeeded)) + for _, name := range succeeded { + textContent += fmt.Sprintf(" ✓ %s\n", name) + } + } else if len(succeeded) == 0 && len(failed) > 0 { + // All failed + textContent = fmt.Sprintf("Failed to %s any categories:\n", strings.ToLower(action)) + for _, f := range failed { + textContent += fmt.Sprintf(" ✗ %s: %s\n", f.Name, f.Reason) + } + } else if len(succeeded) > 0 && len(failed) > 0 { + // Partial success + textContent = "Batch operation completed with errors:\n" + textContent += fmt.Sprintf(" ✓ Succeeded (%d):\n", len(succeeded)) + for _, name := range succeeded { + textContent += fmt.Sprintf(" - %s\n", name) + } + textContent += fmt.Sprintf(" ✗ Failed (%d):\n", len(failed)) + for _, f := range failed { + textContent += fmt.Sprintf(" - %s: %s\n", f.Name, f.Reason) + } + } else { + // Nothing to do + textContent = "No categories to process." + } + + return map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": textContent}, + }, + } +} + +// saveUserPolicy saves the user policy to file. +func (s *Server) saveUserPolicy() error { + return policy.SavePolicy(s.userPolicy, "") +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index e8b3f5d..352c039 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -2,14 +2,23 @@ package mcp import ( "os" + "os/exec" "path/filepath" "testing" "github.com/DevSymphony/sym-cli/internal/policy" + "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// runGitInit initializes a git repository in the given directory +func runGitInit(dir string) error { + cmd := exec.Command("git", "init") + cmd.Dir = dir + return cmd.Run() +} + func TestQueryConventions(t *testing.T) { // Setup: Create a temporary user policy tmpDir := t.TempDir() @@ -209,6 +218,337 @@ func TestQueryConventions(t *testing.T) { }) } +func TestAddCategory(t *testing.T) { + t.Run("add category successfully", func(t *testing.T) { + tmpDir := t.TempDir() + + // Change to temp dir first, before any git operations + originalDir, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(originalDir) }() + + // Initialize real git repository and .sym directory + require.NoError(t, runGitInit(tmpDir)) + require.NoError(t, os.MkdirAll(".sym", 0755)) + + userPolicyPath := filepath.Join(tmpDir, ".sym", "user-policy.json") + userPolicyJSON := `{ + "version": "1.0.0", + "category": [ + {"name": "security", "description": "Security rules"} + ], + "rules": [] +}` + require.NoError(t, os.WriteFile(userPolicyPath, []byte(userPolicyJSON), 0644)) + + server := &Server{ + configPath: userPolicyPath, + loader: policy.NewLoader(false), + } + userPolicy, err := server.loader.LoadUserPolicy(userPolicyPath) + require.NoError(t, err) + server.userPolicy = userPolicy + + input := AddCategoryInput{ + Categories: []CategoryItem{{Name: "testing", Description: "Testing rules"}}, + } + result, rpcErr := server.handleAddCategory(input) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + assert.Contains(t, text, "successfully") + assert.Len(t, server.userPolicy.Category, 2) + assert.Equal(t, "testing", server.userPolicy.Category[1].Name) + }) + + t.Run("batch add multiple categories", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{ + Version: "1.0.0", + Category: []schema.CategoryDef{}, + }, + } + + input := AddCategoryInput{ + Categories: []CategoryItem{ + {Name: "security", Description: "Security rules"}, + {Name: "performance", Description: "Performance rules"}, + }, + } + result, rpcErr := server.handleAddCategory(input) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + assert.Len(t, server.userPolicy.Category, 2) + }) + + t.Run("partial failure in batch", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{ + Version: "1.0.0", + Category: []schema.CategoryDef{ + {Name: "security", Description: "Security rules"}, + }, + }, + } + + input := AddCategoryInput{ + Categories: []CategoryItem{ + {Name: "security", Description: "Duplicate"}, // will fail + {Name: "performance", Description: "Perf"}, // will succeed + }, + } + result, rpcErr := server.handleAddCategory(input) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + assert.Contains(t, text, "errors") + assert.Len(t, server.userPolicy.Category, 2) // security + performance + }) + + t.Run("reject empty categories array", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{Version: "1.0.0"}, + } + + input := AddCategoryInput{Categories: []CategoryItem{}} + _, rpcErr := server.handleAddCategory(input) + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "At least one category is required") + }) +} + +func TestEditCategory(t *testing.T) { + t.Run("edit description only", func(t *testing.T) { + tmpDir := t.TempDir() + + originalDir, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(originalDir) }() + + require.NoError(t, runGitInit(tmpDir)) + require.NoError(t, os.MkdirAll(".sym", 0755)) + + userPolicyPath := filepath.Join(tmpDir, ".sym", "user-policy.json") + userPolicyJSON := `{ + "version": "1.0.0", + "category": [ + {"name": "security", "description": "Old description"} + ], + "rules": [] +}` + require.NoError(t, os.WriteFile(userPolicyPath, []byte(userPolicyJSON), 0644)) + + server := &Server{ + configPath: userPolicyPath, + loader: policy.NewLoader(false), + } + userPolicy, err := server.loader.LoadUserPolicy(userPolicyPath) + require.NoError(t, err) + server.userPolicy = userPolicy + + input := EditCategoryInput{ + Edits: []CategoryEditItem{{Name: "security", Description: "New description"}}, + } + result, rpcErr := server.handleEditCategory(input) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + assert.Equal(t, "New description", server.userPolicy.Category[0].Description) + assert.Equal(t, "security", server.userPolicy.Category[0].Name) + }) + + t.Run("rename category and update rules", func(t *testing.T) { + tmpDir := t.TempDir() + + originalDir, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(originalDir) }() + + require.NoError(t, runGitInit(tmpDir)) + require.NoError(t, os.MkdirAll(".sym", 0755)) + + userPolicyPath := filepath.Join(tmpDir, ".sym", "user-policy.json") + userPolicyJSON := `{ + "version": "1.0.0", + "category": [ + {"name": "old-name", "description": "Description"} + ], + "rules": [ + {"id": "R1", "say": "Rule 1", "category": "old-name"}, + {"id": "R2", "say": "Rule 2", "category": "old-name"}, + {"id": "R3", "say": "Rule 3", "category": "other"} + ] +}` + require.NoError(t, os.WriteFile(userPolicyPath, []byte(userPolicyJSON), 0644)) + + server := &Server{ + configPath: userPolicyPath, + loader: policy.NewLoader(false), + } + userPolicy, err := server.loader.LoadUserPolicy(userPolicyPath) + require.NoError(t, err) + server.userPolicy = userPolicy + + input := EditCategoryInput{ + Edits: []CategoryEditItem{{Name: "old-name", NewName: "new-name"}}, + } + result, rpcErr := server.handleEditCategory(input) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + assert.Contains(t, text, "2 rules updated") + assert.Equal(t, "new-name", server.userPolicy.Category[0].Name) + assert.Equal(t, "new-name", server.userPolicy.Rules[0].Category) + assert.Equal(t, "new-name", server.userPolicy.Rules[1].Category) + assert.Equal(t, "other", server.userPolicy.Rules[2].Category) + }) + + t.Run("batch edit with partial failure", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{ + Version: "1.0.0", + Category: []schema.CategoryDef{ + {Name: "security", Description: "Security"}, + {Name: "style", Description: "Style"}, + }, + }, + } + + input := EditCategoryInput{ + Edits: []CategoryEditItem{ + {Name: "security", NewName: "style"}, // fail: duplicate + {Name: "style", Description: "Updated"}, // succeed + }, + } + result, rpcErr := server.handleEditCategory(input) + require.Nil(t, rpcErr) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + assert.Contains(t, text, "errors") + assert.Equal(t, "Updated", server.userPolicy.Category[1].Description) + }) + + t.Run("reject empty edits array", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{Version: "1.0.0"}, + } + + input := EditCategoryInput{Edits: []CategoryEditItem{}} + _, rpcErr := server.handleEditCategory(input) + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "At least one edit is required") + }) +} + +func TestRemoveCategory(t *testing.T) { + t.Run("remove unused category", func(t *testing.T) { + tmpDir := t.TempDir() + + originalDir, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(originalDir) }() + + require.NoError(t, runGitInit(tmpDir)) + require.NoError(t, os.MkdirAll(".sym", 0755)) + + userPolicyPath := filepath.Join(tmpDir, ".sym", "user-policy.json") + userPolicyJSON := `{ + "version": "1.0.0", + "category": [ + {"name": "security", "description": "Security"}, + {"name": "unused", "description": "Unused category"} + ], + "rules": [ + {"id": "R1", "say": "Rule 1", "category": "security"} + ] +}` + require.NoError(t, os.WriteFile(userPolicyPath, []byte(userPolicyJSON), 0644)) + + server := &Server{ + configPath: userPolicyPath, + loader: policy.NewLoader(false), + } + userPolicy, err := server.loader.LoadUserPolicy(userPolicyPath) + require.NoError(t, err) + server.userPolicy = userPolicy + + input := RemoveCategoryInput{Names: []string{"unused"}} + result, rpcErr := server.handleRemoveCategory(input) + require.Nil(t, rpcErr) + require.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + assert.Contains(t, text, "successfully") + assert.Len(t, server.userPolicy.Category, 1) + assert.Equal(t, "security", server.userPolicy.Category[0].Name) + }) + + t.Run("batch remove with partial failure", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{ + Version: "1.0.0", + Category: []schema.CategoryDef{ + {Name: "security", Description: "Security"}, + {Name: "unused1", Description: "Unused 1"}, + {Name: "unused2", Description: "Unused 2"}, + }, + Rules: []schema.UserRule{ + {ID: "R1", Say: "Rule 1", Category: "security"}, + }, + }, + } + + input := RemoveCategoryInput{Names: []string{"security", "unused1", "unused2"}} + result, rpcErr := server.handleRemoveCategory(input) + require.Nil(t, rpcErr) + + resultMap := result.(map[string]interface{}) + content := resultMap["content"].([]map[string]interface{}) + text := content[0]["text"].(string) + + assert.Contains(t, text, "errors") + assert.Len(t, server.userPolicy.Category, 1) // only security remains + assert.Equal(t, "security", server.userPolicy.Category[0].Name) + }) + + t.Run("reject empty names array", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{Version: "1.0.0"}, + } + + input := RemoveCategoryInput{Names: []string{}} + _, rpcErr := server.handleRemoveCategory(input) + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "At least one category name is required") + }) +} + func TestListCategory(t *testing.T) { t.Run("returns no categories message when no user policy", func(t *testing.T) { server := &Server{ diff --git a/internal/policy/templates/go-template.json b/internal/policy/templates/go-template.json index 884c81f..7312a22 100644 --- a/internal/policy/templates/go-template.json +++ b/internal/policy/templates/go-template.json @@ -1,5 +1,11 @@ { "version": "1.0.0", + "category": [ + {"name": "naming", "description": "Naming convention rules (package names, identifiers, interfaces)"}, + {"name": "error_handling", "description": "Error handling rules (error returns, context, goroutines)"}, + {"name": "formatting", "description": "Code formatting rules (gofmt)"}, + {"name": "documentation", "description": "Documentation rules (exported functions, types)"} + ], "defaults": { "languages": ["go"], "severity": "error", diff --git a/internal/policy/templates/node-template.json b/internal/policy/templates/node-template.json index 43166ca..1f10d7d 100644 --- a/internal/policy/templates/node-template.json +++ b/internal/policy/templates/node-template.json @@ -1,5 +1,12 @@ { "version": "1.0.0", + "category": [ + {"name": "security", "description": "Security rules (authentication, authorization, vulnerability prevention)"}, + {"name": "error_handling", "description": "Error handling and exception management rules"}, + {"name": "naming", "description": "Naming convention rules"}, + {"name": "performance", "description": "Performance optimization rules"}, + {"name": "documentation", "description": "Documentation rules (comments, logging, etc.)"} + ], "defaults": { "languages": ["javascript", "typescript"], "severity": "error", diff --git a/internal/policy/templates/python-template.json b/internal/policy/templates/python-template.json index af94337..99fcdef 100644 --- a/internal/policy/templates/python-template.json +++ b/internal/policy/templates/python-template.json @@ -1,5 +1,12 @@ { "version": "1.0.0", + "category": [ + {"name": "formatting", "description": "Code formatting rules (PEP 8, indentation, line length)"}, + {"name": "naming", "description": "Naming convention rules (snake_case, PascalCase, constants)"}, + {"name": "documentation", "description": "Documentation rules (docstrings, type hints)"}, + {"name": "error_handling", "description": "Error handling and exception management rules"}, + {"name": "security", "description": "Security rules (secrets management, input validation)"} + ], "defaults": { "languages": ["python"], "severity": "warning", diff --git a/internal/server/server.go b/internal/server/server.go index 8fdff78..864c537 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -56,6 +56,10 @@ func (s *Server) Start() error { mux.HandleFunc("/api/policy/convert", s.handleConvert) mux.HandleFunc("/api/users", s.handleUsers) + // Category API endpoints + mux.HandleFunc("/api/categories", s.handleCategories) + mux.HandleFunc("/api/categories/", s.handleCategoryByName) + // Static files staticFS, err := fs.Sub(staticFiles, "static") if err != nil { @@ -728,3 +732,277 @@ func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(result) } + +// handleCategories handles GET (list) and POST (add) for categories +func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleGetCategories(w, r) + case http.MethodPost: + s.handleAddCategory(w, r) + case http.MethodOptions: + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleGetCategories returns all categories from policy +func (s *Server) handleGetCategories(w http.ResponseWriter, r *http.Request) { + userPolicy, err := roles.LoadUserPolicyFromRepo() + if err != nil { + http.Error(w, "Failed to load policy", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userPolicy.Category) +} + +// handleAddCategory adds a new category +func (s *Server) handleAddCategory(w http.ResponseWriter, r *http.Request) { + // Check permission + currentRole, err := roles.GetCurrentRole() + if err != nil { + http.Error(w, "Failed to get current role", http.StatusInternalServerError) + return + } + + canEdit, err := s.hasPermissionForRole(currentRole, "editPolicy") + if err != nil || !canEdit { + http.Error(w, "Forbidden: editPolicy permission required", http.StatusForbidden) + return + } + + // Parse request + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate + if req.Name == "" { + http.Error(w, "Category name is required", http.StatusBadRequest) + return + } + if req.Description == "" { + http.Error(w, "Category description is required", http.StatusBadRequest) + return + } + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + http.Error(w, "Failed to load policy", http.StatusInternalServerError) + return + } + + // Check for duplicate + for _, cat := range userPolicy.Category { + if cat.Name == req.Name { + http.Error(w, fmt.Sprintf("Category '%s' already exists", req.Name), http.StatusConflict) + return + } + } + + // Add category + userPolicy.Category = append(userPolicy.Category, schema.CategoryDef{ + Name: req.Name, + Description: req.Description, + }) + + // Save policy + if err := policy.SavePolicy(userPolicy, ""); err != nil { + http.Error(w, "Failed to save policy", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "success", + "category": userPolicy.Category[len(userPolicy.Category)-1], + }) +} + +// handleCategoryByName handles PUT (edit) and DELETE (remove) for a specific category +func (s *Server) handleCategoryByName(w http.ResponseWriter, r *http.Request) { + // Extract category name from URL + categoryName := strings.TrimPrefix(r.URL.Path, "/api/categories/") + if categoryName == "" { + http.Error(w, "Category name required", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodPut: + s.handleEditCategory(w, r, categoryName) + case http.MethodDelete: + s.handleDeleteCategory(w, r, categoryName) + case http.MethodOptions: + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleEditCategory edits an existing category +func (s *Server) handleEditCategory(w http.ResponseWriter, r *http.Request, categoryName string) { + // Check permission + currentRole, err := roles.GetCurrentRole() + if err != nil { + http.Error(w, "Failed to get current role", http.StatusInternalServerError) + return + } + + canEdit, err := s.hasPermissionForRole(currentRole, "editPolicy") + if err != nil || !canEdit { + http.Error(w, "Forbidden: editPolicy permission required", http.StatusForbidden) + return + } + + // Parse request + var req struct { + NewName string `json:"new_name"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate at least one change + if req.NewName == "" && req.Description == "" { + http.Error(w, "At least one of new_name or description must be provided", http.StatusBadRequest) + return + } + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + http.Error(w, "Failed to load policy", http.StatusInternalServerError) + return + } + + // Find category + categoryIndex := -1 + for i, cat := range userPolicy.Category { + if cat.Name == categoryName { + categoryIndex = i + break + } + } + + if categoryIndex == -1 { + http.Error(w, fmt.Sprintf("Category '%s' not found", categoryName), http.StatusNotFound) + return + } + + // If renaming, check for duplicate and update rule references + affectedRules := 0 + if req.NewName != "" && req.NewName != categoryName { + // Check for duplicate + for _, cat := range userPolicy.Category { + if cat.Name == req.NewName { + http.Error(w, fmt.Sprintf("Category '%s' already exists", req.NewName), http.StatusConflict) + return + } + } + + // Update rule references + for i := range userPolicy.Rules { + if userPolicy.Rules[i].Category == categoryName { + userPolicy.Rules[i].Category = req.NewName + affectedRules++ + } + } + + userPolicy.Category[categoryIndex].Name = req.NewName + } + + // Update description if provided + if req.Description != "" { + userPolicy.Category[categoryIndex].Description = req.Description + } + + // Save policy + if err := policy.SavePolicy(userPolicy, ""); err != nil { + http.Error(w, "Failed to save policy", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "success", + "category": userPolicy.Category[categoryIndex], + "affectedRules": affectedRules, + }) +} + +// handleDeleteCategory removes a category +func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request, categoryName string) { + // Check permission + currentRole, err := roles.GetCurrentRole() + if err != nil { + http.Error(w, "Failed to get current role", http.StatusInternalServerError) + return + } + + canEdit, err := s.hasPermissionForRole(currentRole, "editPolicy") + if err != nil || !canEdit { + http.Error(w, "Forbidden: editPolicy permission required", http.StatusForbidden) + return + } + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + http.Error(w, "Failed to load policy", http.StatusInternalServerError) + return + } + + // Find category + categoryIndex := -1 + for i, cat := range userPolicy.Category { + if cat.Name == categoryName { + categoryIndex = i + break + } + } + + if categoryIndex == -1 { + http.Error(w, fmt.Sprintf("Category '%s' not found", categoryName), http.StatusNotFound) + return + } + + // Check if any rules reference this category + rulesUsingCategory := 0 + for _, rule := range userPolicy.Rules { + if rule.Category == categoryName { + rulesUsingCategory++ + } + } + + if rulesUsingCategory > 0 { + http.Error(w, fmt.Sprintf("Category '%s' is used by %d rule(s). Remove rule references first.", categoryName, rulesUsingCategory), http.StatusConflict) + return + } + + // Remove category + userPolicy.Category = append(userPolicy.Category[:categoryIndex], userPolicy.Category[categoryIndex+1:]...) + + // Save policy + if err := policy.SavePolicy(userPolicy, ""); err != nil { + http.Error(w, "Failed to save policy", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "success", + "message": fmt.Sprintf("Category '%s' removed successfully", categoryName), + }) +} diff --git a/internal/server/static/index.html b/internal/server/static/index.html index 3b62652..e699a99 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -131,6 +131,26 @@

+ + 카테고리 관리 (0) + +
+
+
+
+ + +
+
+ + +
+ +
+
+ diff --git a/internal/server/static/policy-editor.js b/internal/server/static/policy-editor.js index 8c5a46a..14373d8 100644 --- a/internal/server/static/policy-editor.js +++ b/internal/server/static/policy-editor.js @@ -181,6 +181,40 @@ const API = { const result = await res.json(); console.log('Conversion result:', result); return result; + }, + + // Category API methods + async getCategories() { + const res = await fetch('/api/categories'); + return await res.json(); + }, + + async addCategory(name, description) { + const res = await fetch('/api/categories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + if (!res.ok) throw new Error(await res.text()); + return await res.json(); + }, + + async editCategory(name, newName, description) { + const res = await fetch(`/api/categories/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_name: newName, description }) + }); + if (!res.ok) throw new Error(await res.text()); + return await res.json(); + }, + + async deleteCategory(name) { + const res = await fetch(`/api/categories/${encodeURIComponent(name)}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error(await res.text()); + return await res.json(); } }; @@ -381,6 +415,240 @@ function addLanguagesToDefaults(languages) { return addedCount; } +// ==================== Category Management ==================== +function getAvailableCategories() { + return appState.policy.category || []; +} + +function getRulesCountForCategory(categoryName) { + return appState.policy.rules.filter(rule => rule.category === categoryName).length; +} + +function renderCategories() { + const container = document.getElementById('categories-container'); + const countSpan = document.getElementById('category-count'); + const categories = getAvailableCategories(); + + if (countSpan) { + countSpan.textContent = categories.length; + } + + if (categories.length === 0) { + container.innerHTML = '
정의된 카테고리가 없습니다. 아래에서 새 카테고리를 추가하세요.
'; + updateCategoryFilter(); + return; + } + + container.innerHTML = categories.map(cat => { + const rulesCount = getRulesCountForCategory(cat.name); + return ` +
+
+
+
+ ${cat.name} + ${rulesCount} 규칙 +
+

${cat.description || ''}

+
+
+ + +
+
+ +
+ `; + }).join(''); + + // Attach event listeners + document.querySelectorAll('.edit-category-btn').forEach(btn => { + btn.addEventListener('click', handleEditCategoryClick); + }); + + document.querySelectorAll('.delete-category-btn').forEach(btn => { + if (!btn.disabled) { + btn.addEventListener('click', handleDeleteCategory); + } + }); + + document.querySelectorAll('.save-category-btn').forEach(btn => { + btn.addEventListener('click', handleSaveCategory); + }); + + document.querySelectorAll('.cancel-edit-category-btn').forEach(btn => { + btn.addEventListener('click', handleCancelEditCategory); + }); + + // Update category filter dropdown + updateCategoryFilter(); + + // Apply permissions + if (appState.currentUser?.permissions) { + applyPermissions(); + } +} + +function handleEditCategoryClick(e) { + const categoryName = e.target.dataset.categoryName; + const card = document.querySelector(`.category-card[data-category-name="${categoryName}"]`); + if (!card) return; + + card.querySelector('.category-view').classList.add('hidden'); + card.querySelector('.category-edit').classList.remove('hidden'); +} + +function handleCancelEditCategory(e) { + const categoryName = e.target.dataset.categoryName; + const card = document.querySelector(`.category-card[data-category-name="${categoryName}"]`); + if (!card) return; + + card.querySelector('.category-view').classList.remove('hidden'); + card.querySelector('.category-edit').classList.add('hidden'); + + // Reset input values + const category = getAvailableCategories().find(c => c.name === categoryName); + if (category) { + card.querySelector('.edit-category-name').value = category.name; + card.querySelector('.edit-category-description').value = category.description || ''; + } +} + +async function handleSaveCategory(e) { + const originalName = e.target.dataset.categoryName; + const card = document.querySelector(`.category-card[data-category-name="${originalName}"]`); + if (!card) return; + + const newName = card.querySelector('.edit-category-name').value.trim(); + const newDescription = card.querySelector('.edit-category-description').value.trim(); + + if (!newName) { + showToast('카테고리 이름을 입력해주세요', 'warning'); + return; + } + + try { + const result = await API.editCategory(originalName, newName !== originalName ? newName : '', newDescription); + + // Update local state + const categoryIndex = appState.policy.category.findIndex(c => c.name === originalName); + if (categoryIndex !== -1) { + appState.policy.category[categoryIndex].name = newName; + appState.policy.category[categoryIndex].description = newDescription; + + // Update rule references if name changed + if (newName !== originalName) { + appState.policy.rules.forEach(rule => { + if (rule.category === originalName) { + rule.category = newName; + } + }); + } + } + + renderCategories(); + renderRules(); // Update rules to reflect category changes + showToast(result.message || '카테고리가 수정되었습니다'); + } catch (error) { + console.error('Failed to edit category:', error); + showToast('카테고리 수정에 실패했습니다: ' + error.message, 'error'); + } +} + +async function handleAddCategory() { + const nameInput = document.getElementById('new-category-name'); + const descInput = document.getElementById('new-category-description'); + const name = nameInput.value.trim(); + const description = descInput.value.trim(); + + if (!name) { + showToast('카테고리 이름을 입력해주세요', 'warning'); + nameInput.focus(); + return; + } + + if (!description) { + showToast('카테고리 설명을 입력해주세요', 'warning'); + descInput.focus(); + return; + } + + // Check for duplicate locally + if (getAvailableCategories().some(c => c.name === name)) { + showToast(`카테고리 '${name}'이(가) 이미 존재합니다`, 'error'); + return; + } + + try { + await API.addCategory(name, description); + + // Update local state + if (!appState.policy.category) { + appState.policy.category = []; + } + appState.policy.category.push({ name, description }); + + // Clear inputs + nameInput.value = ''; + descInput.value = ''; + + renderCategories(); + showToast(`카테고리 '${name}'이(가) 추가되었습니다`); + } catch (error) { + console.error('Failed to add category:', error); + showToast('카테고리 추가에 실패했습니다: ' + error.message, 'error'); + } +} + +async function handleDeleteCategory(e) { + const categoryName = e.target.dataset.categoryName; + const rulesCount = getRulesCountForCategory(categoryName); + + if (rulesCount > 0) { + showToast(`카테고리 '${categoryName}'은(는) ${rulesCount}개의 규칙에서 사용 중입니다. 먼저 규칙을 삭제하거나 다른 카테고리로 변경해주세요.`, 'error'); + return; + } + + if (!confirm(`카테고리 '${categoryName}'을(를) 삭제하시겠습니까?`)) return; + + try { + await API.deleteCategory(categoryName); + + // Update local state + appState.policy.category = appState.policy.category.filter(c => c.name !== categoryName); + + renderCategories(); + showToast(`카테고리 '${categoryName}'이(가) 삭제되었습니다`); + } catch (error) { + console.error('Failed to delete category:', error); + showToast('카테고리 삭제에 실패했습니다: ' + error.message, 'error'); + } +} + +function updateCategoryFilter() { + const filterSelect = document.getElementById('category-filter'); + if (!filterSelect) return; + + const categories = getAvailableCategories(); + const currentValue = filterSelect.value; + + filterSelect.innerHTML = '' + + categories.map(cat => ``).join(''); +} + // ==================== Role Selection ==================== function renderRoleSelection() { const container = document.getElementById('role-selection-container'); @@ -534,10 +802,11 @@ function createRuleElement(rule, index) { + ${getAvailableCategories().length === 0 ? '

카테고리 관리 섹션에서 카테고리를 먼저 추가해주세요.

' : ''}
@@ -896,7 +1165,7 @@ async function handleApplyTemplate(e) { throw new Error('Invalid template format received'); } - // Preserve existing RBAC + // Preserve existing RBAC only (category is now overwritten from template) const currentRBAC = appState.policy.rbac; // Collect all languages from template (defaults and rules) @@ -921,10 +1190,11 @@ async function handleApplyTemplate(e) { const templateDefaultLang = template.defaults?.defaultLanguage?.toLowerCase() || (languageArray.length > 0 ? languageArray[0] : ''); - // Apply template to policy + // Apply template to policy (category is overwritten from template) appState.policy = { version: template.version || '1.0.0', - rbac: currentRBAC || { roles: {} }, // Keep current RBAC + rbac: currentRBAC || { roles: {} }, // Keep current RBAC + category: template.category || [], // Use template's category (overwrite) defaults: { ...template.defaults, languages: languageArray, // Normalized languages @@ -945,14 +1215,16 @@ async function handleApplyTemplate(e) { }) }; - console.log('Template applied to appState (RBAC preserved):', appState.policy); - console.log('Languages added from template:', Array.from(templateLanguages)); + console.log('Template applied to appState:', appState.policy); + console.log('Languages from template:', Array.from(templateLanguages)); + console.log('Categories from template:', template.category?.length || 0); renderAll(); hideModal('template-modal'); const langCount = templateLanguages.size; - showToast(`템플릿이 적용되었습니다 (${langCount}개 언어 추가됨, RBAC 유지됨)`); + const catCount = template.category?.length || 0; + showToast(`템플릿이 적용되었습니다 (${langCount}개 언어, ${catCount}개 카테고리)`); markDirty(); } catch (error) { console.error('Failed to apply template:', error); @@ -1094,9 +1366,9 @@ async function savePolicy() { const message = changedItems.length > 0 ? `${changedItems.join(', ')}.\n\n` + 'linter 설정 파일(ESLint, Checkstyle, PMD 등)을 자동으로 생성하시겠습니까?\n\n' + - '이 작업은 OpenAI API를 사용하며 몇 분 정도 소요될 수 있습니다.' : + '이 작업은 설정된 LLM Provider를 사용하며 몇 분 정도 소요될 수 있습니다.' : 'linter 설정 파일(ESLint, Checkstyle, PMD 등)을 자동으로 생성하시겠습니까?\n\n' + - '이 작업은 OpenAI API를 사용하며 몇 분 정도 소요될 수 있습니다.'; + '이 작업은 설정된 LLM Provider를 사용하며 몇 분 정도 소요될 수 있습니다.'; const shouldConvert = confirm(message); @@ -1233,6 +1505,9 @@ function renderAll() { // RBAC renderRBAC(); + // Categories + renderCategories(); + // Rules renderRules(); @@ -1288,6 +1563,25 @@ function applyPermissions() { // Show remove buttons on language tags document.querySelectorAll('.remove-language-btn').forEach(el => el.classList.remove('hidden')); + // Show category add form and edit/delete buttons + document.getElementById('add-category-form')?.classList.remove('hidden'); + document.querySelectorAll('.edit-category-btn, .delete-category-btn').forEach(el => el.classList.remove('hidden')); + // Enable category edit inputs + document.querySelectorAll('.edit-category-name, .edit-category-description').forEach(el => { + el.disabled = false; + el.classList.remove('bg-gray-200', 'cursor-not-allowed'); + }); + const newCategoryName = document.getElementById('new-category-name'); + const newCategoryDesc = document.getElementById('new-category-description'); + if (newCategoryName) { + newCategoryName.disabled = false; + newCategoryName.classList.remove('bg-gray-200', 'cursor-not-allowed'); + } + if (newCategoryDesc) { + newCategoryDesc.disabled = false; + newCategoryDesc.classList.remove('bg-gray-200', 'cursor-not-allowed'); + } + // Show rule add/edit/delete buttons document.getElementById('add-rule-btn')?.classList.remove('hidden'); document.getElementById('add-rule-btn-bottom')?.classList.remove('hidden'); @@ -1338,6 +1632,15 @@ function applyPermissions() { // Hide remove buttons on language tags document.querySelectorAll('.remove-language-btn').forEach(el => el.classList.add('hidden')); + // Hide category add form and edit/delete buttons + document.getElementById('add-category-form')?.classList.add('hidden'); + document.querySelectorAll('.edit-category-btn, .delete-category-btn').forEach(el => el.classList.add('hidden')); + // Disable category edit inputs + document.querySelectorAll('.edit-category-name, .edit-category-description').forEach(el => { + el.disabled = true; + el.classList.add('bg-gray-200', 'cursor-not-allowed'); + }); + // Hide rule add/edit/delete buttons document.getElementById('add-rule-btn')?.classList.add('hidden'); document.getElementById('add-rule-btn-bottom')?.classList.add('hidden'); @@ -1423,6 +1726,21 @@ document.addEventListener('DOMContentLoaded', () => { // RBAC management document.getElementById('add-role-btn').addEventListener('click', handleAddRBACRole); + // Category management + document.getElementById('add-category-btn').addEventListener('click', handleAddCategory); + document.getElementById('new-category-name').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddCategory(); + } + }); + document.getElementById('new-category-description').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddCategory(); + } + }); + // Language management document.getElementById('add-language-btn').addEventListener('click', handleAddLanguage); document.getElementById('defaults-language-input').addEventListener('keypress', (e) => {