From 9639080d11b537b92876f2444d2a82aeef6f3fa4 Mon Sep 17 00:00:00 2001 From: KochTobi-Agent Date: Mon, 20 Apr 2026 08:06:17 +0000 Subject: [PATCH] feat(bundle): add bundle validate command Adds oc bundle validate [PATH] command for bundle structure and manifest validation per REQ-F-022. - ValidateBundle() engine with ValidationResult/ValidationError - CLI command with --verbose flag and exit codes 0/1/2 - Tests for valid bundles, missing manifest, invalid JSON, schema violations, missing entrypoints, missing prompt files Closes #165 Refs US-054-T1, US-054-T2, US-054-T3 --- cmd/bundle.go | 125 +++++++++ cmd/bundle_test.go | 248 +++++++++++++++++ internal/bundle/bundle.go | 140 ++++++++++ internal/bundle/bundle_test.go | 469 +++++++++++++++++++++++++++++++++ 4 files changed, 982 insertions(+) diff --git a/cmd/bundle.go b/cmd/bundle.go index 06e27bf..613c101 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -34,6 +34,9 @@ var ( bundlePromptOut io.Writer = os.Stdout bundleInputIsTTY = isInteractiveTTY + // bundle validate flags + bundleValidateVerbose bool + // bundle init flags bundleInitName string bundleInitVersion string @@ -151,6 +154,32 @@ Examples: }, } +// bundleValidateCmd validates a bundle directory or archive +var bundleValidateCmd = &cobra.Command{ + Use: "validate [PATH]", + Short: "Validate bundle structure and manifest", + Long: `Validates a bundle directory or archive against the opencode-bundle manifest schema. + +Examples: + occo bundle validate + occo bundle validate ./my-bundle + occo bundle validate ./bundle.tar.gz`, + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + err := runBundleValidate(cmd, args) + if err != nil { + // Check if it's a system error (exit code 2) + if vErr, ok := err.(*validationError); ok && vErr.IsSystemError { + fmt.Fprintln(os.Stderr, styles.Error("Error:")+" "+vErr.Message) + os.Exit(2) + } + // Return error for default exit code 1 + return err + } + return nil + }, +} + func init() { rootCmd.AddCommand(bundleCmd) @@ -159,6 +188,7 @@ func init() { bundleCmd.AddCommand(bundleStatusCmd) bundleCmd.AddCommand(bundleUpdateCmd) bundleCmd.AddCommand(bundleInitCmd) + bundleCmd.AddCommand(bundleValidateCmd) // Flags for bundle apply bundleInstallCmd.Flags().StringVar(&bundlePreset, "preset", "", "Preset name to apply") @@ -184,6 +214,10 @@ func init() { bundleInitCmd.Flags().StringVar(&bundleInitVersion, "version", "", "Bundle version (defaults to 0.0.1)") bundleInitCmd.Flags().StringVar(&bundleInitOutput, "output", ".", "Output directory for the bundle") bundleInitCmd.Flags().BoolVar(&bundleInitForce, "force", false, "Overwrite existing directory contents") + + // Flags for bundle validate + bundleValidateCmd.Flags().StringVar(&bundleProjectRoot, "project-root", ".", "Project root directory (for default path)") + bundleValidateCmd.Flags().BoolVar(&bundleValidateVerbose, "verbose", false, "Show detailed validation progress") } func runBundleInstall(sourceRef string, interactivePreset bool) error { @@ -1076,3 +1110,94 @@ func validateBundleName(name string) error { } return nil } + +// ValidationError is a custom error type for validation results +type validationError struct { + IsSystemError bool + Message string +} + +func (e *validationError) Error() string { + return e.Message +} + +// runBundleValidate validates a bundle at the given path +// Exit codes: 0 = valid, 1 = validation errors, 2 = system/IO errors +func runBundleValidate(cmd *cobra.Command, args []string) error { + // Determine path (default: current directory) + targetPath := bundleProjectRoot + if targetPath == "." { + // No explicit project root set - use the argument or current directory + if len(args) > 0 { + targetPath = args[0] + } + } else if len(args) > 0 { + // Both project-root flag and argument provided - prefer argument + targetPath = args[0] + } + + if bundleValidateVerbose { + fmt.Println(styles.Muted("→ Checking path:"), targetPath) + } + + // Check if path exists first (for better error handling) + if _, err := os.Stat(targetPath); err != nil { + if os.IsNotExist(err) { + // System error - path doesn't exist (exit code 2) + return &validationError{IsSystemError: true, Message: fmt.Sprintf("path does not exist: %s", targetPath)} + } + // Other system error (exit code 2) + return &validationError{IsSystemError: true, Message: fmt.Sprintf("failed to access path: %v", err)} + } + + // Resolve to local path (handles both directories and .tar.gz archives) + bundleRoot, cleanup, err := bundle.ResolveToLocal("local-directory", targetPath, "") + if err != nil { + // If direct directory doesn't work, try as archive + bundleRoot, cleanup, err = bundle.ResolveToLocal("local-archive", targetPath, "") + if err != nil { + // Path doesn't exist or is invalid (exit code 2) + return &validationError{IsSystemError: true, Message: fmt.Sprintf("failed to resolve path: %v", err)} + } + } + defer cleanup() + + if bundleValidateVerbose { + fmt.Println(styles.Muted("→ Resolved bundle root:"), bundleRoot) + } + + // Validate the bundle + result, err := bundle.ValidateBundle(bundleRoot) + if err != nil { + // System error (IO, permissions, etc.) - exit code 2 + return &validationError{IsSystemError: true, Message: fmt.Sprintf("validation failed: %v", err)} + } + + if bundleValidateVerbose { + fmt.Println(styles.Muted("→ Validation complete:"), len(result.Errors), "errors") + } + + // Print results + if result.Valid { + fmt.Println(styles.Success("✓ Bundle is valid")) + return nil + } + + // Print errors (validation failed - exit code 1) + fmt.Println(styles.Error("✗ Bundle validation failed")) + for _, e := range result.Errors { + fmt.Printf(" %s %s\n", styles.Error("✗"), e.Message) + } + + // Print warnings if any + if len(result.Warnings) > 0 { + fmt.Println() + fmt.Println(styles.Warning("Warnings:")) + for _, w := range result.Warnings { + fmt.Printf(" %s %s\n", styles.Warning("⚠"), w) + } + } + + // Return error for validation failures (exit code 1) + return fmt.Errorf("validation failed") +} diff --git a/cmd/bundle_test.go b/cmd/bundle_test.go index 97fba91..3c2a757 100644 --- a/cmd/bundle_test.go +++ b/cmd/bundle_test.go @@ -4,7 +4,9 @@ import ( "bytes" "io" "os" + "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -1371,3 +1373,249 @@ func TestBundleInitPresetFilePathMatchesEntrypoint(t *testing.T) { t.Fatalf("preset file does not exist at entrypoint path %s: %v", expectedPresetPath, err) } } + +// ============================================================================ +// Bundle Validate Tests +// ============================================================================ + +func TestBundleValidateCommand_ValidBundle(t *testing.T) { + // Create a temp valid bundle + bundleDir := t.TempDir() + manifest := `{"manifest_version":"1.0.0","bundle_name":"test","bundle_version":"v1.0.0","presets":[{"name":"default","entrypoint":"default.json","description":"Default preset"}]}` + if err := os.WriteFile(filepath.Join(bundleDir, "opencode-bundle.manifest.json"), []byte(manifest), 0644); err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(bundleDir, "default.json"), []byte(`{"agents":[]}`), 0644); err != nil { + t.Fatalf("failed to write preset: %v", err) + } + + // Test validation by calling runBundleValidate directly + err := runBundleValidate(bundleCmd, []string{bundleDir}) + if err != nil { + t.Fatalf("runBundleValidate() error = %v", err) + } +} + +func TestBundleValidateCommand_InvalidBundle(t *testing.T) { + // Create temp invalid bundle (missing manifest) + bundleDir := t.TempDir() + // Don't write manifest - this should fail validation + if err := os.WriteFile(filepath.Join(bundleDir, "some-other-file.txt"), []byte("content"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + // Test validation - should return error for invalid bundle + err := runBundleValidate(bundleCmd, []string{bundleDir}) + if err == nil { + t.Fatal("runBundleValidate() expected error for invalid bundle") + } +} + +func TestBundleValidateCommand_NonExistentPath(t *testing.T) { + nonExistentPath := "/path/that/does/not/exist/12345" + + // Test with non-existent path - should return error + err := runBundleValidate(bundleCmd, []string{nonExistentPath}) + if err == nil { + t.Fatal("runBundleValidate() expected error for non-existent path") + } +} + +func TestBundleValidateCommand_ArchivePath(t *testing.T) { + // Create a temp valid bundle directory + bundleDir := t.TempDir() + manifest := `{"manifest_version":"1.0.0","bundle_name":"test-archive","bundle_version":"v1.0.0","presets":[{"name":"default","entrypoint":"default.json","description":"Default preset"}]}` + if err := os.WriteFile(filepath.Join(bundleDir, "opencode-bundle.manifest.json"), []byte(manifest), 0644); err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(bundleDir, "default.json"), []byte(`{"agents":[]}`), 0644); err != nil { + t.Fatalf("failed to write preset: %v", err) + } + + // Create archive using tar command + archiveDir := t.TempDir() + archivePath := filepath.Join(archiveDir, "test-bundle.tar.gz") + + // Use exec.Command to create tarball + tarCmd := exec.Command("tar", "-czf", archivePath, "-C", bundleDir, ".") + if err := tarCmd.Run(); err != nil { + t.Fatalf("failed to create archive: %v", err) + } + + // Test validation on archive - directly call ValidateBundle on extracted root + bundleRoot, cleanup, err := bundle.ResolveToLocal("local-archive", archivePath, "") + if err != nil { + t.Fatalf("failed to resolve archive: %v", err) + } + defer cleanup() + + result, err := bundle.ValidateBundle(bundleRoot) + if err != nil { + t.Fatalf("ValidateBundle() error = %v", err) + } + if !result.Valid { + t.Fatalf("ValidateBundle() expected valid, got errors: %v", result.Errors) + } +} + +func TestBundleValidateCommand_InvalidManifestJSON(t *testing.T) { + // Create temp bundle with invalid JSON manifest + bundleDir := t.TempDir() + // Write invalid JSON + if err := os.WriteFile(filepath.Join(bundleDir, "opencode-bundle.manifest.json"), []byte(`{invalid json}`), 0644); err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + + // Test validation - should return error for invalid JSON + err := runBundleValidate(bundleCmd, []string{bundleDir}) + if err == nil { + t.Fatal("runBundleValidate() expected error for invalid JSON manifest") + } +} + +func TestBundleValidateCommand_MissingEntrypoint(t *testing.T) { + // Create temp bundle with preset referencing non-existent entrypoint + bundleDir := t.TempDir() + manifest := `{"manifest_version":"1.0.0","bundle_name":"test","bundle_version":"v1.0.0","presets":[{"name":"default","entrypoint":"nonexistent.json","description":"Default preset"}]}` + if err := os.WriteFile(filepath.Join(bundleDir, "opencode-bundle.manifest.json"), []byte(manifest), 0644); err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + // Don't write the preset file - this should cause validation failure + + // Test validation - should return error for missing entrypoint + err := runBundleValidate(bundleCmd, []string{bundleDir}) + if err == nil { + t.Fatal("runBundleValidate() expected error for missing entrypoint") + } +} + +func TestBundleValidateFlags(t *testing.T) { + if bundleValidateCmd.Flags().Lookup("project-root") == nil { + t.Error("project-root flag should exist on bundle validate command") + } +} + +// TestBundleValidateCommand_MalformedArchive tests handling of corrupted tar.gz files +func TestBundleValidateCommand_MalformedArchive(t *testing.T) { + // Create a temp file that looks like a .tar.gz but isn't + tmpDir := t.TempDir() + badArchivePath := filepath.Join(tmpDir, "corrupted.tar.gz") + + // Write invalid tar.gz content (just random bytes) + if err := os.WriteFile(badArchivePath, []byte("not a valid gzip archive content here"), 0644); err != nil { + t.Fatal(err) + } + + // Test validation - should return system error (exit code 2) + err := runBundleValidate(bundleCmd, []string{badArchivePath}) + if err == nil { + t.Fatal("runBundleValidate() expected error for malformed archive") + } + + // Should be a validationError with IsSystemError = true + if vErr, ok := err.(*validationError); !ok { + t.Fatalf("expected validationError type, got %T", err) + } else if !vErr.IsSystemError { + t.Error("expected IsSystemError=true for malformed archive") + } +} + +// TestBundleValidateCommand_PermissionDenied tests handling of permission errors +func TestBundleValidateCommand_PermissionDenied(t *testing.T) { + // Skip on Windows - permission handling differs + if runtime.GOOS == "windows" { + t.Skip("permission tests not supported on Windows") + } + + tmpDir := t.TempDir() + + // Create a valid bundle first + manifest := `{"manifest_version":"1.0.0","bundle_name":"test","bundle_version":"v1.0.0","presets":[{"name":"default","entrypoint":"default.json","description":"Default preset"}]}` + if err := os.WriteFile(filepath.Join(tmpDir, "opencode-bundle.manifest.json"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "default.json"), []byte(`{"agents":[]}`), 0644); err != nil { + t.Fatal(err) + } + + // Remove read permission on the directory + if err := os.Chmod(tmpDir, 0000); err != nil { + t.Skipf("cannot change permissions: %v", err) + } + defer func() { _ = os.Chmod(tmpDir, 0755) }() // Restore for cleanup + + // Test validation - should return permission error (exit code 2) + err := runBundleValidate(bundleCmd, []string{tmpDir}) + if err == nil { + t.Fatal("runBundleValidate() expected error for permission denied") + } + + // Should be a validationError with IsSystemError = true + if vErr, ok := err.(*validationError); !ok { + t.Fatalf("expected validationError type, got %T", err) + } else if !vErr.IsSystemError { + t.Error("expected IsSystemError=true for permission denied") + } +} + +// TestBundleValidateCommand_MultipleValidationErrors tests that all errors are displayed +func TestBundleValidateCommand_MultipleValidationErrors(t *testing.T) { + tmpDir := t.TempDir() + + // Create a bundle with multiple validation errors + // - Missing entrypoint (preset1) + // - Missing prompt file (preset2) + // - Invalid JSON in entrypoint (preset3) + manifest := `{ + "manifest_version": "1.0.0", + "bundle_name": "multi-error", + "bundle_version": "v1.0.0", + "presets": [ + {"name": "preset1", "description": "Missing entrypoint", "entrypoint": "missing1.json"}, + {"name": "preset2", "description": "Missing prompt", "entrypoint": "valid2.json", "prompt_files": ["missing.txt"]}, + {"name": "preset3", "description": "Invalid JSON", "entrypoint": "invalid.json"} + ] + }` + if err := os.WriteFile(filepath.Join(tmpDir, "opencode-bundle.manifest.json"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Create valid entrypoint for preset2 + if err := os.WriteFile(filepath.Join(tmpDir, "valid2.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Create invalid JSON for preset3 + if err := os.WriteFile(filepath.Join(tmpDir, "invalid.json"), []byte("not json"), 0644); err != nil { + t.Fatal(err) + } + + err := runBundleValidate(bundleCmd, []string{tmpDir}) + + // Should return error (validation failed) + if err == nil { + t.Fatal("runBundleValidate() expected error for multiple validation errors") + } + + // Should NOT be a system error - it's validation error (exit code 1) + if vErr, ok := err.(*validationError); ok && vErr.IsSystemError { + t.Error("expected validation error (not system error) for multiple validation errors") + } +} + +// TestBundleValidateCommand_FixtureBundleIntegration tests validation of the fixture-bundle +func TestBundleValidateCommand_FixtureBundleIntegration(t *testing.T) { + // Use the existing fixture-bundle from e2e/testdata + fixturePath := filepath.Join("..", "e2e", "testdata", "fixture-bundle") + + // Verify fixture exists + if _, err := os.Stat(fixturePath); os.IsNotExist(err) { + t.Skipf("fixture-bundle not found at %s", fixturePath) + } + + // Test validation - should succeed (fixture is valid) + err := runBundleValidate(bundleCmd, []string{fixturePath}) + if err != nil { + t.Fatalf("runBundleValidate() error = %v for fixture-bundle (should be valid)", err) + } +} diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index a44ff7c..8a3c88c 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -66,6 +66,146 @@ type Preset struct { Description string `json:"description,omitempty"` } +// ValidationError represents an error found during bundle validation. +type ValidationError struct { + Category string // "manifest", "schema", "entrypoint", "prompt_files", "preset" + Message string + File string // path to file with error +} + +// ValidationResult represents the result of bundle validation. +type ValidationResult struct { + Valid bool + Errors []ValidationError + Warnings []string +} + +// ValidateBundle validates a bundle at the given root directory. +// Returns a ValidationResult with all errors collected (not fail-fast). +// System errors (IO, permissions) return a non-nil error. +// Validation errors return nil error with ValidationResult.Valid=false. +func ValidateBundle(bundleRoot string) (*ValidationResult, error) { + result := &ValidationResult{ + Valid: true, + Errors: []ValidationError{}, + Warnings: []string{}, + } + + // 1. Check manifest exists + manifestPath := filepath.Join(bundleRoot, "opencode-bundle.manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + if os.IsNotExist(err) { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Category: "manifest", + Message: "manifest file not found", + File: manifestPath, + }) + return result, nil + } + return nil, fmt.Errorf("failed to read manifest: %w", err) + } + + // 2. Parse JSON and validate against schema + var rawManifest interface{} + if err := json.Unmarshal(manifestData, &rawManifest); err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Category: "manifest", + Message: fmt.Sprintf("invalid JSON: %v", err), + File: manifestPath, + }) + return result, nil + } + + // Validate against embedded schema + schema, err := schemaCompiler.Compile("1.0.0.schema.json") + if err != nil { + return nil, fmt.Errorf("failed to compile schema: %w", err) + } + + if err := schema.Validate(rawManifest); err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Category: "schema", + Message: fmt.Sprintf("schema validation failed: %v", err), + File: manifestPath, + }) + return result, nil + } + + // 3. Load manifest for version validation + manifest, err := LoadManifest(manifestPath) + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Category: "manifest", + Message: err.Error(), + File: manifestPath, + }) + return result, nil + } + + // 4. Validate each preset + for _, preset := range manifest.Presets { + // Check entrypoint exists + entrypointPath := filepath.Join(bundleRoot, preset.Entrypoint) + entrypointData, err := os.ReadFile(entrypointPath) + if err != nil { + result.Valid = false + if os.IsNotExist(err) { + result.Errors = append(result.Errors, ValidationError{ + Category: "entrypoint", + Message: "entrypoint file not found", + File: entrypointPath, + }) + } else { + result.Errors = append(result.Errors, ValidationError{ + Category: "entrypoint", + Message: fmt.Sprintf("failed to read entrypoint: %v", err), + File: entrypointPath, + }) + } + continue + } + + // Check entrypoint is valid JSON + var presetData interface{} + if err := json.Unmarshal(entrypointData, &presetData); err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Category: "preset", + Message: fmt.Sprintf("invalid JSON in preset: %v", err), + File: entrypointPath, + }) + } + + // Check prompt_files exist + for _, promptFile := range preset.PromptFiles { + promptPath := filepath.Join(bundleRoot, promptFile) + if _, err := os.Stat(promptPath); err != nil { + result.Valid = false + if os.IsNotExist(err) { + result.Errors = append(result.Errors, ValidationError{ + Category: "prompt_files", + Message: "prompt file not found", + File: promptPath, + }) + } else { + result.Errors = append(result.Errors, ValidationError{ + Category: "prompt_files", + Message: fmt.Sprintf("failed to read prompt file: %v", err), + File: promptPath, + }) + } + } + } + } + + return result, nil +} + // InstalledAsset represents a file installed from a bundle to the project. type InstalledAsset struct { Source string `json:"source"` // Path in bundle (relative to bundle root) diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index d949300..fd0bc5e 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -3,6 +3,7 @@ package bundle import ( "os" "path/filepath" + "strings" "testing" ) @@ -71,3 +72,471 @@ func containsAt(s, substr string) bool { } return false } + +// TestValidateBundle_ValidBundle tests validation of a valid bundle +func TestValidateBundle_ValidBundle(t *testing.T) { + tmpDir := t.TempDir() + + // Create valid manifest + manifest := `{ + "manifest_version": "1.0.0", + "bundle_name": "test-bundle", + "bundle_version": "v1.0.0", + "presets": [ + { + "name": "test", + "description": "Test preset", + "entrypoint": "test.json", + "prompt_files": [] + } + ] + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Create valid entrypoint + entrypoint := `{"model": "gpt-4", "temperature": 0.7}` + if err := os.WriteFile(filepath.Join(tmpDir, "test.json"), []byte(entrypoint), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if !result.Valid { + t.Errorf("ValidateBundle() expected Valid=true, got false, errors: %v", result.Errors) + } + if len(result.Errors) != 0 { + t.Errorf("ValidateBundle() expected 0 errors, got %d: %v", len(result.Errors), result.Errors) + } +} + +// TestValidateBundle_MissingManifest tests validation with missing manifest +func TestValidateBundle_MissingManifest(t *testing.T) { + tmpDir := t.TempDir() + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for missing manifest") + } + // Should have a manifest error + found := false + for _, e := range result.Errors { + if e.Category == "manifest" && strings.Contains(e.Message, "manifest") { + found = true + break + } + } + if !found { + t.Errorf("ValidateBundle() expected manifest error, got: %v", result.Errors) + } +} + +// TestValidateBundle_InvalidJSON tests validation with malformed JSON +func TestValidateBundle_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + + // Create invalid JSON manifest + manifest := `{invalid json}` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for invalid JSON") + } + // Should have a schema or manifest parse error + found := false + for _, e := range result.Errors { + if e.Category == "schema" || e.Category == "manifest" { + found = true + break + } + } + if !found { + t.Errorf("ValidateBundle() expected schema/manifest error for invalid JSON, got: %v", result.Errors) + } +} + +// TestValidateBundle_InvalidManifestVersion tests validation with unsupported version +func TestValidateBundle_InvalidManifestVersion(t *testing.T) { + tmpDir := t.TempDir() + + // Create manifest with unsupported version + manifest := `{ + "manifest_version": "999.0.0", + "bundle_name": "test-bundle", + "bundle_version": "v1.0.0", + "presets": [{"name": "test", "description": "Test", "entrypoint": "test.json", "prompt_files": []}] + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Create dummy entrypoint to avoid entrypoint errors masking version error + if err := os.WriteFile(filepath.Join(tmpDir, "test.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for unsupported version") + } + // Should have a version error + found := false + for _, e := range result.Errors { + if strings.Contains(e.Message, "version") || strings.Contains(e.Message, "supported") { + found = true + break + } + } + if !found { + t.Errorf("ValidateBundle() expected version error, got: %v", result.Errors) + } +} + +// TestValidateBundle_MissingRequiredFields tests validation with missing required fields +func TestValidateBundle_MissingRequiredFields(t *testing.T) { + tmpDir := t.TempDir() + + // Create manifest missing bundle_name, bundle_version, presets + manifest := `{ + "manifest_version": "1.0.0" + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for missing required fields") + } + // Should have schema validation errors + found := false + for _, e := range result.Errors { + if e.Category == "schema" && (strings.Contains(e.Message, "bundle_name") || + strings.Contains(e.Message, "bundle_version") || + strings.Contains(e.Message, "presets")) { + found = true + break + } + } + if !found { + t.Errorf("ValidateBundle() expected schema errors for missing fields, got: %v", result.Errors) + } +} + +// TestValidateBundle_MissingEntrypoint tests validation with missing entrypoint file +func TestValidateBundle_MissingEntrypoint(t *testing.T) { + tmpDir := t.TempDir() + + // Create valid manifest referencing non-existent entrypoint + manifest := `{ + "manifest_version": "1.0.0", + "bundle_name": "test-bundle", + "bundle_version": "v1.0.0", + "presets": [{"name": "test", "description": "Test", "entrypoint": "nonexistent.json", "prompt_files": []}] + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for missing entrypoint") + } + // Should have an entrypoint error + found := false + for _, e := range result.Errors { + if e.Category == "entrypoint" { + found = true + break + } + } + if !found { + t.Errorf("ValidateBundle() expected entrypoint error, got: %v", result.Errors) + } +} + +// TestValidateBundle_InvalidPresetJSON tests validation with invalid preset JSON +func TestValidateBundle_InvalidPresetJSON(t *testing.T) { + tmpDir := t.TempDir() + + // Create valid manifest + manifest := `{ + "manifest_version": "1.0.0", + "bundle_name": "test-bundle", + "bundle_version": "v1.0.0", + "presets": [{"name": "test", "description": "Test", "entrypoint": "test.json", "prompt_files": []}] + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Create invalid JSON entrypoint + if err := os.WriteFile(filepath.Join(tmpDir, "test.json"), []byte("not valid json"), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for invalid preset JSON") + } + // Should have a preset error + found := false + for _, e := range result.Errors { + if e.Category == "preset" { + found = true + break + } + } + if !found { + t.Errorf("ValidateBundle() expected preset error, got: %v", result.Errors) + } +} + +// TestValidateBundle_MissingPromptFile tests validation with missing prompt file +func TestValidateBundle_MissingPromptFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create valid manifest referencing non-existent prompt file + manifest := `{ + "manifest_version": "1.0.0", + "bundle_name": "test-bundle", + "bundle_version": "v1.0.0", + "presets": [{"name": "test", "description": "Test", "entrypoint": "test.json", "prompt_files": ["missing.txt"]}] + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Create valid entrypoint + if err := os.WriteFile(filepath.Join(tmpDir, "test.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for missing prompt file") + } + // Should have a prompt_files error + found := false + for _, e := range result.Errors { + if e.Category == "prompt_files" { + found = true + break + } + } + if !found { + t.Errorf("ValidateBundle() expected prompt_files error, got: %v", result.Errors) + } +} + +// TestValidateBundle_EmptyPresetsArray tests validation with empty presets array (should fail per schema) +func TestValidateBundle_EmptyPresetsArray(t *testing.T) { + tmpDir := t.TempDir() + + // Create manifest with empty presets array - schema requires minItems: 1 + manifest := `{ + "manifest_version": "1.0.0", + "bundle_name": "test-bundle", + "bundle_version": "v1.0.0", + "presets": [] + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + // Empty presets array should fail validation (schema minItems: 1) + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for empty presets array") + } + // Should have a schema error about presets + found := false + for _, e := range result.Errors { + if e.Category == "schema" && (strings.Contains(e.Message, "presets") || strings.Contains(e.Message, "minItems")) { + found = true + break + } + } + if !found { + t.Errorf("ValidateBundle() expected schema error for empty presets, got: %v", result.Errors) + } +} + +// TestValidateBundle_DeeplyNestedBundle tests validation with nested directory structure +func TestValidateBundle_DeeplyNestedBundle(t *testing.T) { + tmpDir := t.TempDir() + + // Create nested directory structure with manifest at root + presetsDir := filepath.Join(tmpDir, "presets") + subDir := filepath.Join(presetsDir, "v1") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + + // Create manifest with entrypoints in subdirectories + manifest := `{ + "manifest_version": "1.0.0", + "bundle_name": "nested-bundle", + "bundle_version": "v1.0.0", + "presets": [ + { + "name": "default", + "description": "Default preset", + "entrypoint": "presets/v1/default.json", + "prompt_files": ["prompts/readme.md"] + } + ] + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Create preset file in subdirectory + presetContent := `{"agents":[]}` + if err := os.WriteFile(filepath.Join(subDir, "default.json"), []byte(presetContent), 0644); err != nil { + t.Fatal(err) + } + + // Create prompts directory and file + promptsDir := filepath.Join(tmpDir, "prompts") + if err := os.MkdirAll(promptsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(promptsDir, "readme.md"), []byte("# Readme"), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if !result.Valid { + t.Errorf("ValidateBundle() expected Valid=true for nested bundle, got errors: %v", result.Errors) + } +} + +// TestValidateBundle_MultipleErrors tests that all validation errors are collected (not fail-fast) +func TestValidateBundle_MultipleErrors(t *testing.T) { + tmpDir := t.TempDir() + + // Create manifest with multiple problems: + // - Preset 1: missing entrypoint + // - Preset 2: missing prompt_file + // - Preset 3: invalid preset JSON + manifest := `{ + "manifest_version": "1.0.0", + "bundle_name": "multi-error-bundle", + "bundle_version": "v1.0.0", + "presets": [ + { + "name": "missing-entrypoint", + "description": "Preset with missing entrypoint", + "entrypoint": "nonexistent1.json" + }, + { + "name": "missing-prompt", + "description": "Preset with missing prompt file", + "entrypoint": "valid.json", + "prompt_files": ["missing-prompt.md"] + }, + { + "name": "invalid-json", + "description": "Preset with invalid JSON", + "entrypoint": "invalid.json" + } + ] + }` + manifestPath := filepath.Join(tmpDir, "opencode-bundle.manifest.json") + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Create valid.json (exists) + if err := os.WriteFile(filepath.Join(tmpDir, "valid.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Create invalid.json (invalid JSON) + if err := os.WriteFile(filepath.Join(tmpDir, "invalid.json"), []byte("not valid json {"), 0644); err != nil { + t.Fatal(err) + } + + result, err := ValidateBundle(tmpDir) + if err != nil { + t.Errorf("ValidateBundle() unexpected error = %v", err) + return + } + if result.Valid { + t.Errorf("ValidateBundle() expected Valid=false for multiple errors") + } + + // Should collect at least 3 errors (missing entrypoint, missing prompt, invalid json) + // We expect: 1 entrypoint error + 1 prompt_files error + 1 preset error = 3 errors minimum + if len(result.Errors) < 3 { + t.Errorf("ValidateBundle() expected at least 3 errors collected, got %d: %v", len(result.Errors), result.Errors) + } + + // Verify we have each category of error + categories := make(map[string]bool) + for _, e := range result.Errors { + categories[e.Category] = true + } + if !categories["entrypoint"] { + t.Errorf("ValidateBundle() expected entrypoint error category") + } + if !categories["prompt_files"] { + t.Errorf("ValidateBundle() expected prompt_files error category") + } + if !categories["preset"] { + t.Errorf("ValidateBundle() expected preset error category") + } +}