package nigiri import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" ) type MigrationCommand struct { Action string Field string To string Type string Store string } type MigrationHandler func(data []map[string]any, cmd *MigrationCommand) error type Migrator struct { handlers map[string]MigrationHandler patterns map[string]*regexp.Regexp } func NewMigrator() *Migrator { m := &Migrator{ handlers: make(map[string]MigrationHandler), patterns: make(map[string]*regexp.Regexp), } // Register built-in commands m.RegisterCommand("rename", regexp.MustCompile(`^rename\s+(\w+)\s+to\s+(\w+)$`), m.handleRename) m.RegisterCommand("add", regexp.MustCompile(`^add\s+(\w+)\s+(\w+)\s+to\s+(\w+)$`), m.handleAdd) m.RegisterCommand("remove", regexp.MustCompile(`^remove\s+(\w+)\s+from\s+(\w+)$`), m.handleRemove) m.RegisterCommand("change", regexp.MustCompile(`^change\s+(\w+)\s+to\s+(\w+)$`), m.handleChange) return m } func (m *Migrator) RegisterCommand(name string, pattern *regexp.Regexp, handler MigrationHandler) { m.patterns[name] = pattern m.handlers[name] = handler } func (m *Migrator) ParseCommand(input string) (*MigrationCommand, string, error) { input = strings.TrimSpace(input) for name, pattern := range m.patterns { if matches := pattern.FindStringSubmatch(input); matches != nil { cmd := &MigrationCommand{Action: name} switch name { case "rename": cmd.Field, cmd.To = matches[1], matches[2] case "add": cmd.Field, cmd.Type, cmd.Store = matches[1], matches[2], matches[3] case "remove": cmd.Field, cmd.Store = matches[1], matches[2] case "change": cmd.Field, cmd.Type = matches[1], matches[2] default: // Allow custom commands to handle their own parsing cmd.Field = matches[1] if len(matches) > 2 { cmd.To = matches[2] } if len(matches) > 3 { cmd.Type = matches[3] } if len(matches) > 4 { cmd.Store = matches[4] } } return cmd, name, nil } } return nil, "", fmt.Errorf("unknown command: %s", input) } func (m *Migrator) ApplyCommand(data []byte, cmdStr string) ([]byte, error) { cmd, name, err := m.ParseCommand(cmdStr) if err != nil { return nil, err } var items []map[string]any if err := json.Unmarshal(data, &items); err != nil { return nil, fmt.Errorf("invalid JSON: %w", err) } handler := m.handlers[name] if err := handler(items, cmd); err != nil { return nil, err } return json.MarshalIndent(items, "", "\t") } func (m *Migrator) MigrateFile(filename, command string) error { data, err := os.ReadFile(filename) if err != nil { return fmt.Errorf("read file: %w", err) } result, err := m.ApplyCommand(data, command) if err != nil { return err } // Backup and write backupPath := filename + ".backup" if err := os.WriteFile(backupPath, data, 0644); err != nil { return fmt.Errorf("create backup: %w", err) } if err := os.WriteFile(filename, result, 0644); err != nil { return fmt.Errorf("write result: %w", err) } fmt.Printf("✓ Applied: %s\n", command) fmt.Printf("✓ Backup: %s\n", backupPath) return nil } func (m *Migrator) RunScript(dataDir, scriptFile string) error { file, err := os.Open(scriptFile) if err != nil { return fmt.Errorf("open script: %w", err) } defer file.Close() scanner := bufio.NewScanner(file) lineNum := 0 for scanner.Scan() { lineNum++ line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { return fmt.Errorf("line %d: invalid format, use 'file.json: command'", lineNum) } filename := strings.TrimSpace(parts[0]) command := strings.TrimSpace(parts[1]) fullPath := filepath.Join(dataDir, filename) fmt.Printf("Line %d: %s -> %s\n", lineNum, filename, command) if err := m.MigrateFile(fullPath, command); err != nil { return fmt.Errorf("line %d: %w", lineNum, err) } } return scanner.Err() } // Built-in handlers func (m *Migrator) handleRename(items []map[string]any, cmd *MigrationCommand) error { for _, item := range items { if val, exists := item[cmd.Field]; exists { item[cmd.To] = val delete(item, cmd.Field) } } return nil } func (m *Migrator) handleAdd(items []map[string]any, cmd *MigrationCommand) error { defaultVal := getDefaultValue(cmd.Type) for _, item := range items { if _, exists := item[cmd.Field]; !exists { item[cmd.Field] = defaultVal } } return nil } func (m *Migrator) handleRemove(items []map[string]any, cmd *MigrationCommand) error { for _, item := range items { delete(item, cmd.Field) } return nil } func (m *Migrator) handleChange(items []map[string]any, cmd *MigrationCommand) error { for _, item := range items { if val, exists := item[cmd.Field]; exists { converted, err := convertType(val, cmd.Type) if err != nil { return fmt.Errorf("type conversion failed: %w", err) } item[cmd.Field] = converted } } return nil } func getDefaultValue(fieldType string) any { switch fieldType { case "string": return "" case "int": return 0 case "bool": return false case "time": return time.Now().Format(time.RFC3339) case "float": return 0.0 default: return nil } } func convertType(val any, targetType string) (any, error) { switch targetType { case "string": return fmt.Sprintf("%v", val), nil case "int": if str, ok := val.(string); ok { return strconv.Atoi(str) } if f, ok := val.(float64); ok { return int(f), nil } return val, nil case "float": if str, ok := val.(string); ok { return strconv.ParseFloat(str, 64) } if i, ok := val.(int); ok { return float64(i), nil } return val, nil case "bool": if str, ok := val.(string); ok { return strconv.ParseBool(str) } return val, nil default: return val, nil } } // CLI wrapper for host applications type MigrationCLI struct { collection *Collection } func NewMigrationCLI(collection *Collection) *MigrationCLI { return &MigrationCLI{collection: collection} } func (cli *MigrationCLI) Run(args []string) error { if len(args) < 1 { return fmt.Errorf("usage: migrate 'command' OR migrate ") } if strings.HasSuffix(args[0], ".txt") { // Script mode return cli.collection.RunMigrationScript(args[0]) } else { // Single command mode if len(args) < 2 { return fmt.Errorf("usage: migrate 'command'") } // Extract store name from filename filename := args[0] storeName := strings.TrimSuffix(filepath.Base(filename), ".json") command := args[1] return cli.collection.MigrateStore(storeName, command) } } func (cli *MigrationCLI) PrintUsage() { fmt.Println("Migration Commands:") fmt.Println(" rename oldfield to newfield") fmt.Println(" add fieldname type to store") fmt.Println(" remove fieldname from store") fmt.Println(" change fieldname to type") fmt.Println() fmt.Println("Types: string, int, float, bool, time") fmt.Println() fmt.Println("Usage:") fmt.Println(" migrate users.json 'rename Name to FullName'") fmt.Println(" migrate migrations/001_schema.txt") }