diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8f6879e..3caf605 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,13 +1,15 @@ { "name": "Go", - "image": "mcr.microsoft.com/devcontainers/go:2-1.24-bookworm", + "image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie", "features": { "ghcr.io/guiyomh/features/golangci-lint:0": {} }, "customizations": { "vscode": { "extensions": [ - "eamodio.gitlens" + "eamodio.gitlens", + "streetsidesoftware.code-spell-checker", + "esbenp.prettier-vscode" ] } } diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 63f3f50..d3fbfab 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,10 +16,24 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup NodeJS + uses: actions/setup-node@v6 + with: + node-version: 24.x + - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.24' + go-version: '1.25' + + - name: Sanity check + run: make sanity-check + + - name: Lint + uses: golangci/golangci-lint-action@v9 + with: + version: 'v2.5.0' + args: --timeout=5m --verbose - name: Build run: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa348db..d904683 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,18 +23,16 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: 1.24 + go-version: 1.25 + + - name: Sanity check + run: make sanity-check - name: Build binary - run: | - mkdir -p dist - GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -o dist/backup-${{ matrix.goos }}-${{ matrix.goarch }} ./backup/main.go + run: make release-${{ matrix.goos }}-${{ matrix.goarch }} - name: Generate SHA256 checksum - run: | - for file in dist/*; do - sha256sum "$file" > "$file.sha256"; - done + run: make checksums - name: Upload binaries uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index ebfba0f..5bc5994 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ BACKLOG/ logs/ *.log -dist/backup +dist/ \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ca0e978 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,49 @@ +version: "2" +linters: + default: all + disable: + - wsl # The linter 'wsl' is deprecated (since v2.2.0) due to: new major version. Replaced by wsl_v5 + - paralleltest # not used in this project + - revive # temporarily disable + - exhaustruct # temporarily disable + - errcheck + - forbidigo + - gocritic + settings: + depguard: + rules: + main: + list-mode: strict + allow: + - bytes + - context + - errors + - fmt + - io + - log + - os + - path/filepath + - sort + - strings + - testing + - time + - github.com/spf13/cobra + - github.com/spf13/afero + - gopkg.in/yaml.v3 + - backup-rsync/backup/internal + - backup-rsync/backup/cmd + errcheck: + check-type-assertions: true + check-blank: true + gosec: + excludes: + - G304 # ignore Potential file inclusion via variable + varnamelen: + ignore-decls: + - fs afero.Fs +formatters: + enable: + - gofmt + settings: + gofmt: + simplify: true diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3569340 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "cSpell.words": [ + "afero", + "golangci", + "gofmt", + "subpaths" + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile index ca548aa..3e89421 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,69 @@ # Makefile to build the project and place the binary in the dist/ directory -.PHONY: build clean deps test +# Build command with common flags +BUILD_CMD = CGO_ENABLED=0 go build -ldflags="-s -w" +PACKAGE = ./backup/main.go -deps: - go mod tidy +.PHONY: build clean test lint tidy checksums release sanity-check check-mod-tidy lint-config-check lint-fix format check-clean -build: deps - @mkdir -p dist - go build -o dist/backup ./backup/main.go +format: + go fmt ./... + @echo "OK: Code formatted." + +lint-config-check: + @golangci-lint config path + @golangci-lint config verify + @echo "OK: Lint configuration is valid." + +lint: + golangci-lint run ./... + +lint-fix: + golangci-lint run --fix ./... + +check-clean: + @git diff --quiet || (echo "ERROR: Working directory has uncommitted changes." && exit 1) + @echo "OK: Working directory is clean." + +check-mod-tidy: + go mod tidy -diff + @echo "OK: No untidy module files detected." + +sanity-check: format check-clean check-mod-tidy + @echo "OK: All sanity checks passed." test: go test ./... -v +tidy: + go mod tidy + +build: + @mkdir -p dist + $(BUILD_CMD) -o dist/backup $(PACKAGE) + clean: - rm -rf dist \ No newline at end of file + rm -rf dist + +# Build for specific OS and architecture (e.g., make release-linux-amd64) +release-%: + @mkdir -p dist + GOOS=$(word 1,$(subst -, ,$*)) GOARCH=$(word 2,$(subst -, ,$*)) $(BUILD_CMD) -o dist/backup-$* $(PACKAGE) + +checksums: + @for file in dist/*; do \ + if [ "$${file##*.}" != "sha256" ]; then \ + sha256sum "$$file" > "$$file.sha256"; \ + fi; \ + done + +release: release-linux-amd64 release-darwin-amd64 release-windows-amd64 checksums + @echo + @echo "Binaries with sizes and checksums:" + @for file in dist/*; do \ + if [ -f "$$file" ] && [ "$${file##*.}" != "sha256" ]; then \ + size=$$(stat --printf="%s" "$$file"); \ + checksum=$$(cat "$$file.sha256" | awk '{print $$1}'); \ + printf "%-40s %-15s %-64s\n" "$$file" "Size: $$size bytes" "Checksum: $$checksum"; \ + fi; \ + done diff --git a/backup/cmd/backup.go b/backup/cmd/backup.go index 0803363..c736565 100644 --- a/backup/cmd/backup.go +++ b/backup/cmd/backup.go @@ -11,25 +11,32 @@ import ( "github.com/spf13/cobra" ) +const filePermission = 0644 +const logDirPermission = 0755 + func getLogPath(create bool) string { - logPath := fmt.Sprintf("logs/sync-%s", time.Now().Format("2006-01-02T15-04-05")) + logPath := "logs/sync-" + time.Now().Format("2006-01-02T15-04-05") if create { - if err := os.MkdirAll(logPath, 0755); err != nil { + err := os.MkdirAll(logPath, logDirPermission) + if err != nil { log.Fatalf("Failed to create log directory: %v", err) } } + return logPath } func executeSyncJobs(cfg internal.Config, simulate bool) { logPath := getLogPath(true) - overallLogPath := fmt.Sprintf("%s/summary.log", logPath) - overallLogFile, err := os.OpenFile(overallLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + overallLogPath := logPath + "/summary.log" + + overallLogFile, err := os.OpenFile(overallLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, filePermission) if err != nil { log.Fatalf("Failed to open overall log file: %v", err) } defer overallLogFile.Close() + overallLogger := log.New(overallLogFile, "", log.LstdFlags) for _, job := range cfg.Jobs { @@ -48,35 +55,35 @@ func listCommands(cfg internal.Config) { } } -var runCmd = &cobra.Command{ - Use: "run", - Short: "Execute the sync jobs", - Run: func(cmd *cobra.Command, args []string) { - cfg := internal.LoadResolvedConfig(configPath) - executeSyncJobs(cfg, false) - }, -} +func AddBackupCommands(rootCmd *cobra.Command, configPath string) { + var runCmd = &cobra.Command{ + Use: "run", + Short: "Execute the sync jobs", + Run: func(cmd *cobra.Command, args []string) { + cfg := internal.LoadResolvedConfig(configPath) + executeSyncJobs(cfg, false) + }, + } -var simulateCmd = &cobra.Command{ - Use: "simulate", - Short: "Simulate the sync jobs", - Run: func(cmd *cobra.Command, args []string) { - cfg := internal.LoadResolvedConfig(configPath) - executeSyncJobs(cfg, true) - }, -} + var simulateCmd = &cobra.Command{ + Use: "simulate", + Short: "Simulate the sync jobs", + Run: func(cmd *cobra.Command, args []string) { + cfg := internal.LoadResolvedConfig(configPath) + executeSyncJobs(cfg, true) + }, + } -var listCmd = &cobra.Command{ - Use: "list", - Short: "List the commands that will be executed", - Run: func(cmd *cobra.Command, args []string) { - cfg := internal.LoadResolvedConfig(configPath) - listCommands(cfg) - }, -} + var listCmd = &cobra.Command{ + Use: "list", + Short: "List the commands that will be executed", + Run: func(cmd *cobra.Command, args []string) { + cfg := internal.LoadResolvedConfig(configPath) + listCommands(cfg) + }, + } -func init() { - RootCmd.AddCommand(runCmd) - RootCmd.AddCommand(simulateCmd) - RootCmd.AddCommand(listCmd) + rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(simulateCmd) + rootCmd.AddCommand(listCmd) } diff --git a/backup/cmd/check.go b/backup/cmd/check.go index 54f9071..b611c5e 100644 --- a/backup/cmd/check.go +++ b/backup/cmd/check.go @@ -9,21 +9,23 @@ import ( "github.com/spf13/cobra" ) -var AppFs = afero.NewOsFs() - -var checkCmd = &cobra.Command{ - Use: "check-coverage", - Short: "Check path coverage", - Run: func(cmd *cobra.Command, args []string) { - cfg := internal.LoadResolvedConfig(configPath) - uncoveredPaths := internal.ListUncoveredPaths(AppFs, cfg) - fmt.Println("Uncovered paths:") - for _, path := range uncoveredPaths { - fmt.Println(path) - } - }, -} +func AddCheckCommands(rootCmd *cobra.Command, configPath string) { + var fs = afero.NewOsFs() + + var checkCmd = &cobra.Command{ + Use: "check-coverage", + Short: "Check path coverage", + Run: func(cmd *cobra.Command, args []string) { + cfg := internal.LoadResolvedConfig(configPath) + uncoveredPaths := internal.ListUncoveredPaths(fs, cfg) + + fmt.Println("Uncovered paths:") + + for _, path := range uncoveredPaths { + fmt.Println(path) + } + }, + } -func init() { - RootCmd.AddCommand(checkCmd) + rootCmd.AddCommand(checkCmd) } diff --git a/backup/cmd/config.go b/backup/cmd/config.go index bf4f31b..d3ba07c 100644 --- a/backup/cmd/config.go +++ b/backup/cmd/config.go @@ -9,42 +9,45 @@ import ( "gopkg.in/yaml.v3" ) -// configCmd represents the config command -var configCmd = &cobra.Command{ - Use: "config", - Short: "Manage configuration", - Run: func(cmd *cobra.Command, args []string) { - // Implementation for the config command - fmt.Println("Config command executed") - }, -} +// AddConfigCommands binds the config command and its subcommands to the root command. +func AddConfigCommands(rootCmd *cobra.Command, configPath string) { + // configCmd represents the config command. + var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage configuration", + Run: func(cmd *cobra.Command, args []string) { + // Implementation for the config command + fmt.Println("Config command executed") + }, + } -// Extend the config subcommand with the show verb -var showCmd = &cobra.Command{ - Use: "show", - Short: "Show resolved configuration", - Run: func(cmd *cobra.Command, args []string) { - cfg := internal.LoadResolvedConfig(configPath) - out, err := yaml.Marshal(cfg) - if err != nil { - log.Fatalf("Failed to marshal resolved configuration: %v", err) - } - fmt.Printf("Resolved Configuration:\n%s\n", string(out)) - }, -} + // Extend the config subcommand with the show verb. + var showCmd = &cobra.Command{ + Use: "show", + Short: "Show resolved configuration", + Run: func(cmd *cobra.Command, args []string) { + cfg := internal.LoadResolvedConfig(configPath) -// Extend the config subcommand with the validate verb -var validateCmd = &cobra.Command{ - Use: "validate", - Short: "Validate configuration", - Run: func(cmd *cobra.Command, args []string) { - internal.LoadResolvedConfig(configPath) - fmt.Println("Configuration is valid.") - }, -} + out, err := yaml.Marshal(cfg) + if err != nil { + log.Fatalf("Failed to marshal resolved configuration: %v", err) + } + + fmt.Printf("Resolved Configuration:\n%s\n", string(out)) + }, + } + + // Extend the config subcommand with the validate verb. + var validateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate configuration", + Run: func(cmd *cobra.Command, args []string) { + internal.LoadResolvedConfig(configPath) + fmt.Println("Configuration is valid.") + }, + } -func init() { - RootCmd.AddCommand(configCmd) + rootCmd.AddCommand(configCmd) configCmd.AddCommand(showCmd) configCmd.AddCommand(validateCmd) } diff --git a/backup/cmd/root.go b/backup/cmd/root.go index 6d1dc57..1505d84 100644 --- a/backup/cmd/root.go +++ b/backup/cmd/root.go @@ -1,3 +1,4 @@ +// Package cmd contains the commands for the backup-tool CLI application. package cmd import ( @@ -6,23 +7,30 @@ import ( "github.com/spf13/cobra" ) -// RootCmd represents the base command when called without any subcommands -var RootCmd = &cobra.Command{ - Use: "backup-tool", - Short: "A tool for managing backups", - Long: `backup-tool is a CLI tool for managing backups and configurations.`, -} +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() { + var configPath string -// Define a global configPath variable and flag at the root level -var configPath string + rootCmd := &cobra.Command{ + Use: "backup-tool", + Short: "A tool for managing backups", + Long: `backup-tool is a CLI tool for managing backups and configurations.`, + } -func init() { - RootCmd.PersistentFlags().StringVar(&configPath, "config", "config.yaml", "Path to the configuration file") -} + rootCmd.PersistentFlags().StringVar(&configPath, "config", "config.yaml", "Path to the configuration file") -// Execute adds all child commands to the root command and sets flags appropriately. -func Execute() { - if err := RootCmd.Execute(); err != nil { + // Parse flags before adding commands to ensure configPath is available. + err := rootCmd.ParseFlags(os.Args[1:]) + if err != nil { + os.Exit(1) + } + + AddConfigCommands(rootCmd, configPath) + AddBackupCommands(rootCmd, configPath) + AddCheckCommands(rootCmd, configPath) + + err = rootCmd.Execute() + if err != nil { os.Exit(1) } } diff --git a/backup/internal/check.go b/backup/internal/check.go index 29f8160..362b53c 100644 --- a/backup/internal/check.go +++ b/backup/internal/check.go @@ -1,6 +1,7 @@ package internal import ( + "fmt" "log" "path/filepath" "sort" @@ -16,31 +17,38 @@ func isExcluded(path string, job Job) bool { return true } } + return false } -func isExcludedGlobally(path string, sources []Path) bool { +func IsExcludedGlobally(path string, sources []Path) bool { for _, source := range sources { for _, exclusion := range source.Exclusions { exclusionPath := filepath.Join(source.Path, exclusion) if strings.HasPrefix(NormalizePath(path), exclusionPath) { log.Printf("EXCLUDED: Path '%s' is globally excluded by '%s' in source '%s'", path, exclusion, source.Path) + return true } } } + return false } func isCoveredByJob(path string, job Job) bool { if NormalizePath(job.Source) == NormalizePath(path) { log.Printf("COVERED: Path '%s' is covered by job '%s'", path, job.Name) + return true } + if isExcluded(path, job) { log.Printf("EXCLUDED: Path '%s' is excluded by job '%s'", path, job.Name) + return true } + return false } @@ -50,11 +58,13 @@ func isCovered(path string, jobs []Job) bool { return true } } + return false } func ListUncoveredPaths(fs afero.Fs, cfg Config) []string { var result []string + seen := make(map[string]bool) for _, source := range cfg.Sources { @@ -62,31 +72,37 @@ func ListUncoveredPaths(fs afero.Fs, cfg Config) []string { } sort.Strings(result) // Ensure consistent ordering for test comparison + return result } func checkPath(fs afero.Fs, path string, cfg Config, result *[]string, seen map[string]bool) { if seen[path] { log.Printf("SKIP: Path '%s' already seen", path) + return } + seen[path] = true // Skip if globally excluded - if isExcludedGlobally(path, cfg.Sources) { + if IsExcludedGlobally(path, cfg.Sources) { log.Printf("SKIP: Path '%s' is globally excluded", path) + return } // Skip if covered by a job if isCovered(path, cfg.Jobs) { log.Printf("SKIP: Path '%s' is covered by a job", path) + return } // Check if it's effectively covered through descendants if isEffectivelyCovered(fs, path, cfg) { log.Printf("SKIP: Path '%s' is effectively covered", path) + return } @@ -95,23 +111,27 @@ func checkPath(fs afero.Fs, path string, cfg Config, result *[]string, seen map[ *result = append(*result, path) } -// Check if a directory is effectively covered (all its descendants are covered or excluded) +// Check if a directory is effectively covered (all its descendants are covered or excluded). func isEffectivelyCovered(fs afero.Fs, path string, cfg Config) bool { children, err := getChildDirectories(fs, path) if err != nil { log.Printf("ERROR: could not get child directories of '%s': %v", path, err) + return false } if len(children) == 0 { log.Printf("NOT COVERED: Path '%s' has no children", path) + return false // Leaf directories are not effectively covered unless directly covered } allCovered := true + for _, child := range children { - if !isExcludedGlobally(child, cfg.Sources) && !isCovered(child, cfg.Jobs) && !isEffectivelyCovered(fs, child, cfg) { + if !IsExcludedGlobally(child, cfg.Sources) && !isCovered(child, cfg.Jobs) && !isEffectivelyCovered(fs, child, cfg) { log.Printf("UNCOVERED CHILD: Path '%s' has uncovered child '%s'", path, child) + allCovered = false } } @@ -119,14 +139,16 @@ func isEffectivelyCovered(fs afero.Fs, path string, cfg Config) bool { if allCovered { log.Printf("COVERED: Path '%s' is effectively covered", path) } + return allCovered } func getChildDirectories(fs afero.Fs, path string) ([]string, error) { var children []string + fileInfos, err := afero.ReadDir(fs, path) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read directory '%s': %w", path, err) } for _, info := range fileInfos { @@ -134,5 +156,6 @@ func getChildDirectories(fs afero.Fs, path string) ([]string, error) { children = append(children, filepath.Join(path, info.Name())) } } + return children, nil } diff --git a/backup/internal/check_test.go b/backup/internal/check_test.go index 26d7137..a755049 100644 --- a/backup/internal/check_test.go +++ b/backup/internal/check_test.go @@ -1,20 +1,20 @@ -package internal +package internal_test import ( "bytes" "log" - "os" "path/filepath" "sort" "strings" "testing" - "time" + + "backup-rsync/backup/internal" "github.com/spf13/afero" ) func TestIsExcludedGlobally(t *testing.T) { - sources := []Path{ + sources := []internal.Path{ { Path: "/home/data/", Exclusions: []string{"/projects/P1/", "/media/"}, @@ -55,7 +55,7 @@ func TestIsExcludedGlobally(t *testing.T) { var logBuffer bytes.Buffer log.SetOutput(&logBuffer) - result := isExcludedGlobally(test.path, sources) + result := internal.IsExcludedGlobally(test.path, sources) if result != test.expectsError { t.Errorf("Expected exclusion result %v, got %v", test.expectsError, result) } @@ -69,7 +69,13 @@ func TestIsExcludedGlobally(t *testing.T) { } } -func runListUncoveredPathsTest(t *testing.T, fakeFS map[string][]string, cfg Config, expectedUncoveredPaths []string) { +func runListUncoveredPathsTest( + t *testing.T, + fakeFS map[string][]string, + cfg internal.Config, + expectedUncoveredPaths []string, +) { + t.Helper() // Create an in-memory filesystem using Afero fs := afero.NewMemMapFs() @@ -83,7 +89,7 @@ func runListUncoveredPathsTest(t *testing.T, fakeFS map[string][]string, cfg Con } // Call the function - uncoveredPaths := ListUncoveredPaths(fs, cfg) + uncoveredPaths := internal.ListUncoveredPaths(fs, cfg) // Assertions sort.Strings(uncoveredPaths) @@ -92,41 +98,46 @@ func runListUncoveredPathsTest(t *testing.T, fakeFS map[string][]string, cfg Con if len(uncoveredPaths) != len(expectedUncoveredPaths) { t.Errorf("Expected uncovered paths length %d, got %d. Expected: %v, Got: %v", len(expectedUncoveredPaths), len(uncoveredPaths), expectedUncoveredPaths, uncoveredPaths) + return } - for i, path := range uncoveredPaths { - if i >= len(expectedUncoveredPaths) { + for count, path := range uncoveredPaths { + if count >= len(expectedUncoveredPaths) { t.Errorf("Got more uncovered paths than expected. Got: %v", uncoveredPaths) + return } - if path != expectedUncoveredPaths[i] { - t.Errorf("Expected uncovered path '%s', got '%s'", expectedUncoveredPaths[i], path) + + if path != expectedUncoveredPaths[count] { + t.Errorf("Expected uncovered path '%s', got '%s'", expectedUncoveredPaths[count], path) } } } -func TestListUncoveredPathsVariations(t *testing.T) { - // Variation: all paths used +// Variation: all paths used. +func TestListUncoveredPathsVariationsAllCovered(t *testing.T) { runListUncoveredPathsTest(t, map[string][]string{ "/var/log": {"app1", "app2"}, "/tmp": {"cache", "temp"}, }, - Config{ - Sources: []Path{ + internal.Config{ + Sources: []internal.Path{ {Path: "/var/log"}, {Path: "/tmp"}, }, - Jobs: []Job{ + Jobs: []internal.Job{ {Name: "Job1", Source: "/var/log"}, {Name: "Job2", Source: "/tmp"}, }, }, []string{}, ) +} - // Variation: one source covered, one uncovered +// Variation: one source covered, one uncovered. +func TestListUncoveredPathsVariationsOneCoveredOneUncovered(t *testing.T) { runListUncoveredPathsTest(t, map[string][]string{ "/home/data": {"projects", "media"}, @@ -134,35 +145,39 @@ func TestListUncoveredPathsVariations(t *testing.T) { "/home/user/cache": {}, "/home/user/npm": {}, }, - Config{ - Sources: []Path{ + internal.Config{ + Sources: []internal.Path{ {Path: "/home/data"}, {Path: "/home/user"}, }, - Jobs: []Job{ + Jobs: []internal.Job{ {Name: "Job1", Source: "/home/data"}, }, }, []string{"/home/user"}, ) +} - // Variation: one source covered, one uncovered but excluded +// Variation: one source covered, one uncovered but excluded. +func TestListUncoveredPathsVariationsUncoveredExcluded(t *testing.T) { runListUncoveredPathsTest(t, map[string][]string{ "/home/data": {"projects", "media"}, }, - Config{ - Sources: []Path{ + internal.Config{ + Sources: []internal.Path{ {Path: "/home/data", Exclusions: []string{"media"}}, }, - Jobs: []Job{ + Jobs: []internal.Job{ {Name: "Job1", Source: "/home/data/projects"}, }, }, []string{}, ) +} - // Variation: one source covered, subfolders covered +// Variation: one source covered, subfolders covered. +func TestListUncoveredPathsVariationsSubfoldersCovered(t *testing.T) { runListUncoveredPathsTest(t, map[string][]string{ "/home/data": {"family"}, @@ -170,18 +185,21 @@ func TestListUncoveredPathsVariations(t *testing.T) { "/home/data/family/me": {"a"}, "/home/data/family/you": {"a"}, }, - Config{ - Sources: []Path{ + internal.Config{ + Sources: []internal.Path{ {Path: "/home/data"}, }, - Jobs: []Job{ + Jobs: []internal.Job{ {Name: "JobMe", Source: "/home/data/family/me"}, {Name: "JobYou", Source: "/home/data/family/you"}, }, }, []string{}, ) +} +func TestListUncoveredPathsVariationsSubfoldersPartiallyCovered(t *testing.T) { + t.Skip("Skipping test for partially covered subfolders") // // Variation: one source covered, one uncovered subfolder // runListUncoveredPathsTest(t, // map[string][]string{ @@ -201,25 +219,3 @@ func TestListUncoveredPathsVariations(t *testing.T) { // []string{"/home/data/family/you"}, // ) } - -type mockDirEntry struct { - name string - isDir bool -} - -func (m mockDirEntry) Name() string { return m.name } -func (m mockDirEntry) IsDir() bool { return m.isDir } -func (m mockDirEntry) Type() os.FileMode { return 0 } -func (m mockDirEntry) Info() (os.FileInfo, error) { return nil, nil } - -type mockFileInfo struct { - name string - isDir bool -} - -func (m mockFileInfo) Name() string { return m.name } -func (m mockFileInfo) Size() int64 { return 0 } -func (m mockFileInfo) Mode() os.FileMode { return 0 } -func (m mockFileInfo) ModTime() time.Time { return time.Time{} } -func (m mockFileInfo) IsDir() bool { return m.isDir } -func (m mockFileInfo) Sys() interface{} { return nil } diff --git a/backup/internal/config.go b/backup/internal/config.go index ab79d90..74c482e 100644 --- a/backup/internal/config.go +++ b/backup/internal/config.go @@ -1,6 +1,7 @@ package internal import ( + "errors" "fmt" "io" "log" @@ -11,10 +12,20 @@ import ( "gopkg.in/yaml.v3" ) +// Static errors for wrapping.. +var ( + ErrJobValidation = errors.New("job validation failed") + ErrInvalidPath = errors.New("invalid path") + ErrPathValidation = errors.New("path validation failed") + ErrOverlappingPath = errors.New("overlapping path detected") +) + func LoadConfig(reader io.Reader) (Config, error) { var cfg Config - if err := yaml.NewDecoder(reader).Decode(&cfg); err != nil { - return Config{}, err + + err := yaml.NewDecoder(reader).Decode(&cfg) + if err != nil { + return Config{}, fmt.Errorf("failed to decode YAML: %w", err) } // Defaults are handled in Job.UnmarshalYAML @@ -22,72 +33,81 @@ func LoadConfig(reader io.Reader) (Config, error) { return cfg, nil } -func substituteVariables(input string, variables map[string]string) string { +func SubstituteVariables(input string, variables map[string]string) string { for key, value := range variables { placeholder := fmt.Sprintf("${%s}", key) input = strings.ReplaceAll(input, placeholder, value) } + return input } func resolveConfig(cfg Config) Config { resolvedCfg := cfg for i, job := range resolvedCfg.Jobs { - resolvedCfg.Jobs[i].Source = substituteVariables(job.Source, cfg.Variables) - resolvedCfg.Jobs[i].Target = substituteVariables(job.Target, cfg.Variables) + resolvedCfg.Jobs[i].Source = SubstituteVariables(job.Source, cfg.Variables) + resolvedCfg.Jobs[i].Target = SubstituteVariables(job.Target, cfg.Variables) } + return resolvedCfg } -func validateJobNames(jobs []Job) error { +func ValidateJobNames(jobs []Job) error { invalidNames := []string{} nameSet := make(map[string]bool) for _, job := range jobs { if nameSet[job.Name] { - invalidNames = append(invalidNames, fmt.Sprintf("duplicate job name: %s", job.Name)) + invalidNames = append(invalidNames, "duplicate job name: "+job.Name) } else { nameSet[job.Name] = true } for _, r := range job.Name { if r > 127 || r == ' ' { - invalidNames = append(invalidNames, fmt.Sprintf("invalid characters in job name: %s", job.Name)) + invalidNames = append(invalidNames, "invalid characters in job name: "+job.Name) + break } } } if len(invalidNames) > 0 { - return fmt.Errorf("job validation errors: %v", invalidNames) + return fmt.Errorf("%w: %v", ErrJobValidation, invalidNames) } + return nil } -func validatePath(jobPath string, paths []Path, pathType string, jobName string) error { +func ValidatePath(jobPath string, paths []Path, pathType string, jobName string) error { for _, path := range paths { if strings.HasPrefix(jobPath, path.Path) { return nil } } - return fmt.Errorf("invalid %s path for job '%s': %s", pathType, jobName, jobPath) + + return fmt.Errorf("%w for job '%s': %s %s", ErrInvalidPath, jobName, pathType, jobPath) } -func validatePaths(cfg Config) error { +func ValidatePaths(cfg Config) error { invalidPaths := []string{} for _, job := range cfg.Jobs { - if err := validatePath(job.Source, cfg.Sources, "source", job.Name); err != nil { + err := ValidatePath(job.Source, cfg.Sources, "source", job.Name) + if err != nil { invalidPaths = append(invalidPaths, err.Error()) } - if err := validatePath(job.Target, cfg.Targets, "target", job.Name); err != nil { + + err = ValidatePath(job.Target, cfg.Targets, "target", job.Name) + if err != nil { invalidPaths = append(invalidPaths, err.Error()) } } if len(invalidPaths) > 0 { - return fmt.Errorf("path validation errors: %v", invalidPaths) + return fmt.Errorf("%w: %v", ErrPathValidation, invalidPaths) } + return nil } @@ -99,53 +119,61 @@ func validateJobPaths(jobs []Job, pathType string, getPath func(job Job) string) // Check if path2 is part of job1's exclusions excluded := false + if pathType == "source" { for _, exclusion := range job2.Exclusions { exclusionPath := NormalizePath(filepath.Join(job2.Source, exclusion)) // log.Printf("job2: %s %s\n", job2.Name, exclusionPath) if strings.HasPrefix(path1, exclusionPath) { excluded = true + break } } } if !excluded && strings.HasPrefix(path1, path2) { - return fmt.Errorf("Job '%s' has a %s path overlapping with job '%s'", job1.Name, pathType, job2.Name) + return fmt.Errorf("%w: job '%s' has a %s path overlapping with job '%s'", + ErrOverlappingPath, job1.Name, pathType, job2.Name) } } } } + return nil } func LoadResolvedConfig(configPath string) Config { - f, err := os.Open(configPath) + configFile, err := os.Open(configPath) if err != nil { log.Fatalf("Failed to open config: %v", err) } - defer f.Close() + defer configFile.Close() - cfg, err := LoadConfig(f) + cfg, err := LoadConfig(configFile) if err != nil { log.Fatalf("Failed to parse YAML: %v", err) } - if err := validateJobNames(cfg.Jobs); err != nil { + err = ValidateJobNames(cfg.Jobs) + if err != nil { log.Fatalf("Job validation failed: %v", err) } resolvedCfg := resolveConfig(cfg) - if err := validatePaths(resolvedCfg); err != nil { + err = ValidatePaths(resolvedCfg) + if err != nil { log.Fatalf("Path validation failed: %v", err) } - if err := validateJobPaths(resolvedCfg.Jobs, "source", func(job Job) string { return job.Source }); err != nil { + err = validateJobPaths(resolvedCfg.Jobs, "source", func(job Job) string { return job.Source }) + if err != nil { log.Fatalf("Job source path validation failed: %v", err) } - if err := validateJobPaths(resolvedCfg.Jobs, "target", func(job Job) string { return job.Target }); err != nil { + err = validateJobPaths(resolvedCfg.Jobs, "target", func(job Job) string { return job.Target }) + if err != nil { log.Fatalf("Job target path validation failed: %v", err) } diff --git a/backup/internal/config_test.go b/backup/internal/config_test.go index c67d921..a6058ee 100644 --- a/backup/internal/config_test.go +++ b/backup/internal/config_test.go @@ -1,12 +1,13 @@ -package internal +package internal_test import ( "bytes" - "reflect" "strings" "testing" "gopkg.in/yaml.v3" + + "backup-rsync/backup/internal" ) func TestLoadConfig1(t *testing.T) { @@ -21,7 +22,8 @@ jobs: enabled: true ` reader := bytes.NewReader([]byte(yamlData)) - cfg, err := LoadConfig(reader) + + cfg, err := internal.LoadConfig(reader) if err != nil { t.Fatalf("Failed to load config: %v", err) } @@ -38,9 +40,11 @@ jobs: if job.Name != "test_job" { t.Errorf("Expected job name test_job, got %s", job.Name) } + if job.Source != "/home/test/" { t.Errorf("Expected source /home/test/, got %s", job.Source) } + if job.Target != "${target_base}/test/" { t.Errorf("Expected target ${target_base}/test/, got %s", job.Target) } @@ -61,12 +65,13 @@ jobs: // Use a reader instead of a mock file reader := bytes.NewReader([]byte(yamlData)) - cfg, err := LoadConfig(reader) + + cfg, err := internal.LoadConfig(reader) if err != nil { t.Fatalf("Failed to load config: %v", err) } - expected := []Job{ + expected := []internal.Job{ { Name: "job1", Source: "/source1", @@ -83,79 +88,84 @@ jobs: }, } - if !reflect.DeepEqual(cfg.Jobs, expected) { - t.Errorf("got %+v, want %+v", cfg.Jobs, expected) + for i, job := range cfg.Jobs { + assertJobEqual(t, job, expected[i]) } } -func TestYAMLUnmarshalingDefaults(t *testing.T) { - tests := []struct { - name string - yamlData string - expected Job - }{ - { - name: "Defaults applied when fields omitted", - yamlData: ` +func TestYAMLUnmarshalingDefaults_FieldsOmitted(t *testing.T) { + yamlData := ` name: "test_job" source: "/source" target: "/target" -`, - expected: Job{ - Name: "test_job", - Source: "/source", - Target: "/target", - Delete: true, - Enabled: true, - }, - }, - { - name: "Explicit false values preserved", - yamlData: ` +` + expected := internal.Job{ + Name: "test_job", + Source: "/source", + Target: "/target", + Delete: true, + Enabled: true, + } + + var job internal.Job + + err := yaml.Unmarshal([]byte(yamlData), &job) + if err != nil { + t.Fatalf("Failed to unmarshal YAML: %v", err) + } + + assertJobEqual(t, job, expected) +} + +func TestYAMLUnmarshalingDefaults_ExplicitFalseValues(t *testing.T) { + yamlData := ` name: "test_job" source: "/source" target: "/target" delete: false enabled: false -`, - expected: Job{ - Name: "test_job", - Source: "/source", - Target: "/target", - Delete: false, - Enabled: false, - }, - }, - { - name: "Mixed explicit and default values", - yamlData: ` +` + expected := internal.Job{ + Name: "test_job", + Source: "/source", + Target: "/target", + Delete: false, + Enabled: false, + } + + var job internal.Job + + err := yaml.Unmarshal([]byte(yamlData), &job) + if err != nil { + t.Fatalf("Failed to unmarshal YAML: %v", err) + } + + assertJobEqual(t, job, expected) +} + +func TestYAMLUnmarshalingDefaults_MixedValues(t *testing.T) { + yamlData := ` name: "test_job" source: "/source" target: "/target" delete: false -`, - expected: Job{ - Name: "test_job", - Source: "/source", - Target: "/target", - Delete: false, - Enabled: true, // default - }, - }, +` + expected := internal.Job{ + Name: "test_job", + Source: "/source", + Target: "/target", + Delete: false, + Enabled: true, // default } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var job Job - err := yaml.Unmarshal([]byte(tt.yamlData), &job) - if err != nil { - t.Fatalf("Failed to unmarshal YAML: %v", err) - } - if !reflect.DeepEqual(job, tt.expected) { - t.Errorf("got %+v, want %+v", job, tt.expected) - } - }) + var job internal.Job + + err := yaml.Unmarshal([]byte(yamlData), &job) + if err != nil { + t.Fatalf("Failed to unmarshal YAML: %v", err) } + + assertJobEqual(t, job, expected) } func TestSubstituteVariables(t *testing.T) { @@ -164,7 +174,8 @@ func TestSubstituteVariables(t *testing.T) { } input := "${target_base}/user/music/home" expected := "/mnt/backup1/user/music/home" - result := substituteVariables(input, variables) + + result := internal.SubstituteVariables(input, variables) if result != expected { t.Errorf("Expected %s, got %s", expected, result) } @@ -173,13 +184,13 @@ func TestSubstituteVariables(t *testing.T) { func TestValidateJobNames(t *testing.T) { tests := []struct { name string - jobs []Job + jobs []internal.Job expectsError bool errorMessage string }{ { name: "Valid job names", - jobs: []Job{ + jobs: []internal.Job{ {Name: "job1"}, {Name: "job2"}, }, @@ -187,7 +198,7 @@ func TestValidateJobNames(t *testing.T) { }, { name: "Duplicate job names", - jobs: []Job{ + jobs: []internal.Job{ {Name: "job1"}, {Name: "job1"}, }, @@ -196,7 +207,7 @@ func TestValidateJobNames(t *testing.T) { }, { name: "Invalid characters in job name", - jobs: []Job{ + jobs: []internal.Job{ {Name: "job 1"}, }, expectsError: true, @@ -204,7 +215,7 @@ func TestValidateJobNames(t *testing.T) { }, { name: "Mixed errors", - jobs: []Job{ + jobs: []internal.Job{ {Name: "job1"}, {Name: "job 1"}, {Name: "job1"}, @@ -216,7 +227,7 @@ func TestValidateJobNames(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := validateJobNames(test.jobs) + err := internal.ValidateJobNames(test.jobs) if test.expectsError { if err == nil { t.Errorf("Expected error but got none") @@ -224,95 +235,107 @@ func TestValidateJobNames(t *testing.T) { t.Errorf("Expected error message to contain '%s', but got '%s'", test.errorMessage, err.Error()) } } else { - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } + expectNoError(t, err) } }) } } -func TestValidatePath(t *testing.T) { - tests := []struct { - name string - jobPath string - paths []Path - pathType string - jobName string - expectsError bool - errorMessage string +func expectNoError(t *testing.T, err error) { + t.Helper() + + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } +} + +func expectError(t *testing.T, err error, expectedMessage string) { + t.Helper() + + if err == nil { + t.Errorf("Expected error but got none") + } else if err.Error() != expectedMessage { + t.Errorf("Expected error message '%s', but got '%s'", expectedMessage, err.Error()) + } +} + +func TestValidatePath_ValidSourcePath(t *testing.T) { + test := struct { + jobPath string + paths []internal.Path + pathType string }{ - { - name: "Valid source path", - jobPath: "/home/user/documents", - paths: []Path{{Path: "/home/user"}}, - pathType: "source", - jobName: "job1", - expectsError: false, - }, - { - name: "Invalid source path", - jobPath: "/invalid/source", - paths: []Path{{Path: "/home/user"}}, - pathType: "source", - jobName: "job1", - expectsError: true, - errorMessage: "invalid source path for job 'job1': /invalid/source", - }, - { - name: "Valid target path", - jobPath: "/mnt/backup/documents", - paths: []Path{{Path: "/mnt/backup"}}, - pathType: "target", - jobName: "job1", - expectsError: false, - }, - { - name: "Invalid target path", - jobPath: "/invalid/target", - paths: []Path{{Path: "/mnt/backup"}}, - pathType: "target", - jobName: "job1", - expectsError: true, - errorMessage: "invalid target path for job 'job1': /invalid/target", - }, + jobPath: "/home/user/documents", + paths: []internal.Path{{Path: "/home/user"}}, + pathType: "source", } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := validatePath(test.jobPath, test.paths, test.pathType, test.jobName) - if test.expectsError { - if err == nil { - t.Errorf("Expected error but got none") - } else if err.Error() != test.errorMessage { - t.Errorf("Expected error message '%s', but got '%s'", test.errorMessage, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } - } - }) + err := internal.ValidatePath(test.jobPath, test.paths, test.pathType, "job1") + expectNoError(t, err) +} + +func TestValidatePath_InvalidSourcePath(t *testing.T) { + test := struct { + jobPath string + paths []internal.Path + pathType string + }{ + jobPath: "/invalid/source", + paths: []internal.Path{{Path: "/home/user"}}, + pathType: "source", } + + err := internal.ValidatePath(test.jobPath, test.paths, test.pathType, "job1") + expectError(t, err, "invalid path for job 'job1': source /invalid/source") +} + +func TestValidatePath_ValidTargetPath(t *testing.T) { + test := struct { + jobPath string + paths []internal.Path + pathType string + }{ + jobPath: "/mnt/backup/documents", + paths: []internal.Path{{Path: "/mnt/backup"}}, + pathType: "target", + } + + err := internal.ValidatePath(test.jobPath, test.paths, test.pathType, "job1") + expectNoError(t, err) +} + +func TestValidatePath_InvalidTargetPath(t *testing.T) { + test := struct { + jobPath string + paths []internal.Path + pathType string + }{ + jobPath: "/invalid/target", + paths: []internal.Path{{Path: "/mnt/backup"}}, + pathType: "target", + } + + err := internal.ValidatePath(test.jobPath, test.paths, test.pathType, "job1") + expectError(t, err, "invalid path for job 'job1': target /invalid/target") } func TestValidatePaths(t *testing.T) { tests := []struct { name string - cfg Config + cfg internal.Config expectsError bool errorMessage string }{ { name: "Valid paths", - cfg: Config{ - Sources: []Path{ + cfg: internal.Config{ + Sources: []internal.Path{ {Path: "/home/user"}, }, - Targets: []Path{ + Targets: []internal.Path{ {Path: "/mnt/backup"}, }, - Jobs: []Job{ + Jobs: []internal.Job{ {Name: "job1", Source: "/home/user/documents", Target: "/mnt/backup/documents"}, }, }, @@ -320,36 +343,56 @@ func TestValidatePaths(t *testing.T) { }, { name: "Invalid paths", - cfg: Config{ - Sources: []Path{ + cfg: internal.Config{ + Sources: []internal.Path{ {Path: "/home/user"}, }, - Targets: []Path{ + Targets: []internal.Path{ {Path: "/mnt/backup"}, }, - Jobs: []Job{ + Jobs: []internal.Job{ {Name: "job1", Source: "/invalid/source", Target: "/invalid/target"}, }, }, expectsError: true, - errorMessage: "path validation errors: [invalid source path for job 'job1': /invalid/source invalid target path for job 'job1': /invalid/target]", + errorMessage: "path validation failed: [" + + "invalid path for job 'job1': source /invalid/source " + + "invalid path for job 'job1': target /invalid/target]", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := validatePaths(test.cfg) + err := internal.ValidatePaths(test.cfg) if test.expectsError { - if err == nil { - t.Errorf("Expected error but got none") - } else if err.Error() != test.errorMessage { - t.Errorf("Expected error message '%s', but got '%s'", test.errorMessage, err.Error()) - } + expectError(t, err, test.errorMessage) } else { - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } + expectNoError(t, err) } }) } } + +func assertJobEqual(t *testing.T, got, expected internal.Job) { + t.Helper() + + if got.Name != expected.Name { + t.Errorf("Job name mismatch: got %s, want %s", got.Name, expected.Name) + } + + if got.Source != expected.Source { + t.Errorf("Job source mismatch: got %s, want %s", got.Source, expected.Source) + } + + if got.Target != expected.Target { + t.Errorf("Job target mismatch: got %s, want %s", got.Target, expected.Target) + } + + if got.Delete != expected.Delete { + t.Errorf("Job delete flag mismatch: got %v, want %v", got.Delete, expected.Delete) + } + + if got.Enabled != expected.Enabled { + t.Errorf("Job enabled flag mismatch: got %v, want %v", got.Enabled, expected.Enabled) + } +} diff --git a/backup/internal/helper.go b/backup/internal/helper.go index 1f4c426..2089042 100644 --- a/backup/internal/helper.go +++ b/backup/internal/helper.go @@ -1,4 +1,4 @@ -// Correct the package declaration +// Package internal provides helper functions for internal use within the application. package internal import "strings" diff --git a/backup/internal/helper_test.go b/backup/internal/helper_test.go index 80b6c47..e5d2559 100644 --- a/backup/internal/helper_test.go +++ b/backup/internal/helper_test.go @@ -1,6 +1,9 @@ -package internal +package internal_test -import "testing" +import ( + "backup-rsync/backup/internal" + "testing" +) func TestNormalizePath(t *testing.T) { tests := []struct { @@ -14,7 +17,7 @@ func TestNormalizePath(t *testing.T) { } for _, test := range tests { - result := NormalizePath(test.input) + result := internal.NormalizePath(test.input) if result != test.expected { t.Errorf("NormalizePath(%q) = %q; want %q", test.input, result, test.expected) } diff --git a/backup/internal/job.go b/backup/internal/job.go index 650b9e8..3a907fd 100644 --- a/backup/internal/job.go +++ b/backup/internal/job.go @@ -1,37 +1,67 @@ package internal import ( + "context" "fmt" "os/exec" "strings" ) -var execCommand = exec.Command +// CommandExecutor interface for executing commands. +type CommandExecutor interface { + Execute(name string, args ...string) ([]byte, error) +} + +// RealCommandExecutor implements CommandExecutor using actual os/exec. +type RealCommandExecutor struct{} + +// Execute runs the actual command. +func (r *RealCommandExecutor) Execute(name string, args ...string) ([]byte, error) { + ctx := context.Background() + cmd := exec.CommandContext(ctx, name, args...) -func buildRsyncCmd(job Job, simulate bool, logPath string) []string { + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to execute command '%s %s': %w", name, strings.Join(args, " "), err) + } + + return output, nil +} + +func BuildRsyncCmd(job Job, simulate bool, logPath string) []string { args := []string{"-aiv", "--stats"} if job.Delete { args = append(args, "--delete") } + if logPath != "" { - args = append(args, fmt.Sprintf("--log-file=%s", logPath)) + args = append(args, "--log-file="+logPath) } + for _, excl := range job.Exclusions { - args = append(args, fmt.Sprintf("--exclude=%s", excl)) + args = append(args, "--exclude="+excl) } + args = append(args, job.Source, job.Target) if simulate { args = append([]string{"--dry-run"}, args...) } + return args } func ExecuteJob(job Job, simulate bool, show bool, logPath string) string { + var osExec CommandExecutor = &RealCommandExecutor{} + + return ExecuteJobWithExecutor(job, simulate, show, logPath, osExec) +} + +func ExecuteJobWithExecutor(job Job, simulate bool, show bool, logPath string, executor CommandExecutor) string { if !job.Enabled { return "SKIPPED" } - args := buildRsyncCmd(job, simulate, logPath) + args := BuildRsyncCmd(job, simulate, logPath) fmt.Printf("Job: %s\n", job.Name) fmt.Printf("Command: rsync %s\n", strings.Join(args, " ")) @@ -39,11 +69,12 @@ func ExecuteJob(job Job, simulate bool, show bool, logPath string) string { return "SUCCESS" } - cmd := execCommand("rsync", args...) - out, err := cmd.CombinedOutput() + out, err := executor.Execute("rsync", args...) fmt.Printf("Output:\n%s\n", string(out)) + if err != nil { return "FAILURE" } + return "SUCCESS" } diff --git a/backup/internal/job_test.go b/backup/internal/job_test.go index e9485c5..18490d5 100644 --- a/backup/internal/job_test.go +++ b/backup/internal/job_test.go @@ -1,39 +1,113 @@ -package internal +package internal_test import ( - "os/exec" + "errors" "strings" "testing" + + "backup-rsync/backup/internal" ) -var capturedArgs []string +// Static error for testing. +var ErrExitStatus23 = errors.New("exit status 23") + +const statusSuccess = "SUCCESS" + +// MockCommandExecutor implements CommandExecutor for testing. +type MockCommandExecutor struct { + CapturedCommands []MockCommand +} + +// MockCommand represents a captured command execution. +type MockCommand struct { + Name string + Args []string +} + +// Option defines a function that modifies a Job. +type Option func(*internal.Job) + +// NewJob is a job factory with defaults. +func NewJob(opts ...Option) *internal.Job { + // Default values + job := &internal.Job{ + Name: "job", + Source: "", + Target: "", + Delete: true, + Enabled: true, + Exclusions: []string{}, + } + + // Apply all options (overrides defaults) + for _, opt := range opts { + opt(job) + } + + return job +} + +func WithName(name string) Option { + return func(p *internal.Job) { + p.Name = name + } +} + +func WithSource(source string) Option { + return func(p *internal.Job) { + p.Source = source + } +} + +func WithTarget(target string) Option { + return func(p *internal.Job) { + p.Target = target + } +} + +func WithEnabled(enabled bool) Option { + return func(p *internal.Job) { + p.Enabled = enabled + } +} + +func WithExclusions(exclusions []string) Option { + return func(p *internal.Job) { + p.Exclusions = exclusions + } +} + +// Execute captures the command and simulates execution. +func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) { + m.CapturedCommands = append(m.CapturedCommands, MockCommand{ + Name: name, + Args: append([]string{}, args...), // Make a copy of args + }) -var mockExecCommand = func(name string, args ...string) *exec.Cmd { if name == "rsync" { - capturedArgs = append(capturedArgs, args...) // Append arguments for assertions - if strings.Contains(strings.Join(args, " "), "--dry-run") { - return exec.Command("echo", "mocked rsync success") // Simulate success for dry-run - } - if strings.Contains(strings.Join(args, " "), "/invalid/source/path") { - return exec.Command("false") // Simulate failure for invalid paths + // Simulate different scenarios based on arguments + argsStr := strings.Join(args, " ") + + if strings.Contains(argsStr, "/invalid/source/path") { + errMsg := "rsync: link_stat \"/invalid/source/path\" failed: No such file or directory" + + return []byte(errMsg), ErrExitStatus23 } - return exec.Command("echo", "mocked rsync success") // Simulate general success + + return []byte("mocked rsync success"), nil } - return exec.Command(name, args...) -} -func init() { - execCommand = mockExecCommand + return []byte("command not mocked"), nil } func TestBuildRsyncCmd(t *testing.T) { - job := Job{ - Source: "/home/user/Music/", - Target: "/target/user/music/home", - Delete: true, - Exclusions: []string{"*.tmp", "node_modules/"}, - } - args := buildRsyncCmd(job, true, "") + // This test doesn't need mocking since it only builds args + job := *NewJob( + WithSource("/home/user/Music/"), + WithTarget("/target/user/music/home"), + WithExclusions([]string{"*.tmp", "node_modules/"}), + ) + args := internal.BuildRsyncCmd(job, true, "") expectedArgs := []string{ "--dry-run", "-aiv", "--stats", "--delete", @@ -47,107 +121,120 @@ func TestBuildRsyncCmd(t *testing.T) { } func TestExecuteJob(t *testing.T) { - job := Job{ - Name: "test_job", - Source: "/home/test/", - Target: "/mnt/backup1/test/", - Delete: true, - Enabled: true, - Exclusions: []string{"*.tmp"}, - } + // Create mock executor + mockExecutor := &MockCommandExecutor{} + + job := *NewJob( + WithName("test_job"), + WithSource("/home/test/"), + WithTarget("/mnt/backup1/test/"), + WithExclusions([]string{"*.tmp"}), + ) simulate := true - status := ExecuteJob(job, simulate, false, "") - if status != "SUCCESS" { - t.Errorf("Expected status SUCCESS, got %s", status) - } + status := internal.ExecuteJobWithExecutor(job, simulate, false, "", mockExecutor) + expectStatus(t, status, statusSuccess) - disabledJob := Job{ - Name: "disabled_job", - Source: "/home/disabled/", - Target: "/mnt/backup1/disabled/", - Enabled: false, - } + disabledJob := *NewJob( + WithName("disabled_job"), + WithSource("/home/disabled/"), + WithTarget("/mnt/backup1/disabled/"), + WithEnabled(false), + ) - status = ExecuteJob(disabledJob, simulate, false, "") - if status != "SKIPPED" { - t.Errorf("Expected status SKIPPED, got %s", status) - } + status = internal.ExecuteJobWithExecutor(disabledJob, simulate, false, "", mockExecutor) + expectStatus(t, status, "SKIPPED") // Test case for failure (simulate by providing invalid source path) - invalidJob := Job{ - Name: "invalid_job", - Source: "/invalid/source/path", - Target: "/mnt/backup1/invalid/", - Delete: true, - Enabled: true, - } - - status = ExecuteJob(invalidJob, false, false, "") - if status != "FAILURE" { - t.Errorf("Expected status FAILURE, got %s", status) - } + invalidJob := *NewJob( + WithName("invalid_job"), + WithSource("/invalid/source/path"), + WithTarget("/mnt/backup1/invalid/"), + ) + + status = internal.ExecuteJobWithExecutor(invalidJob, false, false, "", mockExecutor) + expectStatus(t, status, "FAILURE") } +// Ensure all references to ExecuteJob are prefixed with internal. func TestJobSkippedEnabledTrue(t *testing.T) { - job := Job{ - Name: "test_job", - Source: "/home/test/", - Target: "/mnt/backup1/test/", - Enabled: true, - } - status := ExecuteJob(job, true, false, "") - if status != "SUCCESS" { - t.Errorf("Expected status SUCCESS, got %s", status) - } + // Create mock executor + mockExecutor := &MockCommandExecutor{} + + job := *NewJob( + WithName("test_job"), + WithSource("/home/test/"), + WithTarget("/mnt/backup1/test/"), + ) + + status := internal.ExecuteJobWithExecutor(job, true, false, "", mockExecutor) + expectStatus(t, status, statusSuccess) } func TestJobSkippedEnabledFalse(t *testing.T) { - disabledJob := Job{ - Name: "disabled_job", - Source: "/home/disabled/", - Target: "/mnt/backup1/disabled/", - Enabled: false, - } - status := ExecuteJob(disabledJob, true, false, "") - if status != "SKIPPED" { - t.Errorf("Expected status SKIPPED, got %s", status) - } + // Create mock executor (won't be used since job is disabled) + mockExecutor := &MockCommandExecutor{} + + disabledJob := *NewJob( + WithName("disabled_job"), + WithSource("/home/disabled/"), + WithTarget("/mnt/backup1/disabled/"), + WithEnabled(false), + ) + + status := internal.ExecuteJobWithExecutor(disabledJob, true, false, "", mockExecutor) + expectStatus(t, status, "SKIPPED") } func TestJobSkippedEnabledOmitted(t *testing.T) { - job := Job{ - Name: "omitted_enabled_job", - Source: "/home/omitted/", - Target: "/mnt/backup1/omitted/", - Delete: true, - Enabled: true, - } - status := ExecuteJob(job, true, false, "") - if status != "SUCCESS" { - t.Errorf("Expected status SUCCESS, got %s", status) - } + // Create mock executor + mockExecutor := &MockCommandExecutor{} + + job := *NewJob( + WithName("omitted_enabled_job"), + WithSource("/home/omitted/"), + WithTarget("/mnt/backup1/omitted/"), + ) + + status := internal.ExecuteJobWithExecutor(job, true, false, "", mockExecutor) + expectStatus(t, status, statusSuccess) } func TestExecuteJobWithMockedRsync(t *testing.T) { - // Reset capturedArgs before the test - capturedArgs = nil + // Create mock executor + mockExecutor := &MockCommandExecutor{} - job := Job{ - Name: "test_job", - Source: "/home/test/", - Target: "/mnt/backup1/test/", - Delete: true, - Enabled: true, - Exclusions: []string{"*.tmp"}, + job := *NewJob( + WithName("test_job"), + WithSource("/home/test/"), + WithTarget("/mnt/backup1/test/"), + WithExclusions([]string{"*.tmp"}), + ) + status := internal.ExecuteJobWithExecutor(job, true, false, "", mockExecutor) + + expectStatus(t, status, statusSuccess) + + // Check that rsync was called with the expected arguments + if len(mockExecutor.CapturedCommands) == 0 { + t.Errorf("Expected at least one command to be executed") + + return } - status := ExecuteJob(job, true, false, "") - if status != "SUCCESS" { - t.Errorf("Expected status SUCCESS, got %s", status) + cmd := mockExecutor.CapturedCommands[0] + if cmd.Name != "rsync" { + t.Errorf("Expected command to be 'rsync', got %s", cmd.Name) } - if len(capturedArgs) == 0 || capturedArgs[0] != "--dry-run" { - t.Errorf("Expected --dry-run flag, got %v", capturedArgs) + if len(cmd.Args) == 0 || cmd.Args[0] != "--dry-run" { + t.Errorf("Expected --dry-run flag, got %v", cmd.Args) + } +} + +func expectStatus(t *testing.T, status, expectedStatus string) { + t.Helper() + + if status != expectedStatus { + t.Errorf("Expected status %s, got %s", expectedStatus, status) } } diff --git a/backup/internal/types.go b/backup/internal/types.go index 1549261..9b553b3 100644 --- a/backup/internal/types.go +++ b/backup/internal/types.go @@ -1,16 +1,20 @@ package internal import ( + "fmt" + "gopkg.in/yaml.v3" ) // Centralized type definitions +// Path represents a source or target path with optional exclusions. type Path struct { Path string `yaml:"path"` Exclusions []string `yaml:"exclusions"` } +// Config represents the overall backup configuration. type Config struct { Sources []Path `yaml:"sources"` Targets []Path `yaml:"targets"` @@ -18,6 +22,7 @@ type Config struct { Jobs []Job `yaml:"jobs"` } +// Job represents a backup job configuration for a source/target pair. type Job struct { Name string `yaml:"name"` Source string `yaml:"source"` @@ -27,7 +32,7 @@ type Job struct { Exclusions []string `yaml:"exclusions,omitempty"` } -// JobYAML is a helper struct for proper YAML unmarshaling with defaults +// JobYAML is a helper struct for proper YAML unmarshaling with defaults. type JobYAML struct { Name string `yaml:"name"` Source string `yaml:"source"` @@ -37,11 +42,13 @@ type JobYAML struct { Exclusions []string `yaml:"exclusions,omitempty"` } -// UnmarshalYAML implements custom YAML unmarshaling to handle defaults properly +// UnmarshalYAML implements custom YAML unmarshaling to handle defaults properly. func (j *Job) UnmarshalYAML(node *yaml.Node) error { var jobYAML JobYAML - if err := node.Decode(&jobYAML); err != nil { - return err + + err := node.Decode(&jobYAML) + if err != nil { + return fmt.Errorf("failed to decode YAML node: %w", err) } // Copy basic fields