diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-12 09:32:26 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-12 09:32:26 +0200 |
| commit | cfd02d2874992f7e293d5098bd328a495825a8d4 (patch) | |
| tree | bb241a61ce35c717c16539ab5d4413264514168d /internal/slashcommands | |
| parent | 0cd9db181218eaf0fb1ec1cddcd83035d984e94c (diff) | |
feat: add automatic MCP prompt to slash command syncing
Adds optional syncing of MCP prompts to Markdown slash command files
for AI agents that don't yet support MCP prompts (e.g., Cursor IDE).
Features:
- Syncs prompts on create/update/delete operations
- Configurable via TOML config, environment vars, or CLI flags
- Backfill support with --sync-all flag
- Thread-safe atomic file writes
- Non-fatal sync failures (logged but don't break operations)
- Comprehensive test coverage (81.1% total)
Configuration:
- Config: [mcp] slashcommand_sync = true, slashcommand_dir = "~/.cursor/commands"
- Env: HEXAI_MCP_SLASHCOMMAND_SYNC, HEXAI_MCP_SLASHCOMMAND_DIR
- CLI: --slashcommand-sync, --slashcommand-dir, --sync-all
Fixes config merging bug where project config would reset global MCP settings.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/slashcommands')
| -rw-r--r-- | internal/slashcommands/converter.go | 113 | ||||
| -rw-r--r-- | internal/slashcommands/converter_test.go | 273 | ||||
| -rw-r--r-- | internal/slashcommands/syncer.go | 175 | ||||
| -rw-r--r-- | internal/slashcommands/syncer_test.go | 369 |
4 files changed, 930 insertions, 0 deletions
diff --git a/internal/slashcommands/converter.go b/internal/slashcommands/converter.go new file mode 100644 index 0000000..87c77c8 --- /dev/null +++ b/internal/slashcommands/converter.go @@ -0,0 +1,113 @@ +// Summary: Converts MCP prompts to generic slash command Markdown format. +package slashcommands + +import ( + "fmt" + "regexp" + "strings" + + "codeberg.org/snonux/hexai/internal/promptstore" +) + +// ConvertPromptToMarkdown converts an MCP prompt to slash command Markdown. +// Returns a formatted Markdown string suitable for writing to a .md file. +func ConvertPromptToMarkdown(prompt *promptstore.Prompt) string { + var sb strings.Builder + + // Title + sb.WriteString("# ") + sb.WriteString(prompt.Title) + sb.WriteString("\n\n") + + // Description + if prompt.Description != "" { + sb.WriteString(prompt.Description) + sb.WriteString("\n\n") + } + + // Arguments section + if len(prompt.Arguments) > 0 { + sb.WriteString(formatArguments(prompt.Arguments)) + sb.WriteString("\n") + } + + // Template section (first user message) + if len(prompt.Messages) > 0 { + sb.WriteString("## Template\n\n") + sb.WriteString(formatMessages(prompt.Messages)) + sb.WriteString("\n\n") + } + + // Tags section + if len(prompt.Tags) > 0 { + sb.WriteString("## Tags\n\n") + sb.WriteString(strings.Join(prompt.Tags, ", ")) + sb.WriteString("\n\n") + } + + // Footer + sb.WriteString("---\n") + sb.WriteString("*Generated from MCP prompt: ") + sb.WriteString(prompt.Name) + sb.WriteString("*\n") + + return sb.String() +} + +// formatArguments creates the Usage section with argument documentation. +// Returns formatted Markdown listing all arguments with their properties. +func formatArguments(args []promptstore.PromptArgument) string { + var sb strings.Builder + + sb.WriteString("## Usage\n\n") + sb.WriteString("This prompt template accepts the following arguments:\n\n") + + for _, arg := range args { + // Format: - **arg_name** (required/optional): Description + sb.WriteString("- **") + sb.WriteString(arg.Name) + sb.WriteString("** (") + if arg.Required { + sb.WriteString("required") + } else { + sb.WriteString("optional") + } + sb.WriteString("): ") + sb.WriteString(arg.Description) + sb.WriteString("\n") + } + + return sb.String() +} + +// formatMessages extracts the first user message for the Template section. +// Shows users what the prompt template looks like with {{placeholders}}. +func formatMessages(messages []promptstore.PromptMessage) string { + // Find first user message + for _, msg := range messages { + if msg.Role == "user" && msg.Content.Text != "" { + return msg.Content.Text + } + } + return "(No template content)" +} + +// sanitizeFilename ensures prompt name is valid for filename. +// Replaces non-alphanumeric characters with hyphens. +func sanitizeFilename(name string) string { + // Replace any character that's not alphanumeric or underscore with hyphen + re := regexp.MustCompile(`[^a-zA-Z0-9_]+`) + sanitized := re.ReplaceAllString(name, "-") + + // Remove leading/trailing hyphens + sanitized = strings.Trim(sanitized, "-") + + // Convert to lowercase for consistency + return strings.ToLower(sanitized) +} + +// MakeFilename creates a slash command filename with hexai- prefix. +// Returns: hexai-{sanitized_name}.md +func MakeFilename(promptName string) string { + return fmt.Sprintf("hexai-%s.md", sanitizeFilename(promptName)) +} diff --git a/internal/slashcommands/converter_test.go b/internal/slashcommands/converter_test.go new file mode 100644 index 0000000..1614dc0 --- /dev/null +++ b/internal/slashcommands/converter_test.go @@ -0,0 +1,273 @@ +package slashcommands + +import ( + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/promptstore" +) + +func TestConvertPromptToMarkdown_MinimalPrompt(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "minimal", + Title: "Minimal Prompt", + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "# Minimal Prompt") { + t.Error("Markdown should contain title") + } + if !strings.Contains(result, "*Generated from MCP prompt: minimal*") { + t.Error("Markdown should contain footer with prompt name") + } +} + +func TestConvertPromptToMarkdown_WithDescription(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "test", + Title: "Test Prompt", + Description: "This is a test prompt description.", + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "This is a test prompt description.") { + t.Error("Markdown should contain description") + } +} + +func TestConvertPromptToMarkdown_WithArguments(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "args-test", + Title: "Arguments Test", + Arguments: []promptstore.PromptArgument{ + {Name: "required_arg", Description: "A required argument", Required: true}, + {Name: "optional_arg", Description: "An optional argument", Required: false}, + }, + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "## Usage") { + t.Error("Markdown should contain Usage section") + } + if !strings.Contains(result, "**required_arg** (required)") { + t.Error("Markdown should mark required arguments") + } + if !strings.Contains(result, "**optional_arg** (optional)") { + t.Error("Markdown should mark optional arguments") + } + if !strings.Contains(result, "A required argument") { + t.Error("Markdown should contain argument descriptions") + } +} + +func TestConvertPromptToMarkdown_WithMessages(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "msg-test", + Title: "Messages Test", + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "This is the template with {{placeholder}}", + }, + }, + { + Role: "assistant", + Content: promptstore.MessageContent{ + Type: "text", + Text: "This should be ignored", + }, + }, + }, + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "## Template") { + t.Error("Markdown should contain Template section") + } + if !strings.Contains(result, "This is the template with {{placeholder}}") { + t.Error("Markdown should contain user message content") + } + if strings.Contains(result, "This should be ignored") { + t.Error("Markdown should only include first user message in template") + } +} + +func TestConvertPromptToMarkdown_WithTags(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "tags-test", + Title: "Tags Test", + Tags: []string{"coding", "review", "refactor"}, + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "## Tags") { + t.Error("Markdown should contain Tags section") + } + if !strings.Contains(result, "coding, review, refactor") { + t.Error("Markdown should contain all tags as comma-separated list") + } +} + +func TestConvertPromptToMarkdown_FullPrompt(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "full-example", + Title: "Full Example Prompt", + Description: "A complete example with all fields.", + Arguments: []promptstore.PromptArgument{ + {Name: "input", Description: "Input text", Required: true}, + {Name: "format", Description: "Output format", Required: false}, + }, + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "Process {{input}} with format {{format}}", + }, + }, + }, + Tags: []string{"example", "test"}, + } + + result := ConvertPromptToMarkdown(prompt) + + // Verify all sections are present in order + sections := []string{ + "# Full Example Prompt", + "A complete example with all fields.", + "## Usage", + "**input** (required)", + "**format** (optional)", + "## Template", + "Process {{input}} with format {{format}}", + "## Tags", + "example, test", + "---", + "*Generated from MCP prompt: full-example*", + } + + for _, section := range sections { + if !strings.Contains(result, section) { + t.Errorf("Markdown missing section: %q", section) + } + } +} + +func TestFormatArguments(t *testing.T) { + args := []promptstore.PromptArgument{ + {Name: "arg1", Description: "First arg", Required: true}, + {Name: "arg2", Description: "Second arg", Required: false}, + } + + result := formatArguments(args) + + if !strings.Contains(result, "## Usage") { + t.Error("formatArguments should include Usage header") + } + if !strings.Contains(result, "**arg1** (required): First arg") { + t.Error("formatArguments should format required arguments correctly") + } + if !strings.Contains(result, "**arg2** (optional): Second arg") { + t.Error("formatArguments should format optional arguments correctly") + } +} + +func TestFormatMessages_FirstUserMessage(t *testing.T) { + messages := []promptstore.PromptMessage{ + {Role: "assistant", Content: promptstore.MessageContent{Text: "Should skip"}}, + {Role: "user", Content: promptstore.MessageContent{Text: "First user message"}}, + {Role: "user", Content: promptstore.MessageContent{Text: "Second user message"}}, + } + + result := formatMessages(messages) + + if result != "First user message" { + t.Errorf("formatMessages() = %q, want %q", result, "First user message") + } +} + +func TestFormatMessages_NoUserMessage(t *testing.T) { + messages := []promptstore.PromptMessage{ + {Role: "assistant", Content: promptstore.MessageContent{Text: "Only assistant"}}, + } + + result := formatMessages(messages) + + if result != "(No template content)" { + t.Errorf("formatMessages() should return placeholder when no user message, got %q", result) + } +} + +func TestFormatMessages_EmptyMessages(t *testing.T) { + messages := []promptstore.PromptMessage{} + + result := formatMessages(messages) + + if result != "(No template content)" { + t.Errorf("formatMessages() should return placeholder for empty messages, got %q", result) + } +} + +func TestSanitizeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple-name", "simple-name"}, + {"Name With Spaces", "name-with-spaces"}, + {"special!@#$chars", "special-chars"}, + {"Multiple___Underscores", "multiple___underscores"}, + {"dots.and.dashes", "dots-and-dashes"}, + {"--leading-trailing--", "leading-trailing"}, + {"UPPERCASE", "uppercase"}, + {"mixed_Case-123", "mixed_case-123"}, + {"unicode-café", "unicode-caf"}, + } + + for _, tt := range tests { + result := sanitizeFilename(tt.input) + if result != tt.expected { + t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestMakeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "hexai-simple.md"}, + {"with spaces", "hexai-with-spaces.md"}, + {"Special_123", "hexai-special_123.md"}, + {"--trim--", "hexai-trim.md"}, + } + + for _, tt := range tests { + result := MakeFilename(tt.input) + if result != tt.expected { + t.Errorf("MakeFilename(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestMakeFilename_AlwaysHasPrefix(t *testing.T) { + result := MakeFilename("test") + if !strings.HasPrefix(result, "hexai-") { + t.Error("MakeFilename should always add hexai- prefix") + } +} + +func TestMakeFilename_AlwaysHasExtension(t *testing.T) { + result := MakeFilename("test") + if !strings.HasSuffix(result, ".md") { + t.Error("MakeFilename should always add .md extension") + } +} diff --git a/internal/slashcommands/syncer.go b/internal/slashcommands/syncer.go new file mode 100644 index 0000000..91dbf50 --- /dev/null +++ b/internal/slashcommands/syncer.go @@ -0,0 +1,175 @@ +// Summary: File syncer for exporting MCP prompts to slash command files. +package slashcommands + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/promptstore" +) + +// Operation represents the type of sync operation. +type Operation int + +const ( + OpCreate Operation = iota + OpUpdate + OpDelete +) + +// Syncer manages syncing MCP prompts to slash command files. +// Thread-safe with mutex protection for concurrent operations. +type Syncer struct { + commandsDir string + enabled bool + mu sync.Mutex +} + +// NewSyncer creates a new syncer and validates the commands directory. +// Returns error if directory cannot be created or is not writable. +func NewSyncer(cfg appconfig.App) (*Syncer, error) { + if !cfg.MCPSlashCommandSync { + return &Syncer{enabled: false}, nil + } + + dir := cfg.MCPSlashCommandDir + if dir == "" { + return nil, fmt.Errorf("commands directory not configured") + } + + // Expand home directory + if len(dir) > 0 && dir[0] == '~' { + home := os.Getenv("HOME") + if home != "" { + dir = filepath.Join(home, dir[1:]) + } + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("cannot create commands directory: %w", err) + } + + return &Syncer{ + commandsDir: dir, + enabled: true, + }, nil +} + +// Sync writes a prompt to disk as hexai-{name}.md. +// Uses atomic write (temp file + rename) for consistency. +func (s *Syncer) Sync(prompt *promptstore.Prompt, op Operation) error { + if !s.enabled { + return nil // Silently skip if disabled + } + + s.mu.Lock() + defer s.mu.Unlock() + + filename := MakeFilename(prompt.Name) + path := filepath.Join(s.commandsDir, filename) + + // Convert prompt to Markdown + markdown := ConvertPromptToMarkdown(prompt) + + // Write atomically: temp file + rename + return s.atomicWrite(path, []byte(markdown)) +} + +// Delete removes hexai-{name}.md file. +// Returns nil if file doesn't exist (idempotent). +func (s *Syncer) Delete(promptName string) error { + if !s.enabled { + return nil // Silently skip if disabled + } + + s.mu.Lock() + defer s.mu.Unlock() + + filename := MakeFilename(promptName) + path := filepath.Join(s.commandsDir, filename) + + // Remove file (ignore if doesn't exist) + err := os.Remove(path) + if os.IsNotExist(err) { + return nil // File already gone, that's fine + } + return err +} + +// SyncAll syncs all prompts from the store to slash commands. +// Used for backfilling existing prompts via --sync-all flag. +func (s *Syncer) SyncAll(store promptstore.PromptStore) error { + if !s.enabled { + return fmt.Errorf("syncer is disabled") + } + + // List all prompts (no pagination, use large limit) + prompts, _, err := store.List("", 1000) + if err != nil { + return fmt.Errorf("cannot list prompts: %w", err) + } + + count := 0 + var errors []error + + for i := range prompts { + if err := s.Sync(&prompts[i], OpCreate); err != nil { + errors = append(errors, fmt.Errorf("%s: %w", prompts[i].Name, err)) + } else { + count++ + } + } + + if len(errors) > 0 { + return fmt.Errorf("synced %d prompts with %d errors: %v", count, len(errors), errors) + } + + return nil +} + +// atomicWrite writes data to a file atomically using temp file + rename. +// This prevents partial writes if interrupted. +func (s *Syncer) atomicWrite(path string, data []byte) error { + // Write to temp file in same directory + dir := filepath.Dir(path) + tmpFile, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + // Clean up temp file on error + defer func() { + if tmpFile != nil { + tmpFile.Close() + os.Remove(tmpPath) + } + }() + + // Write data + if _, err := tmpFile.Write(data); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + + // Sync to disk + if err := tmpFile.Sync(); err != nil { + return fmt.Errorf("sync temp file: %w", err) + } + + // Close before rename + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + tmpFile = nil // Prevent deferred close + + // Atomic rename + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("rename temp file: %w", err) + } + + return nil +} diff --git a/internal/slashcommands/syncer_test.go b/internal/slashcommands/syncer_test.go new file mode 100644 index 0000000..7ae13dd --- /dev/null +++ b/internal/slashcommands/syncer_test.go @@ -0,0 +1,369 @@ +package slashcommands + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/promptstore" +) + +func TestNewSyncer_Disabled(t *testing.T) { + cfg := appconfig.App{ + MCPSlashCommandSync: false, + } + + syncer, err := NewSyncer(cfg) + if err != nil { + t.Fatalf("NewSyncer() with disabled sync failed: %v", err) + } + + if syncer.enabled { + t.Error("NewSyncer() should create disabled syncer when MCPSlashCommandSync is false") + } +} + +func TestNewSyncer_NoDirectory(t *testing.T) { + cfg := appconfig.App{ + MCPSlashCommandSync: true, + MCPSlashCommandDir: "", + } + + _, err := NewSyncer(cfg) + if err == nil { + t.Error("NewSyncer() should fail when directory is not configured") + } +} + +func TestNewSyncer_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "test-commands") + + cfg := appconfig.App{ + MCPSlashCommandSync: true, + MCPSlashCommandDir: testDir, + } + + syncer, err := NewSyncer(cfg) + if err != nil { + t.Fatalf("NewSyncer() failed: %v", err) + } + + if !syncer.enabled { + t.Error("NewSyncer() should create enabled syncer") + } + + // Verify directory was created + if _, err := os.Stat(testDir); os.IsNotExist(err) { + t.Error("NewSyncer() should create commands directory") + } +} + +func TestNewSyncer_ExpandsHomeDirectory(t *testing.T) { + tmpDir := t.TempDir() + home := os.Getenv("HOME") + + // Set temporary HOME for test + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", home) + + cfg := appconfig.App{ + MCPSlashCommandSync: true, + MCPSlashCommandDir: "~/test-commands", + } + + syncer, err := NewSyncer(cfg) + if err != nil { + t.Fatalf("NewSyncer() failed: %v", err) + } + + expectedDir := filepath.Join(tmpDir, "test-commands") + if syncer.commandsDir != expectedDir { + t.Errorf("NewSyncer() commandsDir = %q, want %q", syncer.commandsDir, expectedDir) + } +} + +func TestSync_Disabled(t *testing.T) { + syncer := &Syncer{enabled: false} + + prompt := &promptstore.Prompt{Name: "test"} + err := syncer.Sync(prompt, OpCreate) + if err != nil { + t.Errorf("Sync() with disabled syncer should not error, got: %v", err) + } +} + +func TestSync_Create(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + prompt := &promptstore.Prompt{ + Name: "test-prompt", + Title: "Test Prompt", + Description: "A test prompt", + Arguments: []promptstore.PromptArgument{ + {Name: "arg1", Description: "First argument", Required: true}, + }, + Messages: []promptstore.PromptMessage{ + {Role: "user", Content: promptstore.MessageContent{Type: "text", Text: "Hello {{arg1}}"}}, + }, + Tags: []string{"test", "example"}, + Created: time.Now(), + Updated: time.Now(), + } + + err := syncer.Sync(prompt, OpCreate) + if err != nil { + t.Fatalf("Sync() failed: %v", err) + } + + // Verify file was created + filename := filepath.Join(tmpDir, "hexai-test-prompt.md") + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("Failed to read synced file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "# Test Prompt") { + t.Error("Synced file should contain prompt title") + } + if !strings.Contains(contentStr, "A test prompt") { + t.Error("Synced file should contain description") + } + if !strings.Contains(contentStr, "Hello {{arg1}}") { + t.Error("Synced file should contain message template") + } + if !strings.Contains(contentStr, "test, example") { + t.Error("Synced file should contain tags") + } +} + +func TestSync_Update(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Create initial prompt + prompt := &promptstore.Prompt{ + Name: "test-prompt", + Title: "Original Title", + Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Original"}}}, + } + + if err := syncer.Sync(prompt, OpCreate); err != nil { + t.Fatalf("Initial Sync() failed: %v", err) + } + + // Update prompt + prompt.Title = "Updated Title" + prompt.Messages[0].Content.Text = "Updated" + + if err := syncer.Sync(prompt, OpUpdate); err != nil { + t.Fatalf("Update Sync() failed: %v", err) + } + + // Verify file was updated + filename := filepath.Join(tmpDir, "hexai-test-prompt.md") + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "Updated Title") { + t.Error("Updated file should contain new title") + } + if strings.Contains(contentStr, "Original Title") { + t.Error("Updated file should not contain old title") + } +} + +func TestDelete_Disabled(t *testing.T) { + syncer := &Syncer{enabled: false} + + err := syncer.Delete("test") + if err != nil { + t.Errorf("Delete() with disabled syncer should not error, got: %v", err) + } +} + +func TestDelete_ExistingFile(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Create a test file + filename := filepath.Join(tmpDir, "hexai-test-prompt.md") + if err := os.WriteFile(filename, []byte("test content"), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Delete it + if err := syncer.Delete("test-prompt"); err != nil { + t.Fatalf("Delete() failed: %v", err) + } + + // Verify file was deleted + if _, err := os.Stat(filename); !os.IsNotExist(err) { + t.Error("Delete() should remove the file") + } +} + +func TestDelete_NonExistentFile(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Delete non-existent file (should be idempotent) + err := syncer.Delete("non-existent") + if err != nil { + t.Errorf("Delete() of non-existent file should not error, got: %v", err) + } +} + +func TestSyncAll(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Create a mock store with test prompts + storeDir := t.TempDir() + store, err := promptstore.NewJSONLStore(storeDir) + if err != nil { + t.Fatalf("Failed to create test store: %v", err) + } + + // Add test prompts + prompts := []*promptstore.Prompt{ + {Name: "prompt1", Title: "Prompt 1", Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Test 1"}}}}, + {Name: "prompt2", Title: "Prompt 2", Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Test 2"}}}}, + {Name: "prompt3", Title: "Prompt 3", Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Test 3"}}}}, + } + + for _, p := range prompts { + if err := store.Create(p); err != nil { + t.Fatalf("Failed to create test prompt: %v", err) + } + } + + // Sync all + if err := syncer.SyncAll(store); err != nil { + t.Fatalf("SyncAll() failed: %v", err) + } + + // Verify all files were created + for _, p := range prompts { + filename := filepath.Join(tmpDir, MakeFilename(p.Name)) + if _, err := os.Stat(filename); os.IsNotExist(err) { + t.Errorf("SyncAll() should create file for prompt %q", p.Name) + } + } +} + +func TestSyncAll_Disabled(t *testing.T) { + syncer := &Syncer{enabled: false} + + err := syncer.SyncAll(nil) + if err == nil { + t.Error("SyncAll() with disabled syncer should return error") + } +} + +func TestAtomicWrite(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + filename := filepath.Join(tmpDir, "test-atomic.md") + content := []byte("test content") + + err := syncer.atomicWrite(filename, content) + if err != nil { + t.Fatalf("atomicWrite() failed: %v", err) + } + + // Verify file was created with correct content + actual, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(actual) != string(content) { + t.Errorf("atomicWrite() content = %q, want %q", actual, content) + } + + // Verify no temp files left behind + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("Failed to read directory: %v", err) + } + + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".tmp-") { + t.Errorf("atomicWrite() left temp file: %s", entry.Name()) + } + } +} + +func TestConcurrentSync(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Sync multiple prompts concurrently + done := make(chan bool) + for i := 0; i < 10; i++ { + go func(n int) { + prompt := &promptstore.Prompt{ + Name: "prompt-" + string(rune('0'+n)), + Title: "Concurrent Test", + Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Test"}}}, + } + if err := syncer.Sync(prompt, OpCreate); err != nil { + t.Errorf("Concurrent Sync() failed: %v", err) + } + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Verify all files were created + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("Failed to read directory: %v", err) + } + + if len(entries) != 10 { + t.Errorf("Concurrent sync created %d files, want 10", len(entries)) + } +} |
