From 873bf78c22ae8101732d5762e6a31c73c1d836f1 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 17:38:38 +0200 Subject: [PATCH] Add custom parser for props["pluginctl"] with ignore assets support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PluginCtlConfig struct with IgnoreAssets field for glob patterns - Add ParsePluginCtlConfig function to parse manifest props["pluginctl"] - Update updateassets command to respect ignore patterns with glob matching - Add comprehensive logging when files are skipped due to ignore patterns - Support patterns like *.test.js, build/, node_modules for flexible exclusion - Add extensive tests for config parsing and path matching functionality - Maintain backward compatibility with existing manifests - Fix Makefile check-changes target and add logger init to tests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Makefile | 1 + info_test.go | 3 + plugin.go | 36 +++++++++ plugin_test.go | 204 ++++++++++++++++++++++++++++++++++++++++++++++++ updateassets.go | 82 +++++++++++++++++-- 5 files changed, 320 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index ab8b81c..e754f18 100644 --- a/Makefile +++ b/Makefile @@ -141,6 +141,7 @@ dev: fmt lint snapshot ## Quick development build (fmt, lint, build) # Check changes target .PHONY: check-changes check-changes: lint test ## Check changes (lint, test) + @echo "All checks passed!" # CI target .PHONY: ci diff --git a/info_test.go b/info_test.go index fa0abde..4e1eed2 100644 --- a/info_test.go +++ b/info_test.go @@ -125,6 +125,9 @@ func TestPrintPluginInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Initialize logger for testing + InitLogger() + // Capture stdout oldStdout := os.Stdout r, w, _ := os.Pipe() diff --git a/plugin.go b/plugin.go index 7995333..b8612ad 100644 --- a/plugin.go +++ b/plugin.go @@ -11,6 +11,11 @@ import ( const PluginManifestName = "plugin.json" +// PluginCtlConfig represents the configuration for pluginctl stored in the manifest props. +type PluginCtlConfig struct { + IgnoreAssets []string `json:"ignore_assets,omitempty"` +} + // LoadPluginManifest loads and parses the plugin.json file from the current directory. func LoadPluginManifest() (*model.Manifest, error) { return LoadPluginManifestFromPath(".") @@ -78,3 +83,34 @@ func GetEffectivePluginPath(flagPath string) string { return cwd } + +// ParsePluginCtlConfig extracts and parses the pluginctl configuration from the manifest props. +func ParsePluginCtlConfig(manifest *model.Manifest) (*PluginCtlConfig, error) { + // Default configuration + config := &PluginCtlConfig{ + IgnoreAssets: []string{}, + } + + // Check if props exist + if manifest.Props == nil { + return config, nil + } + + // Check if pluginctl config exists in props + pluginctlData, exists := manifest.Props["pluginctl"] + if !exists { + return config, nil + } + + // Convert to JSON and parse + jsonData, err := json.Marshal(pluginctlData) + if err != nil { + return nil, fmt.Errorf("failed to marshal pluginctl config: %w", err) + } + + if err := json.Unmarshal(jsonData, config); err != nil { + return nil, fmt.Errorf("failed to parse pluginctl config: %w", err) + } + + return config, nil +} diff --git a/plugin_test.go b/plugin_test.go index 9f143d9..834620f 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -402,3 +402,207 @@ func TestHasServerCodeAndWebappCode(t *testing.T) { }) } } + +func TestParsePluginCtlConfig(t *testing.T) { + tests := []struct { + name string + manifest *model.Manifest + expectedConfig *PluginCtlConfig + expectError bool + errorContains string + }{ + { + name: "Manifest with valid pluginctl config", + manifest: &model.Manifest{ + Props: map[string]interface{}{ + "pluginctl": map[string]interface{}{ + "ignore_assets": []string{"*.test.js", "build/", "temp/**"}, + }, + }, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{"*.test.js", "build/", "temp/**"}, + }, + expectError: false, + }, + { + name: "Manifest with no props", + manifest: &model.Manifest{ + Props: nil, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{}, + }, + expectError: false, + }, + { + name: "Manifest with empty props", + manifest: &model.Manifest{ + Props: map[string]interface{}{}, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{}, + }, + expectError: false, + }, + { + name: "Manifest with no pluginctl config", + manifest: &model.Manifest{ + Props: map[string]interface{}{ + "other": "value", + }, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{}, + }, + expectError: false, + }, + { + name: "Manifest with empty pluginctl config", + manifest: &model.Manifest{ + Props: map[string]interface{}{ + "pluginctl": map[string]interface{}{}, + }, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{}, + }, + expectError: false, + }, + { + name: "Manifest with invalid pluginctl config", + manifest: &model.Manifest{ + Props: map[string]interface{}{ + "pluginctl": "invalid", + }, + }, + expectedConfig: nil, + expectError: true, + errorContains: "failed to parse pluginctl config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := ParsePluginCtlConfig(tt.manifest) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got nil") + } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error to contain %q but got: %v", tt.errorContains, err) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if config == nil { + t.Error("Expected config but got nil") + return + } + + if len(config.IgnoreAssets) != len(tt.expectedConfig.IgnoreAssets) { + t.Errorf("Expected %d ignore assets but got %d", len(tt.expectedConfig.IgnoreAssets), len(config.IgnoreAssets)) + return + } + + for i, expected := range tt.expectedConfig.IgnoreAssets { + if config.IgnoreAssets[i] != expected { + t.Errorf("Expected ignore asset %d to be %q but got %q", i, expected, config.IgnoreAssets[i]) + } + } + }) + } +} + +func TestIsPathIgnored(t *testing.T) { + tests := []struct { + name string + relativePath string + ignorePatterns []string + expectedIgnore bool + expectedPattern string + }{ + { + name: "No ignore patterns", + relativePath: "webapp/dist/main.js", + ignorePatterns: []string{}, + expectedIgnore: false, + expectedPattern: "", + }, + { + name: "Direct file match", + relativePath: "test.js", + ignorePatterns: []string{"*.js"}, + expectedIgnore: true, + expectedPattern: "*.js", + }, + { + name: "Directory pattern with slash", + relativePath: "build/output.js", + ignorePatterns: []string{"build/"}, + expectedIgnore: true, + expectedPattern: "build/", + }, + { + name: "Directory pattern without slash", + relativePath: "build/output.js", + ignorePatterns: []string{"build"}, + expectedIgnore: true, + expectedPattern: "build", + }, + { + name: "Nested directory match", + relativePath: "webapp/dist/main.js", + ignorePatterns: []string{"dist"}, + expectedIgnore: true, + expectedPattern: "dist", + }, + { + name: "Multiple patterns - first match", + relativePath: "test.js", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: true, + expectedPattern: "*.js", + }, + { + name: "Multiple patterns - second match", + relativePath: "style.css", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: true, + expectedPattern: "*.css", + }, + { + name: "No match", + relativePath: "README.md", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: false, + expectedPattern: "", + }, + { + name: "Complex path with match", + relativePath: "webapp/node_modules/package/file.js", + ignorePatterns: []string{"node_modules"}, + expectedIgnore: true, + expectedPattern: "node_modules", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ignored, pattern := isPathIgnored(tt.relativePath, tt.ignorePatterns) + + if ignored != tt.expectedIgnore { + t.Errorf("Expected ignore result %v but got %v", tt.expectedIgnore, ignored) + } + + if pattern != tt.expectedPattern { + t.Errorf("Expected pattern %q but got %q", tt.expectedPattern, pattern) + } + }) + } +} diff --git a/updateassets.go b/updateassets.go index 96d7822..60196cd 100644 --- a/updateassets.go +++ b/updateassets.go @@ -32,13 +32,19 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { return fmt.Errorf("failed to load plugin manifest: %w", err) } + pluginCtlConfig, err := ParsePluginCtlConfig(manifest) + if err != nil { + return fmt.Errorf("failed to parse pluginctl config: %w", err) + } + hasWebapp := HasWebappCode(manifest) updatedCount := 0 config := AssetProcessorConfig{ - pluginPath: pluginPath, - hasWebapp: hasWebapp, - updatedCount: &updatedCount, + pluginPath: pluginPath, + hasWebapp: hasWebapp, + updatedCount: &updatedCount, + pluginCtlConfig: pluginCtlConfig, } err = fs.WalkDir(assetsFS, "assets", func(path string, d fs.DirEntry, err error) error { @@ -54,10 +60,67 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { return nil } +// isPathIgnored checks if a path matches any of the ignore patterns. +func isPathIgnored(relativePath string, ignorePatterns []string) (ignored bool, matchedPattern string) { + for _, pattern := range ignorePatterns { + // Direct file or path match + if matched, err := filepath.Match(pattern, relativePath); err == nil && matched { + return true, pattern + } + + // Check if the path starts with the pattern (for directory patterns) + if strings.HasSuffix(pattern, "/") && strings.HasPrefix(relativePath, pattern) { + return true, pattern + } + + // Check if any parent directory matches the pattern + if matchesParentDirectory(relativePath, pattern) { + return true, pattern + } + + // Check if any directory component matches the pattern + if matchesDirectoryComponent(relativePath, pattern) { + return true, pattern + } + } + + return false, "" +} + +// matchesParentDirectory checks if any parent directory matches the pattern. +func matchesParentDirectory(relativePath, pattern string) bool { + dir := filepath.Dir(relativePath) + for dir != "." && dir != "/" { + if matched, err := filepath.Match(pattern, dir); err == nil && matched { + return true + } + // Also check direct string match for directory names + if filepath.Base(dir) == pattern { + return true + } + dir = filepath.Dir(dir) + } + + return false +} + +// matchesDirectoryComponent checks if any directory component matches the pattern. +func matchesDirectoryComponent(relativePath, pattern string) bool { + parts := strings.Split(relativePath, "/") + for _, part := range parts { + if matched, err := filepath.Match(pattern, part); err == nil && matched { + return true + } + } + + return false +} + type AssetProcessorConfig struct { - pluginPath string - hasWebapp bool - updatedCount *int + pluginPath string + hasWebapp bool + updatedCount *int + pluginCtlConfig *PluginCtlConfig } func processAssetEntry(path string, d fs.DirEntry, err error, config AssetProcessorConfig) error { @@ -75,6 +138,13 @@ func processAssetEntry(path string, d fs.DirEntry, err error, config AssetProces return nil } + // Check if path is ignored by pluginctl config + if ignored, pattern := isPathIgnored(relativePath, config.pluginCtlConfig.IgnoreAssets); ignored { + Logger.Info("Skipping asset due to ignore pattern", "path", relativePath, "pattern", pattern) + + return nil + } + targetPath := filepath.Join(config.pluginPath, relativePath) if d.IsDir() {