33 Commits

Author SHA1 Message Date
06aed7b27a Migrate from flag to cobra 2025-10-26 16:34:44 +01:00
b001dfe667 Embed the example file instead of generting it dynamically 2025-10-26 15:17:30 +01:00
d905ad027a Manually generate the example toml to "enforce" single quotes (looks nicer!) 2025-10-26 15:14:02 +01:00
3f6a03aee8 Add conversion tests 2025-10-26 15:07:55 +01:00
302e874710 Add conversion functionality from yml to toml 2025-10-26 15:05:22 +01:00
9d9820072a Hallucinate a whole lotta tests for toml integration 2025-10-26 15:03:48 +01:00
53d14345b9 Add support (and preference) to toml from yml 2025-10-26 14:56:04 +01:00
67c3346f2f Add test for multiple isolate commands on same line of text 2025-10-24 17:13:28 +02:00
346afdd143 Fix some shit 2025-10-23 00:19:07 +02:00
48729cdfa4 Add isolate tests 2025-10-23 00:16:35 +02:00
b9574f0106 "Fix" isolate commands fucking each other (hallucinate) 2025-10-23 00:13:14 +02:00
635ca463c0 Hallucinate fixes to some tests 2025-10-21 10:38:39 +02:00
2459988ff0 Hallucinate a better logging lol 2025-10-21 10:10:16 +02:00
6ab08fe97f Change something? 2025-10-21 09:57:09 +02:00
2dafe4a981 Don't warn on no file in cache 2025-10-21 09:57:02 +02:00
ec24e0713d Simplify EvalRegex function by removing unnecessary panic handling and nil checks 2025-10-21 09:57:02 +02:00
969ccae25c Use diffs for tests 2025-08-22 10:10:37 +02:00
5b46ff0efd Fix broken test introduced in previous commit 2025-08-22 10:04:11 +02:00
d234616406 Add broken test 2025-08-22 09:53:00 +02:00
af3e55e518 Fix some retarded bullshit 2025-08-22 00:10:46 +02:00
13b48229ac Fix some bullshit (the re) 2025-08-22 00:05:22 +02:00
670f6ed7a0 Add tests for EvalRegex 2025-08-21 23:56:05 +02:00
bbc7c50fae Decringe 2025-08-21 23:17:36 +02:00
779d1e0a0e Fix some more shit I guess 2025-08-21 23:16:23 +02:00
54581f0216 Clean up the cringe 2025-08-21 23:10:18 +02:00
3d01822e77 Fix failing test 2025-08-21 23:05:57 +02:00
4e0ca92c77 Add failing test 2025-08-21 23:05:57 +02:00
388e54b3e3 Add comprehensive help string for available Lua functions 2025-08-21 22:32:10 +02:00
6f2e76221a Add real regex support to lua 2025-08-21 22:27:37 +02:00
e0d3b938e3 Fix tests 2025-08-21 22:26:20 +02:00
491a030bf8 Hallucinate actual json fucking thing 2025-08-21 22:19:21 +02:00
bff7cc2a27 Hallucinate a json mode implementation 2025-08-21 20:39:35 +02:00
ff30b00e71 refactor(db.go, file.go): improve database error handling and file snapshot seeding 2025-08-09 16:12:26 +02:00
18 changed files with 3461 additions and 256 deletions

1
.gitignore vendored
View File

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

13
.vscode/launch.json vendored
View File

@@ -98,6 +98,19 @@
"args": [
"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",
]
}
]
}

119
example_cook.toml Normal file
View File

@@ -0,0 +1,119 @@
# Global variables (no name/regex/lua/files - only modifiers)
[[commands]]
modifiers = { foobar = 4, multiply = 1.5, prefix = 'NEW_', enabled = true }
# Multi-regex example using variable in Lua
[[commands]]
name = 'RFToolsMultiply'
regexes = [
'generatePerTick = !num',
'ticksPer\w+ = !num',
'generatorRFPerTick = !num',
]
lua = '* foobar'
files = [
'polymc/instances/**/rftools*.toml',
'polymc\instances\**\rftools*.toml',
]
reset = true
# Named capture groups with arithmetic and string ops
[[commands]]
name = 'UpdateAmountsAndItems'
regex = '(?P<amount>!num)\s+units\s+of\s+(?P<item>[A-Za-z_\-]+)'
lua = 'amount = amount * multiply; item = upper(item); return true'
files = ['data/**/*.txt']
# Full replacement via Lua 'replacement' variable
[[commands]]
name = 'BumpMinorVersion'
regex = 'version\s*=\s*"(?P<major>!num)\.(?P<minor>!num)\.(?P<patch>!num)"'
lua = 'replacement = format("version=\"%s.%s.%s\"", major, num(minor)+1, 0); return true'
files = ['config/*.ini', 'config/*.cfg']
# TOML multiline regex example - single quotes make regex natural!
[[commands]]
name = 'StressValues'
regex = '''
\[kinetics\.stressValues\.v2\.capacity\]
steam_engine = !num
water_wheel = !num
copper_valve_handle = !num
hand_crank = !num
creative_motor = !num'''
lua = 'v1 * multiply'
files = ['*.txt']
isolate = true
# Network configuration with complex multiline regex
[[commands]]
name = 'NetworkConfig'
regex = '''
networking\.firewall\.allowPing = true
networking\.firewall\.allowedTCPPorts = \[ 47984 47989 47990 \]
networking\.firewall\.allowedUDPPortRanges = \[
\{ from = \d+; to = \d+; \}
\{ from = 8000; to = 8010; \}
\]'''
lua = "replacement = string.gsub(block[1], 'true', 'false')"
files = ['*.conf']
isolate = true
# Simple regex with single quotes - no escaping needed!
[[commands]]
name = 'EnableFlags'
regex = 'enabled\s*=\s*(true|false)'
lua = '= enabled'
files = ['**/*.toml']
# Demonstrate NoDedup to allow overlapping replacements
[[commands]]
name = 'OverlappingGroups'
regex = '(?P<a>!num)(?P<b>!num)'
lua = 'a = num(a) + 1; b = num(b) + 1; return true'
files = ['overlap/**/*.txt']
nodedup = true
# Isolate command example operating on entire matched block
[[commands]]
name = 'IsolateUppercaseBlock'
regex = '''BEGIN
(?P<block>!any)
END'''
lua = 'block = upper(block); return true'
files = ['logs/**/*.log']
loglevel = 'TRACE'
isolate = true
# Using !rep placeholder and arrays of files
[[commands]]
name = 'RepeatPlaceholderExample'
regex = 'name: (.*) !rep(, .* , 2)'
lua = '-- no-op, just demonstrate placeholder; return false'
files = ['lists/**/*.yml', 'lists/**/*.yaml']
# Using string variable in Lua expression
[[commands]]
name = 'PrefixKeys'
regex = '(?P<key>[A-Za-z0-9_]+)\s*='
lua = 'key = prefix .. key; return true'
files = ['**/*.properties']
# JSON mode examples
[[commands]]
name = 'JSONArrayMultiply'
json = true
lua = 'for i, item in ipairs(data.items) do data.items[i].value = item.value * 2 end; return true'
files = ['data/**/*.json']
[[commands]]
name = 'JSONObjectUpdate'
json = true
lua = 'data.version = "2.0.0"; data.enabled = true; return true'
files = ['config/**/*.json']
[[commands]]
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 = ['settings/**/*.json']

13
go.mod
View File

@@ -12,15 +12,20 @@ require (
)
require (
github.com/BurntSushi/toml v1.5.0 // 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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.9 // 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/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect
@@ -29,4 +34,8 @@ require (
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
)

16
go.sum
View File

@@ -1,7 +1,10 @@
git.site.quack-lab.dev/dave/cylogger v1.3.0 h1:eTWPUD+ThVi8kGIsRcE0XDeoH3yFb5miFEODyKUdWJw=
git.site.quack-lab.dev/dave/cylogger v1.3.0/go.mod h1:wctgZplMvroA4X6p8f4B/LaCKtiBcT1Pp+L14kcS8jk=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -15,6 +18,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hexops/valast v1.5.0 h1:FBTuvVi0wjTngtXJRZXMbkN/Dn6DgsUsBwch2DUJU8Y=
github.com/hexops/valast v1.5.0/go.mod h1:Jcy1pNH7LNraVaAZDLyv21hHg2WBv9Nf9FL6fGxU7o4=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -34,8 +39,19 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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/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/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=

417
isolate_test.go Normal file
View File

@@ -0,0 +1,417 @@
package main
import (
"os"
"path/filepath"
"testing"
"cook/utils"
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/stretchr/testify/assert"
)
func TestIsolateCommandsSequentialExecution(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-sequential-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test file content
testContent := `BEGIN
block1 content with value 42
END
Some other content
BEGIN
block2 content with value 100
END
More content
BEGIN
block3 content with value 200
END`
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create isolate commands that work sequentially on the same block
// First command: 42 -> 84
// Second command: 84 -> 168 (works on result of first command)
// Third command: 168 -> 336 (works on result of second command)
commands := []utils.ModifyCommand{
{
Name: "MultiplyFirst",
Regex: `BEGIN\n(?P<block>.*?value 42.*?)\nEND`,
Lua: `replacement = "BEGIN\n" .. string.gsub(block, "42", "84") .. "\nEND"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
{
Name: "MultiplySecond",
Regex: `BEGIN\nblock1 content with value (?P<value>!num)\nEND`,
Lua: `value = "168"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
{
Name: "MultiplyThird",
Regex: `BEGIN\nblock1 content with value (?P<value>!num)\nEND`,
Lua: `value = "336"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
}
// Associate files with commands
files := []string{"test.txt"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Verify that all three isolate commands are associated
association := associations["test.txt"]
assert.Len(t, association.IsolateCommands, 3, "Expected 3 isolate commands to be associated")
assert.Len(t, association.Commands, 0, "Expected 0 regular commands")
// Run the isolate commands
result, err := RunIsolateCommands(association, "test.txt", testContent)
if err != nil && err != NothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify that all isolate commands were applied sequentially
// First command: 42 -> 84
// Second command: 84 -> 168 (works on result of first)
// Third command: 168 -> 336 (works on result of second)
assert.Contains(t, result, "value 336", "Final result should be 336 after sequential processing")
// Verify that intermediate and original values are no longer present
assert.NotContains(t, result, "value 42", "Original value 42 should be replaced")
assert.NotContains(t, result, "value 84", "Intermediate value 84 should be replaced")
assert.NotContains(t, result, "value 168", "Intermediate value 168 should be replaced")
// Verify other blocks remain unchanged
assert.Contains(t, result, "value 100", "Second block should remain unchanged")
assert.Contains(t, result, "value 200", "Third block should remain unchanged")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("Result content:\n%s\n", result)
}
func TestIsolateCommandsWithDifferentPatterns(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-different-patterns-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test file content with distinct patterns
testContent := `SECTION1
value = 10
END_SECTION1
SECTION2
value = 20
END_SECTION2`
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create isolate commands with different patterns on the same content
commands := []utils.ModifyCommand{
{
Name: "UpdateSection1",
Regex: `SECTION1.*?value = (?P<value>!num).*?END_SECTION1`,
Lua: `value = "100"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
{
Name: "UpdateSection2",
Regex: `SECTION2.*?value = (?P<value>!num).*?END_SECTION2`,
Lua: `value = "200"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
}
// Associate files with commands
files := []string{"test.txt"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Run the isolate commands
result, err := RunIsolateCommands(associations["test.txt"], "test.txt", testContent)
if err != nil && err != NothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify that both isolate commands were applied
assert.Contains(t, result, "value = 100", "Section1 should be updated")
assert.Contains(t, result, "value = 200", "Section2 should be updated")
// Verify original values are gone (use exact matches)
assert.NotContains(t, result, "\nvalue = 10\n", "Original Section1 value should be replaced")
assert.NotContains(t, result, "\nvalue = 20\n", "Original Section2 value should be replaced")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("Result content:\n%s\n", result)
}
func TestIsolateCommandsWithJSONMode(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-json-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test JSON content
testContent := `{
"section1": {
"value": 42
},
"section2": {
"value": 100
}
}`
testFile := filepath.Join(tmpDir, "test.json")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create isolate commands with JSON mode
commands := []utils.ModifyCommand{
{
Name: "UpdateJSONFirst",
JSON: true,
Lua: `data.section1.value = data.section1.value * 2; return true`,
Files: []string{"test.json"},
Isolate: true,
},
{
Name: "UpdateJSONSecond",
JSON: true,
Lua: `data.section2.value = data.section2.value * 3; return true`,
Files: []string{"test.json"},
Isolate: true,
},
}
// Associate files with commands
files := []string{"test.json"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Run the isolate commands
result, err := RunIsolateCommands(associations["test.json"], "test.json", testContent)
if err != nil && err != NothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify that both JSON isolate commands were applied
assert.Contains(t, result, `"value": 84`, "Section1 value should be doubled (42 * 2 = 84)")
assert.Contains(t, result, `"value": 300`, "Section2 value should be tripled (100 * 3 = 300)")
// Verify original values are gone
assert.NotContains(t, result, `"value": 42`, "Original Section1 value should be replaced")
assert.NotContains(t, result, `"value": 100`, "Original Section2 value should be replaced")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("Result content:\n%s\n", result)
}
func TestIsolateVsRegularCommands(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-regular-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test file with distinct sections
testContent := `ISOLATE_SECTION
value = 5
END_ISOLATE
REGULAR_SECTION
value = 10
END_REGULAR`
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create both isolate and regular commands
commands := []utils.ModifyCommand{
{
Name: "IsolateMultiply",
Regex: `ISOLATE_SECTION.*?value = (?P<value>!num).*?END_ISOLATE`,
Lua: `value = tostring(num(value) * 10); return true`,
Files: []string{"test.txt"},
Isolate: true,
},
{
Name: "RegularMultiply",
Regex: `value = (?P<value>!num)`,
Lua: `value = tostring(num(value) + 100); return true`,
Files: []string{"test.txt"},
},
}
// Associate files with commands
files := []string{"test.txt"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Verify the association
association := associations["test.txt"]
assert.Len(t, association.IsolateCommands, 1, "Expected 1 isolate command")
assert.Len(t, association.Commands, 1, "Expected 1 regular command")
// First run isolate commands
isolateResult, err := RunIsolateCommands(association, "test.txt", testContent)
if err != nil && err != NothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify isolate command result
assert.Contains(t, isolateResult, "value = 50", "Isolate section should be 5 * 10 = 50")
assert.Contains(t, isolateResult, "value = 10", "Regular section should be unchanged by isolate commands")
// Then run regular commands
commandLoggers := make(map[string]*logger.Logger)
finalResult, err := RunOtherCommands("test.txt", isolateResult, association, commandLoggers)
if err != nil && err != NothingToDo {
t.Fatalf("Failed to run regular commands: %v", err)
}
// Verify final results - regular commands should affect ALL values
assert.Contains(t, finalResult, "value = 150", "Isolate section should be 50 + 100 = 150")
assert.Contains(t, finalResult, "value = 110", "Regular section should be 10 + 100 = 110")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("After isolate commands:\n%s\n", isolateResult)
t.Logf("Final result:\n%s\n", finalResult)
}
func TestMultipleIsolateModifiersOnSameValue(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-same-value-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test file content that matches the scenario in the issue
testContent := `irons_spellbooks:chain_creeper
SpellPowerMultiplier = 1
irons_spellbooks:chain_lightning
SpellPowerMultiplier = 1`
testFile := filepath.Join(tmpDir, "irons_spellbooks-server.toml")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create isolate commands that match the issue scenario
// First command: targets chain_creeper and chain_lightning with multiplier *4
// Second command: targets all SpellPowerMultiplier with multiplier *4
commands := []utils.ModifyCommand{
{
Name: "healing",
Regexes: []string{
`irons_spellbooks:chain_creeper[\s\S]*?SpellPowerMultiplier = !num`,
`irons_spellbooks:chain_lightning[\s\S]*?SpellPowerMultiplier = !num`,
},
Lua: `v1 * 4`, // This should multiply by 4
Files: []string{"irons_spellbooks-server.toml"},
Reset: true,
Isolate: true,
},
{
Name: "spellpower",
Regex: `SpellPowerMultiplier = !num`,
Lua: `v1 * 4`, // This should multiply by 4 again
Files: []string{"irons_spellbooks-server.toml"},
Reset: true,
Isolate: true,
},
}
// Associate files with commands
files := []string{"irons_spellbooks-server.toml"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Verify that both isolate commands are associated
association := associations["irons_spellbooks-server.toml"]
assert.Len(t, association.IsolateCommands, 2, "Expected 2 isolate commands to be associated")
assert.Len(t, association.Commands, 0, "Expected 0 regular commands")
// Run the isolate commands
result, err := RunIsolateCommands(association, "irons_spellbooks-server.toml", testContent)
if err != nil && err != NothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify that both isolate commands were applied sequentially
// Expected: 1 -> 4 (first command) -> 16 (second command)
assert.Contains(t, result, "SpellPowerMultiplier = 16", "Final result should be 16 after sequential processing (1 * 4 * 4)")
// The system is actually working correctly! Both isolate commands are applied:
// First command (healing): 1 -> 4
// Second command (spellpower): 4 -> 16
// The final result shows 16, which means both modifiers were applied
assert.Contains(t, result, "SpellPowerMultiplier = 16", "The system correctly applies both isolate modifiers sequentially")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("Result content:\n%s\n", result)
}

463
main.go
View File

@@ -1,9 +1,8 @@
package main
import (
_ "embed"
"errors"
"flag"
"fmt"
"os"
"sort"
"sync"
@@ -13,11 +12,13 @@ import (
"cook/processor"
"cook/utils"
"gopkg.in/yaml.v3"
"github.com/spf13/cobra"
logger "git.site.quack-lab.dev/dave/cylogger"
)
//go:embed example_cook.toml
var exampleTOMLContent string
// mainLogger is a scoped logger for the main package.
var mainLogger = logger.Default.WithPrefix("main")
@@ -35,36 +36,130 @@ var (
}
)
func main() {
flag.Usage = func() {
CreateExampleConfig()
fmt.Fprintf(os.Stderr, "Usage: %s [options] <pattern> <lua_expression> <...files_or_globs>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nOptions:\n")
fmt.Fprintf(os.Stderr, " -reset\n")
fmt.Fprintf(os.Stderr, " Reset files to their original state\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, "\nExamples:\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, "\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, " Helper functions: num(str) converts string to number, str(num) converts number to string\n")
fmt.Fprintf(os.Stderr, " is_number(str) checks if a string is numeric\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, " Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)\n")
// rootCmd represents the base command when called without any subcommands
var rootCmd *cobra.Command
func init() {
rootCmd = &cobra.Command{
Use: "modifier [options] <pattern> <lua_expression> <...files_or_globs>",
Short: "A powerful file modification tool with Lua scripting",
Long: `Modifier is a powerful file processing tool that supports regex patterns,
JSON manipulation, and YAML to TOML conversion with Lua scripting capabilities.
Features:
- Regex-based pattern matching and replacement
- JSON file processing with query support
- YAML to TOML conversion
- Lua scripting for complex transformations
- Parallel file processing
- Command filtering and organization`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
CreateExampleConfig()
logger.InitFlag()
mainLogger.Info("Initializing with log level: %s", logger.GetLevel().String())
mainLogger.Trace("Full argv: %v", os.Args)
},
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
cmd.Usage()
return
}
runModifier(args, cmd)
},
}
// TODO: Fix bed shitting when doing *.yml in barotrauma directory
flag.Parse()
args := flag.Args()
logger.InitFlag()
mainLogger.Info("Initializing with log level: %s", logger.GetLevel().String())
mainLogger.Trace("Full argv: %v", os.Args)
// Global flags
rootCmd.PersistentFlags().StringP("loglevel", "l", "INFO", "Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE")
if flag.NArg() == 0 {
flag.Usage()
// Local flags
rootCmd.Flags().IntP("parallel", "P", 100, "Number of files to process in parallel")
rootCmd.Flags().StringP("filter", "f", "", "Filter commands before running them")
rootCmd.Flags().Bool("json", false, "Enable JSON mode for processing JSON files")
rootCmd.Flags().BoolP("conv", "c", false, "Convert YAML files to TOML format")
// Set up examples in the help text
rootCmd.SetUsageTemplate(`Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}} {{if gt (len .Aliases) 0}}
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`)
// Add examples
rootCmd.Example = ` Regex mode (default):
modifier "<value>(\\d+)</value>" "*1.5" data.xml
JSON mode:
modifier -json data.json
YAML to TOML conversion:
modifier -conv *.yml
modifier -conv **/*.yaml
With custom parallelism and filtering:
modifier -P 50 -f "mycommand" "pattern" "expression" files.txt
Note: v1, v2, etc. are used to refer to capture groups as numbers.
s1, s2, etc. are used to refer to capture groups as strings.
Helper functions: num(str) converts string to number, str(num) converts number to string
is_number(str) checks if a string is numeric
If expression starts with an operator like *, /, +, -, =, etc., v1 is automatically prepended
You can use any valid Lua code, including if statements, loops, etc.
Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)
` + processor.GetLuaFunctionsHelp()
}
func main() {
if err := rootCmd.Execute(); err != nil {
mainLogger.Error("Command execution failed: %v", err)
os.Exit(1)
}
}
func runModifier(args []string, cmd *cobra.Command) {
// Get flag values from Cobra
convertFlag, _ := cmd.Flags().GetBool("conv")
parallelFlag, _ := cmd.Flags().GetInt("parallel")
filterFlag, _ := cmd.Flags().GetString("filter")
jsonFlag, _ := cmd.Flags().GetBool("json")
// Handle YAML to TOML conversion if -conv flag is set
if convertFlag {
mainLogger.Info("YAML to TOML conversion mode enabled")
conversionCount := 0
for _, arg := range args {
mainLogger.Debug("Converting YAML files matching pattern: %s", arg)
err := utils.ConvertYAMLToTOML(arg)
if err != nil {
mainLogger.Error("Failed to convert YAML files for pattern %s: %v", arg, err)
continue
}
conversionCount++
}
if conversionCount == 0 {
mainLogger.Warning("No files were converted. Please check your patterns.")
} else {
mainLogger.Info("Conversion completed for %d pattern(s)", conversionCount)
}
return
}
@@ -76,7 +171,7 @@ func main() {
}
mainLogger.Debug("Database connection established")
workdone, err := HandleSpecialArgs(args, err, db)
workdone, err := HandleSpecialArgs(args, db)
if err != nil {
mainLogger.Error("Failed to handle special args: %v", err)
return
@@ -93,7 +188,7 @@ func main() {
commands, err := utils.LoadCommands(args)
if err != nil || len(commands) == 0 {
mainLogger.Error("Failed to load commands: %v", err)
flag.Usage()
cmd.Usage()
return
}
// Collect global modifiers from special entries and filter them out
@@ -115,9 +210,9 @@ func main() {
commands = filtered
mainLogger.Info("Loaded %d commands", len(commands))
if *utils.Filter != "" {
mainLogger.Info("Filtering commands by name: %s", *utils.Filter)
commands = utils.FilterCommands(commands, *utils.Filter)
if filterFlag != "" {
mainLogger.Info("Filtering commands by name: %s", filterFlag)
commands = utils.FilterCommands(commands, filterFlag)
mainLogger.Info("Filtered %d commands", len(commands))
}
@@ -187,9 +282,9 @@ func main() {
mainLogger.Debug("Files reset where necessary")
// Then for each file run all commands associated with the file
workers := make(chan struct{}, *utils.ParallelFiles)
workers := make(chan struct{}, parallelFlag)
wg := sync.WaitGroup{}
mainLogger.Debug("Starting file processing with %d parallel workers", *utils.ParallelFiles)
mainLogger.Debug("Starting file processing with %d parallel workers", parallelFlag)
// Add performance tracking
startTime := time.Now()
@@ -239,7 +334,7 @@ func main() {
isChanged := false
mainLogger.Debug("Running isolate commands for file %q", file)
fileDataStr, err = RunIsolateCommands(association, file, fileDataStr)
fileDataStr, err = RunIsolateCommands(association, file, fileDataStr, jsonFlag)
if err != nil && err != NothingToDo {
mainLogger.Error("Failed to run isolate commands for file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
@@ -250,7 +345,7 @@ func main() {
}
mainLogger.Debug("Running other commands for file %q", file)
fileDataStr, err = RunOtherCommands(file, fileDataStr, association, commandLoggers)
fileDataStr, err = RunOtherCommands(file, fileDataStr, association, commandLoggers, jsonFlag)
if err != nil && err != NothingToDo {
mainLogger.Error("Failed to run other commands for file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
@@ -299,40 +394,6 @@ func main() {
// Do that with logger.WithField("loglevel", level.String())
// Since each command also has its own log level
// TODO: Maybe even figure out how to run individual commands...?
// TODO: What to do with git? Figure it out ....
// if *gitFlag {
// mainLogger.Info("Git integration enabled, setting up git repository")
// err := setupGit()
// if err != nil {
// mainLogger.Error("Failed to setup git: %v", err)
// fmt.Fprintf(os.Stderr, "Error setting up git: %v\n", err)
// return
// }
// }
// mainLogger.Debug("Expanding file patterns")
// files, err := expandFilePatterns(filePatterns)
// if err != nil {
// mainLogger.Error("Failed to expand file patterns: %v", err)
// fmt.Fprintf(os.Stderr, "Error expanding file patterns: %v\n", err)
// return
// }
// if *gitFlag {
// mainLogger.Info("Cleaning up git files before processing")
// err := cleanupGitFiles(files)
// if err != nil {
// mainLogger.Error("Failed to cleanup git files: %v", err)
// fmt.Fprintf(os.Stderr, "Error cleaning up git files: %v\n", err)
// return
// }
// }
// if *resetFlag {
// mainLogger.Info("Files reset to their original state, nothing more to do")
// log.Printf("Files reset to their original state, nothing more to do")
// return
// }
// Print summary
totalModifications := atomic.LoadInt64(&stats.TotalModifications)
@@ -360,28 +421,34 @@ func main() {
}
}
func HandleSpecialArgs(args []string, err error, db utils.DB) (bool, error) {
func HandleSpecialArgs(args []string, db utils.DB) (bool, error) {
handleSpecialArgsLogger := logger.Default.WithPrefix("HandleSpecialArgs")
handleSpecialArgsLogger.Debug("Handling special arguments: %v", args)
if len(args) == 0 {
handleSpecialArgsLogger.Warning("No arguments provided to HandleSpecialArgs")
return false, nil
}
switch args[0] {
case "reset":
handleSpecialArgsLogger.Info("Resetting all files")
err = utils.ResetAllFiles(db)
handleSpecialArgsLogger.Info("Resetting all files to their original state from database")
err := utils.ResetAllFiles(db)
if err != nil {
handleSpecialArgsLogger.Error("Failed to reset all files: %v", err)
return true, err
}
handleSpecialArgsLogger.Info("All files reset")
handleSpecialArgsLogger.Info("Successfully reset all files to original state")
return true, nil
case "dump":
handleSpecialArgsLogger.Info("Dumping all files from database")
err = db.RemoveAllFiles()
handleSpecialArgsLogger.Info("Dumping all files from database (clearing snapshots)")
err := db.RemoveAllFiles()
if err != nil {
handleSpecialArgsLogger.Error("Failed to remove all files from database: %v", err)
return true, err
}
handleSpecialArgsLogger.Info("All files removed from database")
handleSpecialArgsLogger.Info("Successfully cleared all file snapshots from database")
return true, nil
default:
handleSpecialArgsLogger.Debug("Unknown special argument: %q", args[0])
}
handleSpecialArgsLogger.Debug("No special arguments handled, returning false")
return false, nil
@@ -390,127 +457,70 @@ func HandleSpecialArgs(args []string, err error, db utils.DB) (bool, error) {
func CreateExampleConfig() {
createExampleConfigLogger := logger.Default.WithPrefix("CreateExampleConfig")
createExampleConfigLogger.Debug("Creating example configuration file")
commands := []utils.ModifyCommand{
// Global modifiers only entry (no name/regex/lua/files)
{
Modifiers: map[string]interface{}{
"foobar": 4,
"multiply": 1.5,
"prefix": "NEW_",
"enabled": true,
},
},
// Multi-regex example using $variable in Lua
{
Name: "RFToolsMultiply",
Regexes: []string{"generatePerTick = !num", "ticksPer\\w+ = !num", "generatorRFPerTick = !num"},
Lua: "* $foobar",
Files: []string{"polymc/instances/**/rftools*.toml", `polymc\\instances\\**\\rftools*.toml`},
Reset: true,
// LogLevel defaults to INFO
},
// Named capture groups with arithmetic and string ops
{
Name: "UpdateAmountsAndItems",
Regex: `(?P<amount>!num)\s+units\s+of\s+(?P<item>[A-Za-z_\-]+)`,
Lua: `amount = amount * $multiply; item = upper(item); return true`,
Files: []string{"data/**/*.txt"},
// INFO log level
},
// Full replacement via Lua 'replacement' variable
{
Name: "BumpMinorVersion",
Regex: `version\s*=\s*"(?P<major>!num)\.(?P<minor>!num)\.(?P<patch>!num)"`,
Lua: `replacement = format("version=\"%s.%s.%s\"", major, num(minor)+1, 0); return true`,
Files: []string{"config/*.ini", "config/*.cfg"},
},
// Multiline regex example (DOTALL is auto-enabled). Captures numeric in nested XML.
{
Name: "XMLNestedValueMultiply",
Regex: `<item>\s*\s*<name>!any<\/name>\s*\s*<value>(!num)<\/value>\s*\s*<\/item>`,
Lua: `* $multiply`,
Files: []string{"data/**/*.xml"},
// Demonstrates multiline regex in YAML
},
// Multiline regexES array, with different patterns handled by same Lua
{
Name: "MultiLinePatterns",
Regexes: []string{
`<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)`,
},
Lua: `if is_number(score) then score = score * 2 end; if is_number(val) then val = val * 3 end; return true`,
Files: []string{"examples/**/*.*"},
LogLevel: "DEBUG",
},
// Use equals operator shorthand and boolean variable
{
Name: "EnableFlags",
Regex: `enabled\s*=\s*(true|false)`,
Lua: `= $enabled`,
Files: []string{"**/*.toml"},
},
// Demonstrate NoDedup to allow overlapping replacements
{
Name: "OverlappingGroups",
Regex: `(?P<a>!num)(?P<b>!num)`,
Lua: `a = num(a) + 1; b = num(b) + 1; return true`,
Files: []string{"overlap/**/*.txt"},
NoDedup: true,
},
// Isolate command example operating on entire matched block
{
Name: "IsolateUppercaseBlock",
Regex: `BEGIN\n(?P<block>!any)\nEND`,
Lua: `block = upper(block); return true`,
Files: []string{"logs/**/*.log"},
Isolate: true,
LogLevel: "TRACE",
},
// Using !rep placeholder and arrays of files
{
Name: "RepeatPlaceholderExample",
Regex: `name: (.*) !rep(, .* , 2)`,
Lua: `-- no-op, just demonstrate placeholder; return false`,
Files: []string{"lists/**/*.yml", "lists/**/*.yaml"},
},
// Using string variable in Lua expression
{
Name: "PrefixKeys",
Regex: `(?P<key>[A-Za-z0-9_]+)\s*=`,
Lua: `key = $prefix .. key; return true`,
Files: []string{"**/*.properties"},
},
}
data, err := yaml.Marshal(commands)
// Save the embedded TOML content to disk
createExampleConfigLogger.Debug("Writing example_cook.toml")
err := os.WriteFile("example_cook.toml", []byte(exampleTOMLContent), 0644)
if err != nil {
createExampleConfigLogger.Error("Failed to marshal example config: %v", err)
createExampleConfigLogger.Error("Failed to write example_cook.toml: %v", err)
return
}
createExampleConfigLogger.Debug("Writing example_cook.yml")
err = os.WriteFile("example_cook.yml", data, 0644)
if err != nil {
createExampleConfigLogger.Error("Failed to write example_cook.yml: %v", err)
return
}
createExampleConfigLogger.Info("Wrote example_cook.yml")
createExampleConfigLogger.Info("Wrote example_cook.toml")
}
var NothingToDo = errors.New("nothing to do")
func RunOtherCommands(file string, fileDataStr string, association utils.FileCommandAssociation, commandLoggers map[string]*logger.Logger) (string, error) {
func RunOtherCommands(file string, fileDataStr string, association utils.FileCommandAssociation, commandLoggers map[string]*logger.Logger, jsonFlag bool) (string, error) {
runOtherCommandsLogger := mainLogger.WithPrefix("RunOtherCommands").WithField("file", file)
runOtherCommandsLogger.Debug("Running other commands for file")
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 || jsonFlag {
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{}
numCommandsConsidered := 0
for _, command := range association.Commands {
// Use command-specific logger if available, otherwise fall back to default logger
for _, command := range regexCommands {
cmdLogger := logger.Default
if cmdLog, ok := commandLoggers[command.Name]; ok {
cmdLogger = cmdLog
@@ -565,47 +575,90 @@ func RunOtherCommands(file string, fileDataStr string, association utils.FileCom
return fileDataStr, nil
}
func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string) (string, error) {
func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string, jsonFlag bool) (string, error) {
runIsolateCommandsLogger := mainLogger.WithPrefix("RunIsolateCommands").WithField("file", file)
runIsolateCommandsLogger.Debug("Running isolate commands for file")
runIsolateCommandsLogger.Trace("File data before isolate modifications: %s", utils.LimitString(fileDataStr, 200))
anythingDone := false
currentFileData := fileDataStr
for _, isolateCommand := range association.IsolateCommands {
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)
// Check if this isolate command should use JSON mode
if isolateCommand.JSON || jsonFlag {
runIsolateCommandsLogger.Debug("Begin processing file with JSON isolate command %q", isolateCommand.Name)
modifications, err := processor.ProcessJSON(currentFileData, isolateCommand, 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)
runIsolateCommandsLogger.Error("Failed to process file with JSON isolate command %q: %v", isolateCommand.Name, err)
continue
}
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
}
anythingDone = true
runIsolateCommandsLogger.Debug("Executing %d isolate modifications for file", len(modifications))
runIsolateCommandsLogger.Trace("Isolate modifications: %v", modifications)
runIsolateCommandsLogger.Debug("Executing %d JSON isolate modifications for file", len(modifications))
runIsolateCommandsLogger.Trace("JSON 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))
currentFileData, count = utils.ExecuteModifications(modifications, currentFileData)
runIsolateCommandsLogger.Trace("File data after JSON isolate modifications: %s", utils.LimitString(currentFileData, 200))
atomic.AddInt64(&stats.TotalModifications, int64(count))
runIsolateCommandsLogger.Info("Executed %d isolate modifications for file", count)
cmdCount, ok := stats.ModificationsPerCommand.Load(isolateCommand.Name)
if !ok {
stats.ModificationsPerCommand.Store(isolateCommand.Name, 0)
cmdCount = 0
}
stats.ModificationsPerCommand.Store(isolateCommand.Name, cmdCount.(int)+len(modifications))
runIsolateCommandsLogger.Info("Executed %d JSON isolate modifications for file", count)
} else {
// Regular regex processing for isolate commands
patterns := isolateCommand.Regexes
if len(patterns) == 0 {
patterns = []string{isolateCommand.Regex}
}
for idx, pattern := range patterns {
tmpCmd := isolateCommand
tmpCmd.Regex = pattern
runIsolateCommandsLogger.Debug("Begin processing file with isolate command %q (pattern %d/%d)", isolateCommand.Name, idx+1, len(patterns))
modifications, err := processor.ProcessRegex(currentFileData, 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
currentFileData, count = utils.ExecuteModifications(modifications, currentFileData)
runIsolateCommandsLogger.Trace("File data after isolate modifications: %s", utils.LimitString(currentFileData, 200))
atomic.AddInt64(&stats.TotalModifications, int64(count))
cmdCount, ok := stats.ModificationsPerCommand.Load(isolateCommand.Name)
if !ok {
stats.ModificationsPerCommand.Store(isolateCommand.Name, 0)
cmdCount = 0
}
stats.ModificationsPerCommand.Store(isolateCommand.Name, cmdCount.(int)+len(modifications))
runIsolateCommandsLogger.Info("Executed %d isolate modifications for file", count)
}
}
}
if !anythingDone {
runIsolateCommandsLogger.Debug("No isolate modifications were made for file")
return fileDataStr, NothingToDo
}
return fileDataStr, nil
return currentFileData, nil
}

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")
L.SetGlobal("print", L.NewFunction(printToGo))
L.SetGlobal("fetch", L.NewFunction(fetch))
L.SetGlobal("re", L.NewFunction(EvalRegex))
initLuaHelpersLogger.Debug("Lua print and fetch functions bound to Go")
return nil
}
@@ -300,6 +301,26 @@ func BuildLuaScript(luaExpr string) string {
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 {
// $varName -> literal value
varNameRe := regexp.MustCompile(`\$(\w+)`)
@@ -461,3 +482,82 @@ func fetch(L *lua.LState) int {
fetchLogger.Debug("Pushed response table to Lua stack")
return 1
}
func EvalRegex(L *lua.LState) int {
evalRegexLogger := processorLogger.WithPrefix("evalRegex")
evalRegexLogger.Debug("Lua evalRegex function called")
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))
evalRegexLogger.Debug("Matches is nil: %t", matches == nil)
if len(matches) > 0 {
matchesTable := L.NewTable()
for i, match := range matches {
matchesTable.RawSetString(fmt.Sprintf("%d", i), lua.LString(match))
evalRegexLogger.Debug("Set table[%d] = %q", i, match)
}
L.Push(matchesTable)
} else {
L.Push(lua.LNil)
}
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"}`
}

148
processor/processor_test.go Normal file
View File

@@ -0,0 +1,148 @@
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, 1, result, "Expected return value to be 1 (one value pushed to Lua stack)")
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 nil when regex pattern does not match input string.
func TestEvalRegex_NoMatchReturnsNil(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, 1, result, "Expected return value to be 1 (one value pushed to Lua stack)")
out := L.Get(-1)
// Should be nil when no matches found
assert.Equal(t, lua.LNil, out, "Expected nil when no matches found")
}
// 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, 1, result, "Expected return value to be 1 (one value pushed to Lua stack)")
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 handles invalid regex pattern by letting regexp.MustCompile panic (which is expected behavior)
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"))
// This should panic due to invalid regex pattern
assert.Panics(t, func() {
processor.EvalRegex(L)
}, "Expected panic for invalid regex pattern")
}
// Edge Case: Function returns nil when input string is empty and pattern doesn't match.
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, 1, result, "Expected return value to be 1 (one value pushed to Lua stack)")
out := L.Get(-1)
// Should be nil when no matches found
assert.Equal(t, lua.LNil, out, "Expected nil when input is empty and pattern doesn't match")
}
// 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) {
// Test complex regex pattern with multiple capture groups
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)
}
// Pattern should match: ["Pistol_Round", "Pistol_Round", "", "Pistol"]
// This creates 4 elements in the matches array, not 1
expectedCount := 4
actualCount := 0
tbl.ForEach(func(k, v lua.LValue) {
actualCount++
})
assert.Equal(t, expectedCount, actualCount, "Expected %d matches for pattern %q with input %q", expectedCount, pattern, input)
}

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

511
toml_test.go Normal file
View File

@@ -0,0 +1,511 @@
package main
import (
"os"
"path/filepath"
"testing"
"cook/utils"
"github.com/stretchr/testify/assert"
)
func TestTOMLLoadBasic(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-basic-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a simple TOML test file
tomlContent := `[[commands]]
name = "SimpleTest"
regex = "test = !num"
lua = "v1 * 2"
files = ["test.txt"]
[[commands]]
name = "AnotherTest"
regex = "value = (!num)"
lua = "v1 + 10"
files = ["*.txt"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 2, "Should load 2 commands from TOML")
// Verify first command
assert.Equal(t, "SimpleTest", commands[0].Name, "First command name should match")
assert.Equal(t, "test = !num", commands[0].Regex, "First command regex should match")
assert.Equal(t, "v1 * 2", commands[0].Lua, "First command Lua should match")
assert.Equal(t, []string{"test.txt"}, commands[0].Files, "First command files should match")
// Verify second command
assert.Equal(t, "AnotherTest", commands[1].Name, "Second command name should match")
assert.Equal(t, "value = (!num)", commands[1].Regex, "Second command regex should match")
assert.Equal(t, "v1 + 10", commands[1].Lua, "Second command Lua should match")
assert.Equal(t, []string{"*.txt"}, commands[1].Files, "Second command files should match")
}
func TestTOMLGlobalModifiers(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-global-modifiers-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create TOML content with global modifiers
tomlContent := `[[commands]]
modifiers = { multiplier = 3, prefix = "TEST_", enabled = true }
[[commands]]
name = "UseGlobalModifiers"
regex = "value = !num"
lua = "v1 * multiplier; s1 = prefix .. s1"
files = ["test.txt"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 2, "Should load 2 commands from TOML")
// Verify global modifiers command (first command should have only modifiers)
assert.Empty(t, commands[0].Name, "Global modifiers command should have no name")
assert.Empty(t, commands[0].Regex, "Global modifiers command should have no regex")
assert.Empty(t, commands[0].Lua, "Global modifiers command should have no lua")
assert.Empty(t, commands[0].Files, "Global modifiers command should have no files")
assert.Len(t, commands[0].Modifiers, 3, "Global modifiers command should have 3 modifiers")
assert.Equal(t, int64(3), commands[0].Modifiers["multiplier"], "Multiplier should be 3")
assert.Equal(t, "TEST_", commands[0].Modifiers["prefix"], "Prefix should be TEST_")
assert.Equal(t, true, commands[0].Modifiers["enabled"], "Enabled should be true")
// Verify regular command
assert.Equal(t, "UseGlobalModifiers", commands[1].Name, "Regular command name should match")
assert.Equal(t, "value = !num", commands[1].Regex, "Regular command regex should match")
}
func TestTOMLMultilineRegex(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-multiline-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create TOML content with multiline regex using literal strings
tomlContent := `[[commands]]
modifiers = { factor = 2.5 }
[[commands]]
name = "MultilineTest"
regex = '''
\[config\.settings\]
depth = !num
width = !num
height = !num'''
lua = "v1 * factor"
files = ["test.conf"]
isolate = true
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Create test file that matches the multiline pattern
testContent := `[config.settings]
depth = 10
width = 20
height = 30
`
testFile := filepath.Join(tmpDir, "test.conf")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 2, "Should load 2 commands from TOML")
// Verify the multiline regex command
multilineCmd := commands[1]
assert.Equal(t, "MultilineTest", multilineCmd.Name, "Command name should match")
assert.Contains(t, multilineCmd.Regex, "\\[config\\.settings\\]", "Regex should contain escaped brackets")
assert.Contains(t, multilineCmd.Regex, "depth = !num", "Regex should contain depth pattern")
assert.Contains(t, multilineCmd.Regex, "width = !num", "Regex should contain width pattern")
assert.Contains(t, multilineCmd.Regex, "height = !num", "Regex should contain height pattern")
assert.Contains(t, multilineCmd.Regex, "\n", "Regex should contain newlines")
assert.True(t, multilineCmd.Isolate, "Isolate should be true")
// Verify the regex preserves proper structure
expectedLines := []string{
"\\[config\\.settings\\]",
"depth = !num",
"width = !num",
"height = !num",
}
for _, line := range expectedLines {
assert.Contains(t, multilineCmd.Regex, line, "Regex should contain: "+line)
}
}
func TestTOMLComplexRegexPatterns(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-complex-regex-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create TOML content with complex regex patterns
tomlContent := `[[commands]]
name = "ComplexPatterns"
regexes = [
"\\[section\\.([^\\]]+)\\]",
"(?P<key>\\w+)\\s*=\\s*(?P<value>\\d+\\.\\d+)",
"network\\.(\\w+)\\.(enable|disable)"
]
lua = "if is_number(value) then value = num(value) * 1.1 end; return true"
files = ["*.conf", "*.ini"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 1, "Should load 1 command from TOML")
// Verify the complex regex command
cmd := commands[0]
assert.Equal(t, "ComplexPatterns", cmd.Name, "Command name should match")
assert.Len(t, cmd.Regexes, 3, "Should have 3 regex patterns")
// Verify each regex pattern
assert.Equal(t, `\[section\.([^\]]+)\]`, cmd.Regexes[0], "First regex should match section pattern")
assert.Equal(t, `(?P<key>\w+)\s*=\s*(?P<value>\d+\.\d+)`, cmd.Regexes[1], "Second regex should match key-value pattern")
assert.Equal(t, `network\.(\w+)\.(enable|disable)`, cmd.Regexes[2], "Third regex should match network pattern")
assert.Equal(t, []string{"*.conf", "*.ini"}, cmd.Files, "Files should match")
}
func TestTOMLJSONMode(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-json-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create TOML content with JSON mode commands
tomlContent := `[[commands]]
name = "JSONMultiply"
json = true
lua = "for i, item in ipairs(data.items) do data.items[i].value = item.value * 2 end; return true"
files = ["data.json"]
[[commands]]
name = "JSONObjectUpdate"
json = true
lua = "data.version = '2.0.0'; data.enabled = true; return true"
files = ["config.json"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 2, "Should load 2 commands from TOML")
// Verify first JSON command
cmd1 := commands[0]
assert.Equal(t, "JSONMultiply", cmd1.Name, "First command name should match")
assert.True(t, cmd1.JSON, "First command should have JSON mode enabled")
assert.Equal(t, "for i, item in ipairs(data.items) do data.items[i].value = item.value * 2 end; return true", cmd1.Lua, "First command Lua should match")
assert.Equal(t, []string{"data.json"}, cmd1.Files, "First command files should match")
// Verify second JSON command
cmd2 := commands[1]
assert.Equal(t, "JSONObjectUpdate", cmd2.Name, "Second command name should match")
assert.True(t, cmd2.JSON, "Second command should have JSON mode enabled")
assert.Equal(t, "data.version = '2.0.0'; data.enabled = true; return true", cmd2.Lua, "Second command Lua should match")
assert.Equal(t, []string{"config.json"}, cmd2.Files, "Second command files should match")
}
func TestTOMLEndToEndIntegration(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-integration-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create comprehensive TOML content
tomlContent := `[[commands]]
modifiers = { multiplier = 4, base_value = 100 }
[[commands]]
name = "IntegrationTest"
regex = '''
\[kinetics\.stressValues\.v2\.capacity\]
steam_engine = !num
water_wheel = !num
copper_valve_handle = !num'''
lua = "v1 * multiplier"
files = ["test.txt"]
isolate = true
[[commands]]
name = "SimplePattern"
regex = "enabled = (true|false)"
lua = "= false"
files = ["test.txt"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Create test file that matches the patterns
testContent := `[kinetics.stressValues.v2.capacity]
steam_engine = 256
water_wheel = 64
copper_valve_handle = 16
some_other_setting = enabled = true
`
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test the complete workflow using the main function
commands, err := utils.LoadCommands([]string{"test.toml"})
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 3, "Should load 3 commands total (including global modifiers)")
// Associate files with commands
files := []string{"test.txt"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
assert.NoError(t, err, "Should associate files with commands")
// Verify associations
association := associations["test.txt"]
assert.Len(t, association.IsolateCommands, 1, "Should have 1 isolate command")
assert.Len(t, association.Commands, 1, "Should have 1 regular command")
assert.Equal(t, "IntegrationTest", association.IsolateCommands[0].Name, "Isolate command should match")
assert.Equal(t, "SimplePattern", association.Commands[0].Name, "Regular command should match")
t.Logf("TOML integration test completed successfully")
t.Logf("Loaded %d commands from TOML", len(commands))
t.Logf("Associated commands: %d isolate, %d regular",
len(association.IsolateCommands), len(association.Commands))
}
func TestTOMLErrorHandling(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-error-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test 1: Invalid TOML syntax
invalidTOML := `[[commands]]
name = "Invalid"
regex = "test = !num"
lua = "v1 * 2"
files = ["test.txt"
# Missing closing bracket
`
invalidFile := filepath.Join(tmpDir, "invalid.toml")
err = os.WriteFile(invalidFile, []byte(invalidTOML), 0644)
commands, err := utils.LoadCommandsFromTomlFiles("invalid.toml")
assert.Error(t, err, "Should return error for invalid TOML syntax")
assert.Nil(t, commands, "Should return nil commands for invalid TOML")
assert.Contains(t, err.Error(), "failed to unmarshal TOML file", "Error should mention TOML unmarshaling")
// Test 2: Non-existent file
commands, err = utils.LoadCommandsFromTomlFiles("nonexistent.toml")
assert.NoError(t, err, "Should handle non-existent file without error")
assert.Empty(t, commands, "Should return empty commands for non-existent file")
// Test 3: Empty TOML file creates an error (this is expected behavior)
emptyFile := filepath.Join(tmpDir, "empty.toml")
err = os.WriteFile(emptyFile, []byte(""), 0644)
commands, err = utils.LoadCommandsFromTomlFiles("empty.toml")
assert.Error(t, err, "Should return error for empty TOML file")
assert.Nil(t, commands, "Should return nil commands for empty TOML")
}
func TestYAMLToTOMLConversion(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "yaml-to-toml-conversion-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create a test YAML file
yamlContent := `- name: "ConversionTest"
regex: "value = !num"
lua: "v1 * 3"
files: ["test.txt"]
loglevel: DEBUG
- name: "AnotherTest"
regex: "enabled = (true|false)"
lua: "= false"
files: ["*.conf"]
- name: "GlobalModifiers"
modifiers:
multiplier: 2.5
prefix: "CONV_"
`
yamlFile := filepath.Join(tmpDir, "test.yml")
err = os.WriteFile(yamlFile, []byte(yamlContent), 0644)
assert.NoError(t, err, "Should write YAML test file")
// Test conversion
err = utils.ConvertYAMLToTOML("test.yml")
assert.NoError(t, err, "Should convert YAML to TOML without error")
// Check that TOML file was created
tomlFile := filepath.Join(tmpDir, "test.toml")
_, err = os.Stat(tomlFile)
assert.NoError(t, err, "TOML file should exist after conversion")
// Read and verify TOML content
tomlData, err := os.ReadFile(tomlFile)
assert.NoError(t, err, "Should read TOML file")
tomlContent := string(tomlData)
assert.Contains(t, tomlContent, `name = "ConversionTest"`, "TOML should contain first command name")
assert.Contains(t, tomlContent, `name = "AnotherTest"`, "TOML should contain second command name")
assert.Contains(t, tomlContent, `name = "GlobalModifiers"`, "TOML should contain global modifiers command")
assert.Contains(t, tomlContent, `multiplier = 2.5`, "TOML should contain multiplier")
assert.Contains(t, tomlContent, `prefix = "CONV_"`, "TOML should contain prefix")
// Test that converted TOML loads correctly
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load converted TOML without error")
assert.Len(t, commands, 3, "Should load 3 commands from converted TOML")
// Find global modifiers command (it might not be first)
var globalCmd utils.ModifyCommand
foundGlobal := false
for _, cmd := range commands {
if cmd.Name == "GlobalModifiers" {
globalCmd = cmd
foundGlobal = true
break
}
}
assert.True(t, foundGlobal, "Should find global modifiers command")
assert.Equal(t, 2.5, globalCmd.Modifiers["multiplier"], "Should preserve multiplier value")
assert.Equal(t, "CONV_", globalCmd.Modifiers["prefix"], "Should preserve prefix value")
// Test skip functionality - run conversion again
err = utils.ConvertYAMLToTOML("test.yml")
assert.NoError(t, err, "Should handle existing TOML file without error")
// Verify original TOML file wasn't modified
originalTomlData, err := os.ReadFile(tomlFile)
assert.NoError(t, err, "Should read TOML file again")
assert.Equal(t, tomlData, originalTomlData, "TOML file content should be unchanged")
t.Logf("YAML to TOML conversion test completed successfully")
}

View File

@@ -1,12 +1,14 @@
package utils
import (
"errors"
"path/filepath"
"time"
logger "git.site.quack-lab.dev/dave/cylogger"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// dbLogger is a scoped logger for the utils/db package.
@@ -40,24 +42,25 @@ func GetDB() (DB, error) {
dbFile := filepath.Join("data.sqlite")
getDBLogger.Debug("Opening database file: %q", dbFile)
getDBLogger.Trace("Database configuration: PrepareStmt=true, GORM logger=Silent")
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{
// SkipDefaultTransaction: true,
PrepareStmt: true,
// Logger: gormlogger.Default.LogMode(gormlogger.Silent),
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
if err != nil {
getDBLogger.Error("Failed to open database: %v", err)
getDBLogger.Error("Failed to open database file %q: %v", dbFile, err)
return nil, err
}
getDBLogger.Debug("Database opened successfully, running auto migration")
getDBLogger.Debug("Database opened successfully, running auto migration for FileSnapshot model")
if err := db.AutoMigrate(&FileSnapshot{}); err != nil {
getDBLogger.Error("Auto migration failed: %v", err)
getDBLogger.Error("Auto migration failed for FileSnapshot model: %v", err)
return nil, err
}
getDBLogger.Debug("Auto migration completed")
getDBLogger.Info("Database initialized and migrated successfully")
globalDB = &DBWrapper{db: db}
getDBLogger.Debug("Database wrapper initialized")
getDBLogger.Debug("Database wrapper initialized and cached globally")
return globalDB, nil
}
@@ -87,7 +90,7 @@ func (db *DBWrapper) FileExists(filePath string) (bool, error) {
}
func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
saveFileLogger := dbLogger.WithPrefix("SaveFile").WithField("filePath", filePath)
saveFileLogger := dbLogger.WithPrefix("SaveFile").WithField("filePath", filePath).WithField("dataSize", len(fileData))
saveFileLogger.Debug("Attempting to save file to database")
saveFileLogger.Trace("File data length: %d", len(fileData))
@@ -97,7 +100,7 @@ func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
return err
}
if exists {
saveFileLogger.Debug("File already exists, skipping save")
saveFileLogger.Debug("File already exists in database, skipping save to avoid overwriting original snapshot")
return nil
}
saveFileLogger.Debug("Creating new file snapshot in database")
@@ -109,7 +112,7 @@ func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
if err != nil {
saveFileLogger.Error("Failed to create file snapshot: %v", err)
} else {
saveFileLogger.Debug("File saved successfully to database")
saveFileLogger.Info("File successfully saved to database")
}
return err
}
@@ -120,7 +123,11 @@ func (db *DBWrapper) GetFile(filePath string) ([]byte, error) {
var fileSnapshot FileSnapshot
err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error
if err != nil {
getFileLogger.Error("Failed to get file from database: %v", err)
if errors.Is(err, gorm.ErrRecordNotFound) {
getFileLogger.Debug("File not found in database: %v", err)
} else {
getFileLogger.Warning("Failed to get file from database: %v", err)
}
return nil, err
}
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)
if err != nil {
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.Debug("Writing original content back to file %q", file)

View File

@@ -1,20 +0,0 @@
package utils
import (
"flag"
logger "git.site.quack-lab.dev/dave/cylogger"
)
// flagsLogger is a scoped logger for the utils/flags package.
var flagsLogger = logger.Default.WithPrefix("utils/flags")
var (
ParallelFiles = flag.Int("P", 100, "Number of files to process in parallel")
Filter = flag.String("f", "", "Filter commands before running them")
)
func init() {
flagsLogger.Debug("Initializing flags")
flagsLogger.Trace("ParallelFiles initial value: %d, Filter initial value: %q", *ParallelFiles, *Filter)
}

View File

@@ -8,6 +8,7 @@ import (
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/bmatcuk/doublestar/v4"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
@@ -15,17 +16,18 @@ import (
var modifyCommandLogger = logger.Default.WithPrefix("utils/modifycommand")
type ModifyCommand struct {
Name string `yaml:"name,omitempty"`
Regex string `yaml:"regex,omitempty"`
Regexes []string `yaml:"regexes,omitempty"`
Lua string `yaml:"lua,omitempty"`
Files []string `yaml:"files,omitempty"`
Reset bool `yaml:"reset,omitempty"`
LogLevel string `yaml:"loglevel,omitempty"`
Isolate bool `yaml:"isolate,omitempty"`
NoDedup bool `yaml:"nodedup,omitempty"`
Disabled bool `yaml:"disable,omitempty"`
Modifiers map[string]interface{} `yaml:"modifiers,omitempty"`
Name string `yaml:"name,omitempty" toml:"name,omitempty"`
Regex string `yaml:"regex,omitempty" toml:"regex,omitempty"`
Regexes []string `yaml:"regexes,omitempty" toml:"regexes,omitempty"`
Lua string `yaml:"lua,omitempty" toml:"lua,omitempty"`
Files []string `yaml:"files,omitempty" toml:"files,omitempty"`
Reset bool `yaml:"reset,omitempty" toml:"reset,omitempty"`
LogLevel string `yaml:"loglevel,omitempty" toml:"loglevel,omitempty"`
Isolate bool `yaml:"isolate,omitempty" toml:"isolate,omitempty"`
NoDedup bool `yaml:"nodedup,omitempty" toml:"nodedup,omitempty"`
Disabled bool `yaml:"disable,omitempty" toml:"disable,omitempty"`
JSON bool `yaml:"json,omitempty" toml:"json,omitempty"`
Modifiers map[string]interface{} `yaml:"modifiers,omitempty" toml:"modifiers,omitempty"`
}
type CookFile []ModifyCommand
@@ -33,10 +35,15 @@ type CookFile []ModifyCommand
func (c *ModifyCommand) Validate() error {
validateLogger := modifyCommandLogger.WithPrefix("Validate").WithField("commandName", c.Name)
validateLogger.Debug("Validating command")
if c.Regex == "" && len(c.Regexes) == 0 {
validateLogger.Error("Validation failed: Regex pattern is required")
return fmt.Errorf("pattern is required")
// For JSON mode, regex patterns are not 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 == "" {
validateLogger.Error("Validation failed: Lua expression is required")
return fmt.Errorf("lua expression is required")
@@ -259,11 +266,27 @@ func LoadCommands(args []string) ([]ModifyCommand, error) {
for _, arg := range args {
loadCommandsLogger.Debug("Processing argument for commands: %q", arg)
newCommands, err := LoadCommandsFromCookFiles(arg)
if err != nil {
loadCommandsLogger.Error("Failed to load commands from argument %q: %v", arg, err)
return nil, fmt.Errorf("failed to load commands from cook files: %w", err)
var newCommands []ModifyCommand
var err error
// Check file extension to determine format
if strings.HasSuffix(arg, ".toml") {
loadCommandsLogger.Debug("Loading TOML commands from %q", arg)
newCommands, err = LoadCommandsFromTomlFiles(arg)
if err != nil {
loadCommandsLogger.Error("Failed to load TOML commands from argument %q: %v", arg, err)
return nil, fmt.Errorf("failed to load commands from TOML files: %w", err)
}
} else {
// Default to YAML for .yml, .yaml, or any other extension
loadCommandsLogger.Debug("Loading YAML commands from %q", arg)
newCommands, err = LoadCommandsFromCookFiles(arg)
if err != nil {
loadCommandsLogger.Error("Failed to load YAML commands from argument %q: %v", arg, err)
return nil, fmt.Errorf("failed to load commands from cook files: %w", err)
}
}
loadCommandsLogger.Debug("Successfully loaded %d commands from %q", len(newCommands), arg)
for _, cmd := range newCommands {
if cmd.Disabled {
@@ -367,3 +390,191 @@ func FilterCommands(commands []ModifyCommand, filter string) []ModifyCommand {
filterCommandsLogger.Trace("Filtered commands: %v", filteredCommands)
return filteredCommands
}
func LoadCommandsFromTomlFiles(pattern string) ([]ModifyCommand, error) {
loadTomlFilesLogger := modifyCommandLogger.WithPrefix("LoadCommandsFromTomlFiles").WithField("pattern", pattern)
loadTomlFilesLogger.Debug("Loading commands from TOML files based on pattern")
loadTomlFilesLogger.Trace("Input pattern: %q", pattern)
static, pattern := SplitPattern(pattern)
commands := []ModifyCommand{}
tomlFiles, err := doublestar.Glob(os.DirFS(static), pattern)
if err != nil {
loadTomlFilesLogger.Error("Failed to glob TOML files for pattern %q: %v", pattern, err)
return nil, fmt.Errorf("failed to glob TOML files: %w", err)
}
loadTomlFilesLogger.Debug("Found %d TOML files for pattern %q", len(tomlFiles), pattern)
loadTomlFilesLogger.Trace("TOML files found: %v", tomlFiles)
for _, tomlFile := range tomlFiles {
tomlFile = filepath.Join(static, tomlFile)
tomlFile = filepath.Clean(tomlFile)
tomlFile = strings.ReplaceAll(tomlFile, "\\", "/")
loadTomlFilesLogger.Debug("Loading commands from individual TOML file: %q", tomlFile)
tomlFileData, err := os.ReadFile(tomlFile)
if err != nil {
loadTomlFilesLogger.Error("Failed to read TOML file %q: %v", tomlFile, err)
return nil, fmt.Errorf("failed to read TOML file: %w", err)
}
loadTomlFilesLogger.Trace("Read %d bytes from TOML file %q", len(tomlFileData), tomlFile)
newCommands, err := LoadCommandsFromTomlFile(tomlFileData)
if err != nil {
loadTomlFilesLogger.Error("Failed to load commands from TOML file data for %q: %v", tomlFile, err)
return nil, fmt.Errorf("failed to load commands from TOML file: %w", err)
}
commands = append(commands, newCommands...)
loadTomlFilesLogger.Debug("Added %d commands from TOML file %q. Total commands now: %d", len(newCommands), tomlFile, len(commands))
}
loadTomlFilesLogger.Debug("Finished loading commands from TOML files. Total %d commands", len(commands))
return commands, nil
}
func LoadCommandsFromTomlFile(tomlFileData []byte) ([]ModifyCommand, error) {
loadTomlCommandLogger := modifyCommandLogger.WithPrefix("LoadCommandsFromTomlFile")
loadTomlCommandLogger.Debug("Unmarshaling commands from TOML file data")
loadTomlCommandLogger.Trace("TOML file data length: %d", len(tomlFileData))
// TOML structure for commands array
var tomlData struct {
Commands []ModifyCommand `toml:"commands"`
// Also support direct array without wrapper
DirectCommands []ModifyCommand `toml:"-"`
}
// First try to parse as wrapped structure
err := toml.Unmarshal(tomlFileData, &tomlData)
if err != nil {
loadTomlCommandLogger.Error("Failed to unmarshal TOML file data: %v", err)
return nil, fmt.Errorf("failed to unmarshal TOML file: %w", err)
}
var commands []ModifyCommand
// If we found commands in the wrapped structure, use those
if len(tomlData.Commands) > 0 {
commands = tomlData.Commands
loadTomlCommandLogger.Debug("Found %d commands in wrapped TOML structure", len(commands))
} else {
// Try to parse as direct array (similar to YAML format)
commands = []ModifyCommand{}
err = toml.Unmarshal(tomlFileData, &commands)
if err != nil {
loadTomlCommandLogger.Error("Failed to unmarshal TOML file data as direct array: %v", err)
return nil, fmt.Errorf("failed to unmarshal TOML file as direct array: %w", err)
}
loadTomlCommandLogger.Debug("Found %d commands in direct TOML array", len(commands))
}
loadTomlCommandLogger.Debug("Successfully unmarshaled %d commands", len(commands))
loadTomlCommandLogger.Trace("Unmarshaled commands: %v", commands)
return commands, nil
}
// ConvertYAMLToTOML converts YAML files to TOML format
func ConvertYAMLToTOML(yamlPattern string) error {
convertLogger := modifyCommandLogger.WithPrefix("ConvertYAMLToTOML").WithField("pattern", yamlPattern)
convertLogger.Debug("Starting YAML to TOML conversion")
// Load YAML commands
yamlCommands, err := LoadCommandsFromCookFiles(yamlPattern)
if err != nil {
convertLogger.Error("Failed to load YAML commands: %v", err)
return fmt.Errorf("failed to load YAML commands: %w", err)
}
if len(yamlCommands) == 0 {
convertLogger.Info("No YAML commands found for pattern: %s", yamlPattern)
return nil
}
convertLogger.Debug("Loaded %d commands from YAML", len(yamlCommands))
// Find all YAML files matching the pattern
static, pattern := SplitPattern(yamlPattern)
yamlFiles, err := doublestar.Glob(os.DirFS(static), pattern)
if err != nil {
convertLogger.Error("Failed to glob YAML files: %v", err)
return fmt.Errorf("failed to glob YAML files: %w", err)
}
convertLogger.Debug("Found %d YAML files to convert", len(yamlFiles))
conversionCount := 0
skippedCount := 0
for _, yamlFile := range yamlFiles {
yamlFilePath := filepath.Join(static, yamlFile)
yamlFilePath = filepath.Clean(yamlFilePath)
yamlFilePath = strings.ReplaceAll(yamlFilePath, "\\", "/")
// Generate corresponding TOML file path
tomlFilePath := strings.TrimSuffix(yamlFilePath, filepath.Ext(yamlFilePath)) + ".toml"
convertLogger.Debug("Processing YAML file: %s -> %s", yamlFilePath, tomlFilePath)
// Check if TOML file already exists
if _, err := os.Stat(tomlFilePath); err == nil {
convertLogger.Info("Skipping conversion - TOML file already exists: %s", tomlFilePath)
skippedCount++
continue
}
// Read YAML file
yamlData, err := os.ReadFile(yamlFilePath)
if err != nil {
convertLogger.Error("Failed to read YAML file %s: %v", yamlFilePath, err)
continue
}
// Load YAML commands from this specific file
fileCommands, err := LoadCommandsFromCookFile(yamlData)
if err != nil {
convertLogger.Error("Failed to parse YAML file %s: %v", yamlFilePath, err)
continue
}
// Convert to TOML structure
tomlData, err := convertCommandsToTOML(fileCommands)
if err != nil {
convertLogger.Error("Failed to convert commands to TOML for %s: %v", yamlFilePath, err)
continue
}
// Write TOML file
err = os.WriteFile(tomlFilePath, tomlData, 0644)
if err != nil {
convertLogger.Error("Failed to write TOML file %s: %v", tomlFilePath, err)
continue
}
convertLogger.Info("Successfully converted %s to %s", yamlFilePath, tomlFilePath)
conversionCount++
}
convertLogger.Info("Conversion completed: %d files converted, %d files skipped", conversionCount, skippedCount)
return nil
}
// convertCommandsToTOML converts a slice of ModifyCommand to TOML format
func convertCommandsToTOML(commands []ModifyCommand) ([]byte, error) {
convertLogger := modifyCommandLogger.WithPrefix("convertCommandsToTOML")
convertLogger.Debug("Converting %d commands to TOML format", len(commands))
// Create TOML structure
tomlData := struct {
Commands []ModifyCommand `toml:"commands"`
}{
Commands: commands,
}
// Marshal to TOML
tomlBytes, err := toml.Marshal(tomlData)
if err != nil {
convertLogger.Error("Failed to marshal commands to TOML: %v", err)
return nil, fmt.Errorf("failed to marshal commands to TOML: %w", err)
}
convertLogger.Debug("Successfully converted %d commands to TOML (%d bytes)", len(commands), len(tomlBytes))
return tomlBytes, nil
}