Skip to content

Commit da6f7c9

Browse files
authored
Dynamics in gui (#1442)
* dynamics in gui * lint * repair config at consumption time too
1 parent 84ff2b4 commit da6f7c9

13 files changed

Lines changed: 502 additions & 35 deletions

cmd/curio/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ var configRmCmd = &cli.Command{
203203
return nil
204204
},
205205
}
206+
206207
var configViewCmd = &cli.Command{
207208
Name: "interpret",
208209
Aliases: []string{"view", "stacked", "stack"},

deps/config/dynamic_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ func TestDefaultCurioConfigMarshal(t *testing.T) {
177177
data, err := TransparentMarshal(cfg)
178178
assert.NoError(t, err, "Should be able to marshal DefaultCurioConfig to TOML")
179179
assert.NotEmpty(t, data)
180+
assert.Contains(t, string(data), "PDPUnclaimedUploadKeepHours = 2")
181+
assert.NotContains(t, string(data), "[Subsystems.PDPUnclaimedUploadKeepHours]")
180182
t.Logf("Successfully marshaled config to %d bytes of TOML", len(data))
181183
}
182184

deps/config/dynamic_toml.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"bytes"
45
"reflect"
56
"strings"
67

@@ -30,6 +31,11 @@ func TransparentUnmarshal(data []byte, v any) error {
3031
// with default values (e.g., types.MustParseFIL("0")) before calling this function.
3132
// NOTE: FixTOML should be called BEFORE this function to ensure proper slice lengths and FIL initialization.
3233
func TransparentDecode(data string, v any) (toml.MetaData, error) {
34+
data, err := StripEmptyDynamicTables(data, v)
35+
if err != nil {
36+
return toml.MetaData{}, err
37+
}
38+
3339
// Create a shadow struct to decode into
3440
shadow := createShadowStruct(v)
3541

@@ -331,6 +337,170 @@ func wrapDynamics(shadow, target any) error {
331337
return nil
332338
}
333339

340+
// StripEmptyDynamicTables drops empty tables left by raw toml.Encode of Dynamic[T].
341+
// Prefer this for in-memory decode paths; use RepairEmptyDynamicTablesText when
342+
// persisting a cleaned layer so comments and surrounding formatting are kept.
343+
func StripEmptyDynamicTables(text string, sample any) (string, error) {
344+
fixed, _, err := RepairEmptyDynamicTablesText(text, sample)
345+
return fixed, err
346+
}
347+
348+
// RepairEmptyDynamicTablesText removes empty Dynamic[T] wrapper tables from TOML
349+
// while preserving comments and non-corrupt content. changed is true when any
350+
// empty wrapper table header was dropped.
351+
func RepairEmptyDynamicTablesText(text string, sample any) (string, bool, error) {
352+
if strings.TrimSpace(text) == "" || sample == nil {
353+
return text, false, nil
354+
}
355+
356+
var raw map[string]any
357+
if err := toml.Unmarshal([]byte(text), &raw); err != nil {
358+
// Leave unloadable TOML alone for callers that handle decode errors.
359+
return text, false, nil
360+
}
361+
362+
var empty [][]string
363+
for _, path := range collectDynamicTOMLPaths(reflect.TypeOf(sample), nil) {
364+
if emptyMapAtPath(raw, path) {
365+
empty = append(empty, path)
366+
}
367+
}
368+
if len(empty) == 0 {
369+
return text, false, nil
370+
}
371+
372+
fixed := removeTOMLTableHeaders(text, empty)
373+
if fixed == text {
374+
// Header lines were not found (unusual encoding); fall back to re-encode.
375+
for _, path := range empty {
376+
_ = deleteEmptyMapAtPath(raw, path)
377+
}
378+
var buf bytes.Buffer
379+
if err := toml.NewEncoder(&buf).Encode(raw); err != nil {
380+
return "", false, err
381+
}
382+
return buf.String(), true, nil
383+
}
384+
return fixed, true, nil
385+
}
386+
387+
func emptyMapAtPath(m map[string]any, path []string) bool {
388+
if len(path) == 0 || m == nil {
389+
return false
390+
}
391+
_, val, ok := mapLookupCI(m, path[0])
392+
if !ok {
393+
return false
394+
}
395+
child, isMap := val.(map[string]any)
396+
if !isMap {
397+
return false
398+
}
399+
if len(path) > 1 {
400+
return emptyMapAtPath(child, path[1:])
401+
}
402+
return len(child) == 0
403+
}
404+
405+
// removeTOMLTableHeaders drops plain table header lines whose dotted names match
406+
// paths (case-insensitive). Array tables ([[...]]) are left untouched.
407+
func removeTOMLTableHeaders(text string, paths [][]string) string {
408+
want := make(map[string]struct{}, len(paths))
409+
for _, path := range paths {
410+
want[strings.ToLower(strings.Join(path, "."))] = struct{}{}
411+
}
412+
413+
lines := strings.Split(text, "\n")
414+
out := make([]string, 0, len(lines))
415+
for _, line := range lines {
416+
trimmed := strings.TrimSpace(line)
417+
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") && !strings.HasPrefix(trimmed, "[[") {
418+
inner := strings.TrimSpace(trimmed[1 : len(trimmed)-1])
419+
if _, ok := want[strings.ToLower(inner)]; ok {
420+
continue
421+
}
422+
}
423+
out = append(out, line)
424+
}
425+
return strings.Join(out, "\n")
426+
}
427+
428+
func collectDynamicTOMLPaths(t reflect.Type, prefix []string) [][]string {
429+
if t == nil {
430+
return nil
431+
}
432+
if t.Kind() == reflect.Pointer {
433+
t = t.Elem()
434+
}
435+
if t.Kind() != reflect.Struct {
436+
return nil
437+
}
438+
439+
var out [][]string
440+
for field := range t.Fields() {
441+
if !field.IsExported() {
442+
continue
443+
}
444+
name := tomlFieldName(field)
445+
if name == "-" {
446+
continue
447+
}
448+
path := append(append([]string{}, prefix...), name)
449+
if isDynamicTypeForMarshal(field.Type) {
450+
out = append(out, path)
451+
continue
452+
}
453+
out = append(out, collectDynamicTOMLPaths(field.Type, path)...)
454+
}
455+
return out
456+
}
457+
458+
func tomlFieldName(f reflect.StructField) string {
459+
tag := f.Tag.Get("toml")
460+
if tag == "" {
461+
return f.Name
462+
}
463+
name, _, _ := strings.Cut(tag, ",")
464+
if name == "" {
465+
return f.Name
466+
}
467+
return name
468+
}
469+
470+
func deleteEmptyMapAtPath(m map[string]any, path []string) bool {
471+
if len(path) == 0 || m == nil {
472+
return false
473+
}
474+
key, val, ok := mapLookupCI(m, path[0])
475+
if !ok {
476+
return false
477+
}
478+
child, isMap := val.(map[string]any)
479+
if !isMap {
480+
return false
481+
}
482+
if len(path) > 1 {
483+
return deleteEmptyMapAtPath(child, path[1:])
484+
}
485+
if len(child) > 0 {
486+
return false
487+
}
488+
delete(m, key)
489+
return true
490+
}
491+
492+
func mapLookupCI(m map[string]any, key string) (string, any, bool) {
493+
if v, ok := m[key]; ok {
494+
return key, v, true
495+
}
496+
for k, v := range m {
497+
if strings.EqualFold(k, key) {
498+
return k, v, true
499+
}
500+
}
501+
return "", nil, false
502+
}
503+
334504
// isDynamicTypeForMarshal checks if a type is Dynamic[T]
335505
// (renamed to avoid conflict with isDynamicType in dynamic.go)
336506
func isDynamicTypeForMarshal(t reflect.Type) bool {
@@ -399,6 +569,14 @@ func extractDynamicValue(v reflect.Value) reflect.Value {
399569
return results[0]
400570
}
401571

572+
// DynamicInnerType returns T from Dynamic[T] or *Dynamic[T].
573+
func DynamicInnerType(t reflect.Type) (reflect.Type, bool) {
574+
if !isDynamicTypeForMarshal(t) {
575+
return nil, false
576+
}
577+
return extractDynamicInnerType(t), true
578+
}
579+
402580
// extractDynamicInnerType gets the T from Dynamic[T]
403581
func extractDynamicInnerType(t reflect.Type) reflect.Type {
404582
// Handle pointer to Dynamic
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package config
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/BurntSushi/toml"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestStripEmptyDynamicTables(t *testing.T) {
13+
const broken = `
14+
# keep this comment
15+
[Subsystems]
16+
EnablePDP = true
17+
18+
[Subsystems.PDPUnclaimedUploadKeepHours]
19+
20+
[Ingest]
21+
MaxQueueDownload = 16
22+
23+
[Ingest.MaxMarketRunningPipelines]
24+
`
25+
26+
cfg := DefaultCurioConfig()
27+
md, err := LoadConfigWithUpgrades(broken, cfg)
28+
require.NoError(t, err)
29+
30+
assert.True(t, cfg.Subsystems.EnablePDP)
31+
assert.Equal(t, 2, cfg.Subsystems.PDPUnclaimedUploadKeepHours.Get())
32+
assert.Equal(t, 16, cfg.Ingest.MaxQueueDownload.Get())
33+
assert.Equal(t, 64, cfg.Ingest.MaxMarketRunningPipelines.Get())
34+
assert.False(t, md.IsDefined("Subsystems", "PDPUnclaimedUploadKeepHours"))
35+
assert.False(t, md.IsDefined("Ingest", "MaxMarketRunningPipelines"))
36+
}
37+
38+
func TestRepairEmptyDynamicTablesTextPreservesComments(t *testing.T) {
39+
const broken = `
40+
# layer header
41+
[Subsystems]
42+
EnablePDP = true
43+
44+
# should be removed below
45+
[Subsystems.PDPUnclaimedUploadKeepHours]
46+
47+
[Ingest]
48+
MaxQueueDownload = 16
49+
`
50+
51+
fixed, changed, err := RepairEmptyDynamicTablesText(broken, DefaultCurioConfig())
52+
require.NoError(t, err)
53+
assert.True(t, changed)
54+
assert.Contains(t, fixed, "# layer header")
55+
assert.Contains(t, fixed, "EnablePDP = true")
56+
assert.Contains(t, fixed, "MaxQueueDownload = 16")
57+
assert.NotContains(t, fixed, "[Subsystems.PDPUnclaimedUploadKeepHours]")
58+
// Comment lines above the dropped header are preserved.
59+
assert.Contains(t, fixed, "# should be removed below")
60+
cfg := DefaultCurioConfig()
61+
_, err = LoadConfigWithUpgrades(fixed, cfg)
62+
require.NoError(t, err)
63+
assert.True(t, cfg.Subsystems.EnablePDP)
64+
assert.Equal(t, 16, cfg.Ingest.MaxQueueDownload.Get())
65+
}
66+
67+
func TestRepairEmptyDynamicTablesTextNoop(t *testing.T) {
68+
const ok = `
69+
[Subsystems]
70+
PDPUnclaimedUploadKeepHours = 7
71+
`
72+
fixed, changed, err := RepairEmptyDynamicTablesText(ok, DefaultCurioConfig())
73+
require.NoError(t, err)
74+
assert.False(t, changed)
75+
assert.Equal(t, ok, fixed)
76+
}
77+
78+
func TestStripEmptyDynamicTablesLeavesScalars(t *testing.T) {
79+
const ok = `
80+
[Subsystems]
81+
PDPUnclaimedUploadKeepHours = 7
82+
`
83+
84+
text, err := StripEmptyDynamicTables(ok, DefaultCurioConfig())
85+
require.NoError(t, err)
86+
assert.Equal(t, ok, text)
87+
88+
cfg := DefaultCurioConfig()
89+
_, err = LoadConfigWithUpgrades(ok, cfg)
90+
require.NoError(t, err)
91+
assert.Equal(t, 7, cfg.Subsystems.PDPUnclaimedUploadKeepHours.Get())
92+
}
93+
94+
func TestStripEmptyDynamicTablesAddressesWrapper(t *testing.T) {
95+
const broken = `
96+
[Addresses]
97+
`
98+
99+
cfg := DefaultCurioConfig()
100+
before := len(cfg.Addresses.Get())
101+
_, err := LoadConfigWithUpgrades(broken, cfg)
102+
require.NoError(t, err)
103+
assert.Equal(t, before, len(cfg.Addresses.Get()))
104+
}
105+
106+
func TestRawEncodeDynamicIsEmptyTable(t *testing.T) {
107+
var buf strings.Builder
108+
err := toml.NewEncoder(&buf).Encode(DefaultCurioConfig())
109+
require.NoError(t, err)
110+
assert.Contains(t, buf.String(), "[Subsystems.PDPUnclaimedUploadKeepHours]")
111+
112+
cfg := DefaultCurioConfig()
113+
_, err = LoadConfigWithUpgrades(buf.String(), cfg)
114+
require.NoError(t, err)
115+
assert.Equal(t, 2, cfg.Subsystems.PDPUnclaimedUploadKeepHours.Get())
116+
}

deps/config/dynamic_toml_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,19 @@ func TestHelperFunctions(t *testing.T) {
757757
assert.False(t, extracted.IsValid())
758758
})
759759

760+
t.Run("DynamicInnerType", func(t *testing.T) {
761+
inner, ok := DynamicInnerType(reflect.TypeFor[*Dynamic[int]]())
762+
assert.True(t, ok)
763+
assert.Equal(t, reflect.TypeFor[int](), inner)
764+
765+
inner, ok = DynamicInnerType(reflect.TypeFor[Dynamic[[]string]]())
766+
assert.True(t, ok)
767+
assert.Equal(t, reflect.TypeFor[[]string](), inner)
768+
769+
_, ok = DynamicInnerType(reflect.TypeFor[int]())
770+
assert.False(t, ok)
771+
})
772+
760773
t.Run("extractDynamicInnerType", func(t *testing.T) {
761774
dynType := reflect.TypeFor[Dynamic[int]]()
762775
innerType := extractDynamicInnerType(dynType)

deps/config/load.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,14 @@ func LoadConfigWithUpgrades(text string, curioConfigWithDefaults *CurioConfig) (
565565

566566
func LoadConfigWithUpgradesGeneric[T any](text string, curioConfigWithDefaults T, fixupFn func(string, T) error) (toml.MetaData, error) {
567567

568+
// Drop empty Dynamic wrapper tables before the [addresses] -> [[addresses]]
569+
// rewrite so a raw-encoded *Dynamic[[]CurioAddresses] is not turned into
570+
// a spurious empty address entry.
571+
text, err := StripEmptyDynamicTables(text, curioConfigWithDefaults)
572+
if err != nil {
573+
return toml.MetaData{}, err
574+
}
575+
568576
// allow migration from old config format that was limited to 1 wallet setup.
569577
newText := strings.Join(lo.Map(strings.Split(text, "\n"), func(line string, _ int) string {
570578
if strings.EqualFold(line, "[addresses]") {
@@ -573,7 +581,7 @@ func LoadConfigWithUpgradesGeneric[T any](text string, curioConfigWithDefaults T
573581
return line
574582
}), "\n")
575583

576-
err := fixupFn(newText, curioConfigWithDefaults)
584+
err = fixupFn(newText, curioConfigWithDefaults)
577585

578586
if err != nil {
579587
return toml.MetaData{}, err

0 commit comments

Comments
 (0)