diff --git a/commands.go b/commands.go index d426a05b..ee6497e6 100644 --- a/commands.go +++ b/commands.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -96,6 +97,7 @@ func getOwnCommands() []ownCommand { longDescription: "The \"show\" command prints the effective configuration of the selected profile or group.\n\nThe effective profile or group configuration is built by loading all includes, applying inheritance, mixins, templates and variables and parsing the result.", action: showProfileOrGroup, needConfiguration: true, + flags: map[string]string{"--json": "output the configuration in JSON format"}, }, { name: "schedule", @@ -235,6 +237,8 @@ func completeCommand(ctx commandContext) error { func showProfileOrGroup(ctx commandContext) error { c := ctx.config flags := ctx.flags + args := ctx.request.arguments + jsonOutput := slices.Contains(args, "--json") // Load global section global, err := c.GetGlobalSection() @@ -269,6 +273,10 @@ func showProfileOrGroup(ctx commandContext) error { return fmt.Errorf("profile or group '%s': %w", flags.name, config.ErrNotFound) } + if jsonOutput { + return showProfileOrGroupJSON(ctx, global, profileOrGroup) + } + // Show global err = config.ShowStruct(ctx.terminal, global, constants.SectionConfigurationGlobal) if err != nil { @@ -297,6 +305,44 @@ func showProfileOrGroup(ctx commandContext) error { return nil } +func showProfileOrGroupJSON(ctx commandContext, global *config.Global, profileOrGroup config.Schedulable) error { + flags := ctx.flags + + globalData, err := config.CollectStruct(global) + if err != nil { + return fmt.Errorf("cannot collect global section: %w", err) + } + + profileData, err := config.CollectStruct(profileOrGroup) + if err != nil { + return fmt.Errorf("cannot collect %s '%s': %w", profileOrGroup.Kind(), flags.name, err) + } + + schedules := slices.Collect(maps.Values(profileOrGroup.Schedules())) + slices.SortFunc(schedules, config.CompareSchedules) + var schedulesData []map[string]any + for _, schedule := range schedules { + data, err := config.CollectStruct(schedule.ScheduleConfig) + if err != nil { + continue + } + data["command"] = schedule.ScheduleOrigin().Command + schedulesData = append(schedulesData, data) + } + + output := map[string]any{ + "global": globalData, + profileOrGroup.Kind(): profileData, + } + if len(schedulesData) > 0 { + output["schedules"] = schedulesData + } + + encoder := json.NewEncoder(ctx.terminal) + encoder.SetIndent("", " ") + return encoder.Encode(output) +} + func showSchedules(output io.Writer, schedules []*config.Schedule) { slices.SortFunc(schedules, config.CompareSchedules) for _, schedule := range schedules { diff --git a/config/collect.go b/config/collect.go new file mode 100644 index 00000000..46486d7b --- /dev/null +++ b/config/collect.go @@ -0,0 +1,168 @@ +package config + +import ( + "fmt" + "reflect" + "slices" + "sort" + + "github.com/creativeprojects/resticprofile/util" +) + +// CollectStruct walks a struct using the same tag and visibility rules as ShowStruct, +// but returns a map[string]any suitable for JSON serialization. +func CollectStruct(orig any) (map[string]any, error) { + return collectStructValue(reflect.ValueOf(orig)) +} + +func collectStructValue(value reflect.Value) (map[string]any, error) { + v, isNil := util.UnpackValue(value) + if isNil { + return nil, nil + } + if v.Kind() != reflect.Struct { + return nil, fmt.Errorf("unsupported type %s, expected %s", v.Kind(), reflect.Struct) + } + result := make(map[string]any) + for i := 0; i < v.Type().NumField(); i++ { + ft, fv := v.Type().Field(i), v.Field(i) + key, ok := fieldShown(&ft) + if !ok { + continue + } + uv, isNil := util.UnpackValue(fv) + if isNil { + continue + } + + switch uv.Kind() { + case reflect.Struct: + if getStringer(fv) != nil { + collectLeaf(result, key, fv) + } + if key == ",squash" { + if sub, err := collectStructValue(fv); err == nil { + for k, v := range sub { + result[k] = v + } + } + } else if sub, err := collectStructValue(fv); err == nil && len(sub) > 0 { + result[key] = sub + } + + case reflect.Map: + if fv.Len() > 0 { + m := collectMapValue(fv) + if key == ",remain" { + for k, v := range m { + result[k] = v + } + } else { + result[key] = m + } + } + + case reflect.Array, reflect.Slice: + if isSliceWithStruct(fv) { + if items := collectSliceValue(fv); len(items) > 0 { + result[key] = items + } + } else { + collectLeaf(result, key, fv) + } + + default: + collectLeaf(result, key, fv) + } + } + return result, nil +} + +func collectMapValue(v reflect.Value) map[string]any { + result := make(map[string]any) + keys := v.MapKeys() + sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) + for _, key := range keys { + collectLeaf(result, key.String(), v.MapIndex(key)) + } + return result +} + +func collectSliceValue(v reflect.Value) (items []any) { + for i := 0; i < v.Len(); i++ { + elem := v.Index(i) + uv, isNil := util.UnpackValue(elem) + if isNil { + continue + } + switch uv.Kind() { + case reflect.Struct: + if getStringer(uv) != nil { + items = append(items, uv.Interface().(fmt.Stringer).String()) + } else if sub, err := collectStructValue(elem); err == nil && len(sub) > 0 { + items = append(items, sub) + } + case reflect.Map: + if uv.Len() > 0 { + items = append(items, collectMapValue(uv)) + } + case reflect.Slice, reflect.Array: + if nested := collectSliceValue(uv); len(nested) > 0 { + items = append(items, nested) + } + default: + if vals, ok := stringify(elem, false); ok && len(vals) > 0 { + items = append(items, vals[0]) + } + } + } + return +} + +func collectLeaf(result map[string]any, key string, v reflect.Value) { + if isNotShown(key, nil) { + return + } + uv, isNil := util.UnpackValue(v) + if isNil { + return + } + switch uv.Kind() { + case reflect.Struct: + if getStringer(uv) == nil { + if sub, err := collectStructValue(v); err == nil && len(sub) > 0 { + result[key] = sub + } + } else if vals, ok := stringify(v, false); ok && len(vals) > 0 { + result[key] = vals[0] + } + + case reflect.Map: + if uv.Len() > 0 { + result[key] = collectMapValue(uv) + } + + case reflect.Slice, reflect.Array: + if isSliceWithStruct(uv) { + if items := collectSliceValue(uv); len(items) > 0 { + result[key] = items + } + } else if uv.Len() > 0 { + result[key] = collectSliceValue(uv) + } + + default: + if vals, ok := stringify(v, false); ok { + switch len(vals) { + case 0: + result[key] = true + case 1: + result[key] = vals[0] + default: + result[key] = vals + } + } else if slices.Contains(allowedEmptyValueArgs, key) { + result[key] = "" + } + } +} diff --git a/config/collect_test.go b/config/collect_test.go new file mode 100644 index 00000000..12c7ce2d --- /dev/null +++ b/config/collect_test.go @@ -0,0 +1,248 @@ +package config + +import ( + "fmt" + "testing" + "time" + + "github.com/creativeprojects/resticprofile/util/maybe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectStruct(t *testing.T) { + testData := []struct { + name string + input any + expect map[string]any + }{ + { + name: "simple fields", + input: testObject{Id: 11, Name: "test"}, + expect: map[string]any{ + "id": "11", + "name": "test", + }, + }, + { + name: "nested struct", + input: testObject{Id: 11, Person: testPerson{Name: "test", IsValid: true}}, + expect: map[string]any{ + "id": "11", + "person": map[string]any{ + "name": "test", + "valid": true, + }, + }, + }, + { + name: "nested struct empty fields hidden", + input: testObject{Id: 11, Person: testPerson{Name: "test"}}, + expect: map[string]any{ + "id": "11", + "person": map[string]any{ + "name": "test", + }, + }, + }, + { + name: "pointer struct", + input: testObject{Id: 11, Pointer: &testPointer{IsValid: true}}, + expect: map[string]any{ + "id": "11", + "pointer": map[string]any{ + "valid": true, + }, + }, + }, + { + name: "slice of structs", + input: testObject{Id: 11, Persons: []testPerson{ + {Name: "p1", IsValid: true}, + {Name: "p2"}, + }}, + expect: map[string]any{ + "id": "11", + "persons": []any{ + map[string]any{"name": "p1", "valid": true}, + map[string]any{"name": "p2"}, + }, + }, + }, + { + name: "remain map merged at top level", + input: testObject{Id: 11, Map: map[string]any{"extra": "value"}}, + expect: map[string]any{ + "id": "11", + "extra": "value", + }, + }, + { + name: "show tag field", + input: testObject{Id: 11, OtherShown: "shown"}, + expect: map[string]any{ + "id": "11", + "other-shown": "shown", + }, + }, + { + name: "hidden fields not included", + input: testObject{ + Id: 11, + Other: "should not appear", + Hidden: "should not appear", + AlsoHidden: "should not appear", + }, + expect: map[string]any{ + "id": "11", + }, + }, + { + name: "embedded squash struct", + input: testEmbedded{EmbeddedStruct{Value: true}, 1}, + expect: map[string]any{ + "value": true, + "inline": "1", + }, + }, + { + name: "stringer value", + input: testStringer{Age: 2*time.Minute + 5*time.Second}, + expect: map[string]any{ + "age": "2m5s", + }, + }, + { + name: "zero stringer hidden", + input: testStringer{}, + expect: map[string]any{}, + }, + { + name: "maybe.Bool unset hidden", + input: testObject{IsValid: maybe.Bool{}}, + expect: map[string]any{}, + }, + { + name: "maybe.Bool false shown", + input: testObject{IsValid: maybe.False()}, + expect: map[string]any{ + "valid": "false", + }, + }, + { + name: "maybe.Bool true shown", + input: testObject{IsValid: maybe.True()}, + expect: map[string]any{ + "valid": "true", + }, + }, + { + name: "pointer to struct", + input: &testEmbedded{EmbeddedStruct{Value: true}, 1}, + expect: map[string]any{ + "value": true, + "inline": "1", + }, + }, + { + name: "allowed empty value args", + input: testObject{Map: map[string]any{"tag": "", "keep-tag": "", "group-by": ""}}, + expect: map[string]any{ + "tag": "", + "keep-tag": "", + "group-by": "", + }, + }, + { + name: "nested map in remain", + input: testObject{Map: map[string]any{ + "nested": map[string]any{ + "key1": "val1", + "key2": []string{"a", "b"}, + }, + }}, + expect: map[string]any{ + "nested": map[string]any{ + "key1": "val1", + "key2": []any{"a", "b"}, + }, + }, + }, + { + name: "nested map with deeper nesting", + input: testObject{Map: map[string]any{ + "outer": map[string]any{ + "inner": map[string]any{ + "deep": "value", + }, + }, + }}, + expect: map[string]any{ + "outer": map[string]any{ + "inner": map[string]any{ + "deep": "value", + }, + }, + }, + }, + { + name: "list of mixed types in remain", + input: testObject{Map: map[string]any{ + "items": []any{ + "plain", + map[string]any{"a": "b"}, + }, + }}, + expect: map[string]any{ + "items": []any{ + "plain", + map[string]any{"a": "b"}, + }, + }, + }, + { + name: "nested struct in map", + input: testObject{Map: map[string]any{"person": testPerson{Name: "test", IsValid: true}}}, + expect: map[string]any{ + "person": map[string]any{ + "name": "test", + "valid": true, + }, + }, + }, + { + name: "map with string properties", + input: testObject{Id: 11, Person: testPerson{Properties: map[string][]string{ + "list": {"one", "two", "three"}, + }}}, + expect: map[string]any{ + "id": "11", + "person": map[string]any{ + "properties": map[string]any{ + "list": []any{"one", "two", "three"}, + }, + }, + }, + }, + } + + for i, testItem := range testData { + t.Run(fmt.Sprintf("%d_%s", i, testItem.name), func(t *testing.T) { + result, err := CollectStruct(testItem.input) + require.NoError(t, err) + assert.Equal(t, testItem.expect, result) + }) + } +} + +func TestCollectStructUnsupportedType(t *testing.T) { + _, err := CollectStruct([]string{"not", "a", "struct"}) + assert.Error(t, err) +} + +func TestCollectStructNilPointer(t *testing.T) { + var ptr *testObject + result, err := CollectStruct(ptr) + assert.NoError(t, err) + assert.Nil(t, result) +}