diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 28072989..cfa0aef1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,6 +36,11 @@ jobs: check-latest: true cache: true + - name: Set up Node.js LTS for running JSON schema tests (using ajv) + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Build run: make build env: diff --git a/battery_test.go b/battery_test.go index a6e668ed..c5c4a9ec 100644 --- a/battery_test.go +++ b/battery_test.go @@ -10,11 +10,9 @@ import ( var errTest = errors.New("test error") -func TestNotRunningOnBattery(t *testing.T) { - battery, charge, err := IsRunningOnBattery() +func TestNoErrorIsRunningOnBattery(t *testing.T) { + _, _, err := IsRunningOnBattery() assert.NoError(t, err) - assert.False(t, battery) - assert.Zero(t, charge) } func TestIsFatalError(t *testing.T) { diff --git a/config/config.go b/config/config.go index 2bab3e1f..eb0b2e9f 100644 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,7 @@ import ( "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/filesearch" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/creativeprojects/resticprofile/util/templates" "github.com/mitchellh/mapstructure" "github.com/spf13/viper" @@ -42,6 +43,7 @@ type Config struct { var ( configOption = viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), + maybe.BoolDecoder(), confidentialValueDecoder(), )) diff --git a/config/config_test.go b/config/config_test.go index a11fc735..0eafc5d7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -198,14 +199,9 @@ profile: } func TestBoolPointer(t *testing.T) { - boolPointer := func(value bool) *bool { - output := &value - return output - } - fixtures := []struct { testTemplate - continueOnError *bool + continueOnError maybe.Bool }{ { testTemplate: testTemplate{ @@ -217,7 +213,7 @@ version = 2 profiles = [] `, }, - continueOnError: nil, + continueOnError: maybe.Bool{}, }, { testTemplate: testTemplate{ @@ -229,7 +225,7 @@ groups: profiles: [] `, }, - continueOnError: nil, + continueOnError: maybe.Bool{}, }, { testTemplate: testTemplate{ @@ -245,7 +241,7 @@ groups: } `, }, - continueOnError: nil, + continueOnError: maybe.Bool{}, }, { testTemplate: testTemplate{ @@ -258,7 +254,7 @@ profiles = [] continue-on-error = true `, }, - continueOnError: boolPointer(true), + continueOnError: maybe.True(), }, { testTemplate: testTemplate{ @@ -271,7 +267,7 @@ groups: continue-on-error: true `, }, - continueOnError: boolPointer(true), + continueOnError: maybe.True(), }, { testTemplate: testTemplate{ @@ -288,7 +284,7 @@ groups: } `, }, - continueOnError: boolPointer(true), + continueOnError: maybe.True(), }, { testTemplate: testTemplate{ @@ -301,7 +297,7 @@ profiles = [] continue-on-error = false `, }, - continueOnError: boolPointer(false), + continueOnError: maybe.False(), }, { testTemplate: testTemplate{ @@ -314,7 +310,7 @@ groups: continue-on-error: false `, }, - continueOnError: boolPointer(false), + continueOnError: maybe.False(), }, { testTemplate: testTemplate{ @@ -331,7 +327,7 @@ groups: } `, }, - continueOnError: boolPointer(false), + continueOnError: maybe.False(), }, } diff --git a/config/config_v1.go b/config/config_v1.go index f7b917bb..8448e926 100644 --- a/config/config_v1.go +++ b/config/config_v1.go @@ -6,6 +6,7 @@ import ( "reflect" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/mitchellh/mapstructure" "github.com/spf13/viper" ) @@ -28,11 +29,13 @@ import ( var ( configOptionV1 = viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), + maybe.BoolDecoder(), confidentialValueDecoder(), )) configOptionV1HCL = viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), + maybe.BoolDecoder(), confidentialValueDecoder(), sliceOfMapsToMapHookFunc(), )) @@ -70,7 +73,7 @@ func (c *Config) loadGroupsV1() (err error) { c.groups[groupName] = Group{ Description: "", Profiles: group, - ContinueOnError: nil, + ContinueOnError: maybe.Bool{}, } } } diff --git a/config/display.go b/config/display.go index 8b17cdd9..26597476 100644 --- a/config/display.go +++ b/config/display.go @@ -5,12 +5,18 @@ import ( "io" "strings" "text/tabwriter" + + "github.com/fatih/color" ) const ( indent = " " // 4 spaces ) +var ( + ansiBold = color.New(color.Bold).SprintFunc() +) + // Display is a temporary struct to display a config object to the console type Display struct { topLevel string @@ -30,6 +36,7 @@ func (d *Display) addEntry(stack []string, key string, values []string) { entry := Entry{ section: strings.Join(stack, "."), // in theory we should only have zero or one level, but don't fail if we get more key: key, + keyOnly: false, values: values, } d.entries = append(d.entries, entry) @@ -41,9 +48,10 @@ func (d *Display) addKeyOnlyEntry(stack []string, key string) { } func (d *Display) Flush() { - tabWriter := tabwriter.NewWriter(d.writer, 0, 2, 2, ' ', 0) + const minWidth, tabWidth, padding = 0, 2, 2 + tabWriter := tabwriter.NewWriter(d.writer, minWidth, tabWidth, padding, ' ', 0) // title - fmt.Fprintf(tabWriter, "%s:\n", d.topLevel) + fmt.Fprintf(tabWriter, "%s:\n", ansiBold(d.topLevel)) section := "" prefix := indent for _, entry := range d.entries { diff --git a/config/flag.go b/config/flag.go index f5d98f12..39d058ab 100644 --- a/config/flag.go +++ b/config/flag.go @@ -14,12 +14,8 @@ import ( ) var ( - emptyStringArray []string -) - -func init() { emptyStringArray = make([]string, 0) -} +) var allowedEmptyValueArgs = []string{ constants.ParameterKeepTag, // allows --keep-tag="" - means keep all with any assigned tag @@ -68,8 +64,8 @@ func addArgsFromStruct(args *shell.Args, section any) { } } -func argAliasesFromStruct(section any) (aliases map[string]string) { - aliases = make(map[string]string) +func argAliasesFromStruct(section any) map[string]string { + aliases := make(map[string]string) if t := util.ElementType(reflect.TypeOf(section)); t.Kind() == reflect.Struct { for i := 0; i < t.NumField(); i++ { field := t.Field(i) diff --git a/config/group.go b/config/group.go index 9d7c6384..9f8821ad 100644 --- a/config/group.go +++ b/config/group.go @@ -1,8 +1,10 @@ package config +import "github.com/creativeprojects/resticprofile/util/maybe" + // Group of profiles type Group struct { - Description string `mapstructure:"description" description:"Describe the group"` - Profiles []string `mapstructure:"profiles" description:"Names of the profiles belonging to this group"` - ContinueOnError *bool `mapstructure:"continue-on-error" default:"auto" description:"Continue with the next profile on a failure, overrides \"global.group-continue-on-error\""` + Description string `mapstructure:"description" description:"Describe the group"` + Profiles []string `mapstructure:"profiles" description:"Names of the profiles belonging to this group"` + ContinueOnError maybe.Bool `mapstructure:"continue-on-error" default:"auto" description:"Continue with the next profile on a failure, overrides \"global.group-continue-on-error\""` } diff --git a/config/info_customizer.go b/config/info_customizer.go index a0e615bf..fdd3e034 100644 --- a/config/info_customizer.go +++ b/config/info_customizer.go @@ -9,6 +9,7 @@ import ( "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/util" + "github.com/creativeprojects/resticprofile/util/maybe" ) // resticDescriptionReplacements removes or replaces misleading documentation fragments from restic man pages @@ -123,6 +124,17 @@ func init() { } }) + // Profile: special handling for maybe.Bool + maybeBoolType := reflect.TypeOf(maybe.Bool{}) + registerPropertyInfoCustomizer(func(sectionName, propertyName string, property accessibleProperty) { + if field := property.field(); field != nil { + if util.ElementType(field.Type).AssignableTo(maybeBoolType) { + basic := property.basic().resetTypeInfo() + basic.mayBool = true + } + } + }) + // Profile: deprecated sections (squash with deprecated, e.g. schedule in retention) registerPropertyInfoCustomizer(func(sectionName, propertyName string, property accessibleProperty) { if field := property.sectionField(nil); field != nil { diff --git a/config/info_customizer_test.go b/config/info_customizer_test.go index 8956b7f0..cebcc444 100644 --- a/config/info_customizer_test.go +++ b/config/info_customizer_test.go @@ -2,14 +2,16 @@ package config import ( "fmt" - "github.com/stretchr/testify/require" "reflect" "strings" "testing" + "github.com/stretchr/testify/require" + "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/util/collect" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/stretchr/testify/assert" ) @@ -171,6 +173,36 @@ func TestConfidentialProperty(t *testing.T) { } } +func TestMaybeBoolProperty(t *testing.T) { + var testType = struct { + Simple maybe.Bool `mapstructure:"simple"` + }{} + + set := propertySetFromType(reflect.TypeOf(testType)) + + assert.ElementsMatch(t, []string{"simple"}, set.Properties()) + for _, name := range set.Properties() { + t.Run(name+"/before", func(t *testing.T) { + info := set.PropertyInfo(name) + require.True(t, info.CanBePropertySet()) + assert.Equal(t, "Bool", info.PropertySet().TypeName()) + assert.False(t, info.CanBeBool()) + assert.False(t, info.IsMultiType()) + }) + } + + customizeProperties("any", set.properties) + + for _, name := range set.Properties() { + t.Run(name, func(t *testing.T) { + info := set.PropertyInfo(name) + assert.True(t, info.CanBeBool()) + assert.False(t, info.CanBePropertySet()) + assert.False(t, info.IsMultiType()) + }) + } +} + func TestDeprecatedSection(t *testing.T) { var testType = struct { ScheduleBaseSection `mapstructure:",squash" deprecated:"true"` diff --git a/config/jsonschema/schema_test.go b/config/jsonschema/schema_test.go index 639c1259..efd2644d 100644 --- a/config/jsonschema/schema_test.go +++ b/config/jsonschema/schema_test.go @@ -1,3 +1,5 @@ +//go:build !ajv_test + package jsonschema import ( @@ -67,7 +69,7 @@ func npmRunner(t *testing.T) npmRunnerFunc { var npm npmRunnerFunc func initNpmEnv(t *testing.T) { - if !testing.Short() && npm == nil { + if npm == nil { npm = npmRunner(&testing.T{}) if npm(t, "list", "ajv") != nil { t.Log("Installing AJV JSONSchema validator") @@ -131,7 +133,7 @@ func TestJsonSchemaValidation(t *testing.T) { require.NoError(t, v.ReadInConfig()) v.SetConfigType("json") - filename = path.Join(t.TempDir(), fmt.Sprintf(path.Base(filename)+".json")) + filename = filepath.Join(t.TempDir(), fmt.Sprintf(filepath.Base(filename)+".json")) require.NoError(t, v.WriteConfigAs(filename)) return filename } diff --git a/config/profile.go b/config/profile.go index 30656c05..e47c70b9 100644 --- a/config/profile.go +++ b/config/profile.go @@ -17,7 +17,7 @@ import ( "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/shell" "github.com/creativeprojects/resticprofile/util" - "github.com/creativeprojects/resticprofile/util/bools" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/mitchellh/mapstructure" "golang.org/x/exp/maps" ) @@ -221,8 +221,8 @@ func (s *BackupSection) setRootPath(p *Profile, rootPath string) { type RetentionSection struct { ScheduleBaseSection `mapstructure:",squash" deprecated:"0.11.0"` OtherFlagsSection `mapstructure:",squash"` - BeforeBackup *bool `mapstructure:"before-backup" description:"Apply retention before starting the backup command"` - AfterBackup *bool `mapstructure:"after-backup" description:"Apply retention after the backup command succeeded. Defaults to true in configuration format v2 if any \"keep-*\" flag is set and \"before-backup\" is unset"` + BeforeBackup maybe.Bool `mapstructure:"before-backup" description:"Apply retention before starting the backup command"` + AfterBackup maybe.Bool `mapstructure:"after-backup" description:"Apply retention after the backup command succeeded. Defaults to true in configuration format v2 if any \"keep-*\" flag is set and \"before-backup\" is unset"` } func (r *RetentionSection) IsEmpty() bool { return r == nil } @@ -240,10 +240,10 @@ func (r *RetentionSection) resolve(profile *Profile) { // Extras, only enabled for Version >= 2 (to remain backward compatible in version 1) if profile.config != nil && profile.config.version >= Version02 { // Auto-enable "after-backup" if nothing was specified explicitly and any "keep-" was configured - if bools.IsUndefined(r.AfterBackup) && bools.IsUndefined(r.BeforeBackup) { + if r.AfterBackup.IsUndefined() && r.BeforeBackup.IsUndefined() { for name, _ := range r.OtherFlags { if strings.HasPrefix(name, "keep-") { - r.AfterBackup = bools.True() + r.AfterBackup = maybe.True() break } } @@ -316,7 +316,7 @@ type CopySection struct { SectionWithScheduleAndMonitoring `mapstructure:",squash"` RunShellCommandsSection `mapstructure:",squash"` Initialize bool `mapstructure:"initialize" description:"Initialize the secondary repository if missing"` - InitializeCopyChunkerParams *bool `mapstructure:"initialize-copy-chunker-params" default:"true" description:"Copy chunker parameters when initializing the secondary repository"` + InitializeCopyChunkerParams maybe.Bool `mapstructure:"initialize-copy-chunker-params" default:"true" description:"Copy chunker parameters when initializing the secondary repository"` Repository ConfidentialValue `mapstructure:"repository" description:"Destination repository to copy snapshots to"` RepositoryFile string `mapstructure:"repository-file" description:"File from which to read the destination repository location to copy snapshots to"` PasswordFile string `mapstructure:"password-file" description:"File to read the destination repository password from"` @@ -341,7 +341,7 @@ func (c *CopySection) setRootPath(p *Profile, rootPath string) { func (s *CopySection) getInitFlags(profile *Profile) *shell.Args { var init *InitSection - if bools.IsTrueOrUndefined(s.InitializeCopyChunkerParams) { + if s.InitializeCopyChunkerParams.IsTrueOrUndefined() { // Source repo for CopyChunkerParams init = &InitSection{ CopyChunkerParams: true, diff --git a/config/profile_test.go b/config/profile_test.go index ec042632..b44807db 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -16,7 +16,6 @@ import ( "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/shell" "github.com/creativeprojects/resticprofile/util" - "github.com/creativeprojects/resticprofile/util/bools" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/exp/maps" @@ -683,15 +682,15 @@ func TestPathAndTagInRetention(t *testing.T) { t.Run("AutoEnable", func(t *testing.T) { retentionDisabled := func(t *testing.T, profile *Profile) { - assert.Nil(t, profile.Retention.BeforeBackup) - assert.Nil(t, profile.Retention.AfterBackup) + assert.False(t, profile.Retention.BeforeBackup.HasValue()) + assert.False(t, profile.Retention.AfterBackup.HasValue()) } t.Run("EnableForAnyKeepInV2", func(t *testing.T) { profile := testProfile(t, Version02, ``) retentionDisabled(t, profile) profile = testProfile(t, Version02, `keep-x = 1`) - assert.Nil(t, profile.Retention.BeforeBackup) - assert.Equal(t, bools.True(), profile.Retention.AfterBackup) + assert.False(t, profile.Retention.BeforeBackup.HasValue()) + assert.True(t, profile.Retention.AfterBackup.Value()) }) t.Run("NotEnabledInV1", func(t *testing.T) { profile := testProfile(t, Version01, ``) diff --git a/config/show.go b/config/show.go index 9af51411..7b23a896 100644 --- a/config/show.go +++ b/config/show.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "reflect" + "slices" "sort" "strings" @@ -141,12 +142,12 @@ func showMap(stack []string, display *Display, valueOf reflect.Value) (err error func showKeyValue(stack []string, display *Display, key string, valueOf reflect.Value) (err error) { if isNotShown(key, nil) { - return + return nil } value, isNil := util.UnpackValue(valueOf) if isNil { - return + return nil } if value.Kind() == reflect.Struct && getStringer(value) == nil { @@ -163,9 +164,12 @@ func showKeyValue(stack []string, display *Display, key string, valueOf reflect. convert = append(convert, "true") } display.addEntry(stack, key, convert) + } else if slices.Contains(allowedEmptyValueArgs, key) { + // special case of an empty string that needs to be shown + display.addEntry(stack, key, []string{`""`}) } } - return + return err } func isNotShown(name string, fieldType *reflect.StructField) (noShow bool) { diff --git a/config/show_test.go b/config/show_test.go index a774c0d2..86b354f1 100644 --- a/config/show_test.go +++ b/config/show_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/stretchr/testify/assert" ) @@ -27,6 +28,7 @@ type testObject struct { AlsoHidden string `mapstructure:"use"` Map map[string]interface{} `mapstructure:",remain"` RemainMap map[string]interface{} `show:",remain"` + IsValid maybe.Bool `mapstructure:"valid"` } type testPerson struct { @@ -132,6 +134,15 @@ func TestShowStruct(t *testing.T) { }, output: " id: 11\n name: test\n", }, + { + input: testObject{Map: map[string]interface{}{ + "tag": "", // special field should show empty string + "keep-tag": "", // special field should show empty string + "group-by": "", // special field should show empty string + "other": "", // otherwise we don't show empty string + }}, + output: " group-by: \"\"\n keep-tag: \"\"\n tag: \"\"\n", + }, { input: testEmbedded{EmbeddedStruct{Value: true}, 1}, output: " value: true\n inline: 1\n", @@ -152,6 +163,18 @@ func TestShowStruct(t *testing.T) { input: testStringer{}, output: "", }, + { + input: testObject{IsValid: maybe.Bool{}}, + output: "", // display no value + }, + { + input: testObject{IsValid: maybe.False()}, + output: " valid: false\n", + }, + { + input: testObject{IsValid: maybe.True()}, + output: " valid: true\n", + }, } for i, testItem := range testData { diff --git a/examples/dev.yaml b/examples/dev.yaml index b7aca294..ae172d7e 100644 --- a/examples/dev.yaml +++ b/examples/dev.yaml @@ -28,7 +28,7 @@ default: # lock: "/Volumes/RAMDisk/resticprofile-{{ .Profile.Name }}.lock" copy: initialize: true - initialize-copy-chunker-params: true + # initialize-copy-chunker-params: true password-file: key repository: "/Volumes/RAMDisk/{{ .Profile.Name }}-copy" @@ -233,15 +233,6 @@ stdin-command: inherit: default -dropbox: - initialize: false - inherit: default - backup: - extended-status: false - check-before: false - no-error-on-warning: true - source: "../../../../../Dropbox" - escape: initialize: true inherit: default @@ -323,3 +314,12 @@ tempfile: schedule-permission: user schedule-log: '{{ tempFile "backup.log" }}' run-finally: 'cp {{ tempFile "backup.log" }} ./backup{{ .Now.Format "2006-01-02T15-04-05" }}.log' + +empty-fields: + inherit: default + retention: + before-backup: false + after-backup: true + keep-tag: "" + group-by: "" + tag: "" diff --git a/main.go b/main.go index ba160c3b..2b52dd2d 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,6 @@ import ( "github.com/creativeprojects/resticprofile/remote" "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/term" - "github.com/creativeprojects/resticprofile/util/bools" "github.com/creativeprojects/resticprofile/util/shutdown" "github.com/mackerelio/go-osstat/memory" "github.com/spf13/pflag" @@ -428,8 +427,7 @@ func startProfileOrGroup(ctx *Context) error { ctx = ctx.WithProfile(profileName).WithGroup(groupName) err = runProfile(ctx) if err != nil { - if ctx.global.GroupContinueOnError && bools.IsTrueOrUndefined(group.ContinueOnError) || - bools.IsTrue(group.ContinueOnError) { + if ctx.global.GroupContinueOnError && group.ContinueOnError.IsTrueOrUndefined() { // keep going to the next profile clog.Error(err) continue diff --git a/shell/mock/main.go b/shell/mock/main.go index 35002d66..4564fcdd 100644 --- a/shell/mock/main.go +++ b/shell/mock/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "flag" "fmt" "io" @@ -28,6 +29,8 @@ func main() { arguments := false sleep := 0 flags := flag.NewFlagSet("mock", flag.ContinueOnError) + flags.Usage = func() {} + flags.SetOutput(io.Discard) flags.StringVar(&stderr, "stderr", "", "send this message to stderr") flags.StringVar(&stdoutFile, "stdout-file", "", "redirect stdout to a file") flags.BoolVar(&stdin, "stdin", false, "read stdin and send to stdout") @@ -36,7 +39,7 @@ func main() { flags.IntVar(&sleep, "sleep", 0, "sleep timer in ms") if err := flags.Parse(os.Args[2:]); err != nil { - if err == flag.ErrHelp { + if errors.Is(err, flag.ErrHelp) { return } else if !strings.Contains(err.Error(), "flag provided but not defined") { os.Exit(1) diff --git a/util/maybe/bool.go b/util/maybe/bool.go new file mode 100644 index 00000000..db0381cc --- /dev/null +++ b/util/maybe/bool.go @@ -0,0 +1,62 @@ +package maybe + +import ( + "reflect" + "strconv" +) + +type Bool struct { + Optional[bool] +} + +func False() Bool { + return Bool{Set(false)} +} + +func True() Bool { + return Bool{Set(true)} +} + +func (value Bool) String() string { + if !value.HasValue() { + return "" + } + return strconv.FormatBool(value.Value()) +} + +func (value Bool) IsTrue() bool { + return value.HasValue() && value.Value() +} + +func (value Bool) IsStrictlyFalse() bool { + return value.HasValue() && value.Value() == false +} + +func (value Bool) IsFalseOrUndefined() bool { + return !value.HasValue() || value.Value() == false +} + +func (value Bool) IsUndefined() bool { + return !value.HasValue() +} + +func (value Bool) IsTrueOrUndefined() bool { + return !value.HasValue() || value.Value() == true +} + +// BoolDecoder implements config parsing for maybe.Bool +func BoolDecoder() func(from reflect.Type, to reflect.Type, data interface{}) (interface{}, error) { + boolValueType := reflect.TypeOf(Bool{}) + + return func(from reflect.Type, to reflect.Type, data interface{}) (interface{}, error) { + if from != reflect.TypeOf(true) || to != boolValueType { + return data, nil + } + boolValue, ok := data.(bool) + if !ok { + // it should never happen + return data, nil + } + return Bool{Set(boolValue)}, nil + } +} diff --git a/util/maybe/bool_test.go b/util/maybe/bool_test.go new file mode 100644 index 00000000..305aa558 --- /dev/null +++ b/util/maybe/bool_test.go @@ -0,0 +1,137 @@ +package maybe_test + +import ( + "reflect" + "testing" + + "github.com/creativeprojects/resticprofile/util/maybe" + "github.com/stretchr/testify/assert" +) + +func TestMaybeBool(t *testing.T) { + fixtures := []struct { + source maybe.Bool + isTrue bool + isStrictlyFalse bool + isFalseOrUndefined bool + isUndefined bool + isTrueOrUndefined bool + }{ + { + source: maybe.Bool{}, + isTrue: false, + isStrictlyFalse: false, + isFalseOrUndefined: true, + isUndefined: true, + isTrueOrUndefined: true, + }, + { + source: maybe.True(), + isTrue: true, + isStrictlyFalse: false, + isFalseOrUndefined: false, + isUndefined: false, + isTrueOrUndefined: true, + }, + { + source: maybe.False(), + isTrue: false, + isStrictlyFalse: true, + isFalseOrUndefined: true, + isUndefined: false, + isTrueOrUndefined: false, + }, + } + + for _, fixture := range fixtures { + t.Run(fixture.source.String(), func(t *testing.T) { + assert.Equal(t, fixture.isTrue, fixture.source.IsTrue()) + assert.Equal(t, fixture.isStrictlyFalse, fixture.source.IsStrictlyFalse()) + assert.Equal(t, fixture.isFalseOrUndefined, fixture.source.IsFalseOrUndefined()) + assert.Equal(t, fixture.isUndefined, fixture.source.IsUndefined()) + assert.Equal(t, fixture.isTrueOrUndefined, fixture.source.IsTrueOrUndefined()) + }) + } +} + +func TestBoolDecoder(t *testing.T) { + fixtures := []struct { + from reflect.Type + to reflect.Type + source any + expected any + }{ + { + from: reflect.TypeOf(""), + to: reflect.TypeOf(maybe.Bool{}), + source: true, + expected: true, // same value returned as the "from" type in unexpected + }, + { + from: reflect.TypeOf(true), + to: reflect.TypeOf(""), + source: false, + expected: false, // same value returned as the "to" type in unexpected + }, + { + from: reflect.TypeOf(true), + to: reflect.TypeOf(maybe.Bool{}), + source: "", + expected: "", // same value returned as the original value in unexpected + }, + { + from: reflect.TypeOf(true), + to: reflect.TypeOf(maybe.Bool{}), + source: true, + expected: maybe.True(), + }, + { + from: reflect.TypeOf(true), + to: reflect.TypeOf(maybe.Bool{}), + source: false, + expected: maybe.False(), + }, + } + for _, fixture := range fixtures { + t.Run("", func(t *testing.T) { + decoder := maybe.BoolDecoder() + decoded, err := decoder(fixture.from, fixture.to, fixture.source) + assert.NoError(t, err) + assert.Equal(t, fixture.expected, decoded) + }) + } +} + +func TestBoolJSON(t *testing.T) { + fixtures := []struct { + source maybe.Bool + expected string + }{ + { + source: maybe.Bool{}, + expected: "null", + }, + { + source: maybe.True(), + expected: "true", + }, + { + source: maybe.False(), + expected: "false", + }, + } + for _, fixture := range fixtures { + t.Run(fixture.source.String(), func(t *testing.T) { + // encode value into JSON + encoded, err := fixture.source.MarshalJSON() + assert.NoError(t, err) + assert.Equal(t, fixture.expected, string(encoded)) + + // decode value from JSON + decodedValue := maybe.Bool{} + err = decodedValue.UnmarshalJSON(encoded) + assert.NoError(t, err) + assert.Equal(t, fixture.source, decodedValue) + }) + } +} diff --git a/util/maybe/optional.go b/util/maybe/optional.go new file mode 100644 index 00000000..f1ed86d3 --- /dev/null +++ b/util/maybe/optional.go @@ -0,0 +1,48 @@ +package maybe + +import ( + "encoding/json" +) + +type Optional[T any] struct { + value T + hasValue bool +} + +func Set[T any](value T) Optional[T] { + return Optional[T]{ + value: value, + hasValue: true, + } +} + +func (m Optional[T]) HasValue() bool { + return m.hasValue +} + +func (m Optional[T]) Value() T { + return m.value +} + +func (m *Optional[T]) UnmarshalJSON(data []byte) error { + var t *T + if err := json.Unmarshal(data, &t); err != nil { + return err + } + + if t != nil { + *m = Set(*t) + } + + return nil +} + +func (m Optional[T]) MarshalJSON() ([]byte, error) { + var t *T + + if m.hasValue { + t = &m.value + } + + return json.Marshal(t) +} diff --git a/wrapper.go b/wrapper.go index 6d2d0923..20228aa2 100644 --- a/wrapper.go +++ b/wrapper.go @@ -22,7 +22,6 @@ import ( "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/shell" "github.com/creativeprojects/resticprofile/term" - "github.com/creativeprojects/resticprofile/util/bools" "github.com/creativeprojects/resticprofile/util/collect" ) @@ -159,7 +158,7 @@ func (r *resticWrapper) getBackupAction() func() error { } // Retention before - if err == nil && r.profile.Retention != nil && bools.IsTrue(r.profile.Retention.BeforeBackup) { + if err == nil && r.profile.Retention != nil && r.profile.Retention.BeforeBackup.IsTrue() { err = r.runRetention() } @@ -169,7 +168,7 @@ func (r *resticWrapper) getBackupAction() func() error { } // Retention after - if err == nil && r.profile.Retention != nil && bools.IsTrue(r.profile.Retention.AfterBackup) { + if err == nil && r.profile.Retention != nil && r.profile.Retention.AfterBackup.IsTrue() { err = r.runRetention() } diff --git a/wrapper_test.go b/wrapper_test.go index 4f63dff5..08326f19 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -27,9 +27,8 @@ import ( "github.com/creativeprojects/resticprofile/restic" "github.com/creativeprojects/resticprofile/shell" "github.com/creativeprojects/resticprofile/term" - "github.com/creativeprojects/resticprofile/util" - "github.com/creativeprojects/resticprofile/util/bools" "github.com/creativeprojects/resticprofile/util/collect" + "github.com/creativeprojects/resticprofile/util/maybe" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -572,7 +571,7 @@ func TestInitializeWithError(t *testing.T) { func TestInitializeCopyNoError(t *testing.T) { profile := config.NewProfile(nil, "name") - profile.Copy = &config.CopySection{InitializeCopyChunkerParams: bools.False()} + profile.Copy = &config.CopySection{InitializeCopyChunkerParams: maybe.False()} ctx := &Context{ binary: mockBinary, profile: profile, @@ -585,7 +584,7 @@ func TestInitializeCopyNoError(t *testing.T) { func TestInitializeCopyWithError(t *testing.T) { profile := config.NewProfile(nil, "name") - profile.Copy = &config.CopySection{InitializeCopyChunkerParams: bools.False()} + profile.Copy = &config.CopySection{InitializeCopyChunkerParams: maybe.False()} ctx := &Context{ binary: mockBinary, profile: profile, @@ -1522,13 +1521,13 @@ func popUntilPrefix(prefix string, log *clog.MemoryHandler) (line string) { } func TestRunInitCopyCommand(t *testing.T) { - makeProfile := func(copyChunkerParams bool, resticVersion string) (p *config.Profile) { + makeProfile := func(copyChunkerParams maybe.Bool, resticVersion string) (p *config.Profile) { p = &config.Profile{ Name: "profile", Repository: config.NewConfidentialValue("repo_origin"), PasswordFile: "password_origin", Copy: &config.CopySection{ - InitializeCopyChunkerParams: util.CopyRef(copyChunkerParams), + InitializeCopyChunkerParams: copyChunkerParams, Repository: config.NewConfidentialValue("repo_copy"), PasswordFile: "password_copy", }, @@ -1543,22 +1542,22 @@ func TestRunInitCopyCommand(t *testing.T) { expectedCopy string }{ { - profile: makeProfile(true, "0.13"), + profile: makeProfile(maybe.True(), "0.13"), expectedInit: "dry-run: test init --copy-chunker-params --password-file=password_copy --password-file2=password_origin --repo=repo_copy --repo2=repo_origin", expectedCopy: "dry-run: test copy --password-file=password_origin --password-file2=password_copy --repo=repo_origin --repo2=repo_copy", }, { - profile: makeProfile(false, "0.13"), + profile: makeProfile(maybe.False(), "0.13"), expectedInit: "dry-run: test init --password-file=password_copy --repo=repo_copy", expectedCopy: "dry-run: test copy --password-file=password_origin --password-file2=password_copy --repo=repo_origin --repo2=repo_copy", }, { - profile: makeProfile(true, "0.14"), + profile: makeProfile(maybe.True(), "0.14"), expectedInit: "dry-run: test init --copy-chunker-params --from-password-file=password_origin --from-repo=repo_origin --password-file=password_copy --repo=repo_copy", expectedCopy: "dry-run: test copy --from-password-file=password_origin --from-repo=repo_origin --password-file=password_copy --repo=repo_copy", }, { - profile: makeProfile(false, "0.14"), + profile: makeProfile(maybe.False(), "0.14"), expectedInit: "dry-run: test init --password-file=password_copy --repo=repo_copy", expectedCopy: "dry-run: test copy --from-password-file=password_origin --from-repo=repo_origin --password-file=password_copy --repo=repo_copy", },