Repository: creativeprojects/resticprofile (language: python)

Issue:
Profile `show` leaves out some information.
When viewing a profile with `show` command, some parameters are left out of the output. Namely, I have noticed the following are missing:

```
backup: check-before, check-after
retention: after-backup, before-backup
```


Discussion / hints:
You're right, there's a bug that prevent the display of `after-backup` and `before-backup`.

But actually `check-before` and `check-after` are working (unless you specify a value of `false` in which case they keep their default value and are not shown)


Fix patch:
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/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_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/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/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/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/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()
 		}
 


Test patch:
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_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/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_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_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/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/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",
 		},