17 Commits

15 changed files with 2051 additions and 39 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
*.exe *.exe
.qodo .qodo
*.sqlite *.sqlite
testfiles

13
.vscode/launch.json vendored
View File

@@ -98,6 +98,19 @@
"args": [ "args": [
"cook_tacz.yml", "cook_tacz.yml",
] ]
},
{
"name": "Launch Package (ICARUS)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "C:/Users/Administrator/Seafile/Games-ICARUS/Icarus/Saved/IME3/Mods",
"args": [
"-loglevel",
"trace",
"cook_processorrecipes.yml",
]
} }
] ]
} }

9
go.mod
View File

@@ -13,7 +13,6 @@ require (
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hexops/valast v1.5.0 // indirect github.com/hexops/valast v1.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@@ -21,6 +20,8 @@ require (
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/mod v0.21.0 // indirect golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
@@ -29,4 +30,8 @@ require (
mvdan.cc/gofumpt v0.4.0 // indirect mvdan.cc/gofumpt v0.4.0 // indirect
) )
require gorm.io/driver/sqlite v1.6.0 require (
github.com/google/go-cmp v0.6.0
github.com/tidwall/gjson v1.18.0
gorm.io/driver/sqlite v1.6.0
)

6
go.sum
View File

@@ -36,6 +36,12 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=

152
main.go
View File

@@ -44,9 +44,13 @@ func main() {
fmt.Fprintf(os.Stderr, " Reset files to their original state\n") fmt.Fprintf(os.Stderr, " Reset files to their original state\n")
fmt.Fprintf(os.Stderr, " -loglevel string\n") fmt.Fprintf(os.Stderr, " -loglevel string\n")
fmt.Fprintf(os.Stderr, " Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE (default \"INFO\")\n") fmt.Fprintf(os.Stderr, " Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE (default \"INFO\")\n")
fmt.Fprintf(os.Stderr, " -json\n")
fmt.Fprintf(os.Stderr, " Enable JSON mode for processing JSON files\n")
fmt.Fprintf(os.Stderr, "\nExamples:\n") fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " Regex mode (default):\n") fmt.Fprintf(os.Stderr, " Regex mode (default):\n")
fmt.Fprintf(os.Stderr, " %s \"<value>(\\\\d+)</value>\" \"*1.5\" data.xml\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s \"<value>(\\\\d+)</value>\" \"*1.5\" data.xml\n", os.Args[0])
fmt.Fprintf(os.Stderr, " JSON mode:\n")
fmt.Fprintf(os.Stderr, " %s -json data.json\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nNote: v1, v2, etc. are used to refer to capture groups as numbers.\n") fmt.Fprintf(os.Stderr, "\nNote: v1, v2, etc. are used to refer to capture groups as numbers.\n")
fmt.Fprintf(os.Stderr, " s1, s2, etc. are used to refer to capture groups as strings.\n") fmt.Fprintf(os.Stderr, " s1, s2, etc. are used to refer to capture groups as strings.\n")
fmt.Fprintf(os.Stderr, " Helper functions: num(str) converts string to number, str(num) converts number to string\n") fmt.Fprintf(os.Stderr, " Helper functions: num(str) converts string to number, str(num) converts number to string\n")
@@ -54,6 +58,8 @@ func main() {
fmt.Fprintf(os.Stderr, " If expression starts with an operator like *, /, +, -, =, etc., v1 is automatically prepended\n") fmt.Fprintf(os.Stderr, " If expression starts with an operator like *, /, +, -, =, etc., v1 is automatically prepended\n")
fmt.Fprintf(os.Stderr, " You can use any valid Lua code, including if statements, loops, etc.\n") fmt.Fprintf(os.Stderr, " You can use any valid Lua code, including if statements, loops, etc.\n")
fmt.Fprintf(os.Stderr, " Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)\n") fmt.Fprintf(os.Stderr, " Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)\n")
fmt.Fprintf(os.Stderr, "\nLua Functions Available:\n")
fmt.Fprintf(os.Stderr, "%s\n", processor.GetLuaFunctionsHelp())
} }
// TODO: Fix bed shitting when doing *.yml in barotrauma directory // TODO: Fix bed shitting when doing *.yml in barotrauma directory
flag.Parse() flag.Parse()
@@ -426,7 +432,7 @@ func CreateExampleConfig() {
}, },
// Multiline regex example (DOTALL is auto-enabled). Captures numeric in nested XML. // Multiline regex example (DOTALL is auto-enabled). Captures numeric in nested XML.
{ {
Name: "XMLNestedValueMultiply", Name: "XMLNestedValueMultiply",
Regex: `<item>\s*\s*<name>!any<\/name>\s*\s*<value>(!num)<\/value>\s*\s*<\/item>`, Regex: `<item>\s*\s*<name>!any<\/name>\s*\s*<value>(!num)<\/value>\s*\s*<\/item>`,
Lua: `* $multiply`, Lua: `* $multiply`,
Files: []string{"data/**/*.xml"}, Files: []string{"data/**/*.xml"},
@@ -439,8 +445,8 @@ func CreateExampleConfig() {
`<entry>\s*\n\s*<id>(?P<id>!num)</id>\s*\n\s*<score>(?P<score>!num)</score>\s*\n\s*</entry>`, `<entry>\s*\n\s*<id>(?P<id>!num)</id>\s*\n\s*<score>(?P<score>!num)</score>\s*\n\s*</entry>`,
`\[block\]\nkey=(?P<key>[A-Za-z_]+)\nvalue=(?P<val>!num)`, `\[block\]\nkey=(?P<key>[A-Za-z_]+)\nvalue=(?P<val>!num)`,
}, },
Lua: `if is_number(score) then score = score * 2 end; if is_number(val) then val = val * 3 end; return true`, Lua: `if is_number(score) then score = score * 2 end; if is_number(val) then val = val * 3 end; return true`,
Files: []string{"examples/**/*.*"}, Files: []string{"examples/**/*.*"},
LogLevel: "DEBUG", LogLevel: "DEBUG",
}, },
// Use equals operator shorthand and boolean variable // Use equals operator shorthand and boolean variable
@@ -452,19 +458,19 @@ func CreateExampleConfig() {
}, },
// Demonstrate NoDedup to allow overlapping replacements // Demonstrate NoDedup to allow overlapping replacements
{ {
Name: "OverlappingGroups", Name: "OverlappingGroups",
Regex: `(?P<a>!num)(?P<b>!num)`, Regex: `(?P<a>!num)(?P<b>!num)`,
Lua: `a = num(a) + 1; b = num(b) + 1; return true`, Lua: `a = num(a) + 1; b = num(b) + 1; return true`,
Files: []string{"overlap/**/*.txt"}, Files: []string{"overlap/**/*.txt"},
NoDedup: true, NoDedup: true,
}, },
// Isolate command example operating on entire matched block // Isolate command example operating on entire matched block
{ {
Name: "IsolateUppercaseBlock", Name: "IsolateUppercaseBlock",
Regex: `BEGIN\n(?P<block>!any)\nEND`, Regex: `BEGIN\n(?P<block>!any)\nEND`,
Lua: `block = upper(block); return true`, Lua: `block = upper(block); return true`,
Files: []string{"logs/**/*.log"}, Files: []string{"logs/**/*.log"},
Isolate: true, Isolate: true,
LogLevel: "TRACE", LogLevel: "TRACE",
}, },
// Using !rep placeholder and arrays of files // Using !rep placeholder and arrays of files
@@ -481,6 +487,25 @@ func CreateExampleConfig() {
Lua: `key = $prefix .. key; return true`, Lua: `key = $prefix .. key; return true`,
Files: []string{"**/*.properties"}, Files: []string{"**/*.properties"},
}, },
// JSON mode examples
{
Name: "JSONArrayMultiply",
JSON: true,
Lua: `for i, item in ipairs(data.items) do data.items[i].value = item.value * 2 end; return true`,
Files: []string{"data/**/*.json"},
},
{
Name: "JSONObjectUpdate",
JSON: true,
Lua: `data.version = "2.0.0"; data.enabled = true; return true`,
Files: []string{"config/**/*.json"},
},
{
Name: "JSONNestedModify",
JSON: true,
Lua: `if data.settings and data.settings.performance then data.settings.performance.multiplier = data.settings.performance.multiplier * 1.5 end; return true`,
Files: []string{"settings/**/*.json"},
},
} }
data, err := yaml.Marshal(commands) data, err := yaml.Marshal(commands)
@@ -506,11 +531,51 @@ func RunOtherCommands(file string, fileDataStr string, association utils.FileCom
runOtherCommandsLogger.Debug("Running other commands for file") runOtherCommandsLogger.Debug("Running other commands for file")
runOtherCommandsLogger.Trace("File data before modifications: %s", utils.LimitString(fileDataStr, 200)) runOtherCommandsLogger.Trace("File data before modifications: %s", utils.LimitString(fileDataStr, 200))
// Aggregate all the modifications and execute them // Separate JSON and regex commands for different processing approaches
jsonCommands := []utils.ModifyCommand{}
regexCommands := []utils.ModifyCommand{}
for _, command := range association.Commands {
if command.JSON || *utils.JSON {
jsonCommands = append(jsonCommands, command)
} else {
regexCommands = append(regexCommands, command)
}
}
// Process JSON commands sequentially (each operates on the entire file)
for _, command := range jsonCommands {
cmdLogger := logger.Default
if cmdLog, ok := commandLoggers[command.Name]; ok {
cmdLogger = cmdLog
}
cmdLogger.Debug("Processing file with JSON mode for command %q", command.Name)
newModifications, err := processor.ProcessJSON(fileDataStr, command, file)
if err != nil {
runOtherCommandsLogger.Error("Failed to process file with JSON command %q: %v", command.Name, err)
continue
}
// Apply JSON modifications immediately
if len(newModifications) > 0 {
var count int
fileDataStr, count = utils.ExecuteModifications(newModifications, fileDataStr)
atomic.AddInt64(&stats.TotalModifications, int64(count))
cmdLogger.Debug("Applied %d JSON modifications for command %q", count, command.Name)
}
count, ok := stats.ModificationsPerCommand.Load(command.Name)
if !ok {
count = 0
}
stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications))
}
// Aggregate regex modifications and execute them
modifications := []utils.ReplaceCommand{} modifications := []utils.ReplaceCommand{}
numCommandsConsidered := 0 numCommandsConsidered := 0
for _, command := range association.Commands { for _, command := range regexCommands {
// Use command-specific logger if available, otherwise fall back to default logger
cmdLogger := logger.Default cmdLogger := logger.Default
if cmdLog, ok := commandLoggers[command.Name]; ok { if cmdLog, ok := commandLoggers[command.Name]; ok {
cmdLogger = cmdLog cmdLogger = cmdLog
@@ -572,35 +637,62 @@ func RunIsolateCommands(association utils.FileCommandAssociation, file string, f
anythingDone := false anythingDone := false
for _, isolateCommand := range association.IsolateCommands { for _, isolateCommand := range association.IsolateCommands {
runIsolateCommandsLogger.Debug("Begin processing file with isolate command %q", isolateCommand.Regex) // Check if this isolate command should use JSON mode
patterns := isolateCommand.Regexes if isolateCommand.JSON || *utils.JSON {
if len(patterns) == 0 { runIsolateCommandsLogger.Debug("Begin processing file with JSON isolate command %q", isolateCommand.Name)
patterns = []string{isolateCommand.Regex} modifications, err := processor.ProcessJSON(fileDataStr, isolateCommand, file)
}
for idx, pattern := range patterns {
tmpCmd := isolateCommand
tmpCmd.Regex = pattern
modifications, err := processor.ProcessRegex(fileDataStr, tmpCmd, file)
if err != nil { if err != nil {
runIsolateCommandsLogger.Error("Failed to process file with isolate command %q (pattern %d/%d): %v", isolateCommand.Name, idx+1, len(patterns), err) runIsolateCommandsLogger.Error("Failed to process file with JSON isolate command %q: %v", isolateCommand.Name, err)
continue continue
} }
if len(modifications) == 0 { if len(modifications) == 0 {
runIsolateCommandsLogger.Debug("Isolate command %q produced no modifications (pattern %d/%d)", isolateCommand.Name, idx+1, len(patterns)) runIsolateCommandsLogger.Debug("JSON isolate command %q produced no modifications", isolateCommand.Name)
continue continue
} }
anythingDone = true anythingDone = true
runIsolateCommandsLogger.Debug("Executing %d isolate modifications for file", len(modifications)) runIsolateCommandsLogger.Debug("Executing %d JSON isolate modifications for file", len(modifications))
runIsolateCommandsLogger.Trace("Isolate modifications: %v", modifications) runIsolateCommandsLogger.Trace("JSON isolate modifications: %v", modifications)
var count int var count int
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr) fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
runIsolateCommandsLogger.Trace("File data after isolate modifications: %s", utils.LimitString(fileDataStr, 200)) runIsolateCommandsLogger.Trace("File data after JSON isolate modifications: %s", utils.LimitString(fileDataStr, 200))
atomic.AddInt64(&stats.TotalModifications, int64(count)) atomic.AddInt64(&stats.TotalModifications, int64(count))
runIsolateCommandsLogger.Info("Executed %d isolate modifications for file", count) runIsolateCommandsLogger.Info("Executed %d JSON isolate modifications for file", count)
} else {
// Regular regex processing for isolate commands
runIsolateCommandsLogger.Debug("Begin processing file with isolate command %q", isolateCommand.Regex)
patterns := isolateCommand.Regexes
if len(patterns) == 0 {
patterns = []string{isolateCommand.Regex}
}
for idx, pattern := range patterns {
tmpCmd := isolateCommand
tmpCmd.Regex = pattern
modifications, err := processor.ProcessRegex(fileDataStr, tmpCmd, file)
if err != nil {
runIsolateCommandsLogger.Error("Failed to process file with isolate command %q (pattern %d/%d): %v", isolateCommand.Name, idx+1, len(patterns), err)
continue
}
if len(modifications) == 0 {
runIsolateCommandsLogger.Debug("Isolate command %q produced no modifications (pattern %d/%d)", isolateCommand.Name, idx+1, len(patterns))
continue
}
anythingDone = true
runIsolateCommandsLogger.Debug("Executing %d isolate modifications for file", len(modifications))
runIsolateCommandsLogger.Trace("Isolate modifications: %v", modifications)
var count int
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
runIsolateCommandsLogger.Trace("File data after isolate modifications: %s", utils.LimitString(fileDataStr, 200))
atomic.AddInt64(&stats.TotalModifications, int64(count))
runIsolateCommandsLogger.Info("Executed %d isolate modifications for file", count)
}
} }
} }
if !anythingDone { if !anythingDone {

656
processor/json.go Normal file
View File

@@ -0,0 +1,656 @@
package processor
import (
"cook/utils"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/tidwall/gjson"
lua "github.com/yuin/gopher-lua"
)
// jsonLogger is a scoped logger for the processor/json package.
var jsonLogger = logger.Default.WithPrefix("processor/json")
// ProcessJSON applies Lua processing to JSON content
func ProcessJSON(content string, command utils.ModifyCommand, filename string) ([]utils.ReplaceCommand, error) {
processJsonLogger := jsonLogger.WithPrefix("ProcessJSON").WithField("commandName", command.Name).WithField("file", filename)
processJsonLogger.Debug("Starting JSON processing for file")
processJsonLogger.Trace("Initial file content length: %d", len(content))
var commands []utils.ReplaceCommand
startTime := time.Now()
// Parse JSON content
var jsonData interface{}
err := json.Unmarshal([]byte(content), &jsonData)
if err != nil {
processJsonLogger.Error("Failed to parse JSON content: %v", err)
return commands, fmt.Errorf("failed to parse JSON: %v", err)
}
processJsonLogger.Debug("Successfully parsed JSON content")
// Create Lua state
L, err := NewLuaState()
if err != nil {
processJsonLogger.Error("Error creating Lua state: %v", err)
return commands, fmt.Errorf("error creating Lua state: %v", err)
}
defer L.Close()
// Set filename global
L.SetGlobal("file", lua.LString(filename))
// Convert JSON data to Lua table
luaTable, err := ToLuaTable(L, jsonData)
if err != nil {
processJsonLogger.Error("Failed to convert JSON to Lua table: %v", err)
return commands, fmt.Errorf("failed to convert JSON to Lua table: %v", err)
}
// Set the JSON data as a global variable
L.SetGlobal("data", luaTable)
processJsonLogger.Debug("Set JSON data as Lua global 'data'")
// Build and execute Lua script for JSON mode
luaExpr := BuildJSONLuaScript(command.Lua)
processJsonLogger.Debug("Built Lua script from expression: %q", command.Lua)
processJsonLogger.Trace("Full Lua script: %q", utils.LimitString(luaExpr, 200))
if err := L.DoString(luaExpr); err != nil {
processJsonLogger.Error("Lua script execution failed: %v\nScript: %s", err, utils.LimitString(luaExpr, 200))
return commands, fmt.Errorf("lua script execution failed: %v", err)
}
processJsonLogger.Debug("Lua script executed successfully")
// Check if modification flag is set
modifiedVal := L.GetGlobal("modified")
if modifiedVal.Type() != lua.LTBool || !lua.LVAsBool(modifiedVal) {
processJsonLogger.Debug("Skipping - no modifications indicated by Lua script")
return commands, nil
}
// Get the modified data from Lua
modifiedData := L.GetGlobal("data")
if modifiedData.Type() != lua.LTTable {
processJsonLogger.Error("Expected 'data' to be a table after Lua processing, got %s", modifiedData.Type().String())
return commands, fmt.Errorf("expected 'data' to be a table after Lua processing")
}
// Convert back to Go interface
goData, err := FromLua(L, modifiedData)
if err != nil {
processJsonLogger.Error("Failed to convert Lua table back to Go: %v", err)
return commands, fmt.Errorf("failed to convert Lua table back to Go: %v", err)
}
processJsonLogger.Debug("About to call applyChanges with original data and modified data")
commands, err = applyChanges(content, jsonData, goData)
if err != nil {
processJsonLogger.Error("Failed to apply surgical JSON changes: %v", err)
return commands, fmt.Errorf("failed to apply surgical JSON changes: %v", err)
}
processJsonLogger.Debug("Total JSON processing time: %v", time.Since(startTime))
processJsonLogger.Debug("Generated %d total modifications", len(commands))
return commands, nil
}
// applyJSONChanges compares original and modified data and applies changes surgically
func applyJSONChanges(content string, originalData, modifiedData interface{}) ([]utils.ReplaceCommand, error) {
var commands []utils.ReplaceCommand
appliedCommands, err := applyChanges(content, originalData, modifiedData)
if err == nil && len(appliedCommands) > 0 {
return appliedCommands, nil
}
return commands, fmt.Errorf("failed to make any changes to the json")
}
// applyChanges attempts to make surgical changes while preserving exact formatting
func applyChanges(content string, originalData, modifiedData interface{}) ([]utils.ReplaceCommand, error) {
var commands []utils.ReplaceCommand
// Find all changes between original and modified data
changes := findDeepChanges("", originalData, modifiedData)
jsonLogger.Debug("applyChanges: Found %d changes: %v", len(changes), changes)
if len(changes) == 0 {
return commands, nil
}
// Sort removal operations by index in descending order to avoid index shifting
var removals []string
var additions []string
var valueChanges []string
for path := range changes {
if strings.HasSuffix(path, "@remove") {
removals = append(removals, path)
} else if strings.HasSuffix(path, "@add") {
additions = append(additions, path)
} else {
valueChanges = append(valueChanges, path)
}
}
jsonLogger.Debug("applyChanges: %d removals, %d additions, %d value changes", len(removals), len(additions), len(valueChanges))
// Apply removals first (from end to beginning to avoid index shifting)
for _, removalPath := range removals {
actualPath := strings.TrimSuffix(removalPath, "@remove")
elementIndex := extractIndexFromRemovalPath(actualPath)
arrayPath := getArrayPathFromElementPath(actualPath)
jsonLogger.Debug("Processing removal: path=%s, index=%d, arrayPath=%s", actualPath, elementIndex, arrayPath)
// Find the exact byte range to remove
from, to := findArrayElementRemovalRange(content, arrayPath, elementIndex)
jsonLogger.Debug("Removing bytes %d-%d", from, to)
commands = append(commands, utils.ReplaceCommand{
From: from,
To: to,
With: "",
})
jsonLogger.Debug("Added removal command: From=%d, To=%d, With=\"\"", from, to)
}
// Apply additions (new fields)
for _, additionPath := range additions {
actualPath := strings.TrimSuffix(additionPath, "@add")
newValue := changes[additionPath]
jsonLogger.Debug("Processing addition: path=%s, value=%v", actualPath, newValue)
// Find the parent object to add the field to
parentPath := getParentPath(actualPath)
fieldName := getFieldName(actualPath)
jsonLogger.Debug("Parent path: %s, field name: %s", parentPath, fieldName)
// Get the parent object
var parentResult gjson.Result
if parentPath == "" {
// Adding to root object - get the entire JSON
parentResult = gjson.Parse(content)
} else {
parentResult = gjson.Get(content, parentPath)
}
if !parentResult.Exists() {
jsonLogger.Debug("Parent path %s does not exist, skipping", parentPath)
continue
}
// Find where to insert the new field (at the end of the object)
startPos := int(parentResult.Index + len(parentResult.Raw) - 1) // Before closing brace
jsonLogger.Debug("Inserting at pos %d", startPos)
// Convert the new value to JSON string
newValueStr := convertValueToJSONString(newValue)
// Insert the new field with pretty-printed formatting
// Format: ,"fieldName": { ... }
insertText := fmt.Sprintf(`,"%s": %s`, fieldName, newValueStr)
commands = append(commands, utils.ReplaceCommand{
From: startPos,
To: startPos,
With: insertText,
})
jsonLogger.Debug("Added addition command: From=%d, To=%d, With=%q", startPos, startPos, insertText)
}
// Apply value changes (in reverse order to avoid position shifting)
sort.Slice(valueChanges, func(i, j int) bool {
// Get positions for comparison
resultI := gjson.Get(content, valueChanges[i])
resultJ := gjson.Get(content, valueChanges[j])
return resultI.Index > resultJ.Index // Descending order
})
for _, path := range valueChanges {
newValue := changes[path]
jsonLogger.Debug("Processing value change: path=%s, value=%v", path, newValue)
// Get the current value and its position in the original JSON
result := gjson.Get(content, path)
if !result.Exists() {
jsonLogger.Debug("Path %s does not exist, skipping", path)
continue // Skip if path doesn't exist
}
// Get the exact byte positions of this value
startPos := result.Index
endPos := startPos + len(result.Raw)
jsonLogger.Debug("Found value at pos %d-%d: %q", startPos, endPos, result.Raw)
// Convert the new value to JSON string
newValueStr := convertValueToJSONString(newValue)
jsonLogger.Debug("Converting to: %q", newValueStr)
// Create a replacement command for this specific value
commands = append(commands, utils.ReplaceCommand{
From: int(startPos),
To: int(endPos),
With: newValueStr,
})
jsonLogger.Debug("Added command: From=%d, To=%d, With=%q", int(startPos), int(endPos), newValueStr)
}
return commands, nil
}
// extractIndexFromRemovalPath extracts the array index from a removal path like "Rows.0.Inputs.1@remove"
func extractIndexFromRemovalPath(path string) int {
parts := strings.Split(strings.TrimSuffix(path, "@remove"), ".")
if len(parts) > 0 {
lastPart := parts[len(parts)-1]
if index, err := strconv.Atoi(lastPart); err == nil {
return index
}
}
return -1
}
// getArrayPathFromElementPath converts "Rows.0.Inputs.1" to "Rows.0.Inputs"
func getArrayPathFromElementPath(elementPath string) string {
parts := strings.Split(elementPath, ".")
if len(parts) > 0 {
return strings.Join(parts[:len(parts)-1], ".")
}
return ""
}
// getParentPath extracts the parent path from a full path like "Rows.0.Inputs.1"
func getParentPath(fullPath string) string {
parts := strings.Split(fullPath, ".")
if len(parts) > 0 {
return strings.Join(parts[:len(parts)-1], ".")
}
return ""
}
// getFieldName extracts the field name from a full path like "Rows.0.Inputs.1"
func getFieldName(fullPath string) string {
parts := strings.Split(fullPath, ".")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}
// convertValueToJSONString converts a Go interface{} to a JSON string representation
func convertValueToJSONString(value interface{}) string {
switch v := value.(type) {
case string:
return `"` + strings.ReplaceAll(v, `"`, `\"`) + `"`
case float64:
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
return strconv.FormatBool(v)
case nil:
return "null"
case map[string]interface{}:
// Handle maps specially to avoid double-escaping of keys
var pairs []string
for key, val := range v {
// The key might already have escaped quotes from Lua, so we need to be careful
// If the key already contains escaped quotes, we need to unescape them first
keyStr := key
if strings.Contains(key, `\"`) {
// Key already has escaped quotes, use it as-is
keyStr = `"` + key + `"`
} else {
// Normal key, escape quotes
keyStr = `"` + strings.ReplaceAll(key, `"`, `\"`) + `"`
}
valStr := convertValueToJSONString(val)
pairs = append(pairs, keyStr+":"+valStr)
}
return "{" + strings.Join(pairs, ",") + "}"
default:
// For other complex types (arrays), we need to use json.Marshal
jsonBytes, err := json.Marshal(v)
if err != nil {
return "null" // Fallback to null if marshaling fails
}
return string(jsonBytes)
}
}
// findArrayElementRemovalRange finds the exact byte range to remove for an array element
func findArrayElementRemovalRange(content, arrayPath string, elementIndex int) (int, int) {
// Get the array using gjson
arrayResult := gjson.Get(content, arrayPath)
if !arrayResult.Exists() || !arrayResult.IsArray() {
return -1, -1
}
// Get all array elements
elements := arrayResult.Array()
if elementIndex >= len(elements) {
return -1, -1
}
// Get the target element
elementResult := elements[elementIndex]
startPos := int(elementResult.Index)
endPos := int(elementResult.Index + len(elementResult.Raw))
// Handle comma removal properly
if elementIndex == 0 && len(elements) > 1 {
// First element but not the only one - remove comma after
for i := endPos; i < len(content) && i < endPos+50; i++ {
if content[i] == ',' {
endPos = i + 1
break
}
}
} else if elementIndex == len(elements)-1 && len(elements) > 1 {
// Last element and not the only one - remove comma before
prevElementEnd := int(elements[elementIndex-1].Index + len(elements[elementIndex-1].Raw))
for i := prevElementEnd; i < startPos && i < len(content); i++ {
if content[i] == ',' {
startPos = i
break
}
}
}
// If it's the only element, don't remove any commas
return startPos, endPos
}
// findDeepChanges recursively finds all paths that need to be changed
func findDeepChanges(basePath string, original, modified interface{}) map[string]interface{} {
changes := make(map[string]interface{})
switch orig := original.(type) {
case map[string]interface{}:
if mod, ok := modified.(map[string]interface{}); ok {
// Check for new keys added in modified data
for key, modValue := range mod {
var currentPath string
if basePath == "" {
currentPath = key
} else {
currentPath = basePath + "." + key
}
if origValue, exists := orig[key]; exists {
// Key exists in both, check if value changed
switch modValue.(type) {
case map[string]interface{}, []interface{}:
// Recursively check nested structures
nestedChanges := findDeepChanges(currentPath, origValue, modValue)
for nestedPath, nestedValue := range nestedChanges {
changes[nestedPath] = nestedValue
}
default:
// Primitive value - check if changed
if !deepEqual(origValue, modValue) {
changes[currentPath] = modValue
}
}
} else {
// New key added - mark for addition
changes[currentPath+"@add"] = modValue
}
}
}
case []interface{}:
if mod, ok := modified.([]interface{}); ok {
// Handle array changes by detecting specific element operations
if len(orig) != len(mod) {
// Array length changed - detect if it's element removal
if len(orig) > len(mod) {
// Element(s) removed - find which ones by comparing content
removedIndices := findRemovedArrayElements(orig, mod)
for _, removedIndex := range removedIndices {
var currentPath string
if basePath == "" {
currentPath = fmt.Sprintf("%d@remove", removedIndex)
} else {
currentPath = fmt.Sprintf("%s.%d@remove", basePath, removedIndex)
}
changes[currentPath] = nil // Mark for removal
}
} else {
// Elements added - more complex, skip for now
}
} else {
// Same length - check individual elements for value changes
for i, modValue := range mod {
var currentPath string
if basePath == "" {
currentPath = strconv.Itoa(i)
} else {
currentPath = basePath + "." + strconv.Itoa(i)
}
if i < len(orig) {
// Index exists in both, check if value changed
switch modValue.(type) {
case map[string]interface{}, []interface{}:
// Recursively check nested structures
nestedChanges := findDeepChanges(currentPath, orig[i], modValue)
for nestedPath, nestedValue := range nestedChanges {
changes[nestedPath] = nestedValue
}
default:
// Primitive value - check if changed
if !deepEqual(orig[i], modValue) {
changes[currentPath] = modValue
}
}
}
}
}
}
default:
// For primitive types, compare directly
if !deepEqual(original, modified) {
if basePath == "" {
changes[""] = modified
} else {
changes[basePath] = modified
}
}
}
return changes
}
// findRemovedArrayElements compares two arrays and returns indices of removed elements
func findRemovedArrayElements(original, modified []interface{}) []int {
var removedIndices []int
// Simple approach: find elements in original that don't exist in modified
for i, origElement := range original {
found := false
for _, modElement := range modified {
if deepEqual(origElement, modElement) {
found = true
break
}
}
if !found {
removedIndices = append(removedIndices, i)
}
}
return removedIndices
}
// deepEqual performs deep comparison of two values
func deepEqual(a, b interface{}) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
switch av := a.(type) {
case map[string]interface{}:
if bv, ok := b.(map[string]interface{}); ok {
if len(av) != len(bv) {
return false
}
for k, v := range av {
if !deepEqual(v, bv[k]) {
return false
}
}
return true
}
return false
case []interface{}:
if bv, ok := b.([]interface{}); ok {
if len(av) != len(bv) {
return false
}
for i, v := range av {
if !deepEqual(v, bv[i]) {
return false
}
}
return true
}
return false
default:
return a == b
}
}
// ToLuaTable converts a Go interface{} to a Lua table recursively
func ToLuaTable(L *lua.LState, data interface{}) (*lua.LTable, error) {
toLuaTableLogger := jsonLogger.WithPrefix("ToLuaTable")
toLuaTableLogger.Debug("Converting Go interface to Lua table")
toLuaTableLogger.Trace("Input data type: %T", data)
switch v := data.(type) {
case map[string]interface{}:
toLuaTableLogger.Debug("Converting map to Lua table")
table := L.CreateTable(0, len(v))
for key, value := range v {
luaValue, err := ToLuaValue(L, value)
if err != nil {
toLuaTableLogger.Error("Failed to convert map value for key %q: %v", key, err)
return nil, err
}
table.RawSetString(key, luaValue)
}
return table, nil
case []interface{}:
toLuaTableLogger.Debug("Converting slice to Lua table")
table := L.CreateTable(len(v), 0)
for i, value := range v {
luaValue, err := ToLuaValue(L, value)
if err != nil {
toLuaTableLogger.Error("Failed to convert slice value at index %d: %v", i, err)
return nil, err
}
table.RawSetInt(i+1, luaValue) // Lua arrays are 1-indexed
}
return table, nil
case string:
toLuaTableLogger.Debug("Converting string to Lua string")
return nil, fmt.Errorf("expected table or array, got string")
case float64:
toLuaTableLogger.Debug("Converting float64 to Lua number")
return nil, fmt.Errorf("expected table or array, got number")
case bool:
toLuaTableLogger.Debug("Converting bool to Lua boolean")
return nil, fmt.Errorf("expected table or array, got boolean")
case nil:
toLuaTableLogger.Debug("Converting nil to Lua nil")
return nil, fmt.Errorf("expected table or array, got nil")
default:
toLuaTableLogger.Error("Unsupported type for Lua table conversion: %T", v)
return nil, fmt.Errorf("unsupported type for Lua table conversion: %T", v)
}
}
// ToLuaValue converts a Go interface{} to a Lua value
func ToLuaValue(L *lua.LState, data interface{}) (lua.LValue, error) {
toLuaValueLogger := jsonLogger.WithPrefix("ToLuaValue")
toLuaValueLogger.Debug("Converting Go interface to Lua value")
toLuaValueLogger.Trace("Input data type: %T", data)
switch v := data.(type) {
case map[string]interface{}:
toLuaValueLogger.Debug("Converting map to Lua table")
table := L.CreateTable(0, len(v))
for key, value := range v {
luaValue, err := ToLuaValue(L, value)
if err != nil {
toLuaValueLogger.Error("Failed to convert map value for key %q: %v", key, err)
return lua.LNil, err
}
table.RawSetString(key, luaValue)
}
return table, nil
case []interface{}:
toLuaValueLogger.Debug("Converting slice to Lua table")
table := L.CreateTable(len(v), 0)
for i, value := range v {
luaValue, err := ToLuaValue(L, value)
if err != nil {
toLuaValueLogger.Error("Failed to convert slice value at index %d: %v", i, err)
return lua.LNil, err
}
table.RawSetInt(i+1, luaValue) // Lua arrays are 1-indexed
}
return table, nil
case string:
toLuaValueLogger.Debug("Converting string to Lua string")
return lua.LString(v), nil
case float64:
toLuaValueLogger.Debug("Converting float64 to Lua number")
return lua.LNumber(v), nil
case bool:
toLuaValueLogger.Debug("Converting bool to Lua boolean")
return lua.LBool(v), nil
case nil:
toLuaValueLogger.Debug("Converting nil to Lua nil")
return lua.LNil, nil
default:
toLuaValueLogger.Error("Unsupported type for Lua value conversion: %T", v)
return lua.LNil, fmt.Errorf("unsupported type for Lua value conversion: %T", v)
}
}

96
processor/json_test.go Normal file
View File

@@ -0,0 +1,96 @@
package processor
import (
"cook/utils"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProcessJSON(t *testing.T) {
tests := []struct {
name string
input string
luaExpression string
expectedOutput string
expectedMods int
}{
{
name: "Basic JSON object modification",
input: `{"name": "test", "value": 42}`,
luaExpression: `data.value = data.value * 2; return true`,
expectedOutput: `{"name": "test", "value": 84}`,
expectedMods: 1,
},
{
name: "JSON array modification",
input: `{"items": [{"name": "item1", "value": 10}, {"name": "item2", "value": 20}]}`,
luaExpression: `for i, item in ipairs(data.items) do item.value = item.value * 2 end modified = true`,
expectedOutput: `{"items": [{"name": "item1", "value": 20}, {"name": "item2", "value": 40}]}`,
expectedMods: 2,
},
{
name: "JSON nested object modification",
input: `{"config": {"setting1": {"enabled": true, "value": 5}, "setting2": {"enabled": false, "value": 10}}}`,
luaExpression: `data.config.setting1.enabled = false data.config.setting2.value = 15 modified = true`,
expectedOutput: `{"config": {"setting1": {"enabled": false, "value": 5}, "setting2": {"enabled": false, "value": 15}}}`,
expectedMods: 2,
},
{
name: "JSON no modification",
input: `{"name": "test", "value": 42}`,
luaExpression: `return false`,
expectedOutput: `{"name": "test", "value": 42}`,
expectedMods: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
command := utils.ModifyCommand{
Name: tt.name,
JSON: true,
Lua: tt.luaExpression,
}
modifications, err := ProcessJSON(tt.input, command, "test.json")
assert.NoError(t, err, "ProcessJSON failed: %v", err)
if len(modifications) > 0 {
// Execute modifications
result, count := utils.ExecuteModifications(modifications, tt.input)
assert.Equal(t, tt.expectedMods, count, "Expected %d modifications, got %d", tt.expectedMods, count)
assert.Equal(t, tt.expectedOutput, result, "Expected output: %s, got: %s", tt.expectedOutput, result)
} else {
assert.Equal(t, 0, tt.expectedMods, "Expected no modifications but got some")
}
})
}
}
func TestToLuaValue(t *testing.T) {
L, err := NewLuaState()
if err != nil {
t.Fatalf("Failed to create Lua state: %v", err)
}
defer L.Close()
tests := []struct {
name string
input interface{}
expected string
}{
{"string", "hello", "hello"},
{"number", 42.0, "42"},
{"boolean", true, "true"},
{"nil", nil, "nil"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ToLuaValue(L, tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result.String())
})
}
}

View File

@@ -247,6 +247,7 @@ modified = false
initLuaHelpersLogger.Debug("Setting up Lua print function to Go") initLuaHelpersLogger.Debug("Setting up Lua print function to Go")
L.SetGlobal("print", L.NewFunction(printToGo)) L.SetGlobal("print", L.NewFunction(printToGo))
L.SetGlobal("fetch", L.NewFunction(fetch)) L.SetGlobal("fetch", L.NewFunction(fetch))
L.SetGlobal("re", L.NewFunction(EvalRegex))
initLuaHelpersLogger.Debug("Lua print and fetch functions bound to Go") initLuaHelpersLogger.Debug("Lua print and fetch functions bound to Go")
return nil return nil
} }
@@ -300,6 +301,26 @@ func BuildLuaScript(luaExpr string) string {
return fullScript return fullScript
} }
// BuildJSONLuaScript prepares a Lua expression for JSON mode
func BuildJSONLuaScript(luaExpr string) string {
buildJsonLuaScriptLogger := processorLogger.WithPrefix("BuildJSONLuaScript").WithField("inputLuaExpr", luaExpr)
buildJsonLuaScriptLogger.Debug("Building full Lua script for JSON mode from expression")
// Perform $var substitutions from globalVariables
luaExpr = replaceVariables(luaExpr)
fullScript := fmt.Sprintf(`
function run()
%s
end
local res = run()
modified = res == nil or res
`, luaExpr)
buildJsonLuaScriptLogger.Trace("Generated full JSON Lua script: %q", utils.LimitString(fullScript, 200))
return fullScript
}
func replaceVariables(expr string) string { func replaceVariables(expr string) string {
// $varName -> literal value // $varName -> literal value
varNameRe := regexp.MustCompile(`\$(\w+)`) varNameRe := regexp.MustCompile(`\$(\w+)`)
@@ -461,3 +482,86 @@ func fetch(L *lua.LState) int {
fetchLogger.Debug("Pushed response table to Lua stack") fetchLogger.Debug("Pushed response table to Lua stack")
return 1 return 1
} }
func EvalRegex(L *lua.LState) int {
evalRegexLogger := processorLogger.WithPrefix("evalRegex")
evalRegexLogger.Debug("Lua evalRegex function called")
defer func() {
if r := recover(); r != nil {
evalRegexLogger.Error("Panic in EvalRegex: %v", r)
// Push empty table on panic
emptyTable := L.NewTable()
L.Push(emptyTable)
}
}()
pattern := L.ToString(1)
input := L.ToString(2)
evalRegexLogger.Debug("Pattern: %q, Input: %q", pattern, input)
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(input)
evalRegexLogger.Debug("Go regex matches: %v (count: %d)", matches, len(matches))
matchesTable := L.NewTable()
for i, match := range matches {
matchesTable.RawSetInt(i, lua.LString(match))
evalRegexLogger.Debug("Set table[%d] = %q", i, match)
}
L.Push(matchesTable)
evalRegexLogger.Debug("Pushed matches table to Lua stack")
return 1
}
// GetLuaFunctionsHelp returns a comprehensive help string for all available Lua functions
func GetLuaFunctionsHelp() string {
return `Lua Functions Available in Global Environment:
MATH FUNCTIONS:
min(a, b) - Returns the minimum of two numbers
max(a, b) - Returns the maximum of two numbers
round(x, n) - Rounds x to n decimal places (default 0)
floor(x) - Returns the floor of x
ceil(x) - Returns the ceiling of x
STRING FUNCTIONS:
upper(s) - Converts string to uppercase
lower(s) - Converts string to lowercase
format(s, ...) - Formats string using Lua string.format
trim(s) - Removes leading/trailing whitespace
strsplit(inputstr, sep) - Splits string by separator (default: whitespace)
num(str) - Converts string to number (returns 0 if invalid)
str(num) - Converts number to string
is_number(str) - Returns true if string is numeric
TABLE FUNCTIONS:
DumpTable(table, depth) - Prints table structure recursively
isArray(t) - Returns true if table is a sequential array
HTTP FUNCTIONS:
fetch(url, options) - Makes HTTP request, returns response table
options: {method="GET", headers={}, body=""}
returns: {status, statusText, ok, body, headers}
REGEX FUNCTIONS:
re(pattern, input) - Applies regex pattern to input string
returns: table with matches (index 0 = full match, 1+ = groups)
UTILITY FUNCTIONS:
print(...) - Prints arguments to Go logger
EXAMPLES:
round(3.14159, 2) -> 3.14
strsplit("a,b,c", ",") -> {"a", "b", "c"}
upper("hello") -> "HELLO"
min(5, 3) -> 3
num("123") -> 123
is_number("abc") -> false
fetch("https://api.example.com/data")
re("(\\w+)@(\\w+)", "user@domain.com") -> {"user@domain.com", "user", "domain.com"}`
}

162
processor/processor_test.go Normal file
View File

@@ -0,0 +1,162 @@
package processor_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
lua "github.com/yuin/gopher-lua"
"cook/processor"
)
// Happy Path: Function correctly returns all regex capture groups as Lua table when given valid pattern and input.
func TestEvalRegex_CaptureGroupsReturned(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `(\w+)-(\d+)`
input := "test-42"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result, "Expected return value to be 0")
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
expected := []string{"test-42", "test", "42"}
for i, v := range expected {
val := tbl.RawGetString(fmt.Sprintf("%d", i))
assert.Equal(t, lua.LString(v), val, "Expected index %d to be %q", i, v)
}
}
// Happy Path: Function returns an empty Lua table when regex pattern does not match input string.
func TestEvalRegex_NoMatchReturnsEmptyTable(t *testing.T) {
L := lua.NewState()
defer L.Close()
L.Push(lua.LString(`(foo)(bar)`))
L.Push(lua.LString("no-match-here"))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Zero(t, count, "Expected no items in the table for non-matching input")
}
// Happy Path: Function handles patterns with no capture groups by returning the full match in the Lua table.
func TestEvalRegex_NoCaptureGroups(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `foo\d+`
input := "foo123"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
fullMatch := tbl.RawGetString("0")
assert.Equal(t, lua.LString("foo123"), fullMatch)
// There should be only the full match (index 0)
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Equal(t, 1, count)
}
// Edge Case: Function panics or errors when given an invalid regex pattern.
func TestEvalRegex_InvalidPattern(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `([a-z` // invalid regex
L.Push(lua.LString(pattern))
L.Push(lua.LString("someinput"))
defer func() {
if r := recover(); r == nil {
t.Error("Expected panic for invalid regex pattern, but did not panic")
}
}()
processor.EvalRegex(L)
}
// Edge Case: Function returns an empty Lua table when input string is empty.
func TestEvalRegex_EmptyInputString(t *testing.T) {
L := lua.NewState()
defer L.Close()
L.Push(lua.LString(`(foo)`))
L.Push(lua.LString(""))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
// Should be empty
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Zero(t, count, "Expected empty table when input is empty")
}
// Edge Case: Function handles nil or missing arguments gracefully without causing a runtime panic.
func TestEvalRegex_MissingArguments(t *testing.T) {
L := lua.NewState()
defer L.Close()
defer func() {
if r := recover(); r != nil {
t.Errorf("Did not expect panic when arguments are missing, got: %v", r)
}
}()
// No arguments pushed at all
processor.EvalRegex(L)
// Should just not match anything or produce empty table, but must not panic
}
func TestEvalComplexRegex(t *testing.T) {
// 23:47:35.567068 processor.go:369 [g:22 ] [LUA] Pistol_Round ^((Bulk_)?(Pistol|Rifle).*?Round.*?)$
L := lua.NewState()
defer L.Close()
pattern := `^((Bulk_)?(Pistol|Rifle).*?Round.*?)$`
input := "Pistol_Round"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
processor.EvalRegex(L)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
count := 0
tbl.ForEach(func(k, v lua.LValue) {
fmt.Println(k, v)
count++
})
assert.Equal(t, 1, count)
}

View File

@@ -0,0 +1,847 @@
package processor
import (
"cook/utils"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestSurgicalJSONEditing(t *testing.T) {
tests := []struct {
name string
content string
luaCode string
expected string
}{
{
name: "Modify single field",
content: `{
"name": "test",
"value": 42,
"description": "original"
}`,
luaCode: `
data.value = 84
modified = true
`,
expected: `{
"name": "test",
"value": 84,
"description": "original"
}`,
},
{
name: "Add new field",
content: `{
"name": "test",
"value": 42
}`,
luaCode: `
data.newField = "added"
modified = true
`,
expected: `{
"name": "test",
"value": 42
,"newField": "added"}`, // sjson.Set() adds new fields in compact format
},
{
name: "Modify nested field",
content: `{
"config": {
"settings": {
"enabled": false,
"timeout": 30
}
}
}`,
luaCode: `
data.config.settings.enabled = true
data.config.settings.timeout = 60
modified = true
`,
expected: `{
"config": {
"settings": {
"enabled": true,
"timeout": 60
}
}
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
command := utils.ModifyCommand{
Name: "test",
Lua: tt.luaCode,
}
commands, err := ProcessJSON(tt.content, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := tt.content
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, tt.expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check the actual result matches expected
if result != tt.expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", tt.expected, result)
}
})
}
}
func TestSurgicalJSONPreservesFormatting(t *testing.T) {
// Test that surgical editing preserves the original formatting structure
content := `{
"Defaults": {
"Behaviour": "None",
"Description": "",
"DisplayName": "",
"FlavorText": "",
"Icon": "None",
"MaxStack": 1,
"Override_Glow_Icon": "None",
"Weight": 0,
"bAllowZeroWeight": false
},
"RowStruct": "/Script/Icarus.ItemableData",
"Rows": [
{
"Description": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-Description\", \"A bundle of soft fiber, highly useful.\")",
"DisplayName": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-DisplayName\", \"Fiber\")",
"FlavorText": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-FlavorText\", \"Fiber is collected from bast, the strong inner bark of certain flowering plants.\")",
"Icon": "/Game/Assets/2DArt/UI/Items/Item_Icons/Resources/ITEM_Fibre.ITEM_Fibre",
"MaxStack": 1000000,
"Name": "Item_Fiber",
"Weight": 10
}
]
}`
expected := `{
"Defaults": {
"Behaviour": "None",
"Description": "",
"DisplayName": "",
"FlavorText": "",
"Icon": "None",
"MaxStack": 1,
"Override_Glow_Icon": "None",
"Weight": 0,
"bAllowZeroWeight": false
},
"RowStruct": "/Script/Icarus.ItemableData",
"Rows": [
{
"Description": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-Description\", \"A bundle of soft fiber, highly useful.\")",
"DisplayName": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-DisplayName\", \"Fiber\")",
"FlavorText": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-FlavorText\", \"Fiber is collected from bast, the strong inner bark of certain flowering plants.\")",
"Icon": "/Game/Assets/2DArt/UI/Items/Item_Icons/Resources/ITEM_Fibre.ITEM_Fibre",
"MaxStack": 1000000,
"Name": "Item_Fiber",
"Weight": 500
}
]
}`
command := utils.ModifyCommand{
Name: "test",
Lua: `
-- Modify the weight of the first item
data.Rows[1].Weight = 500
modified = true
`,
}
commands, err := ProcessJSON(content, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := content
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the result matches expected (preserves formatting and changes weight)
if result != expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, result)
}
}
func TestSurgicalJSONPreservesFormatting2(t *testing.T) {
// Test that surgical editing preserves the original formatting structure
content := `
{
"RowStruct": "/Script/Icarus.ProcessorRecipe",
"Defaults": {
"bForceDisableRecipe": false,
"Requirement": {
"RowName": "None",
"DataTableName": "D_Talents"
},
"SessionRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"CharacterRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"RequiredMillijoules": 2500,
"RecipeSets": [],
"ResourceCostMultipliers": [],
"Inputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Container": {
"Value": "None"
},
"ResourceInputs": [],
"bSelectOutputItemRandomly": false,
"bContainsContainer": false,
"ItemIconOverride": {
"ItemStaticData": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"ItemDynamicData": [],
"ItemCustomStats": [],
"CustomProperties": {
"StaticWorldStats": [],
"StaticWorldHeldStats": [],
"Stats": [],
"Alterations": [],
"LivingItemSlots": []
},
"DatabaseGUID": "",
"ItemOwnerLookupId": -1,
"RuntimeTags": {
"GameplayTags": []
}
},
"Outputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemTemplate"
},
"Count": 1,
"DynamicProperties": []
}
],
"ResourceOutputs": [],
"Refundable": "Inherit",
"ExperienceMultiplier": 1,
"Audio": {
"RowName": "None",
"DataTableName": "D_CraftingAudioData"
}
},
"Rows": [
{
"Name": "Biofuel1",
"RecipeSets": [
{
"RowName": "Composter",
"DataTableName": "D_RecipeSets"
}
],
"Inputs": [
{
"Element": {
"RowName": "Raw_Meat",
"DataTableName": "D_ItemsStatic"
},
"Count": 2,
"DynamicProperties": []
},
{
"Element": {
"RowName": "Tree_Sap",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Outputs": [],
"Audio": {
"RowName": "Composter"
},
"ResourceOutputs": [
{
"Type": {
"Value": "Biofuel"
},
"RequiredUnits": 100
}
]
}
]
}
`
expected := `
{
"RowStruct": "/Script/Icarus.ProcessorRecipe",
"Defaults": {
"bForceDisableRecipe": false,
"Requirement": {
"RowName": "None",
"DataTableName": "D_Talents"
},
"SessionRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"CharacterRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"RequiredMillijoules": 2500,
"RecipeSets": [],
"ResourceCostMultipliers": [],
"Inputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Container": {
"Value": "None"
},
"ResourceInputs": [],
"bSelectOutputItemRandomly": false,
"bContainsContainer": false,
"ItemIconOverride": {
"ItemStaticData": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"ItemDynamicData": [],
"ItemCustomStats": [],
"CustomProperties": {
"StaticWorldStats": [],
"StaticWorldHeldStats": [],
"Stats": [],
"Alterations": [],
"LivingItemSlots": []
},
"DatabaseGUID": "",
"ItemOwnerLookupId": -1,
"RuntimeTags": {
"GameplayTags": []
}
},
"Outputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemTemplate"
},
"Count": 1,
"DynamicProperties": []
}
],
"ResourceOutputs": [],
"Refundable": "Inherit",
"ExperienceMultiplier": 1,
"Audio": {
"RowName": "None",
"DataTableName": "D_CraftingAudioData"
}
},
"Rows": [
{
"Name": "Biofuel1",
"RecipeSets": [
{
"RowName": "Composter",
"DataTableName": "D_RecipeSets"
}
],
"Inputs": [
{
"Element": {
"RowName": "Raw_Meat",
"DataTableName": "D_ItemsStatic"
},
"Count": 2,
"DynamicProperties": []
}
],
"Outputs": [],
"Audio": {
"RowName": "Composter"
},
"ResourceOutputs": [
{
"Type": {
"Value": "Biofuel"
},
"RequiredUnits": 100
}
]
}
]
}
`
command := utils.ModifyCommand{
Name: "test",
Lua: `
-- Define regex patterns for matching recipe names
local function matchesPattern(name, pattern)
local matches = re(pattern, name)
-- Check if matches table has any content (index 0 or 1 should exist if there's a match)
return matches and (matches[0] or matches[1])
end
-- Selection pattern for recipes that get multiplied
local selectionPattern = "(?-s)(Bulk_)?(Pistol|Rifle).*?Round.*?|(Carbon|Composite)_Paste.*|(Gold|Copper)_Wire|(Ironw|Copper)_Nail|(Platinum|Steel|Cold_Steel|Titanium)_Ingot|.*?Shotgun_Shell.*?|.*_Arrow|.*_Bolt|.*_Fertilizer_?\\d*|.*_Grenade|.*_Pill|.*_Tonic|Aluminum|Ammo_Casing|Animal_Fat|Carbon_Fiber|Composites|Concrete_Mix|Cured_Leather_?\\d?|Electronics|Epoxy_?\\d?|Glass\\d?|Gunpowder\\w*|Health_.*|Titanium_Plate|Organic_Resin|Platinum_Sheath|Refined_[a-zA-Z]+|Rope|Shotgun_Casing|Steel_Bloom\\d?|Tree_Sap\\w*"
-- Ingot pattern for recipes that get count set to 1
local ingotPattern = "(?-s)(Platinum|Steel|Cold_Steel|Titanium)_Ingot|Aluminum|Refined_[a-zA-Z]+|Glass\\d?"
local factor = 16
local bonus = 0.5
for _, row in ipairs(data.Rows) do
local recipeName = row.Name
-- Special case: Biofuel recipes - remove Tree_Sap input
if string.find(recipeName, "Biofuel") then
if row.Inputs then
for i = #row.Inputs, 1, -1 do
local input = row.Inputs[i]
if input.Element and input.Element.RowName and string.find(input.Element.RowName, "Tree_Sap") then
table.remove(row.Inputs, i)
print("Removing input 'Tree_Sap' from processor recipe '" .. recipeName .. "'")
end
end
end
end
-- Ingot recipes: set input and output counts to 1
if matchesPattern(recipeName, ingotPattern) then
if row.Inputs then
for _, input in ipairs(row.Inputs) do
input.Count = 1
end
end
if row.Outputs then
for _, output in ipairs(row.Outputs) do
output.Count = 1
end
end
end
-- Selected recipes: multiply inputs by factor, outputs by factor * (1 + bonus)
if matchesPattern(recipeName, selectionPattern) then
if row.Inputs then
for _, input in ipairs(row.Inputs) do
local oldCount = input.Count
input.Count = input.Count * factor
print("Recipe " .. recipeName .. " Input.Count: " .. oldCount .. " -> " .. input.Count)
end
end
if row.Outputs then
for _, output in ipairs(row.Outputs) do
local oldCount = output.Count
output.Count = math.floor(output.Count * factor * (1 + bonus))
print("Recipe " .. recipeName .. " Output.Count: " .. oldCount .. " -> " .. output.Count)
end
end
end
end
`,
}
commands, err := ProcessJSON(content, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := content
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the result matches expected (preserves formatting and changes weight)
if result != expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, result)
}
}
func TestRetardedJSONEditing(t *testing.T) {
original := `{
"RowStruct": "/Script/Icarus.ItemableData",
"Defaults": {
"Behaviour": "None",
"DisplayName": "",
"Icon": "None",
"Override_Glow_Icon": "None",
"Description": "",
"FlavorText": "",
"Weight": 0,
"bAllowZeroWeight": false,
"MaxStack": 1
},
"Rows": [
{
"DisplayName": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-DisplayName\", \"Fiber\")",
"Icon": "/Game/Assets/2DArt/UI/Items/Item_Icons/Resources/ITEM_Fibre.ITEM_Fibre",
"Description": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-Description\", \"A bundle of soft fiber, highly useful.\")",
"FlavorText": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-FlavorText\", \"Fiber is collected from bast, the strong inner bark of certain flowering plants.\")",
"Weight": 10,
"MaxStack": 200,
"Name": "Item_Fiber"
}
]
}`
expected := `{
"RowStruct": "/Script/Icarus.ItemableData",
"Defaults": {
"Behaviour": "None",
"DisplayName": "",
"Icon": "None",
"Override_Glow_Icon": "None",
"Description": "",
"FlavorText": "",
"Weight": 0,
"bAllowZeroWeight": false,
"MaxStack": 1
},
"Rows": [
{
"DisplayName": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-DisplayName\", \"Fiber\")",
"Icon": "/Game/Assets/2DArt/UI/Items/Item_Icons/Resources/ITEM_Fibre.ITEM_Fibre",
"Description": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-Description\", \"A bundle of soft fiber, highly useful.\")",
"FlavorText": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-FlavorText\", \"Fiber is collected from bast, the strong inner bark of certain flowering plants.\")",
"Weight": 10,
"MaxStack": 1000000,
"Name": "Item_Fiber"
}
]
}`
command := utils.ModifyCommand{
Name: "test",
Lua: `
for _, row in ipairs(data.Rows) do
if row.MaxStack then
if string.find(row.Name, "Carrot") or string.find(row.Name, "Potato") then
row.MaxStack = 25
else
row.MaxStack = row.MaxStack * 10000
if row.MaxStack > 1000000 then
row.MaxStack = 1000000
end
end
end
end
`,
}
commands, err := ProcessJSON(original, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := original
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the weight was changed
if result != expected {
t.Errorf("Expected:\n%s\nGot:\n%s", expected, result)
}
}
func TestRetardedJSONEditing2(t *testing.T) {
original := `
{
"Rows": [
{
"Name": "Deep_Mining_Drill_Biofuel",
"Meshable": {
"RowName": "Mesh_Deep_Mining_Drill_Biofuel"
},
"Itemable": {
"RowName": "Item_Deep_Mining_Drill_Biofuel"
},
"Interactable": {
"RowName": "Deployable"
},
"Focusable": {
"RowName": "Focusable_1H"
},
"Highlightable": {
"RowName": "Generic"
},
"Actionable": {
"RowName": "Deployable"
},
"Usable": {
"RowName": "Place"
},
"Deployable": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Durable": {
"RowName": "Deployable_750"
},
"Inventory": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Decayable": {
"RowName": "Decay_MetaItem"
},
"Generator": {
"RowName": "Deep_Mining_Biofuel_Drill"
},
"Resource": {
"RowName": "Simple_Internal_Flow_Only"
},
"Manual_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
}
]
},
"Generated_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
},
{
"TagName": "Traits.Meshable"
},
{
"TagName": "Traits.Itemable"
},
{
"TagName": "Traits.Interactable"
},
{
"TagName": "Traits.Highlightable"
},
{
"TagName": "Traits.Actionable"
},
{
"TagName": "Traits.Usable"
},
{
"TagName": "Traits.Deployable"
},
{
"TagName": "Traits.Durable"
},
{
"TagName": "Traits.Inventory"
}
],
"ParentTags": []
}
}
]
}
`
expected := `
{
"Rows": [
{
"Name": "Deep_Mining_Drill_Biofuel",
"Meshable": {
"RowName": "Mesh_Deep_Mining_Drill_Biofuel"
},
"Itemable": {
"RowName": "Item_Deep_Mining_Drill_Biofuel"
},
"Interactable": {
"RowName": "Deployable"
},
"Focusable": {
"RowName": "Focusable_1H"
},
"Highlightable": {
"RowName": "Generic"
},
"Actionable": {
"RowName": "Deployable"
},
"Usable": {
"RowName": "Place"
},
"Deployable": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Durable": {
"RowName": "Deployable_750"
},
"Inventory": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Decayable": {
"RowName": "Decay_MetaItem"
},
"Generator": {
"RowName": "Deep_Mining_Biofuel_Drill"
},
"Resource": {
"RowName": "Simple_Internal_Flow_Only"
},
"Manual_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
}
]
},
"Generated_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
},
{
"TagName": "Traits.Meshable"
},
{
"TagName": "Traits.Itemable"
},
{
"TagName": "Traits.Interactable"
},
{
"TagName": "Traits.Highlightable"
},
{
"TagName": "Traits.Actionable"
},
{
"TagName": "Traits.Usable"
},
{
"TagName": "Traits.Deployable"
},
{
"TagName": "Traits.Durable"
},
{
"TagName": "Traits.Inventory"
}
],
"ParentTags": []
}
,"AdditionalStats": {"(Value=\"BaseDeepMiningDrillSpeed_+%\")":4000}}
]
}
`
command := utils.ModifyCommand{
Name: "test",
Lua: `
for i, row in ipairs(data.Rows) do
-- Special case: Deep_Mining_Drill_Biofuel
if string.find(row.Name, "Deep_Mining_Drill_Biofuel") then
print("[DEBUG] Special case: Deep_Mining_Drill_Biofuel")
if not row.AdditionalStats then
print("[DEBUG] Creating AdditionalStats table for Deep_Mining_Drill_Biofuel")
row.AdditionalStats = {}
end
print("[DEBUG] Setting BaseDeepMiningDrillSpeed_+% to 4000")
row.AdditionalStats["(Value=\\\"BaseDeepMiningDrillSpeed_+%\\\")"] = 4000
end
end
`,
}
commands, err := ProcessJSON(original, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := original
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
if result != expected {
t.Errorf("Expected:\n%s\nGot:\n%s", expected, result)
}
}

11
test_surgical.yml Normal file
View File

@@ -0,0 +1,11 @@
- name: SurgicalWeightTest
json: true
lua: |
-- This demonstrates surgical JSON editing
-- Only the Weight field of Item_Fiber will be modified
data.Rows[1].Weight = 999
modified = true
files:
- 'D_Itemable.json'
reset: false
loglevel: INFO

View File

@@ -7,6 +7,7 @@ import (
logger "git.site.quack-lab.dev/dave/cylogger" logger "git.site.quack-lab.dev/dave/cylogger"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
) )
// dbLogger is a scoped logger for the utils/db package. // dbLogger is a scoped logger for the utils/db package.
@@ -43,7 +44,7 @@ func GetDB() (DB, error) {
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{ db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{
// SkipDefaultTransaction: true, // SkipDefaultTransaction: true,
PrepareStmt: true, PrepareStmt: true,
// Logger: gormlogger.Default.LogMode(gormlogger.Silent), Logger: gormlogger.Default.LogMode(gormlogger.Silent),
}) })
if err != nil { if err != nil {
getDBLogger.Error("Failed to open database: %v", err) getDBLogger.Error("Failed to open database: %v", err)
@@ -120,7 +121,8 @@ func (db *DBWrapper) GetFile(filePath string) ([]byte, error) {
var fileSnapshot FileSnapshot var fileSnapshot FileSnapshot
err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error
if err != nil { if err != nil {
getFileLogger.Error("Failed to get file from database: %v", err) // Downgrade not-found to warning to avoid noisy errors during first run
getFileLogger.Warning("Failed to get file from database: %v", err)
return nil, err return nil, err
} }
getFileLogger.Debug("File found in database") getFileLogger.Debug("File found in database")

View File

@@ -102,7 +102,17 @@ func ResetWhereNecessary(associations map[string]FileCommandAssociation, db DB)
fileData, err := db.GetFile(file) fileData, err := db.GetFile(file)
if err != nil { if err != nil {
resetWhereNecessaryLogger.Warning("Failed to get original content for file %q from database: %v", file, err) resetWhereNecessaryLogger.Warning("Failed to get original content for file %q from database: %v", file, err)
continue // Seed the snapshot from current disk content if missing, then use it as fallback
currentData, readErr := os.ReadFile(file)
if readErr != nil {
resetWhereNecessaryLogger.Warning("Additionally failed to read current file content for %q: %v", file, readErr)
continue
}
// Best-effort attempt to save baseline; ignore errors to avoid blocking reset
if saveErr := db.SaveFile(file, currentData); saveErr != nil {
resetWhereNecessaryLogger.Warning("Failed to seed baseline snapshot for %q: %v", file, saveErr)
}
fileData = currentData
} }
resetWhereNecessaryLogger.Trace("Retrieved original file data length for %q: %d", file, len(fileData)) resetWhereNecessaryLogger.Trace("Retrieved original file data length for %q: %d", file, len(fileData))
resetWhereNecessaryLogger.Debug("Writing original content back to file %q", file) resetWhereNecessaryLogger.Debug("Writing original content back to file %q", file)

View File

@@ -12,9 +12,10 @@ var flagsLogger = logger.Default.WithPrefix("utils/flags")
var ( var (
ParallelFiles = flag.Int("P", 100, "Number of files to process in parallel") ParallelFiles = flag.Int("P", 100, "Number of files to process in parallel")
Filter = flag.String("f", "", "Filter commands before running them") Filter = flag.String("f", "", "Filter commands before running them")
JSON = flag.Bool("json", false, "Enable JSON mode for processing JSON files")
) )
func init() { func init() {
flagsLogger.Debug("Initializing flags") flagsLogger.Debug("Initializing flags")
flagsLogger.Trace("ParallelFiles initial value: %d, Filter initial value: %q", *ParallelFiles, *Filter) flagsLogger.Trace("ParallelFiles initial value: %d, Filter initial value: %q, JSON initial value: %t", *ParallelFiles, *Filter, *JSON)
} }

View File

@@ -25,6 +25,7 @@ type ModifyCommand struct {
Isolate bool `yaml:"isolate,omitempty"` Isolate bool `yaml:"isolate,omitempty"`
NoDedup bool `yaml:"nodedup,omitempty"` NoDedup bool `yaml:"nodedup,omitempty"`
Disabled bool `yaml:"disable,omitempty"` Disabled bool `yaml:"disable,omitempty"`
JSON bool `yaml:"json,omitempty"`
Modifiers map[string]interface{} `yaml:"modifiers,omitempty"` Modifiers map[string]interface{} `yaml:"modifiers,omitempty"`
} }
@@ -33,10 +34,15 @@ type CookFile []ModifyCommand
func (c *ModifyCommand) Validate() error { func (c *ModifyCommand) Validate() error {
validateLogger := modifyCommandLogger.WithPrefix("Validate").WithField("commandName", c.Name) validateLogger := modifyCommandLogger.WithPrefix("Validate").WithField("commandName", c.Name)
validateLogger.Debug("Validating command") validateLogger.Debug("Validating command")
if c.Regex == "" && len(c.Regexes) == 0 {
validateLogger.Error("Validation failed: Regex pattern is required") // For JSON mode, regex patterns are not required
return fmt.Errorf("pattern is required") if !c.JSON {
if c.Regex == "" && len(c.Regexes) == 0 {
validateLogger.Error("Validation failed: Regex pattern is required for non-JSON mode")
return fmt.Errorf("pattern is required for non-JSON mode")
}
} }
if c.Lua == "" { if c.Lua == "" {
validateLogger.Error("Validation failed: Lua expression is required") validateLogger.Error("Validation failed: Lua expression is required")
return fmt.Errorf("lua expression is required") return fmt.Errorf("lua expression is required")