diff --git a/backup/cmd/backup.go b/backup/cmd/backup.go index 126f0f5..a2ff473 100644 --- a/backup/cmd/backup.go +++ b/backup/cmd/backup.go @@ -1,19 +1,31 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" ) -// backupCmd represents the backup command -var backupCmd = &cobra.Command{ - Use: "backup", - Short: "Perform backup operations", - Long: `The backup subcommand allows you to perform backup operations.`, +// Add the run and simulate verbs with empty implementations +var runCmd = &cobra.Command{ + Use: "run", + Short: "Run the backup jobs", + Run: func(cmd *cobra.Command, args []string) { + // Empty implementation for now + fmt.Println("Run command executed.") + }, +} + +var simulateCmd = &cobra.Command{ + Use: "simulate", + Short: "Simulate the backup jobs", Run: func(cmd *cobra.Command, args []string) { - // TODO: Implement backup logic + // Empty implementation for now + fmt.Println("Simulate command executed.") }, } func init() { - RootCmd.AddCommand(backupCmd) + RootCmd.AddCommand(runCmd) + RootCmd.AddCommand(simulateCmd) } diff --git a/backup/cmd/config.go b/backup/cmd/config.go index 28e0097..72f3578 100644 --- a/backup/cmd/config.go +++ b/backup/cmd/config.go @@ -1,19 +1,194 @@ package cmd import ( + "fmt" + "io" + "log" + "os" + "strings" + + "path/filepath" + + "backup-rsync/backup/internal" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) +func loadConfig(reader io.Reader) (internal.Config, error) { + var cfg internal.Config + if err := yaml.NewDecoder(reader).Decode(&cfg); err != nil { + return internal.Config{}, err + } + return cfg, nil +} + +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 internal.Config) internal.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) + } + return resolvedCfg +} + +func validateJobNames(jobs []internal.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)) + } 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)) + break + } + } + } + + if len(invalidNames) > 0 { + return fmt.Errorf("job validation errors: %v", invalidNames) + } + return nil +} + +func validatePath(jobPath string, paths []internal.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) +} + +func validatePaths(cfg internal.Config) error { + invalidPaths := []string{} + + for _, job := range cfg.Jobs { + if err := validatePath(job.Source, cfg.Sources, "source", job.Name); err != nil { + invalidPaths = append(invalidPaths, err.Error()) + } + if err := validatePath(job.Target, cfg.Targets, "target", job.Name); err != nil { + invalidPaths = append(invalidPaths, err.Error()) + } + } + + if len(invalidPaths) > 0 { + return fmt.Errorf("path validation errors: %v", invalidPaths) + } + return nil +} + +func validateJobPaths(jobs []internal.Job, pathType string, getPath func(job internal.Job) string) error { + for i, job1 := range jobs { + for j, job2 := range jobs { + if i != j { + path1, path2 := internal.NormalizePath(getPath(job1)), internal.NormalizePath(getPath(job2)) + + // Check if path2 is part of job1's exclusions + excluded := false + if pathType == "source" { + for _, exclusion := range job2.Exclusions { + exclusionPath := internal.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 nil +} + +func loadResolvedConfig(configPath string) internal.Config { + f, err := os.Open(configPath) + if err != nil { + log.Fatalf("Failed to open config: %v", err) + } + defer f.Close() + + cfg, err := loadConfig(f) + if err != nil { + log.Fatalf("Failed to parse YAML: %v", err) + } + + if err := validateJobNames(cfg.Jobs); err != nil { + log.Fatalf("Job validation failed: %v", err) + } + + resolvedCfg := resolveConfig(cfg) + + if err := validatePaths(resolvedCfg); err != nil { + log.Fatalf("Path validation failed: %v", err) + } + + if err := validateJobPaths(resolvedCfg.Jobs, "source", func(job internal.Job) string { return job.Source }); err != nil { + log.Fatalf("Job source path validation failed: %v", err) + } + + if err := validateJobPaths(resolvedCfg.Jobs, "target", func(job internal.Job) string { return job.Target }); err != nil { + log.Fatalf("Job target path validation failed: %v", err) + } + + return resolvedCfg +} + // configCmd represents the config command var configCmd = &cobra.Command{ Use: "config", - Short: "Manage configuration settings", - Long: `The config subcommand allows you to manage configuration settings.`, + 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 := 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 validate verb +var validateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate configuration", Run: func(cmd *cobra.Command, args []string) { - // TODO: Implement config logic + loadResolvedConfig(configPath) + fmt.Println("Configuration is valid.") }, } func init() { RootCmd.AddCommand(configCmd) + configCmd.AddCommand(showCmd) + configCmd.AddCommand(validateCmd) } diff --git a/backup/cmd/config_test.go b/backup/cmd/config_test.go new file mode 100644 index 0000000..93d974b --- /dev/null +++ b/backup/cmd/config_test.go @@ -0,0 +1,242 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "backup-rsync/backup/internal" +) + +func TestSubstituteVariables(t *testing.T) { + variables := map[string]string{ + "target_base": "/mnt/backup1", + } + input := "${target_base}/user/music/home" + expected := "/mnt/backup1/user/music/home" + result := substituteVariables(input, variables) + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } +} + +func TestLoadConfig(t *testing.T) { + yamlData := ` +variables: + target_base: "/mnt/backup1" + +jobs: + - name: "test_job" + source: "/home/test/" + target: "${target_base}/test/" + enabled: true +` + reader := bytes.NewReader([]byte(yamlData)) + cfg, err := loadConfig(reader) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.Variables["target_base"] != "/mnt/backup1" { + t.Errorf("Expected /mnt/backup1, got %s", cfg.Variables["target_base"]) + } + + if len(cfg.Jobs) != 1 { + t.Fatalf("Expected 1 job, got %d", len(cfg.Jobs)) + } + + job := cfg.Jobs[0] + 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) + } +} + +func TestValidateJobNames(t *testing.T) { + tests := []struct { + name string + jobs []internal.Job + expectsError bool + errorMessage string + }{ + { + name: "Valid job names", + jobs: []internal.Job{ + {Name: "job1"}, + {Name: "job2"}, + }, + expectsError: false, + }, + { + name: "Duplicate job names", + jobs: []internal.Job{ + {Name: "job1"}, + {Name: "job1"}, + }, + expectsError: true, + errorMessage: "duplicate job name: job1", + }, + { + name: "Invalid characters in job name", + jobs: []internal.Job{ + {Name: "job 1"}, + }, + expectsError: true, + errorMessage: "invalid characters in job name: job 1", + }, + { + name: "Mixed errors", + jobs: []internal.Job{ + {Name: "job1"}, + {Name: "job 1"}, + {Name: "job1"}, + }, + expectsError: true, + errorMessage: "duplicate job name: job1", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateJobNames(test.jobs) + if test.expectsError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), test.errorMessage) { + 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) + } + } + }) + } +} + +func TestValidatePath(t *testing.T) { + tests := []struct { + name string + jobPath string + paths []internal.Path + pathType string + jobName string + expectsError bool + errorMessage string + }{ + { + name: "Valid source path", + jobPath: "/home/user/documents", + paths: []internal.Path{{Path: "/home/user"}}, + pathType: "source", + jobName: "job1", + expectsError: false, + }, + { + name: "Invalid source path", + jobPath: "/invalid/source", + paths: []internal.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: []internal.Path{{Path: "/mnt/backup"}}, + pathType: "target", + jobName: "job1", + expectsError: false, + }, + { + name: "Invalid target path", + jobPath: "/invalid/target", + paths: []internal.Path{{Path: "/mnt/backup"}}, + pathType: "target", + jobName: "job1", + expectsError: true, + errorMessage: "invalid target path for job 'job1': /invalid/target", + }, + } + + 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) + } + } + }) + } +} + +func TestValidatePaths(t *testing.T) { + tests := []struct { + name string + cfg internal.Config + expectsError bool + errorMessage string + }{ + { + name: "Valid paths", + cfg: internal.Config{ + Sources: []internal.Path{ + {Path: "/home/user"}, + }, + Targets: []internal.Path{ + {Path: "/mnt/backup"}, + }, + Jobs: []internal.Job{ + {Name: "job1", Source: "/home/user/documents", Target: "/mnt/backup/documents"}, + }, + }, + expectsError: false, + }, + { + name: "Invalid paths", + cfg: internal.Config{ + Sources: []internal.Path{ + {Path: "/home/user"}, + }, + Targets: []internal.Path{ + {Path: "/mnt/backup"}, + }, + 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]", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := 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()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + } + }) + } +} diff --git a/backup/cmd/root.go b/backup/cmd/root.go index 4d119fd..6d1dc57 100644 --- a/backup/cmd/root.go +++ b/backup/cmd/root.go @@ -13,6 +13,13 @@ var RootCmd = &cobra.Command{ Long: `backup-tool is a CLI tool for managing backups and configurations.`, } +// Define a global configPath variable and flag at the root level +var configPath string + +func init() { + 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 { diff --git a/backup/internal/check.go b/backup/internal/check.go new file mode 100644 index 0000000..cc3e7f8 --- /dev/null +++ b/backup/internal/check.go @@ -0,0 +1,138 @@ +package internal + +import ( + "log" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/afero" +) + +func isExcluded(path string, job Job) bool { + for _, exclusion := range job.Exclusions { + exclusionPath := filepath.Join(job.Source, exclusion) + if strings.HasPrefix(NormalizePath(path), exclusionPath) { + return true + } + } + return false +} + +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 +} + +func isCovered(path string, jobs []Job) bool { + for _, job := range jobs { + if isCoveredByJob(path, job) { + 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 { + checkPath(fs, source.Path, cfg, &result, seen) + } + + 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) { + 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 + } + + // Add uncovered path + log.Printf("ADD: Path '%s' is uncovered", path) + *result = append(*result, path) +} + +// 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) { + log.Printf("UNCOVERED CHILD: Path '%s' has uncovered child '%s'", path, child) + allCovered = false + } + } + + 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 + } + + for _, info := range fileInfos { + if info.IsDir() { + 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 new file mode 100644 index 0000000..8abb4ea --- /dev/null +++ b/backup/internal/check_test.go @@ -0,0 +1,225 @@ +package internal + +import ( + "bytes" + "log" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/spf13/afero" +) + +func TestIsExcludedGlobally(t *testing.T) { + sources := []Path{ + { + Path: "/home/data/", + Exclusions: []string{"/projects/P1/", "/media/"}, + }, + { + Path: "/home/user/", + Exclusions: []string{"/cache/", "/npm/"}, + }, + } + + tests := []struct { + name string + path string + expectsError bool + expectedLog string + }{ + { + name: "Path is globally excluded", + path: "/home/data/projects/P1", + expectsError: true, + expectedLog: "Path '/home/data/projects/P1' is globally excluded by '/projects/P1/' in source '/home/data/'", + }, + { + name: "Path is not excluded", + path: "/home/data/projects/Other", + expectsError: false, + }, + { + name: "Path is excluded in another source", + path: "/home/user/cache", + expectsError: true, + expectedLog: "Path '/home/user/cache' is globally excluded by '/cache/' in source '/home/user/'", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var logBuffer bytes.Buffer + log.SetOutput(&logBuffer) + + result := isExcludedGlobally(test.path, sources) + if result != test.expectsError { + t.Errorf("Expected exclusion result %v, got %v", test.expectsError, result) + } + + if test.expectsError { + if !strings.Contains(logBuffer.String(), test.expectedLog) { + t.Errorf("Expected log message '%s', but got '%s'", test.expectedLog, logBuffer.String()) + } + } + }) + } +} + +func runListUncoveredPathsTest(t *testing.T, fakeFS map[string][]string, cfg Config, expectedUncoveredPaths []string) { + // Create an in-memory filesystem using Afero + fs := afero.NewMemMapFs() + + // Populate the in-memory filesystem with the fakeFS structure + for path, entries := range fakeFS { + _ = fs.MkdirAll(path, 0755) + for _, entry := range entries { + entryPath := filepath.Join(path, entry) + _ = fs.MkdirAll(entryPath, 0755) + } + } + + // Call the function + uncoveredPaths := listUncoveredPaths(fs, cfg) + + // Assertions + sort.Strings(uncoveredPaths) + sort.Strings(expectedUncoveredPaths) + + 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) { + 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) + } + } +} + +func TestListUncoveredPathsVariations(t *testing.T) { + // Variation: all paths used + runListUncoveredPathsTest(t, + map[string][]string{ + "/var/log": {"app1", "app2"}, + "/tmp": {"cache", "temp"}, + }, + Config{ + Sources: []Path{ + {Path: "/var/log"}, + {Path: "/tmp"}, + }, + Jobs: []Job{ + {Name: "Job1", Source: "/var/log"}, + {Name: "Job2", Source: "/tmp"}, + }, + }, + []string{}, + ) + + // Variation: one source covered, one uncovered + runListUncoveredPathsTest(t, + map[string][]string{ + "/home/data": {"projects", "media"}, + "/home/user": {"cache", "npm"}, + "/home/user/cache": {}, + "/home/user/npm": {}, + }, + Config{ + Sources: []Path{ + {Path: "/home/data"}, + {Path: "/home/user"}, + }, + Jobs: []Job{ + {Name: "Job1", Source: "/home/data"}, + }, + }, + []string{"/home/user"}, + ) + + // Variation: one source covered, one uncovered but excluded + runListUncoveredPathsTest(t, + map[string][]string{ + "/home/data": {"projects", "media"}, + }, + Config{ + Sources: []Path{ + {Path: "/home/data", Exclusions: []string{"media"}}, + }, + Jobs: []Job{ + {Name: "Job1", Source: "/home/data/projects"}, + }, + }, + []string{}, + ) + + // Variation: one source covered, subfolders covered + runListUncoveredPathsTest(t, + map[string][]string{ + "/home/data": {"family"}, + "/home/data/family": {"me", "you"}, + "/home/data/family/me": {"a"}, + "/home/data/family/you": {"a"}, + }, + Config{ + Sources: []Path{ + {Path: "/home/data"}, + }, + Jobs: []Job{ + {Name: "JobMe", Source: "/home/data/family/me"}, + {Name: "JobYou", Source: "/home/data/family/you"}, + }, + }, + []string{}, + ) + + // // Variation: one source covered, one uncovered subfolder + // runListUncoveredPathsTest(t, + // map[string][]string{ + // "/home/data": {"family"}, + // "/home/data/family": {"me", "you"}, + // "/home/data/family/me": {"a"}, + // "/home/data/family/you": {"a"}, + // }, + // Config{ + // Sources: []Path{ + // {Path: "/home/data"}, + // }, + // Jobs: []Job{ + // {Name: "JobMe", Source: "/home/data/family/me"}, + // }, + // }, + // []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/helper.go b/backup/internal/helper.go new file mode 100644 index 0000000..1f4c426 --- /dev/null +++ b/backup/internal/helper.go @@ -0,0 +1,8 @@ +// Correct the package declaration +package internal + +import "strings" + +func NormalizePath(path string) string { + return strings.TrimSuffix(strings.ReplaceAll(path, "//", "/"), "/") +} diff --git a/backup/internal/helper_test.go b/backup/internal/helper_test.go new file mode 100644 index 0000000..80b6c47 --- /dev/null +++ b/backup/internal/helper_test.go @@ -0,0 +1,22 @@ +package internal + +import "testing" + +func TestNormalizePath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/path/to//normalize/", "/path/to/normalize"}, + {"/path//with//double/slashes/", "/path/with/double/slashes"}, + {"/trailing/slash/", "/trailing/slash"}, + {"/no/trailing/slash", "/no/trailing/slash"}, + } + + for _, test := range tests { + result := 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 new file mode 100644 index 0000000..4efb5c8 --- /dev/null +++ b/backup/internal/job.go @@ -0,0 +1,43 @@ +package internal + +import ( + "fmt" + "log" + "os/exec" + "strings" +) + +func buildRsyncCmd(job Job, dryRun bool) *exec.Cmd { + args := []string{"-aiv", "--info=progress2"} + if job.Delete == nil || *job.Delete { + args = append(args, "--delete") + } + for _, excl := range job.Exclusions { + args = append(args, fmt.Sprintf("--exclude=%s", excl)) + } + args = append(args, job.Source, job.Target) + if dryRun { + args = append([]string{"--dry-run"}, args...) + } + return exec.Command("rsync", args...) +} + +func executeJob(job Job, dryRun bool, logger *log.Logger) string { + if job.Enabled != nil && !*job.Enabled { + logger.Printf("SKIPPED [%s]: Job is disabled", job.Name) + return "SKIPPED" + } + + cmd := buildRsyncCmd(job, dryRun) + fmt.Printf("Job: %s\n", job.Name) + fmt.Printf("Command: %s\n", strings.Join(cmd.Args, " ")) + if !dryRun { + out, err := cmd.CombinedOutput() + if err != nil { + logger.Printf("ERROR [%s]: %v\nOutput: %s", job.Name, err, string(out)) + return "FAILURE" + } + logger.Printf("SUCCESS [%s]: %s", job.Name, string(out)) + } + return "SUCCESS" +} diff --git a/backup/internal/job_test.go b/backup/internal/job_test.go new file mode 100644 index 0000000..0cd1865 --- /dev/null +++ b/backup/internal/job_test.go @@ -0,0 +1,122 @@ +package internal + +import ( + "bytes" + "log" + "strings" + "testing" +) + +// Helper function to create a pointer to a boolean value +func boolPtr(b bool) *bool { + return &b +} + +func TestBuildRsyncCmd(t *testing.T) { + job := Job{ + Source: "/home/user/Music/", + Target: "/target/user/music/home", + Delete: nil, + Exclusions: []string{"*.tmp", "node_modules/"}, + } + dryRun := true + cmd := buildRsyncCmd(job, dryRun) + + expectedArgs := []string{ + "rsync", "--dry-run", "-aiv", "--info=progress2", "--delete", + "--exclude=*.tmp", "--exclude=node_modules/", + "/home/user/Music/", "/target/user/music/home", + } + + if strings.Join(cmd.Args, " ") != strings.Join(expectedArgs, " ") { + t.Errorf("Expected %v, got %v", expectedArgs, cmd.Args) + } +} + +func TestExecuteJob(t *testing.T) { + job := Job{ + Name: "test_job", + Source: "/home/test/", + Target: "/mnt/backup1/test/", + Delete: nil, + Exclusions: []string{"*.tmp"}, + } + dryRun := true + logger := log.New(&bytes.Buffer{}, "", log.LstdFlags) + + status := executeJob(job, dryRun, logger) + if status != "SUCCESS" { + t.Errorf("Expected status SUCCESS, got %s", status) + } + + disabledJob := Job{ + Name: "disabled_job", + Source: "/home/disabled/", + Target: "/mnt/backup1/disabled/", + Enabled: boolPtr(false), + } + + status = executeJob(disabledJob, dryRun, logger) + if status != "SKIPPED" { + t.Errorf("Expected status SKIPPED, got %s", status) + } + + // Test case for failure (simulate by providing invalid source path) + invalidJob := Job{ + Name: "invalid_job", + Source: "/invalid/source/path", + Target: "/mnt/backup1/invalid/", + } + + status = executeJob(invalidJob, false, logger) + if status != "FAILURE" { + t.Errorf("Expected status FAILURE, got %s", status) + } +} + +func TestJobSkippedEnabledTrue(t *testing.T) { + job := Job{ + Name: "test_job", + Source: "/home/test/", + Target: "/mnt/backup1/test/", + Enabled: boolPtr(true), + } + dryRun := true + logger := log.New(&bytes.Buffer{}, "", log.LstdFlags) + + status := executeJob(job, dryRun, logger) + if status != "SUCCESS" { + t.Errorf("Expected status SUCCESS, got %s", status) + } +} + +func TestJobSkippedEnabledFalse(t *testing.T) { + disabledJob := Job{ + Name: "disabled_job", + Source: "/home/disabled/", + Target: "/mnt/backup1/disabled/", + Enabled: boolPtr(false), + } + dryRun := true + logger := log.New(&bytes.Buffer{}, "", log.LstdFlags) + + status := executeJob(disabledJob, dryRun, logger) + if status != "SKIPPED" { + t.Errorf("Expected status SKIPPED, got %s", status) + } +} + +func TestJobSkippedEnabledOmitted(t *testing.T) { + job := Job{ + Name: "omitted_enabled_job", + Source: "/home/omitted/", + Target: "/mnt/backup1/omitted/", + } + dryRun := true + logger := log.New(&bytes.Buffer{}, "", log.LstdFlags) + + status := executeJob(job, dryRun, logger) + if status != "SUCCESS" { + t.Errorf("Expected status SUCCESS, got %s", status) + } +} diff --git a/backup/internal/types.go b/backup/internal/types.go new file mode 100644 index 0000000..32f1626 --- /dev/null +++ b/backup/internal/types.go @@ -0,0 +1,24 @@ +package internal + +// Centralized type definitions + +type Path struct { + Path string `yaml:"path"` + Exclusions []string `yaml:"exclusions"` +} + +type Config struct { + Sources []Path `yaml:"sources"` + Targets []Path `yaml:"targets"` + Variables map[string]string `yaml:"variables"` + Jobs []Job `yaml:"jobs"` +} + +type Job struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Target string `yaml:"target"` + Delete *bool `yaml:"delete"` + Exclusions []string `yaml:"exclusions"` + Enabled *bool `yaml:"enabled"` +} diff --git a/go.mod b/go.mod index 3ef4b66..b77173c 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,14 @@ module backup-rsync go 1.24.9 -require github.com/spf13/cobra v1.10.1 +require ( + github.com/spf13/afero v1.15.0 + github.com/spf13/cobra v1.10.1 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index e613680..ec5d064 100644 --- a/go.sum +++ b/go.sum @@ -2,9 +2,15 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=