summaryrefslogtreecommitdiff
path: root/internal/slashcommands
diff options
context:
space:
mode:
Diffstat (limited to 'internal/slashcommands')
-rw-r--r--internal/slashcommands/converter.go113
-rw-r--r--internal/slashcommands/converter_test.go273
-rw-r--r--internal/slashcommands/syncer.go175
-rw-r--r--internal/slashcommands/syncer_test.go369
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))
+ }
+}