diff --git a/cmd/bd/config.go b/cmd/bd/config.go index 07a34e12..e3c7045e 100644 --- a/cmd/bd/config.go +++ b/cmd/bd/config.go @@ -3,10 +3,13 @@ package main import ( "fmt" "os" + "regexp" "sort" "strings" "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/steveyegge/beads/cmd/bd/doctor" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/syncbranch" ) @@ -268,10 +271,195 @@ var configUnsetCmd = &cobra.Command{ }, } +var configValidateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate sync-related configuration", + Long: `Validate sync-related configuration settings. + +Checks: + - sync.mode is a valid value (local, git-branch, external) + - conflict.strategy is valid (lww, manual, ours, theirs) + - federation.sovereignty is valid (if set) + - federation.remote is set when sync.mode requires it + - Remote URL format is valid (dolthub://, gs://, s3://, file://) + - sync.branch is a valid git branch name + - routing.mode is valid (auto, maintainer, contributor) + +Examples: + bd config validate + bd config validate --json`, + Run: func(cmd *cobra.Command, args []string) { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Find repo root by walking up to find .beads directory + repoPath := findBeadsRepoRoot(cwd) + if repoPath == "" { + fmt.Fprintf(os.Stderr, "Error: not in a beads repository (no .beads directory found)\n") + os.Exit(1) + } + + // Run the existing doctor config values check + doctorCheck := doctor.CheckConfigValues(repoPath) + + // Run additional sync-related validations + syncIssues := validateSyncConfig(repoPath) + + // Combine results + allIssues := []string{} + if doctorCheck.Detail != "" { + allIssues = append(allIssues, strings.Split(doctorCheck.Detail, "\n")...) + } + allIssues = append(allIssues, syncIssues...) + + // Output results + if jsonOutput { + result := map[string]interface{}{ + "valid": len(allIssues) == 0, + "issues": allIssues, + } + outputJSON(result) + return + } + + if len(allIssues) == 0 { + fmt.Println("✓ All sync-related configuration is valid") + return + } + + fmt.Println("Configuration validation found issues:") + for _, issue := range allIssues { + if issue != "" { + fmt.Printf(" • %s\n", issue) + } + } + fmt.Println("\nRun 'bd config set ' to fix configuration issues.") + os.Exit(1) + }, +} + +// validateSyncConfig performs additional sync-related config validation +// beyond what doctor.CheckConfigValues covers. +func validateSyncConfig(repoPath string) []string { + var issues []string + + // Load config.yaml directly from the repo path + configPath := repoPath + "/.beads/config.yaml" + v := viper.New() + v.SetConfigType("yaml") + v.SetConfigFile(configPath) + + // Try to read config, but don't error if it doesn't exist + if err := v.ReadInConfig(); err != nil { + // Config file doesn't exist or is unreadable - nothing to validate + return issues + } + + // Get config from yaml + syncMode := v.GetString("sync.mode") + conflictStrategy := v.GetString("conflict.strategy") + federationSov := v.GetString("federation.sovereignty") + federationRemote := v.GetString("federation.remote") + + // Validate sync.mode + validSyncModes := map[string]bool{ + "": true, // not set is valid (uses default) + "local": true, + "git-branch": true, + "external": true, + } + if syncMode != "" && !validSyncModes[syncMode] { + issues = append(issues, fmt.Sprintf("sync.mode: %q is invalid (valid values: local, git-branch, external)", syncMode)) + } + + // Validate conflict.strategy + validConflictStrategies := map[string]bool{ + "": true, // not set is valid (uses default lww) + "lww": true, // last-write-wins (default) + "manual": true, // require manual resolution + "ours": true, // prefer local changes + "theirs": true, // prefer remote changes + } + if conflictStrategy != "" && !validConflictStrategies[conflictStrategy] { + issues = append(issues, fmt.Sprintf("conflict.strategy: %q is invalid (valid values: lww, manual, ours, theirs)", conflictStrategy)) + } + + // Validate federation.sovereignty + validSovereignties := map[string]bool{ + "": true, // not set is valid + "none": true, // no sovereignty restrictions + "isolated": true, // fully isolated, no federation + "federated": true, // participates in federation + } + if federationSov != "" && !validSovereignties[federationSov] { + issues = append(issues, fmt.Sprintf("federation.sovereignty: %q is invalid (valid values: none, isolated, federated)", federationSov)) + } + + // Validate federation.remote when required + if syncMode == "external" && federationRemote == "" { + issues = append(issues, "federation.remote: required when sync.mode is 'external'") + } + + // Validate remote URL format + if federationRemote != "" { + if !isValidRemoteURL(federationRemote) { + issues = append(issues, fmt.Sprintf("federation.remote: %q is not a valid remote URL (expected dolthub://, gs://, s3://, file://, or standard git URL)", federationRemote)) + } + } + + return issues +} + +// isValidRemoteURL validates remote URL formats for sync configuration +func isValidRemoteURL(url string) bool { + // Valid URL schemes for beads remotes + validSchemes := []string{ + "dolthub://", + "gs://", + "s3://", + "file://", + "https://", + "http://", + "ssh://", + } + + for _, scheme := range validSchemes { + if strings.HasPrefix(url, scheme) { + return true + } + } + + // Also allow standard git remote patterns (user@host:path) + // The host must have at least one character before the colon + // Pattern: username@hostname:path where hostname has at least 2 chars + gitSSHPattern := regexp.MustCompile(`^[a-zA-Z0-9._-]+@[a-zA-Z0-9][a-zA-Z0-9._-]*:.+$`) + return gitSSHPattern.MatchString(url) +} + +// findBeadsRepoRoot walks up from the given path to find the repo root (containing .beads) +func findBeadsRepoRoot(startPath string) string { + path := startPath + for { + beadsDir := path + "/.beads" + if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + return path + } + parent := path[:strings.LastIndex(path, "/")] + if parent == path || parent == "" { + return "" + } + path = parent + } +} + func init() { configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configListCmd) configCmd.AddCommand(configUnsetCmd) + configCmd.AddCommand(configValidateCmd) rootCmd.AddCommand(configCmd) } diff --git a/cmd/bd/config_test.go b/cmd/bd/config_test.go index 7a753f81..6e4ab172 100644 --- a/cmd/bd/config_test.go +++ b/cmd/bd/config_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/steveyegge/beads/internal/config" @@ -224,3 +225,230 @@ func setupTestDB(t *testing.T) (*sqlite.SQLiteStorage, func()) { return store, cleanup } + +// TestIsValidRemoteURL tests the remote URL validation function +func TestIsValidRemoteURL(t *testing.T) { + tests := []struct { + name string + url string + expected bool + }{ + // Valid URLs + {"dolthub scheme", "dolthub://org/repo", true}, + {"gs scheme", "gs://bucket/path", true}, + {"s3 scheme", "s3://bucket/path", true}, + {"file scheme", "file:///path/to/repo", true}, + {"https scheme", "https://github.com/user/repo", true}, + {"http scheme", "http://github.com/user/repo", true}, + {"ssh scheme", "ssh://git@github.com/user/repo", true}, + {"git ssh format", "git@github.com:user/repo.git", true}, + {"git ssh with underscore", "git@gitlab.example_host.com:user/repo.git", true}, + + // Invalid URLs + {"empty string", "", false}, + {"no scheme", "github.com/user/repo", false}, + {"invalid scheme", "ftp://server/path", false}, + {"malformed git ssh", "git@:repo", false}, + {"just path", "/path/to/repo", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isValidRemoteURL(tt.url) + if got != tt.expected { + t.Errorf("isValidRemoteURL(%q) = %v, want %v", tt.url, got, tt.expected) + } + }) + } +} + +// TestValidateSyncConfig tests the sync config validation function +func TestValidateSyncConfig(t *testing.T) { + // Create a temp directory for testing + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + + t.Run("valid empty config", func(t *testing.T) { + // Create minimal config.yaml + configContent := `prefix: test +` + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + issues := validateSyncConfig(tmpDir) + if len(issues) != 0 { + t.Errorf("Expected no issues for valid empty config, got: %v", issues) + } + }) + + t.Run("invalid sync.mode", func(t *testing.T) { + configContent := `prefix: test +sync: + mode: "invalid-mode" +` + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + issues := validateSyncConfig(tmpDir) + found := false + for _, issue := range issues { + if strings.Contains(issue, "sync.mode") { + found = true + break + } + } + if !found { + t.Errorf("Expected issue about sync.mode, got: %v", issues) + } + }) + + t.Run("invalid conflict.strategy", func(t *testing.T) { + configContent := `prefix: test +conflict: + strategy: "invalid-strategy" +` + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + issues := validateSyncConfig(tmpDir) + found := false + for _, issue := range issues { + if strings.Contains(issue, "conflict.strategy") { + found = true + break + } + } + if !found { + t.Errorf("Expected issue about conflict.strategy, got: %v", issues) + } + }) + + t.Run("invalid federation.sovereignty", func(t *testing.T) { + configContent := `prefix: test +federation: + sovereignty: "invalid-value" +` + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + issues := validateSyncConfig(tmpDir) + found := false + for _, issue := range issues { + if strings.Contains(issue, "federation.sovereignty") { + found = true + break + } + } + if !found { + t.Errorf("Expected issue about federation.sovereignty, got: %v", issues) + } + }) + + t.Run("external mode without remote", func(t *testing.T) { + configContent := `prefix: test +sync: + mode: "external" +` + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + issues := validateSyncConfig(tmpDir) + found := false + for _, issue := range issues { + if strings.Contains(issue, "federation.remote") && strings.Contains(issue, "required") { + found = true + break + } + } + if !found { + t.Errorf("Expected issue about federation.remote being required, got: %v", issues) + } + }) + + t.Run("invalid remote URL", func(t *testing.T) { + configContent := `prefix: test +federation: + remote: "invalid-url" +` + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + issues := validateSyncConfig(tmpDir) + found := false + for _, issue := range issues { + if strings.Contains(issue, "federation.remote") && strings.Contains(issue, "not a valid remote URL") { + found = true + break + } + } + if !found { + t.Errorf("Expected issue about invalid remote URL, got: %v", issues) + } + }) + + t.Run("valid sync config", func(t *testing.T) { + configContent := `prefix: test +sync: + mode: "git-branch" +conflict: + strategy: "lww" +federation: + sovereignty: "federated" + remote: "https://github.com/user/beads-data.git" +` + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + issues := validateSyncConfig(tmpDir) + if len(issues) != 0 { + t.Errorf("Expected no issues for valid config, got: %v", issues) + } + }) +} + +// TestFindBeadsRepoRoot tests the repo root finding function +func TestFindBeadsRepoRoot(t *testing.T) { + // Create a temp directory structure + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + subDir := filepath.Join(tmpDir, "sub", "dir") + + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("Failed to create sub dir: %v", err) + } + + t.Run("from repo root", func(t *testing.T) { + got := findBeadsRepoRoot(tmpDir) + if got != tmpDir { + t.Errorf("findBeadsRepoRoot(%q) = %q, want %q", tmpDir, got, tmpDir) + } + }) + + t.Run("from subdirectory", func(t *testing.T) { + got := findBeadsRepoRoot(subDir) + if got != tmpDir { + t.Errorf("findBeadsRepoRoot(%q) = %q, want %q", subDir, got, tmpDir) + } + }) + + t.Run("not in repo", func(t *testing.T) { + noRepoDir := t.TempDir() + got := findBeadsRepoRoot(noRepoDir) + if got != "" { + t.Errorf("findBeadsRepoRoot(%q) = %q, want empty string", noRepoDir, got) + } + }) +}