Repository: creativeprojects/resticprofile (language: python)

Issue:
`continue-on-error` broken since `0.26.0`
I updated my old version of `resticprofile` and `continue-on-error` no longer seems to be working.

## `0.25.0` and earlier
```
...
Warning: at least one source file could not be read
2024/09/30 13:12:22 backup on profile 'user-data': exit status 3
2024/09/30 13:12:22 profile 'spaces': starting 'backup'
open repository
...
```

## `0.26.0` and later
```
...
Warning: at least one source file could not be read
2024/09/30 13:10:28 backup on profile 'user-data': exit status 3

$ 
```


Discussion / hints:

Fix patch:
diff --git a/commands.go b/commands.go
index 99beb757..59c9771d 100644
--- a/commands.go
+++ b/commands.go
@@ -552,7 +552,7 @@ func prepareScheduledProfile(ctx *Context) {
 }
 
 func runSchedule(_ io.Writer, cmdCtx commandContext) error {
-	err := startProfileOrGroup(&cmdCtx.Context)
+	err := startProfileOrGroup(&cmdCtx.Context, runProfile)
 	if err != nil {
 		return err
 	}
diff --git a/main.go b/main.go
index dda16e3c..fe629ee1 100644
--- a/main.go
+++ b/main.go
@@ -5,19 +5,14 @@ import (
 	"fmt"
 	"math/rand"
 	"os"
-	"os/signal"
-	"path/filepath"
 	"runtime"
 	"strings"
-	"syscall"
 	"text/tabwriter"
 
 	"github.com/creativeprojects/clog"
 	"github.com/creativeprojects/resticprofile/config"
 	"github.com/creativeprojects/resticprofile/constants"
 	"github.com/creativeprojects/resticprofile/filesearch"
-	"github.com/creativeprojects/resticprofile/monitor/prom"
-	"github.com/creativeprojects/resticprofile/monitor/status"
 	"github.com/creativeprojects/resticprofile/preventsleep"
 	"github.com/creativeprojects/resticprofile/priority"
 	"github.com/creativeprojects/resticprofile/remote"
@@ -261,7 +256,7 @@ func main() {
 	ctx = ctx.WithCommand(ctx.request.command)
 
 	// it wasn't an internal command so we run a profile
-	err = startProfileOrGroup(ctx)
+	err = startProfileOrGroup(ctx, runProfile)
 	if err != nil {
 		clog.Error(err)
 		if errors.Is(err, ErrProfileNotFound) {
@@ -367,186 +362,6 @@ func detectResticBinary(global *config.Global) (string, error) {
 	return resticBinary, nil
 }
 
-func startProfileOrGroup(ctx *Context) error {
-	if ctx.config.HasProfile(ctx.request.profile) {
-		// if running as a systemd timer
-		notifyStart()
-		defer notifyStop()
-
-		// Single profile run
-		err := runProfile(ctx)
-		if err != nil {
-			return err
-		}
-
-	} else if ctx.config.HasProfileGroup(ctx.request.profile) {
-		// Group run
-		group, err := ctx.config.GetProfileGroup(ctx.request.profile)
-		if err != nil {
-			clog.Errorf("cannot load group '%s': %v", ctx.request.profile, err)
-		}
-		if group != nil && len(group.Profiles) > 0 {
-			// if running as a systemd timer
-			notifyStart()
-			defer notifyStop()
-
-			// profile name is the group name
-			groupName := ctx.request.profile
-
-			for i, profileName := range group.Profiles {
-				clog.Debugf("[%d/%d] starting profile '%s' from group '%s'", i+1, len(group.Profiles), profileName, groupName)
-				ctx = ctx.WithProfile(profileName).WithGroup(groupName)
-				err = runProfile(ctx)
-				if err != nil {
-					if ctx.global.GroupContinueOnError && group.ContinueOnError.IsTrueOrUndefined() {
-						// keep going to the next profile
-						clog.Error(err)
-						continue
-					}
-					// fail otherwise
-					return err
-				}
-			}
-		}
-
-	} else {
-		return fmt.Errorf("%w: %q", ErrProfileNotFound, ctx.request.profile)
-	}
-	return nil
-}
-
-func openProfile(c *config.Config, profileName string) (profile *config.Profile, cleanup func(), err error) {
-	done := false
-	for attempts := 3; attempts > 0 && !done; attempts-- {
-		profile, err = c.GetProfile(profileName)
-		if err != nil || profile == nil {
-			err = fmt.Errorf("cannot load profile '%s': %w", profileName, err)
-			break
-		}
-
-		done = true
-
-		// Adjust baseDir if needed
-		if len(profile.BaseDir) > 0 {
-			var currentDir string
-			currentDir, err = os.Getwd()
-			if err != nil {
-				err = fmt.Errorf("changing base directory not allowed as current directory is unknown in profile %q: %w", profileName, err)
-				break
-			}
-
-			if baseDir, _ := filepath.Abs(profile.BaseDir); filepath.ToSlash(baseDir) != filepath.ToSlash(currentDir) {
-				if cleanup == nil {
-					cleanup = func() {
-						if e := os.Chdir(currentDir); e != nil {
-							panic(fmt.Errorf(`fatal: failed restoring working directory "%s": %w`, currentDir, e))
-						}
-					}
-				}
-
-				if err = os.Chdir(baseDir); err == nil {
-					clog.Infof("profile '%s': base directory is %q", profileName, baseDir)
-					done = false // reload the profile as .CurrentDir & .Env has changed
-				} else {
-					err = fmt.Errorf(`cannot change to base directory "%s" in profile %q: %w`, baseDir, profileName, err)
-					break
-				}
-			}
-		}
-	}
-
-	if cleanup == nil {
-		cleanup = func() {
-			// nothing to do
-		}
-	}
-	return
-}
-
-func runProfile(ctx *Context) error {
-	profile, cleanup, err := openProfile(ctx.config, ctx.request.profile)
-	defer cleanup()
-	if err != nil {
-		return err
-	}
-	ctx.profile = profile
-
-	displayProfileDeprecationNotices(profile)
-	ctx.config.DisplayConfigurationIssues()
-
-	// Send the quiet/verbose down to restic as well (override profile configuration)
-	if ctx.flags.quiet {
-		profile.Quiet = true
-		profile.Verbose = constants.VerbosityNone
-	}
-	if ctx.flags.verbose {
-		profile.Verbose = constants.VerbosityLevel1
-		profile.Quiet = false
-	}
-	if ctx.flags.veryVerbose {
-		profile.Verbose = constants.VerbosityLevel3
-		profile.Quiet = false
-	}
-
-	// change log filter according to profile settings
-	if profile.Quiet {
-		changeLevelFilter(clog.LevelWarning)
-	} else if profile.Verbose > constants.VerbosityNone && !ctx.flags.veryVerbose {
-		changeLevelFilter(clog.LevelDebug)
-	}
-
-	// tell the profile what version of restic is in use
-	if e := profile.SetResticVersion(ctx.global.ResticVersion); e != nil {
-		clog.Warningf("restic version %q is no valid semver: %s", ctx.global.ResticVersion, e.Error())
-	}
-
-	// Specific case for the "host" flag where an empty value should be replaced by the hostname
-	hostname := "none"
-	currentHost, err := os.Hostname()
-	if err == nil {
-		hostname = currentHost
-	}
-	profile.SetHost(hostname)
-
-	if ctx.request.schedule != "" {
-		// this is a scheduled profile
-		loadScheduledProfile(ctx)
-	}
-
-	// Catch CTR-C keypress, or other signal sent by a service manager (systemd)
-	sigChan := make(chan os.Signal, 1)
-	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGABRT)
-	// remove signal catch before leaving
-	defer signal.Stop(sigChan)
-
-	ctx.sigChan = sigChan
-	wrapper := newResticWrapper(ctx)
-
-	if ctx.noLock {
-		wrapper.ignoreLock()
-	} else if ctx.lockWait > 0 {
-		wrapper.maxWaitOnLock(ctx.lockWait)
-	}
-
-	// add progress receivers if necessary
-	if profile.StatusFile != "" {
-		wrapper.addProgress(status.NewProgress(profile, status.NewStatus(profile.StatusFile)))
-	}
-	if profile.PrometheusPush != "" || profile.PrometheusSaveToFile != "" {
-		wrapper.addProgress(prom.NewProgress(profile, prom.NewMetrics(profile.Name, ctx.request.group, version, profile.PrometheusLabels)))
-	}
-
-	err = wrapper.runProfile()
-	if err != nil {
-		return err
-	}
-	return nil
-}
-
-func loadScheduledProfile(ctx *Context) {
-	ctx.schedule = ctx.profile.Schedules()[ctx.command]
-}
-
 // randomBool returns true for Heads and false for Tails
 func randomBool() bool {
 	return rand.Int31n(10000) < 5000
diff --git a/main_profile.go b/main_profile.go
new file mode 100644
index 00000000..e208f8c7
--- /dev/null
+++ b/main_profile.go
@@ -0,0 +1,206 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"syscall"
+
+	"github.com/creativeprojects/clog"
+	"github.com/creativeprojects/resticprofile/config"
+	"github.com/creativeprojects/resticprofile/constants"
+	"github.com/creativeprojects/resticprofile/monitor/prom"
+	"github.com/creativeprojects/resticprofile/monitor/status"
+)
+
+// startProfileOrGroup starts a profile or a group of profiles based on the provided context.
+// It first checks if the requested profile exists and runs it. If the profile is part of a group,
+// it runs all profiles in the group sequentially. If any profile in the group fails and the
+// ContinueOnError flag is set, it continues with the next profile. Otherwise, it stops and returns the error.
+//
+// Parameters:
+//   - ctx: A pointer to the Context struct containing configuration and request details.
+//   - runProfile: A function that takes a Context pointer and returns an error.
+//
+// Returns:
+//   - error: An error if the profile or group run fails, or if the requested profile is not found.
+func startProfileOrGroup(ctx *Context, runProfile func(ctx *Context) error) error {
+	if ctx.config.HasProfile(ctx.request.profile) {
+		// if running as a systemd timer
+		notifyStart()
+		defer notifyStop()
+
+		// Single profile run
+		err := runProfile(ctx)
+		if err != nil {
+			return err
+		}
+
+	} else if ctx.config.HasProfileGroup(ctx.request.profile) {
+		// Group run
+		group, err := ctx.config.GetProfileGroup(ctx.request.profile)
+		if err != nil {
+			clog.Errorf("cannot load group '%s': %v", ctx.request.profile, err)
+		}
+		if group != nil && len(group.Profiles) > 0 {
+			// if running as a systemd timer
+			notifyStart()
+			defer notifyStop()
+
+			// profile name is the group name
+			groupName := ctx.request.profile
+
+			for i, profileName := range group.Profiles {
+				clog.Debugf("[%d/%d] starting profile '%s' from group '%s'", i+1, len(group.Profiles), profileName, groupName)
+				ctx = ctx.WithProfile(profileName).WithGroup(groupName)
+				err = runProfile(ctx)
+				if err != nil {
+					if group.ContinueOnError.IsTrue() || (ctx.global.GroupContinueOnError && group.ContinueOnError.IsUndefined()) {
+						// keep going to the next profile
+						clog.Error(err)
+						continue
+					}
+					// fail otherwise
+					return err
+				}
+			}
+		}
+
+	} else {
+		return fmt.Errorf("%w: %q", ErrProfileNotFound, ctx.request.profile)
+	}
+	return nil
+}
+
+func openProfile(c *config.Config, profileName string) (profile *config.Profile, cleanup func(), err error) {
+	done := false
+	for attempts := 3; attempts > 0 && !done; attempts-- {
+		profile, err = c.GetProfile(profileName)
+		if err != nil || profile == nil {
+			err = fmt.Errorf("cannot load profile '%s': %w", profileName, err)
+			break
+		}
+
+		done = true
+
+		// Adjust baseDir if needed
+		if len(profile.BaseDir) > 0 {
+			var currentDir string
+			currentDir, err = os.Getwd()
+			if err != nil {
+				err = fmt.Errorf("changing base directory not allowed as current directory is unknown in profile %q: %w", profileName, err)
+				break
+			}
+
+			if baseDir, _ := filepath.Abs(profile.BaseDir); filepath.ToSlash(baseDir) != filepath.ToSlash(currentDir) {
+				if cleanup == nil {
+					cleanup = func() {
+						if e := os.Chdir(currentDir); e != nil {
+							panic(fmt.Errorf(`fatal: failed restoring working directory "%s": %w`, currentDir, e))
+						}
+					}
+				}
+
+				if err = os.Chdir(baseDir); err == nil {
+					clog.Infof("profile '%s': base directory is %q", profileName, baseDir)
+					done = false // reload the profile as .CurrentDir & .Env has changed
+				} else {
+					err = fmt.Errorf(`cannot change to base directory "%s" in profile %q: %w`, baseDir, profileName, err)
+					break
+				}
+			}
+		}
+	}
+
+	if cleanup == nil {
+		cleanup = func() {
+			// nothing to do
+		}
+	}
+	return
+}
+
+func runProfile(ctx *Context) error {
+	profile, cleanup, err := openProfile(ctx.config, ctx.request.profile)
+	defer cleanup()
+	if err != nil {
+		return err
+	}
+	ctx.profile = profile
+
+	displayProfileDeprecationNotices(profile)
+	ctx.config.DisplayConfigurationIssues()
+
+	// Send the quiet/verbose down to restic as well (override profile configuration)
+	if ctx.flags.quiet {
+		profile.Quiet = true
+		profile.Verbose = constants.VerbosityNone
+	}
+	if ctx.flags.verbose {
+		profile.Verbose = constants.VerbosityLevel1
+		profile.Quiet = false
+	}
+	if ctx.flags.veryVerbose {
+		profile.Verbose = constants.VerbosityLevel3
+		profile.Quiet = false
+	}
+
+	// change log filter according to profile settings
+	if profile.Quiet {
+		changeLevelFilter(clog.LevelWarning)
+	} else if profile.Verbose > constants.VerbosityNone && !ctx.flags.veryVerbose {
+		changeLevelFilter(clog.LevelDebug)
+	}
+
+	// tell the profile what version of restic is in use
+	if e := profile.SetResticVersion(ctx.global.ResticVersion); e != nil {
+		clog.Warningf("restic version %q is no valid semver: %s", ctx.global.ResticVersion, e.Error())
+	}
+
+	// Specific case for the "host" flag where an empty value should be replaced by the hostname
+	hostname := "none"
+	currentHost, err := os.Hostname()
+	if err == nil {
+		hostname = currentHost
+	}
+	profile.SetHost(hostname)
+
+	if ctx.request.schedule != "" {
+		// this is a scheduled profile
+		loadScheduledProfile(ctx)
+	}
+
+	// Catch CTR-C keypress, or other signal sent by a service manager (systemd)
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGABRT)
+	// remove signal catch before leaving
+	defer signal.Stop(sigChan)
+
+	ctx.sigChan = sigChan
+	wrapper := newResticWrapper(ctx)
+
+	if ctx.noLock {
+		wrapper.ignoreLock()
+	} else if ctx.lockWait > 0 {
+		wrapper.maxWaitOnLock(ctx.lockWait)
+	}
+
+	// add progress receivers if necessary
+	if profile.StatusFile != "" {
+		wrapper.addProgress(status.NewProgress(profile, status.NewStatus(profile.StatusFile)))
+	}
+	if profile.PrometheusPush != "" || profile.PrometheusSaveToFile != "" {
+		wrapper.addProgress(prom.NewProgress(profile, prom.NewMetrics(profile.Name, ctx.request.group, version, profile.PrometheusLabels)))
+	}
+
+	err = wrapper.runProfile()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func loadScheduledProfile(ctx *Context) {
+	ctx.schedule = ctx.profile.Schedules()[ctx.command]
+}


Test patch:
diff --git a/main_profile_test.go b/main_profile_test.go
new file mode 100644
index 00000000..5a7208e2
--- /dev/null
+++ b/main_profile_test.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+	"bytes"
+	"errors"
+	"testing"
+
+	"github.com/creativeprojects/resticprofile/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestStartProfileOrGroup(t *testing.T) {
+	// Sample configuration
+	configContent := `version = "2"
+        [profiles.default]
+         repository = "test-repo"
+        [profiles.profile1]
+         inherit = "default"
+        [profiles.profile2]
+         inherit = "default"
+        [groups.group_undefined]
+         profiles = ["profile1", "profile2"]
+        [groups.group_true]
+         profiles = ["profile1", "profile2"]
+         continue-on-error = true
+        [groups.group_false]
+         profiles = ["profile1", "profile2"]
+         continue-on-error = false
+    `
+
+	// Load configuration
+	cfg, err := config.Load(bytes.NewBufferString(configContent), config.FormatTOML)
+	require.NoError(t, err)
+
+	// Mock context
+	ctx := &Context{
+		config: cfg,
+		global: &config.Global{},
+		request: Request{
+			profile: "profile1",
+		},
+	}
+
+	t.Run("ProfileNotFound", func(t *testing.T) {
+		ctx.request.profile = "unknown"
+		err := startProfileOrGroup(ctx, nil)
+		assert.Error(t, err)
+		assert.ErrorIs(t, err, ErrProfileNotFound)
+	})
+
+	t.Run("ProfileExists", func(t *testing.T) {
+		ctx.request.profile = "profile1"
+		err := startProfileOrGroup(ctx, func(ctx *Context) error {
+			return nil
+		})
+		assert.NoError(t, err)
+	})
+
+	t.Run("ProfileGroupExists", func(t *testing.T) {
+		ctx.request.profile = "group_undefined"
+		err := startProfileOrGroup(ctx, func(ctx *Context) error {
+			return nil
+		})
+		assert.NoError(t, err)
+	})
+
+	t.Run("ProfileGroupGlobalContinueOnErrorTrue", func(t *testing.T) {
+		calls := 0
+		ctx.request.profile = "group_undefined"
+		ctx.global.GroupContinueOnError = true
+		err := startProfileOrGroup(ctx, func(ctx *Context) error {
+			calls++
+			return errors.New("error")
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, 2, calls)
+	})
+
+	t.Run("ProfileGroupContinueOnErrorTrue", func(t *testing.T) {
+		calls := 0
+		ctx.request.profile = "group_true"
+		err := startProfileOrGroup(ctx, func(ctx *Context) error {
+			calls++
+			return errors.New("error")
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, 2, calls)
+	})
+
+	t.Run("ProfileGroupContinueOnErrorFalse", func(t *testing.T) {
+		calls := 0
+		ctx.request.profile = "group_false"
+		err := startProfileOrGroup(ctx, func(ctx *Context) error {
+			calls++
+			return errors.New("error")
+		})
+		assert.Error(t, err)
+		assert.Equal(t, 1, calls)
+	})
+}