From f7c7f3569e63e4c7e3bf23bc3f31cf25c9878176 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Wed, 10 Dec 2025 07:35:01 +0000 Subject: [PATCH 01/13] feat: add category subcommands (list/add/edit/remove) - Refactor sym category to parent command with subcommands - Add sym category list (moved from sym category) - Add sym category add - Add sym category edit --name/--description - Add sym category remove (blocked if rules reference it) --- internal/cmd/category.go | 229 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 5 deletions(-) diff --git a/internal/cmd/category.go b/internal/cmd/category.go index 332904d..60a8706 100644 --- a/internal/cmd/category.go +++ b/internal/cmd/category.go @@ -3,28 +3,93 @@ package cmd import ( "fmt" + "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" ) 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 ", + Short: "Add a new category", + Long: `Add a new convention category. + +Example: + sym category add accessibility "Accessibility rules (WCAG, ARIA, etc.)"`, + Args: cobra.ExactArgs(2), + RunE: runCategoryAdd, +} + +var categoryEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit an existing category", + Long: `Edit an existing category's name or description. -You can add, remove, or modify categories directly in user-policy.json.`, - RunE: runCategory, +Use --name to change the category name (will update all rule references). +Use --description to change the category description. + +Examples: + 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"`, + Args: cobra.ExactArgs(1), + RunE: runCategoryEdit, +} + +var categoryRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a category", + Long: `Remove a category 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. + +Example: + sym category remove deprecated-category`, + Args: cobra.ExactArgs(1), + RunE: runCategoryRemove, } func init() { rootCmd.AddCommand(categoryCmd) + + // Add subcommands + categoryCmd.AddCommand(categoryListCmd) + categoryCmd.AddCommand(categoryAddCmd) + categoryCmd.AddCommand(categoryEditCmd) + categoryCmd.AddCommand(categoryRemoveCmd) + + // Edit command flags + categoryEditCmd.Flags().String("name", "", "New category name") + categoryEditCmd.Flags().String("description", "", "New category description") } -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 +115,157 @@ func runCategory(cmd *cobra.Command, args []string) error { return nil } + +func runCategoryAdd(cmd *cobra.Command, args []string) error { + name := args[0] + description := args[1] + + // Validate inputs + if name == "" { + return fmt.Errorf("category name is required") + } + if description == "" { + return fmt.Errorf("category description is required") + } + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + return fmt.Errorf("failed to load policy: %w", err) + } + + // Check for duplicate + for _, cat := range userPolicy.Category { + if cat.Name == name { + return fmt.Errorf("category '%s' already exists", name) + } + } + + // Add new category + userPolicy.Category = append(userPolicy.Category, schema.CategoryDef{ + Name: name, + Description: description, + }) + + // Save policy + if err := policy.SavePolicy(userPolicy, ""); err != nil { + return fmt.Errorf("failed to save policy: %w", err) + } + + printDone(fmt.Sprintf("Category '%s' added successfully", name)) + return nil +} + +func runCategoryEdit(cmd *cobra.Command, args []string) error { + currentName := args[0] + newName, _ := cmd.Flags().GetString("name") + newDescription, _ := cmd.Flags().GetString("description") + + // Validate at least one change + if newName == "" && newDescription == "" { + return fmt.Errorf("at least one of --name or --description must be provided") + } + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + return fmt.Errorf("failed to load policy: %w", err) + } + + // Find category + var categoryIndex = -1 + for i, cat := range userPolicy.Category { + if cat.Name == currentName { + categoryIndex = i + break + } + } + + if categoryIndex == -1 { + return fmt.Errorf("category '%s' not found", currentName) + } + + // If renaming, check for duplicate and update rule references + affectedRules := 0 + if newName != "" && newName != currentName { + // Check for duplicate + for _, cat := range userPolicy.Category { + if cat.Name == newName { + return fmt.Errorf("category '%s' already exists", newName) + } + } + + // Update rule references + for i := range userPolicy.Rules { + if userPolicy.Rules[i].Category == currentName { + userPolicy.Rules[i].Category = newName + affectedRules++ + } + } + + userPolicy.Category[categoryIndex].Name = newName + } + + // Update description if provided + if newDescription != "" { + userPolicy.Category[categoryIndex].Description = newDescription + } + + // Save policy + if err := policy.SavePolicy(userPolicy, ""); err != nil { + return fmt.Errorf("failed to save policy: %w", err) + } + + if affectedRules > 0 { + printDone(fmt.Sprintf("Category updated successfully (%d rule(s) updated)", affectedRules)) + } else { + printDone("Category updated successfully") + } + return nil +} + +func runCategoryRemove(cmd *cobra.Command, args []string) error { + name := args[0] + + // Load policy + userPolicy, err := policy.LoadPolicy("") + if err != nil { + return fmt.Errorf("failed to load policy: %w", err) + } + + // Find category + var categoryIndex = -1 + for i, cat := range userPolicy.Category { + if cat.Name == name { + categoryIndex = i + break + } + } + + if categoryIndex == -1 { + return fmt.Errorf("category '%s' not found", name) + } + + // Check if any rules reference this category + rulesUsingCategory := 0 + for _, rule := range userPolicy.Rules { + if rule.Category == name { + rulesUsingCategory++ + } + } + + if rulesUsingCategory > 0 { + return fmt.Errorf("category '%s' is used by %d rule(s). Remove rule references first", name, rulesUsingCategory) + } + + // Remove category + userPolicy.Category = append(userPolicy.Category[:categoryIndex], userPolicy.Category[categoryIndex+1:]...) + + // Save policy + if err := policy.SavePolicy(userPolicy, ""); err != nil { + return fmt.Errorf("failed to save policy: %w", err) + } + + printDone(fmt.Sprintf("Category '%s' removed successfully", name)) + return nil +} From cf5264f9c69ad1cdd914a012f9dea9d10d7576fa Mon Sep 17 00:00:00 2001 From: ikjeong Date: Wed, 10 Dec 2025 07:37:08 +0000 Subject: [PATCH 02/13] feat: add MCP tools for category management - add_category: add new category with name and description - edit_category: edit category name/description, updates rule refs - remove_category: remove category (blocked if rules reference it) --- internal/mcp/server.go | 229 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 7fe4149..7c35dab 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -202,6 +202,24 @@ type ListCategoryInput struct { // No parameters - returns all categories } +// AddCategoryInput represents the input schema for the add_category tool (go-sdk). +type AddCategoryInput struct { + Name string `json:"name" jsonschema:"required,description=Category name (unique identifier)"` + Description string `json:"description" jsonschema:"required,description=Category description (1-2 lines)"` +} + +// EditCategoryInput represents the input schema for the edit_category tool (go-sdk). +type EditCategoryInput struct { + Name string `json:"name" jsonschema:"required,description=Current category name to edit"` + NewName string `json:"new_name,omitempty" jsonschema:"description=New category name (optional)"` + Description string `json:"description,omitempty" jsonschema:"description=New description (optional)"` +} + +// RemoveCategoryInput represents the input schema for the remove_category tool (go-sdk). +type RemoveCategoryInput struct { + Name string `json:"name" jsonschema:"required,description=Category name 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 +271,42 @@ func (s *Server) runStdioWithSDK(ctx context.Context) error { return nil, result.(map[string]any), nil }) + // Tool: add_category + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "add_category", + Description: "Add a new convention category. Requires name and description.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input AddCategoryInput) (*sdkmcp.CallToolResult, map[string]any, error) { + result, rpcErr := s.handleAddCategory(input.Name, input.Description) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + return nil, result.(map[string]any), nil + }) + + // Tool: edit_category + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "edit_category", + Description: "Edit an existing convention category. Requires current name; new_name and description are optional.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input EditCategoryInput) (*sdkmcp.CallToolResult, map[string]any, error) { + result, rpcErr := s.handleEditCategory(input.Name, input.NewName, input.Description) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + return nil, result.(map[string]any), nil + }) + + // Tool: remove_category + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "remove_category", + Description: "Remove a convention category. Cannot remove if rules reference the category.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input RemoveCategoryInput) (*sdkmcp.CallToolResult, map[string]any, error) { + result, rpcErr := s.handleRemoveCategory(input.Name) + 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 +909,178 @@ func (s *Server) getCategory() []schema.CategoryDef { } return nil } + +// handleAddCategory handles adding a new category. +func (s *Server) handleAddCategory(name, description string) (interface{}, *RPCError) { + // Validate inputs + if name == "" { + return nil, &RPCError{Code: -32602, Message: "Category name is required"} + } + if description == "" { + return nil, &RPCError{Code: -32602, Message: "Category description is required"} + } + + // Check for duplicate + for _, cat := range s.userPolicy.Category { + if cat.Name == name { + return nil, &RPCError{Code: -32602, Message: fmt.Sprintf("Category '%s' already exists", name)} + } + } + + // Add new category + s.userPolicy.Category = append(s.userPolicy.Category, schema.CategoryDef{ + Name: name, + Description: description, + }) + + // Save policy + if err := s.saveUserPolicy(); err != nil { + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Failed to save policy: %v", err)} + } + + return map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": fmt.Sprintf("Category '%s' added successfully.", name)}, + }, + }, nil +} + +// handleEditCategory handles editing an existing category. +func (s *Server) handleEditCategory(name, newName, description string) (interface{}, *RPCError) { + // Validate inputs + if name == "" { + return nil, &RPCError{Code: -32602, Message: "Category name is required"} + } + if newName == "" && description == "" { + return nil, &RPCError{Code: -32602, Message: "At least one of new_name or description must be provided"} + } + + // Find category + categoryIndex := -1 + for i, cat := range s.userPolicy.Category { + if cat.Name == name { + categoryIndex = i + break + } + } + + if categoryIndex == -1 { + return nil, &RPCError{Code: -32602, Message: fmt.Sprintf("Category '%s' not found", name)} + } + + // If renaming, check for duplicate and update rule references + affectedRules := 0 + if newName != "" && newName != name { + // Check for duplicate + for _, cat := range s.userPolicy.Category { + if cat.Name == newName { + return nil, &RPCError{Code: -32602, Message: fmt.Sprintf("Category '%s' already exists", newName)} + } + } + + // Update rule references + for i := range s.userPolicy.Rules { + if s.userPolicy.Rules[i].Category == name { + s.userPolicy.Rules[i].Category = newName + affectedRules++ + } + } + + s.userPolicy.Category[categoryIndex].Name = newName + } + + // Update description if provided + if description != "" { + s.userPolicy.Category[categoryIndex].Description = description + } + + // Save policy + if err := s.saveUserPolicy(); err != nil { + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Failed to save policy: %v", err)} + } + + var textContent string + if affectedRules > 0 { + textContent = fmt.Sprintf("Category updated successfully. %d rule(s) updated.", affectedRules) + } else { + textContent = "Category updated successfully." + } + + return map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": textContent}, + }, + }, nil +} + +// handleRemoveCategory handles removing a category. +func (s *Server) handleRemoveCategory(name string) (interface{}, *RPCError) { + // Validate inputs + if name == "" { + return nil, &RPCError{Code: -32602, Message: "Category name is required"} + } + + // Find category + categoryIndex := -1 + for i, cat := range s.userPolicy.Category { + if cat.Name == name { + categoryIndex = i + break + } + } + + if categoryIndex == -1 { + return nil, &RPCError{Code: -32602, Message: fmt.Sprintf("Category '%s' not found", name)} + } + + // Check if any rules reference this category + rulesUsingCategory := 0 + for _, rule := range s.userPolicy.Rules { + if rule.Category == name { + rulesUsingCategory++ + } + } + + if rulesUsingCategory > 0 { + return nil, &RPCError{ + Code: -32602, + Message: fmt.Sprintf("Category '%s' is used by %d rule(s). Remove rule references first.", name, rulesUsingCategory), + } + } + + // Remove category + s.userPolicy.Category = append(s.userPolicy.Category[:categoryIndex], s.userPolicy.Category[categoryIndex+1:]...) + + // Save policy + if err := s.saveUserPolicy(); err != nil { + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Failed to save policy: %v", err)} + } + + return map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": fmt.Sprintf("Category '%s' removed successfully.", name)}, + }, + }, nil +} + +// saveUserPolicy saves the user policy to file. +func (s *Server) saveUserPolicy() error { + // Get policy path + projectCfg, _ := config.LoadProjectConfig() + userPolicyPath := projectCfg.PolicyPath + if userPolicyPath == "" { + userPolicyPath = ".sym/user-policy.json" + } + + // Make absolute path if relative + if !filepath.IsAbs(userPolicyPath) { + repoRoot, err := git.GetRepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + userPolicyPath = filepath.Join(repoRoot, userPolicyPath) + } + + // Save using policy package + return policy.SavePolicy(s.userPolicy, "") +} From 919f1656b254b03f08ecda92205cd20a72234467 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Wed, 10 Dec 2025 07:41:18 +0000 Subject: [PATCH 03/13] test: add MCP category management tests - TestAddCategory: success, duplicate, empty name/description - TestEditCategory: description update, rename with rule refs, conflicts - TestRemoveCategory: unused category, rules blocking, not found --- internal/mcp/server_test.go | 304 ++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index e8b3f5d..dbf635b 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,301 @@ 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 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 + + result, rpcErr := server.handleAddCategory("testing", "Testing rules") + 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, "added successfully") + assert.Len(t, server.userPolicy.Category, 2) + assert.Equal(t, "testing", server.userPolicy.Category[1].Name) + }) + + t.Run("reject duplicate category", 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"}, + }, + }, + } + + _, rpcErr := server.handleAddCategory("security", "Duplicate") + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "already exists") + }) + + t.Run("reject empty name", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{Version: "1.0.0"}, + } + + _, rpcErr := server.handleAddCategory("", "Some description") + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "name is required") + }) + + t.Run("reject empty description", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{Version: "1.0.0"}, + } + + _, rpcErr := server.handleAddCategory("testing", "") + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "description 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 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 + + result, rpcErr := server.handleEditCategory("security", "", "New description") + 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 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 + + result, rpcErr := server.handleEditCategory("old-name", "new-name", "") + 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 rule(s) 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("reject rename to existing name", 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"}, + }, + }, + } + + _, rpcErr := server.handleEditCategory("security", "style", "") + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "already exists") + }) + + t.Run("reject not found category", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{ + Version: "1.0.0", + Category: []schema.CategoryDef{}, + }, + } + + _, rpcErr := server.handleEditCategory("nonexistent", "new-name", "") + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "not found") + }) + + t.Run("reject no changes provided", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{ + Version: "1.0.0", + Category: []schema.CategoryDef{ + {Name: "security", Description: "Security"}, + }, + }, + } + + _, rpcErr := server.handleEditCategory("security", "", "") + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "At least one") + }) +} + +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 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 + + result, rpcErr := server.handleRemoveCategory("unused") + 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, "removed successfully") + assert.Len(t, server.userPolicy.Category, 1) + assert.Equal(t, "security", server.userPolicy.Category[0].Name) + }) + + t.Run("reject remove category with rules", 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: []schema.UserRule{ + {ID: "R1", Say: "Rule 1", Category: "security"}, + {ID: "R2", Say: "Rule 2", Category: "security"}, + }, + }, + } + + _, rpcErr := server.handleRemoveCategory("security") + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "is used by 2 rule(s)") + }) + + t.Run("reject not found category", func(t *testing.T) { + server := &Server{ + loader: policy.NewLoader(false), + userPolicy: &schema.UserPolicy{ + Version: "1.0.0", + Category: []schema.CategoryDef{}, + }, + } + + _, rpcErr := server.handleRemoveCategory("nonexistent") + require.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "not found") + }) +} + func TestListCategory(t *testing.T) { t.Run("returns no categories message when no user policy", func(t *testing.T) { server := &Server{ From 9847bba0961e3b2f595b015f1f7ffbbd1a3f7181 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Wed, 10 Dec 2025 07:42:34 +0000 Subject: [PATCH 04/13] feat: add dashboard API endpoints for category management - GET /api/categories: list all categories - POST /api/categories: add new category - PUT /api/categories/{name}: edit category - DELETE /api/categories/{name}: remove category (blocked if used) --- internal/server/server.go | 278 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) 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), + }) +} From 3c443bfa3f1b98e57ee4f29b6ddee2b545d61403 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Wed, 10 Dec 2025 07:47:58 +0000 Subject: [PATCH 05/13] feat: add category management UI to dashboard --- internal/server/static/index.html | 27 ++ internal/server/static/policy-editor.js | 319 +++++++++++++++++++++++- 2 files changed, 344 insertions(+), 2 deletions(-) diff --git a/internal/server/static/index.html b/internal/server/static/index.html index 3b62652..8100d11 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -134,6 +134,33 @@

+

+ Categories + 카테고리 관리 +

+
+ + Convention Categories (0) + +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ +
diff --git a/internal/server/static/policy-editor.js b/internal/server/static/policy-editor.js index 8c5a46a..b03b209 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 ? '

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

' : ''}
@@ -1233,6 +1502,9 @@ function renderAll() { // RBAC renderRBAC(); + // Categories + renderCategories(); + // Rules renderRules(); @@ -1288,6 +1560,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 +1629,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 +1723,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) => { From 57f155c2509375d783c80261da22fb236b362337 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Wed, 10 Dec 2025 07:56:32 +0000 Subject: [PATCH 06/13] fix: move category UI to Global Settings and fix input sizing --- internal/server/static/index.html | 43 +++++++++++-------------- internal/server/static/policy-editor.js | 6 ++-- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/internal/server/static/index.html b/internal/server/static/index.html index 8100d11..e699a99 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -131,34 +131,27 @@

-

- Categories - 카테고리 관리 -

-
- - Convention Categories (0) - -
-
-
-
- - -
-
- - +
+ + 카테고리 관리 (0) + +
+
+
+
+ + +
+
+ + +
+
-
-
-
+ +
diff --git a/internal/server/static/policy-editor.js b/internal/server/static/policy-editor.js index b03b209..74fec3e 100644 --- a/internal/server/static/policy-editor.js +++ b/internal/server/static/policy-editor.js @@ -457,12 +457,12 @@ function renderCategories() {