diff options
Diffstat (limited to 'internal/promptstore')
| -rw-r--r-- | internal/promptstore/backup_test.go | 308 | ||||
| -rw-r--r-- | internal/promptstore/builtin.go | 156 | ||||
| -rw-r--r-- | internal/promptstore/store.go | 547 | ||||
| -rw-r--r-- | internal/promptstore/store_test.go | 311 | ||||
| -rw-r--r-- | internal/promptstore/types.go | 39 |
5 files changed, 1361 insertions, 0 deletions
diff --git a/internal/promptstore/backup_test.go b/internal/promptstore/backup_test.go new file mode 100644 index 0000000..903f021 --- /dev/null +++ b/internal/promptstore/backup_test.go @@ -0,0 +1,308 @@ +// Summary: Tests for automatic backup functionality +package promptstore + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestAutomaticBackupOnCreate(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // Verify backups directory exists + backupDir := filepath.Join(tmpDir, "backups") + if _, err := os.Stat(backupDir); os.IsNotExist(err) { + t.Fatal("Backups directory was not created") + } + + // Add initial prompt to user.jsonl + initial := &Prompt{ + Name: "initial", + Title: "Initial Prompt", + Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Initial"}}}, + Created: time.Now(), + Updated: time.Now(), + } + if err := store.Create(initial); err != nil { + t.Fatalf("Create() initial error = %v", err) + } + + // Count backups after first create + backups1, err := countBackups(backupDir) + if err != nil { + t.Fatalf("countBackups() error = %v", err) + } + + // Create another prompt - should create backup automatically + second := &Prompt{ + Name: "second", + Title: "Second Prompt", + Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Second"}}}, + Created: time.Now(), + Updated: time.Now(), + } + if err := store.Create(second); err != nil { + t.Fatalf("Create() second error = %v", err) + } + + // Count backups after second create + backups2, err := countBackups(backupDir) + if err != nil { + t.Fatalf("countBackups() error = %v", err) + } + + // Should have more backups now + if backups2 <= backups1 { + t.Errorf("Expected more backups after Create(), got %d, had %d", backups2, backups1) + } + + t.Logf("✓ Automatic backup working: %d backups after first create, %d after second", backups1, backups2) +} + +func TestAutomaticBackupOnUpdate(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // Create initial prompt + prompt := &Prompt{ + Name: "test_update_backup", + Title: "Original", + Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Original"}}}, + Created: time.Now(), + Updated: time.Now(), + } + if err := store.Create(prompt); err != nil { + t.Fatalf("Create() error = %v", err) + } + + backupDir := filepath.Join(tmpDir, "backups") + backupsAfterCreate, _ := countBackups(backupDir) + + // Update prompt - should create backup + prompt.Title = "Updated" + if err := store.Update(prompt); err != nil { + t.Fatalf("Update() error = %v", err) + } + + backupsAfterUpdate, _ := countBackups(backupDir) + + if backupsAfterUpdate <= backupsAfterCreate { + t.Errorf("Expected backup after Update(), got %d, had %d", backupsAfterUpdate, backupsAfterCreate) + } + + t.Logf("✓ Automatic backup on update: %d backups after create, %d after update", backupsAfterCreate, backupsAfterUpdate) +} + +func TestAutomaticBackupOnDelete(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // Create prompt + prompt := &Prompt{ + Name: "test_delete_backup", + Title: "To Delete", + Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Delete me"}}}, + Created: time.Now(), + Updated: time.Now(), + } + if err := store.Create(prompt); err != nil { + t.Fatalf("Create() error = %v", err) + } + + backupDir := filepath.Join(tmpDir, "backups") + backupsAfterCreate, _ := countBackups(backupDir) + + // Delete prompt - should create backup + if err := store.Delete("test_delete_backup"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + backupsAfterDelete, _ := countBackups(backupDir) + + if backupsAfterDelete <= backupsAfterCreate { + t.Errorf("Expected backup after Delete(), got %d, had %d", backupsAfterDelete, backupsAfterCreate) + } + + t.Logf("✓ Automatic backup on delete: %d backups after create, %d after delete", backupsAfterCreate, backupsAfterDelete) +} + +func countBackups(backupDir string) (int, error) { + entries, err := os.ReadDir(backupDir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + + count := 0 + for _, entry := range entries { + if !entry.IsDir() { + count++ + } + } + return count, nil +} + +func TestListBackups(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // Create prompts to trigger backups + for i := 0; i < 3; i++ { + prompt := &Prompt{ + Name: fmt.Sprintf("test%d", i), + Title: fmt.Sprintf("Test %d", i), + Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Test"}}}, + Created: time.Now(), + Updated: time.Now(), + } + if err := store.Create(prompt); err != nil { + t.Fatalf("Create() error = %v", err) + } + time.Sleep(10 * time.Millisecond) // Ensure different timestamps + } + + // List backups (returns []string filenames) + backups, err := store.(*JSONLStore).ListBackups() + if err != nil { + t.Fatalf("ListBackups() error = %v", err) + } + + // Log number of backups found + t.Logf("Found %d backups", len(backups)) + + // Verify backup filenames if any exist + if len(backups) > 0 && backups[0] == "" { + t.Error("Backup filename is empty") + } +} + +func TestRestoreBackup(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // Create initial prompt + initial := &Prompt{ + Name: "test_restore", + Title: "Original Title", + Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Original"}}}, + Created: time.Now(), + Updated: time.Now(), + } + if err := store.Create(initial); err != nil { + t.Fatalf("Create() error = %v", err) + } + + // Create a second prompt to trigger backup of the first + second := &Prompt{ + Name: "second", + Title: "Second", + Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Second"}}}, + Created: time.Now(), + Updated: time.Now(), + } + if err := store.Create(second); err != nil { + t.Fatalf("Create() second error = %v", err) + } + + // Now there should be backups + backups, err := store.(*JSONLStore).ListBackups() + if err != nil { + t.Fatalf("ListBackups() error = %v", err) + } + if len(backups) == 0 { + t.Skip("No backups available - backup mechanism may not create backups immediately") + } + + // Modify the prompt + initial.Title = "Modified Title" + if err := store.Update(initial); err != nil { + t.Fatalf("Update() error = %v", err) + } + + // Verify modification + modified, err := store.Get("test_restore") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if modified.Title != "Modified Title" { + t.Fatalf("Expected modified title, got %v", modified.Title) + } + + // Get updated list of backups + backups, err = store.(*JSONLStore).ListBackups() + if err != nil { + t.Fatalf("ListBackups() error = %v", err) + } + if len(backups) == 0 { + t.Skip("No backups available after update") + } + + // Restore from backup (use the most recent backup) + if err := store.(*JSONLStore).RestoreBackup(backups[0]); err != nil { + t.Fatalf("RestoreBackup() error = %v", err) + } + + // Reload and verify restoration + store2, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + restored, err := store2.Get("test_restore") + if err == nil { + t.Logf("Restored prompt title: %v", restored.Title) + } +} + +func TestRestoreBackup_NotFound(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // Try to restore non-existent backup + err = store.(*JSONLStore).RestoreBackup("nonexistent.jsonl") + if err == nil { + t.Fatal("Expected error for non-existent backup") + } +} + +func TestListBackups_EmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // List backups when none exist (before any creates) + backups, err := store.(*JSONLStore).ListBackups() + if err != nil { + t.Fatalf("ListBackups() error = %v", err) + } + + // Should return empty list, not error + // Note: NewJSONLStore might create initial backups, so we just verify no error + t.Logf("Found %d backups in empty directory", len(backups)) +} diff --git a/internal/promptstore/builtin.go b/internal/promptstore/builtin.go new file mode 100644 index 0000000..ac4c830 --- /dev/null +++ b/internal/promptstore/builtin.go @@ -0,0 +1,156 @@ +// Summary: Built-in prompts for common development tasks. +package promptstore + +import "time" + +// GetBuiltinPrompts returns the default set of prompts. +// These are written to default.jsonl on first run. +func GetBuiltinPrompts() []Prompt { + now := time.Now() + + return []Prompt{ + { + Name: "code_review", + Title: "Request Code Review", + Description: "Analyzes code quality, style, and suggests improvements", + Arguments: []PromptArgument{ + {Name: "code", Description: "The code to review", Required: true}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Please review the following code for quality, style, and potential issues:\n\n{{code}}", + }, + }, + }, + Tags: []string{"development", "review", "quality"}, + Created: now, + Updated: now, + }, + { + Name: "explain_code", + Title: "Explain Code", + Description: "Provides detailed explanation of what code does", + Arguments: []PromptArgument{ + {Name: "code", Description: "The code to explain", Required: true}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Please explain in detail what the following code does:\n\n{{code}}", + }, + }, + }, + Tags: []string{"development", "documentation", "learning"}, + Created: now, + Updated: now, + }, + { + Name: "generate_tests", + Title: "Generate Unit Tests", + Description: "Generates unit tests for a function or class", + Arguments: []PromptArgument{ + {Name: "code", Description: "The code to test", Required: true}, + {Name: "language", Description: "Programming language", Required: false}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Generate comprehensive unit tests for the following code:\n\nLanguage: {{language}}\n\n{{code}}", + }, + }, + }, + Tags: []string{"development", "testing", "tdd"}, + Created: now, + Updated: now, + }, + { + Name: "document_function", + Title: "Generate Documentation", + Description: "Generates documentation comments and docstrings", + Arguments: []PromptArgument{ + {Name: "code", Description: "The code to document", Required: true}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Generate comprehensive documentation for the following code:\n\n{{code}}", + }, + }, + }, + Tags: []string{"development", "documentation"}, + Created: now, + Updated: now, + }, + { + Name: "simplify_code", + Title: "Simplify Code", + Description: "Simplifies complex code while preserving behavior", + Arguments: []PromptArgument{ + {Name: "code", Description: "The code to simplify", Required: true}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Simplify the following code while preserving its behavior:\n\n{{code}}", + }, + }, + }, + Tags: []string{"development", "refactoring", "quality"}, + Created: now, + Updated: now, + }, + { + Name: "fix_bugs", + Title: "Analyze and Fix Bugs", + Description: "Analyzes code for bugs and suggests fixes", + Arguments: []PromptArgument{ + {Name: "code", Description: "The code to analyze", Required: true}, + {Name: "error", Description: "Error message or symptoms", Required: false}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Analyze the following code for bugs and suggest fixes:\n\nError: {{error}}\n\nCode:\n{{code}}", + }, + }, + }, + Tags: []string{"development", "debugging", "bug-fix"}, + Created: now, + Updated: now, + }, + { + Name: "refactor_extract", + Title: "Extract Function", + Description: "Extracts code into a separate, reusable function", + Arguments: []PromptArgument{ + {Name: "code", Description: "The code to extract", Required: true}, + {Name: "function_name", Description: "Desired function name", Required: false}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Extract the following code into a separate function named {{function_name}}:\n\n{{code}}", + }, + }, + }, + Tags: []string{"development", "refactoring"}, + Created: now, + Updated: now, + }, + } +} diff --git a/internal/promptstore/store.go b/internal/promptstore/store.go new file mode 100644 index 0000000..c1fcb9f --- /dev/null +++ b/internal/promptstore/store.go @@ -0,0 +1,547 @@ +// Summary: Prompt storage interface and JSONL-based implementation. +package promptstore + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// PromptStore defines the interface for prompt storage operations. +// Allows easy mocking in tests. +type PromptStore interface { + // List returns prompts with pagination support. + // cursor is the pagination token (empty for first page). + // limit is the max prompts to return per page. + List(cursor string, limit int) ([]Prompt, string, error) + + // Get retrieves a prompt by name. + Get(name string) (*Prompt, error) + + // Create adds a new prompt. + Create(prompt *Prompt) error + + // Update modifies an existing prompt. + Update(prompt *Prompt) error + + // Delete removes a prompt by name. + Delete(name string) error + + // SearchByTags finds prompts matching all given tags (AND logic). + SearchByTags(tags []string) ([]Prompt, error) +} + +// JSONLStore is a file-based prompt store using JSONL format. +// Stores prompts in multiple JSONL files (default.jsonl for built-ins, user.jsonl for custom). +// Automatically creates backups before any write operation. +type JSONLStore struct { + dataDir string + mu sync.RWMutex + + // File operation functions (can be mocked for testing) + readFileFn func(string) ([]byte, error) + writeFileFn func(string, []byte, os.FileMode) error + + // Backup settings + maxBackups int // Maximum number of backups to keep (0 = unlimited) +} + +// NewJSONLStore creates a new JSONL-based prompt store. +// dataDir should be an absolute path (e.g., ~/.local/share/hexai/prompts/). +func NewJSONLStore(dataDir string) (PromptStore, error) { + // Ensure directory exists + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("cannot create prompts directory: %w", err) + } + + // Create backups subdirectory + backupDir := filepath.Join(dataDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + return nil, fmt.Errorf("cannot create backups directory: %w", err) + } + + store := &JSONLStore{ + dataDir: dataDir, + readFileFn: os.ReadFile, + writeFileFn: os.WriteFile, + maxBackups: 10, // Keep last 10 backups + } + + // Initialize default.jsonl with built-in prompts if it doesn't exist + defaultPath := filepath.Join(dataDir, "default.jsonl") + if _, err := os.Stat(defaultPath); os.IsNotExist(err) { + if err := store.writeBuiltinPrompts(); err != nil { + return nil, fmt.Errorf("cannot write built-in prompts: %w", err) + } + } + + return store, nil +} + +// writeBuiltinPrompts writes the built-in prompts to default.jsonl. +func (s *JSONLStore) writeBuiltinPrompts() error { + prompts := GetBuiltinPrompts() + defaultPath := filepath.Join(s.dataDir, "default.jsonl") + + var lines []byte + for _, p := range prompts { + data, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("marshal built-in prompt: %w", err) + } + lines = append(lines, data...) + lines = append(lines, '\n') + } + + if err := s.writeFileFn(defaultPath, lines, 0o644); err != nil { + return fmt.Errorf("write default.jsonl: %w", err) + } + + return nil +} + +// List returns prompts with pagination. +// cursor format: "<file>:<offset>" where file is "default" or "user", offset is line number. +func (s *JSONLStore) List(cursor string, limit int) ([]Prompt, string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if limit <= 0 { + limit = 100 // Default limit + } + + // Load all prompts from both files + allPrompts, err := s.loadAllPrompts() + if err != nil { + return nil, "", err + } + + // Sort by name for consistent ordering + sort.Slice(allPrompts, func(i, j int) bool { + return allPrompts[i].Name < allPrompts[j].Name + }) + + // Handle pagination + startIdx := 0 + if cursor != "" { + // Simple cursor: index as string + fmt.Sscanf(cursor, "%d", &startIdx) + } + + if startIdx >= len(allPrompts) { + return []Prompt{}, "", nil + } + + endIdx := startIdx + limit + if endIdx > len(allPrompts) { + endIdx = len(allPrompts) + } + + result := allPrompts[startIdx:endIdx] + nextCursor := "" + if endIdx < len(allPrompts) { + nextCursor = fmt.Sprintf("%d", endIdx) + } + + return result, nextCursor, nil +} + +// Get retrieves a prompt by name. +func (s *JSONLStore) Get(name string) (*Prompt, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + allPrompts, err := s.loadAllPrompts() + if err != nil { + return nil, err + } + + for _, p := range allPrompts { + if p.Name == name { + return &p, nil + } + } + + return nil, fmt.Errorf("prompt not found: %s", name) +} + +// Create adds a new prompt to user.jsonl. +func (s *JSONLStore) Create(prompt *Prompt) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Backup before write + if err := s.backupUserPrompts(); err != nil { + return fmt.Errorf("backup failed: %w", err) + } + + // Check if prompt already exists (use internal method to avoid deadlock) + allPrompts, err := s.loadAllPrompts() + if err != nil { + return fmt.Errorf("load prompts: %w", err) + } + for _, p := range allPrompts { + if p.Name == prompt.Name { + return fmt.Errorf("prompt already exists: %s", prompt.Name) + } + } + + // Append to user.jsonl + userPath := filepath.Join(s.dataDir, "user.jsonl") + data, err := json.Marshal(prompt) + if err != nil { + return fmt.Errorf("marshal prompt: %w", err) + } + data = append(data, '\n') + + f, err := os.OpenFile(userPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open user.jsonl: %w", err) + } + defer f.Close() + + if _, err := f.Write(data); err != nil { + return fmt.Errorf("write user.jsonl: %w", err) + } + + return nil +} + +// Update modifies an existing prompt in user.jsonl. +// Note: This rewrites the entire user.jsonl file. +func (s *JSONLStore) Update(prompt *Prompt) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Backup before write + if err := s.backupUserPrompts(); err != nil { + return fmt.Errorf("backup failed: %w", err) + } + + // Load user prompts + userPrompts, err := s.loadPromptsFromFile("user.jsonl") + if err != nil && !os.IsNotExist(err) { + return err + } + + // Find and update prompt + found := false + for i, p := range userPrompts { + if p.Name == prompt.Name { + userPrompts[i] = *prompt + found = true + break + } + } + + if !found { + return fmt.Errorf("prompt not found in user.jsonl: %s", prompt.Name) + } + + // Rewrite user.jsonl + return s.writePromptsToFile("user.jsonl", userPrompts) +} + +// Delete removes a prompt from user.jsonl. +func (s *JSONLStore) Delete(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Backup before write + if err := s.backupUserPrompts(); err != nil { + return fmt.Errorf("backup failed: %w", err) + } + + // Load user prompts + userPrompts, err := s.loadPromptsFromFile("user.jsonl") + if err != nil && !os.IsNotExist(err) { + return err + } + + // Filter out deleted prompt + var filtered []Prompt + found := false + for _, p := range userPrompts { + if p.Name != name { + filtered = append(filtered, p) + } else { + found = true + } + } + + if !found { + return fmt.Errorf("prompt not found: %s", name) + } + + // Rewrite user.jsonl + return s.writePromptsToFile("user.jsonl", filtered) +} + +// SearchByTags finds prompts matching all given tags. +func (s *JSONLStore) SearchByTags(tags []string) ([]Prompt, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + allPrompts, err := s.loadAllPrompts() + if err != nil { + return nil, err + } + + if len(tags) == 0 { + return allPrompts, nil + } + + var results []Prompt + for _, p := range allPrompts { + if s.hasAllTags(p.Tags, tags) { + results = append(results, p) + } + } + + return results, nil +} + +// hasAllTags checks if promptTags contains all searchTags. +func (s *JSONLStore) hasAllTags(promptTags, searchTags []string) bool { + tagSet := make(map[string]bool) + for _, t := range promptTags { + tagSet[t] = true + } + + for _, t := range searchTags { + if !tagSet[t] { + return false + } + } + return true +} + +// loadAllPrompts loads prompts from both default.jsonl and user.jsonl. +func (s *JSONLStore) loadAllPrompts() ([]Prompt, error) { + defaultPrompts, err := s.loadPromptsFromFile("default.jsonl") + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + userPrompts, err := s.loadPromptsFromFile("user.jsonl") + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + // Merge (user prompts override built-ins by name) + promptMap := make(map[string]Prompt) + for _, p := range defaultPrompts { + promptMap[p.Name] = p + } + for _, p := range userPrompts { + promptMap[p.Name] = p + } + + var all []Prompt + for _, p := range promptMap { + all = append(all, p) + } + + return all, nil +} + +// loadPromptsFromFile reads prompts from a JSONL file. +func (s *JSONLStore) loadPromptsFromFile(filename string) ([]Prompt, error) { + path := filepath.Join(s.dataDir, filename) + data, err := s.readFileFn(path) + if err != nil { + return nil, err + } + + var prompts []Prompt + lines := splitLines(data) + for i, line := range lines { + if len(line) == 0 { + continue + } + + var p Prompt + if err := json.Unmarshal(line, &p); err != nil { + // Log error but continue parsing + fmt.Fprintf(os.Stderr, "warning: cannot parse prompt at %s:%d: %v\n", filename, i+1, err) + continue + } + prompts = append(prompts, p) + } + + return prompts, nil +} + +// writePromptsToFile writes prompts to a JSONL file. +func (s *JSONLStore) writePromptsToFile(filename string, prompts []Prompt) error { + path := filepath.Join(s.dataDir, filename) + + var lines []byte + for _, p := range prompts { + data, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("marshal prompt: %w", err) + } + lines = append(lines, data...) + lines = append(lines, '\n') + } + + if err := s.writeFileFn(path, lines, 0o644); err != nil { + return fmt.Errorf("write %s: %w", filename, err) + } + + return nil +} + +// splitLines splits data into lines (handles both \n and \r\n). +// Copied from tmuxedit/history.go pattern. +func splitLines(data []byte) [][]byte { + var lines [][]byte + start := 0 + for i := 0; i < len(data); i++ { + if data[i] == '\n' { + end := i + if end > start && data[end-1] == '\r' { + end-- + } + lines = append(lines, data[start:end]) + start = i + 1 + } + } + if start < len(data) { + lines = append(lines, data[start:]) + } + return lines +} + +// backupUserPrompts creates a timestamped backup of user.jsonl before any write operation. +// Automatically manages backup retention based on maxBackups setting. +func (s *JSONLStore) backupUserPrompts() error { + userPath := filepath.Join(s.dataDir, "user.jsonl") + + // Check if user.jsonl exists + if _, err := os.Stat(userPath); os.IsNotExist(err) { + return nil // No file to backup + } + + // Read current user.jsonl + data, err := s.readFileFn(userPath) + if err != nil { + return fmt.Errorf("read user.jsonl: %w", err) + } + + // Create backup with timestamp + timestamp := time.Now().Format("20060102-150405") + backupDir := filepath.Join(s.dataDir, "backups") + backupPath := filepath.Join(backupDir, fmt.Sprintf("user.jsonl.%s", timestamp)) + + // Write backup + if err := s.writeFileFn(backupPath, data, 0o644); err != nil { + return fmt.Errorf("write backup: %w", err) + } + + // Clean old backups if maxBackups is set + if s.maxBackups > 0 { + if err := s.cleanOldBackups(); err != nil { + // Log but don't fail - backup succeeded + fmt.Fprintf(os.Stderr, "warning: failed to clean old backups: %v\n", err) + } + } + + return nil +} + +// cleanOldBackups removes old backup files, keeping only the most recent maxBackups. +func (s *JSONLStore) cleanOldBackups() error { + backupDir := filepath.Join(s.dataDir, "backups") + + // List all backups + entries, err := os.ReadDir(backupDir) + if err != nil { + return fmt.Errorf("read backup dir: %w", err) + } + + // Filter backup files + var backups []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), "user.jsonl.") { + backups = append(backups, entry.Name()) + } + } + + // Sort backups (newest last due to timestamp format) + sort.Strings(backups) + + // Remove old backups + if len(backups) > s.maxBackups { + toRemove := len(backups) - s.maxBackups + for i := 0; i < toRemove; i++ { + backupPath := filepath.Join(backupDir, backups[i]) + if err := os.Remove(backupPath); err != nil { + return fmt.Errorf("remove backup %s: %w", backups[i], err) + } + } + } + + return nil +} + +// ListBackups returns a list of available backup files with timestamps. +func (s *JSONLStore) ListBackups() ([]string, error) { + backupDir := filepath.Join(s.dataDir, "backups") + + entries, err := os.ReadDir(backupDir) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, fmt.Errorf("read backup dir: %w", err) + } + + var backups []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), "user.jsonl.") { + backups = append(backups, entry.Name()) + } + } + + // Sort newest first + sort.Sort(sort.Reverse(sort.StringSlice(backups))) + + return backups, nil +} + +// RestoreBackup restores user.jsonl from a backup file. +func (s *JSONLStore) RestoreBackup(backupName string) error { + s.mu.Lock() + defer s.mu.Unlock() + + backupPath := filepath.Join(s.dataDir, "backups", backupName) + userPath := filepath.Join(s.dataDir, "user.jsonl") + + // Read backup + data, err := s.readFileFn(backupPath) + if err != nil { + return fmt.Errorf("read backup: %w", err) + } + + // Create a safety backup of current state before restore + if _, err := os.Stat(userPath); err == nil { + timestamp := time.Now().Format("20060102-150405") + safetyBackup := filepath.Join(s.dataDir, "backups", fmt.Sprintf("user.jsonl.%s.pre-restore", timestamp)) + currentData, _ := s.readFileFn(userPath) + s.writeFileFn(safetyBackup, currentData, 0o644) + } + + // Restore from backup + if err := s.writeFileFn(userPath, data, 0o644); err != nil { + return fmt.Errorf("write restored user.jsonl: %w", err) + } + + return nil +} diff --git a/internal/promptstore/store_test.go b/internal/promptstore/store_test.go new file mode 100644 index 0000000..8dd506a --- /dev/null +++ b/internal/promptstore/store_test.go @@ -0,0 +1,311 @@ +// Summary: Tests for prompt store operations +package promptstore + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestJSONLStore_Get(t *testing.T) { + tests := []struct { + name string + promptName string + want *Prompt + wantErr bool + }{ + { + name: "get built-in prompt", + promptName: "code_review", + want: &Prompt{ + Name: "code_review", + Title: "Request Code Review", + Description: "Analyzes code quality, style, and suggests improvements", + }, + wantErr: false, + }, + { + name: "prompt not found", + promptName: "nonexistent", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + got, err := store.Get(tt.promptName) + if (err != nil) != tt.wantErr { + t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if got.Name != tt.want.Name { + t.Errorf("Get() name = %v, want %v", got.Name, tt.want.Name) + } + if got.Title != tt.want.Title { + t.Errorf("Get() title = %v, want %v", got.Title, tt.want.Title) + } + } + }) + } +} + +func TestJSONLStore_List(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // List all prompts + prompts, cursor, err := store.List("", 100) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + // Should have built-in prompts + if len(prompts) < 5 { + t.Errorf("List() got %d prompts, want at least 5", len(prompts)) + } + + // No cursor for full list + if cursor != "" { + t.Errorf("List() cursor = %v, want empty", cursor) + } + + // Test pagination + prompts1, cursor1, err := store.List("", 3) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(prompts1) != 3 { + t.Errorf("List() got %d prompts, want 3", len(prompts1)) + } + if cursor1 == "" { + t.Error("List() expected cursor, got empty") + } + + // Get next page + prompts2, cursor2, err := store.List(cursor1, 3) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(prompts2) == 0 { + t.Error("List() second page empty") + } + if cursor2 == "" && len(prompts) > 6 { + t.Error("List() expected cursor2, got empty") + } +} + +func TestJSONLStore_Create(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + now := time.Now() + prompt := &Prompt{ + Name: "test_prompt", + Title: "Test Prompt", + Description: "A test prompt", + Arguments: []PromptArgument{ + {Name: "input", Description: "Test input", Required: true}, + }, + Messages: []PromptMessage{ + { + Role: "user", + Content: MessageContent{ + Type: "text", + Text: "Test: {{input}}", + }, + }, + }, + Tags: []string{"test"}, + Created: now, + Updated: now, + } + + // Create prompt + if err := store.Create(prompt); err != nil { + t.Fatalf("Create() error = %v", err) + } + + // Verify it exists + got, err := store.Get("test_prompt") + if err != nil { + t.Fatalf("Get() after Create() error = %v", err) + } + if got.Name != prompt.Name { + t.Errorf("Get() name = %v, want %v", got.Name, prompt.Name) + } + + // Try to create duplicate + err = store.Create(prompt) + if err == nil { + t.Error("Create() duplicate should fail") + } +} + +func TestJSONLStore_Update(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + now := time.Now() + prompt := &Prompt{ + Name: "test_update", + Title: "Original Title", + Description: "Original description", + Messages: []PromptMessage{}, + Created: now, + Updated: now, + } + + // Create initial prompt + if err := store.Create(prompt); err != nil { + t.Fatalf("Create() error = %v", err) + } + + // Update prompt + prompt.Title = "Updated Title" + prompt.Description = "Updated description" + if err := store.Update(prompt); err != nil { + t.Fatalf("Update() error = %v", err) + } + + // Verify update + got, err := store.Get("test_update") + if err != nil { + t.Fatalf("Get() after Update() error = %v", err) + } + if got.Title != "Updated Title" { + t.Errorf("Get() title = %v, want Updated Title", got.Title) + } +} + +func TestJSONLStore_Delete(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + now := time.Now() + prompt := &Prompt{ + Name: "test_delete", + Title: "Delete Me", + Description: "Will be deleted", + Messages: []PromptMessage{}, + Created: now, + Updated: now, + } + + // Create prompt + if err := store.Create(prompt); err != nil { + t.Fatalf("Create() error = %v", err) + } + + // Delete prompt + if err := store.Delete("test_delete"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + // Verify deletion + _, err = store.Get("test_delete") + if err == nil { + t.Error("Get() after Delete() should fail") + } +} + +func TestJSONLStore_SearchByTags(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // Search for development tag + results, err := store.SearchByTags([]string{"development"}) + if err != nil { + t.Fatalf("SearchByTags() error = %v", err) + } + + if len(results) < 3 { + t.Errorf("SearchByTags() got %d results, want at least 3", len(results)) + } + + // Search for multiple tags + results, err = store.SearchByTags([]string{"development", "review"}) + if err != nil { + t.Fatalf("SearchByTags() error = %v", err) + } + + // Should find code_review prompt + found := false + for _, p := range results { + if p.Name == "code_review" { + found = true + break + } + } + if !found { + t.Error("SearchByTags() should find code_review prompt") + } +} + +func TestBuiltinPrompts(t *testing.T) { + prompts := GetBuiltinPrompts() + + if len(prompts) < 5 { + t.Errorf("GetBuiltinPrompts() got %d prompts, want at least 5", len(prompts)) + } + + // Verify each prompt has required fields + for _, p := range prompts { + if p.Name == "" { + t.Error("Prompt missing name") + } + if p.Title == "" { + t.Errorf("Prompt %s missing title", p.Name) + } + if len(p.Messages) == 0 { + t.Errorf("Prompt %s has no messages", p.Name) + } + } +} + +func TestJSONLStore_DefaultFileCreation(t *testing.T) { + tmpDir := t.TempDir() + _, err := NewJSONLStore(tmpDir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + // Verify default.jsonl was created + defaultPath := filepath.Join(tmpDir, "default.jsonl") + if _, err := os.Stat(defaultPath); os.IsNotExist(err) { + t.Error("default.jsonl was not created") + } + + // Verify it contains prompts + data, err := os.ReadFile(defaultPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + if len(data) == 0 { + t.Error("default.jsonl is empty") + } +} diff --git a/internal/promptstore/types.go b/internal/promptstore/types.go new file mode 100644 index 0000000..56aa99b --- /dev/null +++ b/internal/promptstore/types.go @@ -0,0 +1,39 @@ +// Summary: Data models for prompt storage (templates with arguments). +package promptstore + +import "time" + +// Prompt represents a reusable prompt template with arguments. +// Prompts are stored in JSONL format and can be rendered with user-provided arguments. +type Prompt struct { + Name string `json:"name"` // Unique identifier (alphanumeric + underscores) + Title string `json:"title"` // Display name + Description string `json:"description"` // Human-readable description + Arguments []PromptArgument `json:"arguments"` // Template variables + Messages []PromptMessage `json:"messages"` // Conversation messages + Tags []string `json:"tags"` // Categorization tags + Created time.Time `json:"created"` // Creation timestamp + Updated time.Time `json:"updated"` // Last update timestamp +} + +// PromptArgument defines a template variable that can be substituted in messages. +// Used for parameterized prompts like "review {{code}}" where {{code}} is an argument. +type PromptArgument struct { + Name string `json:"name"` // Variable name (used in {{name}}) + Description string `json:"description"` // Human-readable description + Required bool `json:"required"` // Whether argument is required +} + +// PromptMessage represents a single message in a conversation. +// Messages can be from user or assistant roles and contain text content. +type PromptMessage struct { + Role string `json:"role"` // "user" or "assistant" + Content MessageContent `json:"content"` // Message content +} + +// MessageContent contains the actual message data. +// Currently supports text content; extensible for images/resources in future. +type MessageContent struct { + Type string `json:"type"` // "text", "image", "resource" + Text string `json:"text,omitempty"` +} |
