diff --git a/AGENTS.md b/AGENTS.md index b00c06b..e7d790e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ backup/ | `JobCommand` | interface | `Run(job Job) JobStatus` + `GetVersionInfo()` — implemented by `ListCommand`, `SimulateCommand`, `SyncCommand` | `Exec` via constructors | | `SharedCommand` | struct | Base for all commands — holds `BinPath`, `BaseLogPath`, `Shell Exec`, `Output io.Writer` | `io.Writer`, `Exec` | | `CoverageChecker` | struct | Analyzes path coverage | `*log.Logger`, `afero.Fs` | -| `Config` | struct | YAML config (`Config`, `Job`, `Path`, `${var}` substitution); custom `UnmarshalYAML` for job defaults | `*log.Logger` in `Apply()` | +| `Config` | struct | YAML config (`Config`, `Mapping`, `Job`, `Path`, `${var}` substitution); custom `UnmarshalYAML` for job defaults | `*log.Logger` in `Apply()` | Additional injection points: `afero.Fs` into `BuildRootCommandWithFs()` → `buildCheckCoverageCommand(fs)`; commands use `cmd.OutOrStdout()` for testable output. diff --git a/backup/cmd/test/commands_test.go b/backup/cmd/test/commands_test.go index a59236b..4ea4c0d 100644 --- a/backup/cmd/test/commands_test.go +++ b/backup/cmd/test/commands_test.go @@ -66,8 +66,8 @@ func executeCommandWithDeps(t *testing.T, fs afero.Fs, shell internal.Exec, args func TestConfigShow_ValidConfig(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home").Target("/backup"). - AddJob("docs", "/home/docs/", "/backup/docs/"). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("docs", "docs", "docs"). Build()) stdout, err := executeCommand(t, "config", "show", "--config", cfgPath) @@ -116,8 +116,8 @@ func TestCreateLoggerError(t *testing.T) { for _, command := range commands { t.Run(command, func(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home").Target("/backup"). - AddJob("docs", "/home/docs/", "/backup/docs/"). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("docs", "docs", "docs"). Build()) fs := afero.NewReadOnlyFs(afero.NewMemMapFs()) @@ -145,8 +145,8 @@ func TestConfigShow_InvalidYAML(t *testing.T) { func TestConfigValidate_ValidConfig(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home").Target("/backup"). - AddJob("docs", "/home/docs/", "/backup/docs/"). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("docs", "docs", "docs"). Build()) stdout, err := executeCommand(t, "config", "validate", "--config", cfgPath) @@ -164,9 +164,9 @@ func TestConfigValidate_MissingFile(t *testing.T) { func TestConfigValidate_DuplicateJobNames(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home").Target("/backup"). - AddJob("same", "/home/a/", "/backup/a/"). - AddJob("same", "/home/b/", "/backup/b/"). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("same", "a", "a"). + AddJobToMapping("same", "b", "b"). Build()) _, err := executeCommand(t, "config", "validate", "--config", cfgPath) @@ -179,8 +179,8 @@ func TestConfigValidate_DuplicateJobNames(t *testing.T) { func TestRun_ValidConfig(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home").Target("/backup"). - AddJob("docs", "/home/docs/", "/backup/docs/", testutil.Enabled(true), testutil.Delete(true)). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("docs", "docs", "docs", testutil.Enabled(true), testutil.Delete(true)). Build()) shell := &stubExec{output: []byte("rsync version 3.2.7 protocol version 31\n")} @@ -196,8 +196,8 @@ func TestRun_ValidConfig(t *testing.T) { func TestSimulate_ValidConfig(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home").Target("/backup"). - AddJob("docs", "/home/docs/", "/backup/docs/", testutil.Enabled(true)). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("docs", "docs", "docs", testutil.Enabled(true)). Build()) shell := &stubExec{output: []byte("rsync version 3.2.7 protocol version 31\n")} @@ -240,8 +240,8 @@ func TestCheckCoverage_MissingConfig(t *testing.T) { func TestCheckCoverage_WithUncoveredPaths(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/src").Target("/dst"). - AddJob("docs", "/src/docs/", "/dst/docs/"). + AddMapping("m", "/src", "/dst"). + AddJobToMapping("docs", "docs", "docs"). Build()) fs := afero.NewMemMapFs() @@ -257,8 +257,8 @@ func TestCheckCoverage_WithUncoveredPaths(t *testing.T) { func TestCheckCoverage_ValidConfig(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/src").Target("/dst"). - AddJob("docs", "/src/docs/", "/dst/docs/"). + AddMapping("m", "/src", "/dst"). + AddJobToMapping("docs", "docs", "docs"). Build()) fs := afero.NewMemMapFs() @@ -300,8 +300,8 @@ func TestVersion_WithMockExec(t *testing.T) { func TestList_ValidConfig(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home").Target("/backup"). - AddJob("docs", "/home/docs/", "/backup/docs/", testutil.Enabled(true)). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("docs", "docs", "docs", testutil.Enabled(true)). Build()) shell := &stubExec{output: []byte("rsync version 3.2.7 protocol version 31\n")} @@ -318,8 +318,8 @@ func TestList_ValidConfig(t *testing.T) { func TestRun_LoggerOpenDuringApply(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home").Target("/backup"). - AddJob("docs", "/home/docs/", "/backup/docs/", testutil.Enabled(true)). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("docs", "docs", "docs", testutil.Enabled(true)). Build()) shell := &stubExec{output: []byte("rsync version 3.2.7 protocol version 31\n")} @@ -364,24 +364,24 @@ func TestRun_LoggerOpenDuringApply(t *testing.T) { func TestConfigShow_WithSetFlag(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home/${user}").Target("/backup/${user}"). Variable("user", "default"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build()) stdout, err := executeCommand(t, "config", "show", "--config", cfgPath, "--set", "user=alice") require.NoError(t, err) assert.Contains(t, stdout, "alice_docs") - assert.Contains(t, stdout, "/home/alice/docs/") - assert.Contains(t, stdout, "/backup/alice/docs/") + assert.Contains(t, stdout, "/home/alice/docs") + assert.Contains(t, stdout, "/backup/alice/docs") assert.NotContains(t, stdout, "${user}") } func TestConfigValidate_WithSetFlag(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build()) stdout, err := executeCommand(t, "config", "validate", "--config", cfgPath, "--set", "user=bob") @@ -392,8 +392,8 @@ func TestConfigValidate_WithSetFlag(t *testing.T) { func TestList_WithSetFlag(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build()) shell := &stubExec{output: []byte("rsync version 3.2.7 protocol version 31\n")} @@ -410,8 +410,8 @@ func TestList_WithSetFlag(t *testing.T) { func TestConfigValidate_TemplateVarsMissing(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). TemplateVar("user").TemplateVar("user_cap"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("docs", "docs", "docs"). Build()) _, err := executeCommand(t, "config", "validate", "--config", cfgPath) @@ -423,8 +423,8 @@ func TestConfigValidate_TemplateVarsMissing(t *testing.T) { func TestConfigValidate_TemplateVarsProvidedViaSet(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build()) stdout, err := executeCommand(t, "config", "validate", "--config", cfgPath, "--set", "user=alice") @@ -436,8 +436,8 @@ func TestConfigValidate_TemplateVarsProvidedViaSet(t *testing.T) { func TestConfigShow_TemplateVarsResolved(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build()) stdout, err := executeCommand(t, "config", "show", "--config", cfgPath, "--set", "user=alice") @@ -454,8 +454,8 @@ func TestConfigShow_WithInclude(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -468,7 +468,7 @@ func TestConfigShow_WithInclude(t *testing.T) { require.NoError(t, err) assert.Contains(t, stdout, "alice_docs") - assert.Contains(t, stdout, "/home/alice/docs/") + assert.Contains(t, stdout, "/home/alice/docs") } func TestConfigValidate_WithInclude(t *testing.T) { @@ -476,8 +476,8 @@ func TestConfigValidate_WithInclude(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -497,8 +497,8 @@ func TestConfigValidate_IncludeMissingVars(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user").TemplateVar("user_cap"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -518,8 +518,8 @@ func TestList_WithInclude(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) diff --git a/backup/cmd/test/integration_test.go b/backup/cmd/test/integration_test.go index 171a95e..3272136 100644 --- a/backup/cmd/test/integration_test.go +++ b/backup/cmd/test/integration_test.go @@ -81,8 +81,8 @@ func TestIntegration_Run_BasicSync(t *testing.T) { writeFile(t, filepath.Join(src, "subdir", "nested.txt"), "nested content") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("basic", src+"/", dst+"/", testutil.Delete(false)). + AddMapping("m", src, dst). + AddJobToMapping("basic", "", "", testutil.Delete(false)). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -103,8 +103,8 @@ func TestIntegration_Run_IdempotentSync(t *testing.T) { writeFile(t, filepath.Join(src, "data.txt"), "same content") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("idem", src+"/", dst+"/"). + AddMapping("m", src, dst). + AddJobToMapping("idem", "", ""). Build()) // First sync @@ -129,8 +129,8 @@ func TestIntegration_Run_DeleteRemovesExtraFiles(t *testing.T) { writeFile(t, filepath.Join(dst, "stale.txt"), "should be removed") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("cleanup", src+"/", dst+"/", testutil.Delete(true)). + AddMapping("m", src, dst). + AddJobToMapping("cleanup", "", "", testutil.Delete(true)). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -151,8 +151,8 @@ func TestIntegration_Run_NoDeletePreservesExtraFiles(t *testing.T) { writeFile(t, filepath.Join(dst, "extra.txt"), "should remain") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("nodelete", src+"/", dst+"/", testutil.Delete(false)). + AddMapping("m", src, dst). + AddJobToMapping("nodelete", "", "", testutil.Delete(false)). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -174,8 +174,8 @@ func TestIntegration_Run_Exclusions(t *testing.T) { writeFile(t, filepath.Join(src, "logs", "app.log"), "log data") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("filtered", src+"/", dst+"/", testutil.Exclusions("cache", "logs")). + AddMapping("m", src, dst). + AddJobToMapping("filtered", "", "", testutil.Exclusions("cache", "logs")). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -196,8 +196,8 @@ func TestIntegration_Run_DisabledJobSkipped(t *testing.T) { writeFile(t, filepath.Join(src, "file.txt"), "content") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("disabled-job", src+"/", dst+"/", testutil.Enabled(false)). + AddMapping("m", src, dst). + AddJobToMapping("disabled-job", "", "", testutil.Enabled(false)). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -226,9 +226,10 @@ func TestIntegration_Run_MultipleJobs(t *testing.T) { writeFile(t, filepath.Join(srcB, "b.txt"), "bravo") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(base).Target(base). - AddJob("jobA", srcA+"/", dstA+"/"). - AddJob("jobB", srcB+"/", dstB+"/"). + AddMapping("mA", srcA, dstA). + AddJobToMapping("jobA", "", ""). + AddMapping("mB", srcB, dstB). + AddJobToMapping("jobB", "", ""). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -251,8 +252,8 @@ func TestIntegration_Run_PartialChanges(t *testing.T) { writeFile(t, filepath.Join(src, "modified.txt"), "original") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("partial", src+"/", dst+"/"). + AddMapping("m", src, dst). + AddJobToMapping("partial", "", ""). Build()) // Initial sync @@ -282,8 +283,8 @@ func TestIntegration_Simulate_NoChanges(t *testing.T) { writeFile(t, filepath.Join(src, "new.txt"), "should not appear in target") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("dryrun", src+"/", dst+"/"). + AddMapping("m", src, dst). + AddJobToMapping("dryrun", "", ""). Build()) stdout, err := executeIntegrationCommand(t, "simulate", "--config", cfgPath) @@ -305,8 +306,8 @@ func TestIntegration_Simulate_ShowsChanges(t *testing.T) { writeFile(t, filepath.Join(src, "report.txt"), "quarterly report") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("preview", src+"/", dst+"/"). + AddMapping("m", src, dst). + AddJobToMapping("preview", "", ""). Build()) stdout, err := executeIntegrationCommand(t, "simulate", "--config", cfgPath) @@ -325,8 +326,8 @@ func TestIntegration_SimulateThenRun(t *testing.T) { writeFile(t, filepath.Join(src, "data.txt"), "important data") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("workflow", src+"/", dst+"/"). + AddMapping("m", src, dst). + AddJobToMapping("workflow", "", ""). Build()) // Simulate first @@ -351,8 +352,8 @@ func TestIntegration_List_ShowsCommands(t *testing.T) { writeFile(t, filepath.Join(src, "x.txt"), "x") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("listjob", src+"/", dst+"/", testutil.Exclusions("temp")). + AddMapping("m", src, dst). + AddJobToMapping("listjob", "", "", testutil.Exclusions("temp")). Build()) stdout, err := executeIntegrationCommand(t, "list", "--config", cfgPath) @@ -361,7 +362,7 @@ func TestIntegration_List_ShowsCommands(t *testing.T) { assert.Contains(t, stdout, "Job: listjob") assert.Contains(t, stdout, "--exclude=temp") assert.Contains(t, stdout, src+"/") - assert.Contains(t, stdout, dst+"/") + assert.Contains(t, stdout, dst) assert.NotContains(t, stdout, "Status [listjob]:") assert.NotContains(t, stdout, "Summary:") @@ -377,9 +378,9 @@ func TestIntegration_Run_VariableSubstitution(t *testing.T) { writeFile(t, filepath.Join(src, "v.txt"), "vars work") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). Variable("src_dir", src).Variable("dst_dir", dst). - AddJob("var-job", "${src_dir}/", "${dst_dir}/"). + AddMapping("m", "${src_dir}", "${dst_dir}"). + AddJobToMapping("var-job", "", ""). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -408,9 +409,10 @@ func TestIntegration_Run_MixedJobsSummary(t *testing.T) { writeFile(t, filepath.Join(srcSkip, "skip.txt"), "skip") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(base).Target(base). - AddJob("active", srcOK+"/", dstOK+"/", testutil.Enabled(true)). - AddJob("inactive", srcSkip+"/", dstSkip+"/", testutil.Enabled(false)). + AddMapping("mOK", srcOK, dstOK). + AddJobToMapping("active", "", "", testutil.Enabled(true)). + AddMapping("mSkip", srcSkip, dstSkip). + AddJobToMapping("inactive", "", "", testutil.Enabled(false)). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -430,8 +432,8 @@ func TestIntegration_Run_EmptySource(t *testing.T) { src, dst := setupDirs(t) cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("empty", src+"/", dst+"/"). + AddMapping("m", src, dst). + AddJobToMapping("empty", "", ""). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -448,8 +450,8 @@ func TestIntegration_Run_DeepHierarchy(t *testing.T) { writeFile(t, filepath.Join(src, "a", "b", "c", "d", "deep.txt"), "deep file") cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("deep", src+"/", dst+"/"). + AddMapping("m", src, dst). + AddJobToMapping("deep", "", ""). Build()) stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath) @@ -470,9 +472,9 @@ func TestIntegration_CheckCoverage_FullCoverage(t *testing.T) { dst := t.TempDir() cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("docs", filepath.Join(src, "docs")+"/", filepath.Join(dst, "docs")+"/"). - AddJob("photos", filepath.Join(src, "photos")+"/", filepath.Join(dst, "photos")+"/"). + AddMapping("m", src, dst). + AddJobToMapping("docs", "docs", "docs"). + AddJobToMapping("photos", "photos", "photos"). Build()) stdout, err := executeIntegrationCommand(t, "check-coverage", "--config", cfgPath) @@ -496,8 +498,8 @@ func TestIntegration_CheckCoverage_IncompleteCoverage(t *testing.T) { dst := t.TempDir() cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source(src).Target(dst). - AddJob("docs-only", filepath.Join(src, "docs")+"/", filepath.Join(dst, "docs")+"/"). + AddMapping("m", src, dst). + AddJobToMapping("docs-only", "docs", "docs"). Build()) stdout, err := executeIntegrationCommand(t, "check-coverage", "--config", cfgPath) @@ -511,15 +513,15 @@ func TestIntegration_CheckCoverage_IncompleteCoverage(t *testing.T) { func TestIntegration_ConfigShow(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/data").Target("/backup"). Variable("base", "/backup"). - AddJob("resolved", "/data/files/", "${base}/files/"). + AddMapping("m", "/data", "${base}"). + AddJobToMapping("resolved", "files", "files"). Build()) stdout, err := executeIntegrationCommand(t, "config", "show", "--config", cfgPath) require.NoError(t, err) - assert.Contains(t, stdout, "/backup/files/") + assert.Contains(t, stdout, "/backup/files") assert.Contains(t, stdout, "resolved") } @@ -527,8 +529,8 @@ func TestIntegration_ConfigShow(t *testing.T) { func TestIntegration_ConfigValidate_Valid(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/data").Target("/backup"). - AddJob("valid", "/data/stuff/", "/backup/stuff/"). + AddMapping("m", "/data", "/backup"). + AddJobToMapping("valid", "stuff", "stuff"). Build()) stdout, err := executeIntegrationCommand(t, "config", "validate", "--config", cfgPath) @@ -541,9 +543,9 @@ func TestIntegration_ConfigValidate_Valid(t *testing.T) { func TestIntegration_ConfigValidate_OverlappingSources(t *testing.T) { cfgPath := testutil.WriteConfigFile(t, testutil.NewConfigBuilder(). - Source("/data").Target("/backup"). - AddJob("parent", "/data/user/", "/backup/user/"). - AddJob("child", "/data/user/docs/", "/backup/docs/"). + AddMapping("m", "/data", "/backup"). + AddJobToMapping("parent", "user", "user"). + AddJobToMapping("child", "user/docs", "docs"). Build()) _, err := executeIntegrationCommand(t, "config", "validate", "--config", cfgPath) diff --git a/backup/internal/check.go b/backup/internal/check.go index a1976d5..bfb1da5 100644 --- a/backup/internal/check.go +++ b/backup/internal/check.go @@ -16,12 +16,13 @@ type CoverageChecker struct { Fs afero.Fs } -func (c *CoverageChecker) IsExcludedGlobally(path string, sources []Path) bool { - for _, source := range sources { - for _, exclusion := range source.Exclusions { - exclusionPath := filepath.Join(source.Path, exclusion) +func (c *CoverageChecker) IsExcludedGlobally(path string, mappings []Mapping) bool { + for _, mapping := range mappings { + for _, exclusion := range mapping.Exclusions { + exclusionPath := filepath.Join(mapping.Source, exclusion) if strings.HasPrefix(NormalizePath(path), exclusionPath) { - c.Logger.Printf("EXCLUDED: Path '%s' is globally excluded by '%s' in source '%s'", path, exclusion, source.Path) + c.Logger.Printf("EXCLUDED: Path '%s' is globally excluded by '%s' in source '%s'", + path, exclusion, mapping.Source) return true } @@ -36,8 +37,8 @@ func (c *CoverageChecker) ListUncoveredPaths(cfg Config) []string { seen := make(map[string]bool) - for _, source := range cfg.Sources { - c.checkPath(source.Path, cfg, &result, seen) + for _, mapping := range cfg.Mappings { + c.checkPath(mapping.Source, cfg.Mappings, &result, seen) } slices.Sort(result) // Ensure consistent ordering for test comparison @@ -69,13 +70,21 @@ func (c *CoverageChecker) isCoveredByJob(path string, job Job) bool { return false } -func (c *CoverageChecker) isCovered(path string, jobs []Job) bool { - return slices.ContainsFunc(jobs, func(job Job) bool { - return c.isCoveredByJob(path, job) - }) +func (c *CoverageChecker) isCovered(path string, mappings []Mapping) bool { + for _, mapping := range mappings { + if slices.ContainsFunc(mapping.Jobs, func(job Job) bool { + return c.isCoveredByJob(path, job) + }) { + return true + } + } + + return false } -func (c *CoverageChecker) checkPath(path string, cfg Config, result *[]string, seen map[string]bool) { +func (c *CoverageChecker) checkPath( + path string, mappings []Mapping, result *[]string, seen map[string]bool, +) { if seen[path] { c.Logger.Printf("SKIP: Path '%s' already seen", path) @@ -85,21 +94,21 @@ func (c *CoverageChecker) checkPath(path string, cfg Config, result *[]string, s seen[path] = true // Skip if globally excluded - if c.IsExcludedGlobally(path, cfg.Sources) { + if c.IsExcludedGlobally(path, mappings) { c.Logger.Printf("SKIP: Path '%s' is globally excluded", path) return } // Skip if covered by a job - if c.isCovered(path, cfg.Jobs) { + if c.isCovered(path, mappings) { c.Logger.Printf("SKIP: Path '%s' is covered by a job", path) return } // Check if it's effectively covered through descendants - if c.isEffectivelyCovered(path, cfg) { + if c.isEffectivelyCovered(path, mappings) { c.Logger.Printf("SKIP: Path '%s' is effectively covered", path) return @@ -112,7 +121,7 @@ func (c *CoverageChecker) checkPath(path string, cfg Config, result *[]string, s // isEffectivelyCovered checks if a directory is effectively covered // (all its descendants are covered or excluded). -func (c *CoverageChecker) isEffectivelyCovered(path string, cfg Config) bool { +func (c *CoverageChecker) isEffectivelyCovered(path string, mappings []Mapping) bool { children, err := getChildDirectories(c.Fs, path) if err != nil { c.Logger.Printf("ERROR: could not get child directories of '%s': %v", path, err) @@ -129,7 +138,9 @@ func (c *CoverageChecker) isEffectivelyCovered(path string, cfg Config) bool { allCovered := true for _, child := range children { - if !c.IsExcludedGlobally(child, cfg.Sources) && !c.isCovered(child, cfg.Jobs) && !c.isEffectivelyCovered(child, cfg) { + covered := c.IsExcludedGlobally(child, mappings) || c.isCovered(child, mappings) || + c.isEffectivelyCovered(child, mappings) + if !covered { c.Logger.Printf("UNCOVERED CHILD: Path '%s' has uncovered child '%s'", path, child) allCovered = false diff --git a/backup/internal/config.go b/backup/internal/config.go index 6162acc..721adca 100644 --- a/backup/internal/config.go +++ b/backup/internal/config.go @@ -17,8 +17,6 @@ import ( // 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") ErrJobFailure = errors.New("one or more jobs failed") ErrMissingTemplateVars = errors.New("missing required template variables") @@ -36,14 +34,32 @@ type Include struct { With map[string]string `yaml:"with"` } +// Mapping defines a source-to-target directory pair with its own list of backup jobs. +// Job paths within a mapping are relative to the mapping's Source and Target. +type Mapping struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Target string `yaml:"target"` + Exclusions []string `yaml:"exclusions,omitempty"` + Jobs []Job `yaml:"jobs"` +} + // Config represents the overall backup configuration. type Config struct { Template *Template `yaml:"template,omitempty"` Include []Include `yaml:"include,omitempty"` - Sources []Path `yaml:"sources"` - Targets []Path `yaml:"targets"` - Variables map[string]string `yaml:"variables"` - Jobs []Job `yaml:"jobs"` + Variables map[string]string `yaml:"variables,omitempty"` + Mappings []Mapping `yaml:"mappings"` +} + +// AllJobs returns a flat list of all jobs across all mappings. +func (cfg Config) AllJobs() []Job { + var jobs []Job + for _, m := range cfg.Mappings { + jobs = append(jobs, m.Jobs...) + } + + return jobs } func (cfg Config) String() string { @@ -65,8 +81,9 @@ func (cfg Config) Apply(rsync JobCommand, logger *log.Logger) error { } counts := make(map[JobStatus]int) + allJobs := cfg.AllJobs() - for _, job := range cfg.Jobs { + for _, job := range allJobs { status := job.Apply(rsync) rsync.ReportJobStatus(job.Name, status, logger) counts[status]++ @@ -75,7 +92,7 @@ func (cfg Config) Apply(rsync JobCommand, logger *log.Logger) error { rsync.ReportSummary(counts, logger) if counts[Failure] > 0 { - return fmt.Errorf("%w: %d of %d jobs", ErrJobFailure, counts[Failure], len(cfg.Jobs)) + return fmt.Errorf("%w: %d of %d jobs", ErrJobFailure, counts[Failure], len(allJobs)) } return nil @@ -142,57 +159,80 @@ func ResolveVariables(variables map[string]string) map[string]string { return resolved } -func ResolveConfig(cfg Config) (Config, error) { - resolvedCfg := cfg - - resolvedCfg.Variables = ResolveVariables(cfg.Variables) - - for idx, source := range resolvedCfg.Sources { - resolved, err := resolveField(source.Path, resolvedCfg.Variables) +// resolveFields resolves variables and macros in all mapping and job fields. +// When joinPaths is true, job paths are joined with their mapping base paths +// and unresolved macros are validated — this is the final resolution step. +// When joinPaths is false, only variable/macro substitution is performed, +// used by expandIncludes so that path joining happens only once. +func resolveFields(cfg Config, joinPaths bool) (Config, error) { + resolved := cfg + resolved.Variables = ResolveVariables(cfg.Variables) + + for mIdx := range resolved.Mappings { + err := resolveMapping(&resolved.Mappings[mIdx], resolved.Variables, joinPaths) if err != nil { - return Config{}, fmt.Errorf("resolving source path %q: %w", source.Path, err) + return Config{}, err } - - resolvedCfg.Sources[idx].Path = resolved } - for idx, target := range resolvedCfg.Targets { - resolved, err := resolveField(target.Path, resolvedCfg.Variables) + if joinPaths { + err := ValidateNoUnresolvedMacros(resolved) if err != nil { - return Config{}, fmt.Errorf("resolving target path %q: %w", target.Path, err) + return Config{}, fmt.Errorf("macro resolution incomplete: %w", err) } + } - resolvedCfg.Targets[idx].Path = resolved + return resolved, nil +} + +func resolveMapping(mapping *Mapping, variables map[string]string, joinPaths bool) error { + var err error + + mapping.Name, err = resolveField(mapping.Name, variables) + if err != nil { + return fmt.Errorf("resolving mapping name %q: %w", mapping.Name, err) } - for idx := range resolvedCfg.Jobs { - job := &resolvedCfg.Jobs[idx] + mapping.Source, err = resolveField(mapping.Source, variables) + if err != nil { + return fmt.Errorf("resolving mapping source %q: %w", mapping.Source, err) + } - errs := make([]error, 0, 3) //nolint:mnd // 3 fields to resolve: Source, Target, Name + mapping.Target, err = resolveField(mapping.Target, variables) + if err != nil { + return fmt.Errorf("resolving mapping target %q: %w", mapping.Target, err) + } - var err error + for jIdx := range mapping.Jobs { + job := &mapping.Jobs[jIdx] - job.Source, err = resolveField(job.Source, resolvedCfg.Variables) - errs = append(errs, err) + job.Name, err = resolveField(job.Name, variables) + if err != nil { + return fmt.Errorf("resolving job name %q: %w", job.Name, err) + } - job.Target, err = resolveField(job.Target, resolvedCfg.Variables) - errs = append(errs, err) + job.Source, err = resolveField(job.Source, variables) + if err != nil { + return fmt.Errorf("resolving job source %q: %w", job.Source, err) + } - job.Name, err = resolveField(job.Name, resolvedCfg.Variables) - errs = append(errs, err) + job.Target, err = resolveField(job.Target, variables) + if err != nil { + return fmt.Errorf("resolving job target %q: %w", job.Target, err) + } - joined := errors.Join(errs...) - if joined != nil { - return Config{}, fmt.Errorf("resolving job %q: %w", job.Name, joined) + if joinPaths { + job.Source = filepath.Join(mapping.Source, job.Source) + "/" + job.Target = filepath.Join(mapping.Target, job.Target) } } - err := ValidateNoUnresolvedMacros(resolvedCfg) - if err != nil { - return Config{}, fmt.Errorf("macro resolution incomplete: %w", err) - } + return nil +} - return resolvedCfg, nil +// ResolveConfig resolves all variables, macros, and joins job paths with mapping base paths. +func ResolveConfig(cfg Config) (Config, error) { + return resolveFields(cfg, true) } func ValidateJobNames(jobs []Job) error { @@ -219,30 +259,6 @@ func ValidateJobNames(jobs []Job) error { return nil } -func ValidatePath(jobPath string, paths []Path, pathType string, jobName string) error { - if slices.ContainsFunc(paths, func(p Path) bool { return strings.HasPrefix(jobPath, p.Path) }) { - return nil - } - - return fmt.Errorf("%w for job '%s': %s %s", ErrInvalidPath, jobName, pathType, jobPath) -} - -func ValidatePaths(cfg Config) error { - errs := make([]error, 0, len(cfg.Jobs)*2) //nolint:mnd // 2 validations per job: source + target - - for _, job := range cfg.Jobs { - errs = append(errs, ValidatePath(job.Source, cfg.Sources, "source", job.Name)) - errs = append(errs, ValidatePath(job.Target, cfg.Targets, "target", job.Name)) - } - - joined := errors.Join(errs...) - if joined != nil { - return fmt.Errorf("%w: %w", ErrPathValidation, joined) - } - - return nil -} - func validateJobPaths(jobs []Job, pathType string, getPath func(job Job) string) error { for i, job1 := range jobs { for j, job2 := range jobs { @@ -327,14 +343,12 @@ func expandIncludes(cfg *Config, configDir string) error { return fmt.Errorf("include %q: %w", inc.Uses, err) } - resolved, err := ResolveConfig(tmplCfg) + resolved, err := resolveFields(tmplCfg, false) if err != nil { return fmt.Errorf("include %q: resolving config: %w", inc.Uses, err) } - cfg.Sources = append(cfg.Sources, resolved.Sources...) - cfg.Targets = append(cfg.Targets, resolved.Targets...) - cfg.Jobs = append(cfg.Jobs, resolved.Jobs...) + cfg.Mappings = append(cfg.Mappings, resolved.Mappings...) } cfg.Include = nil @@ -387,22 +401,19 @@ func resolveAndValidate(cfg Config, configDir string) (Config, error) { return Config{}, fmt.Errorf("config resolution failed: %w", err) } - err = ValidateJobNames(resolvedCfg.Jobs) - if err != nil { - return Config{}, fmt.Errorf("job validation failed: %w", err) - } + allJobs := resolvedCfg.AllJobs() - err = ValidatePaths(resolvedCfg) + err = ValidateJobNames(allJobs) if err != nil { - return Config{}, fmt.Errorf("path validation failed: %w", err) + return Config{}, fmt.Errorf("job validation failed: %w", err) } - err = validateJobPaths(resolvedCfg.Jobs, "source", func(job Job) string { return job.Source }) + err = validateJobPaths(allJobs, "source", func(job Job) string { return job.Source }) if err != nil { return Config{}, fmt.Errorf("job source path validation failed: %w", err) } - err = validateJobPaths(resolvedCfg.Jobs, "target", func(job Job) string { return job.Target }) + err = validateJobPaths(allJobs, "target", func(job Job) string { return job.Target }) if err != nil { return Config{}, fmt.Errorf("job target path validation failed: %w", err) } diff --git a/backup/internal/helper.go b/backup/internal/helper.go index b77df1a..ac5cde7 100644 --- a/backup/internal/helper.go +++ b/backup/internal/helper.go @@ -35,12 +35,6 @@ func NewUTCLogger(w io.Writer) *log.Logger { return log.New(&UTCLogWriter{W: w, Now: time.Now}, "", 0) } -// Path represents a source or target path with optional exclusions. -type Path struct { - Path string `yaml:"path"` - Exclusions []string `yaml:"exclusions"` -} - func NormalizePath(path string) string { return strings.TrimSuffix(strings.ReplaceAll(path, "//", "/"), "/") } diff --git a/backup/internal/macros.go b/backup/internal/macros.go index e84d09e..1540439 100644 --- a/backup/internal/macros.go +++ b/backup/internal/macros.go @@ -210,17 +210,32 @@ func findInnermostMacro(input string) (int, int, string, string, bool) { func ValidateNoUnresolvedMacros(cfg Config) error { var errs []error - for _, job := range cfg.Jobs { + for _, mapping := range cfg.Mappings { for _, field := range []struct { name, value string }{ - {"source", job.Source}, - {"target", job.Target}, - {"name", job.Name}, + {"mapping source", mapping.Source}, + {"mapping target", mapping.Target}, + {"mapping name", mapping.Name}, } { if strings.Contains(field.value, macroPrefix) { errs = append(errs, fmt.Errorf( - "%w in job %q field %q: %s", ErrUnresolvedMacro, job.Name, field.name, field.value)) + "%w in mapping %q field %q: %s", ErrUnresolvedMacro, mapping.Name, field.name, field.value)) + } + } + + for _, job := range mapping.Jobs { + for _, field := range []struct { + name, value string + }{ + {"source", job.Source}, + {"target", job.Target}, + {"name", job.Name}, + } { + if strings.Contains(field.value, macroPrefix) { + errs = append(errs, fmt.Errorf( + "%w in job %q field %q: %s", ErrUnresolvedMacro, job.Name, field.name, field.value)) + } } } } diff --git a/backup/internal/test/check_test.go b/backup/internal/test/check_test.go index d549276..550b6c4 100644 --- a/backup/internal/test/check_test.go +++ b/backup/internal/test/check_test.go @@ -29,9 +29,9 @@ func newSilentChecker(fs afero.Fs) *CoverageChecker { } func TestIsExcludedGlobally(t *testing.T) { - sources := []Path{ - {Path: "/home/data/", Exclusions: []string{"/projects/P1/", "/media/"}}, - {Path: "/home/user/", Exclusions: []string{"/cache/", "/npm/"}}, + mappings := []Mapping{ + {Name: "data", Source: "/home/data/", Exclusions: []string{"/projects/P1/", "/media/"}}, + {Name: "user", Source: "/home/user/", Exclusions: []string{"/cache/", "/npm/"}}, } tests := []struct { @@ -51,7 +51,7 @@ func TestIsExcludedGlobally(t *testing.T) { checker := newTestChecker(nil, &logBuf) - result := checker.IsExcludedGlobally(test.path, sources) + result := checker.IsExcludedGlobally(test.path, mappings) assert.Equal(t, test.want, result) @@ -101,48 +101,39 @@ func TestListUncoveredPathsVariations(t *testing.T) { cfg Config wantPaths []string }{ - { - name: "AllCovered", + {name: "AllCovered", fakeFS: map[string][]string{"/var/log": {"app1", "app2"}, "/tmp": {"cache", "temp"}}, - cfg: Config{ - Sources: []Path{{Path: "/var/log"}, {Path: "/tmp"}}, - Jobs: []Job{{Name: "Job1", Source: "/var/log"}, {Name: "Job2", Source: "/tmp"}}, - }, - }, - { - name: "OneCoveredOneUncovered", + cfg: Config{Mappings: []Mapping{ + {Name: "logs", Source: "/var/log", Target: "/bak/log", Jobs: []Job{{Name: "Job1", Source: "/var/log"}}}, + {Name: "tmp", Source: "/tmp", Target: "/bak/tmp", Jobs: []Job{{Name: "Job2", Source: "/tmp"}}}, + }}}, + {name: "OneCoveredOneUncovered", fakeFS: map[string][]string{ "/home/data": {"projects", "media"}, "/home/user": {"cache", "npm"}, "/home/user/cache": {}, "/home/user/npm": {}, }, - cfg: Config{ - Sources: []Path{{Path: "/home/data"}, {Path: "/home/user"}}, - Jobs: []Job{{Name: "Job1", Source: "/home/data"}}, - }, - wantPaths: []string{"/home/user"}, - }, - { - name: "UncoveredExcluded", + cfg: Config{Mappings: []Mapping{ + {Name: "data", Source: "/home/data", Target: "/bak/data", Jobs: []Job{{Name: "Job1", Source: "/home/data"}}}, + {Name: "user", Source: "/home/user", Target: "/bak/user", Jobs: []Job{}}, + }}, + wantPaths: []string{"/home/user"}}, + {name: "UncoveredExcluded", fakeFS: map[string][]string{"/home/data": {"projects", "media"}}, - cfg: Config{ - Sources: []Path{{Path: "/home/data", Exclusions: []string{"media"}}}, - Jobs: []Job{{Name: "Job1", Source: "/home/data/projects"}}, - }, - }, - { - name: "SubfoldersCovered", + cfg: Config{Mappings: []Mapping{ + {Name: "data", Source: "/home/data", Target: "/bak/data", Exclusions: []string{"media"}, + Jobs: []Job{{Name: "Job1", Source: "/home/data/projects"}}}, + }}}, + {name: "SubfoldersCovered", fakeFS: map[string][]string{ "/home/data": {"family"}, "/home/data/family": {"me", "you"}, "/home/data/family/me": {"a"}, "/home/data/family/you": {"a"}, }, - cfg: Config{ - Sources: []Path{{Path: "/home/data"}}, - Jobs: []Job{ + cfg: Config{Mappings: []Mapping{ + {Name: "data", Source: "/home/data", Target: "/bak/data", Jobs: []Job{ {Name: "JobMe", Source: "/home/data/family/me"}, {Name: "JobYou", Source: "/home/data/family/you"}, - }, - }, - }, + }}, + }}}, } for _, test := range tests { @@ -165,11 +156,10 @@ func TestListUncoveredPaths_JobExclusion(t *testing.T) { "/data/cache": {}, }, Config{ - Sources: []Path{ - {Path: "/data"}, - }, - Jobs: []Job{ - {Name: "backup", Source: "/data/", Exclusions: []string{"cache"}}, + Mappings: []Mapping{ + {Name: "data", Source: "/data", Target: "/bak/data", Jobs: []Job{ + {Name: "backup", Source: "/data/", Exclusions: []string{"cache"}}, + }}, }, }, []string{}, @@ -186,12 +176,9 @@ func TestListUncoveredPaths_DuplicateSourcesSkipped(t *testing.T) { checker := newTestChecker(fs, &logBuf) cfg := Config{ - Sources: []Path{ - {Path: "/data"}, - {Path: "/data"}, - }, - Jobs: []Job{ - {Name: "backup", Source: "/data"}, + Mappings: []Mapping{ + {Name: "m1", Source: "/data", Target: "/bak1", Jobs: []Job{{Name: "backup", Source: "/data"}}}, + {Name: "m2", Source: "/data", Target: "/bak2", Jobs: []Job{}}, }, } @@ -211,10 +198,9 @@ func TestListUncoveredPaths_UnreadableDirectory(t *testing.T) { checker := newTestChecker(fs, &logBuf) cfg := Config{ - Sources: []Path{ - {Path: "/data"}, + Mappings: []Mapping{ + {Name: "data", Source: "/data", Target: "/bak", Jobs: []Job{}}, }, - Jobs: []Job{}, } result := checker.ListUncoveredPaths(cfg) @@ -235,14 +221,13 @@ func TestListUncoveredPaths_ChildPathExcludedByJob(t *testing.T) { checker := newTestChecker(fs, &logBuf) cfg := Config{ - Sources: []Path{ - {Path: "/data/stuff"}, - }, - Jobs: []Job{ - // Source "/data" with exclusion "stuff/cache" so exclusionPath = "/data/stuff/cache" - {Name: "data-backup", Source: "/data", Exclusions: []string{"stuff/cache"}}, - // Covers the /data/stuff/docs child directly - {Name: "docs", Source: "/data/stuff/docs"}, + Mappings: []Mapping{ + {Name: "stuff", Source: "/data/stuff", Target: "/bak/stuff", Jobs: []Job{ + // Source "/data" with exclusion "stuff/cache" so exclusionPath = "/data/stuff/cache" + {Name: "data-backup", Source: "/data", Exclusions: []string{"stuff/cache"}}, + // Covers the /data/stuff/docs child directly + {Name: "docs", Source: "/data/stuff/docs"}, + }}, }, } @@ -262,12 +247,10 @@ func TestListUncoveredPaths_GloballyExcludedSourceSkipped(t *testing.T) { checker := newTestChecker(fs, &logBuf) cfg := Config{ - Sources: []Path{ - {Path: "/data", Exclusions: []string{"cache"}}, - {Path: "/data/cache"}, - }, - Jobs: []Job{ - {Name: "backup", Source: "/data"}, + Mappings: []Mapping{ + {Name: "data", Source: "/data", Target: "/bak/data", Exclusions: []string{"cache"}, + Jobs: []Job{{Name: "backup", Source: "/data"}}}, + {Name: "cache", Source: "/data/cache", Target: "/bak/cache", Jobs: []Job{}}, }, } diff --git a/backup/internal/test/config_test.go b/backup/internal/test/config_test.go index f914841..47a9501 100644 --- a/backup/internal/test/config_test.go +++ b/backup/internal/test/config_test.go @@ -19,11 +19,15 @@ func TestLoadConfig1(t *testing.T) { variables: target_base: "/mnt/backup1" -jobs: - - name: "test_job" - source: "/home/test/" - target: "${target_base}/test/" - enabled: true +mappings: + - name: "test" + source: "/home/test" + target: "${target_base}/test" + jobs: + - name: "test_job" + source: "" + target: "" + enabled: true ` reader := bytes.NewReader([]byte(yamlData)) @@ -31,25 +35,28 @@ jobs: require.NoError(t, err) assert.Equal(t, "/mnt/backup1", cfg.Variables["target_base"]) - assert.Len(t, cfg.Jobs, 1) + assert.Len(t, cfg.Mappings, 1) + assert.Len(t, cfg.Mappings[0].Jobs, 1) - job := cfg.Jobs[0] + job := cfg.Mappings[0].Jobs[0] assert.Equal(t, "test_job", job.Name) - assert.Equal(t, "/home/test/", job.Source) - assert.Equal(t, "${target_base}/test/", job.Target) } func TestLoadConfig2(t *testing.T) { yamlData := ` -jobs: - - name: "job1" - source: "/source1" - target: "/target1" - - name: "job2" - source: "/source2" - target: "/target2" - delete: false - enabled: false +mappings: + - name: "m1" + source: "/source" + target: "/target" + jobs: + - name: "job1" + source: "a" + target: "a" + - name: "job2" + source: "b" + target: "b" + delete: false + enabled: false ` reader := bytes.NewReader([]byte(yamlData)) @@ -57,26 +64,17 @@ jobs: cfg, err := LoadConfig(reader) require.NoError(t, err) - expected := []Job{ - { - Name: "job1", - Source: "/source1", - Target: "/target1", - Delete: true, - Enabled: true, - }, - { - Name: "job2", - Source: "/source2", - Target: "/target2", - Delete: false, - Enabled: false, - }, - } + require.Len(t, cfg.Mappings, 1) + jobs := cfg.Mappings[0].Jobs + require.Len(t, jobs, 2) - for i, job := range cfg.Jobs { - assert.Equal(t, expected[i], job, "Job mismatch at index %d", i) - } + assert.Equal(t, "job1", jobs[0].Name) + assert.True(t, jobs[0].Delete) + assert.True(t, jobs[0].Enabled) + + assert.Equal(t, "job2", jobs[1].Name) + assert.False(t, jobs[1].Delete) + assert.False(t, jobs[1].Enabled) } func TestYAMLUnmarshalingDefaults(t *testing.T) { @@ -174,107 +172,13 @@ func TestValidateJobNames(t *testing.T) { } } -func TestValidatePath(t *testing.T) { - tests := []struct { - name string - jobPath string - paths []Path - pathType string - wantErr string - }{ - { - name: "ValidSourcePath", - jobPath: "/home/user/documents", - paths: []Path{{Path: "/home/user"}}, - pathType: "source", - }, - { - name: "InvalidSourcePath", - jobPath: "/invalid/source", - paths: []Path{{Path: "/home/user"}}, - pathType: "source", - wantErr: "invalid path for job 'job1': source /invalid/source", - }, - { - name: "ValidTargetPath", - jobPath: "/mnt/backup/documents", - paths: []Path{{Path: "/mnt/backup"}}, - pathType: "target", - }, - { - name: "InvalidTargetPath", - jobPath: "/invalid/target", - paths: []Path{{Path: "/mnt/backup"}}, - pathType: "target", - wantErr: "invalid path for job 'job1': target /invalid/target", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := ValidatePath(test.jobPath, test.paths, test.pathType, "job1") - - if test.wantErr != "" { - require.Error(t, err) - assert.EqualError(t, err, test.wantErr) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestValidatePaths(t *testing.T) { - tests := []struct { - name string - cfg Config - wantErr string - }{ - { - name: "ValidPaths", - cfg: Config{ - Sources: []Path{{Path: "/home/user"}}, - Targets: []Path{{Path: "/mnt/backup"}}, - Jobs: []Job{{Name: "job1", Source: "/home/user/documents", Target: "/mnt/backup/documents"}}, - }, - }, - { - name: "InvalidPaths", - cfg: Config{ - Sources: []Path{{Path: "/home/user"}}, - Targets: []Path{{Path: "/mnt/backup"}}, - Jobs: []Job{{Name: "job1", Source: "/invalid/source", Target: "/invalid/target"}}, - }, - wantErr: "path validation failed", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := ValidatePaths(test.cfg) - - if test.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), test.wantErr) - } else { - assert.NoError(t, err) - } - }) - } -} - func TestConfigString_ValidConfig(t *testing.T) { cfg := Config{ - Sources: []Path{}, - Targets: []Path{}, - Variables: map[string]string{}, - Jobs: []Job{}, + Mappings: []Mapping{}, } - expectedOutput := "sources: []\ntargets: []\nvariables: {}\njobs: []\n" actualOutput := cfg.String() - - assert.Equal(t, expectedOutput, actualOutput) + assert.Contains(t, actualOutput, "mappings: []") } func TestResolveConfig(t *testing.T) { @@ -283,16 +187,15 @@ func TestResolveConfig(t *testing.T) { "source_base": "/home/user", "target_base": "/backup/user", }, - Jobs: []Job{ - { - Name: "job1", - Source: "${source_base}/Documents", - Target: "${target_base}/Documents", - }, + Mappings: []Mapping{ { - Name: "job2", - Source: "${source_base}/Pictures", - Target: "${target_base}/Pictures", + Name: "data", + Source: "${source_base}", + Target: "${target_base}", + Jobs: []Job{ + {Name: "job1", Source: "Documents", Target: "Documents", Enabled: true, Delete: true}, + {Name: "job2", Source: "Pictures", Target: "Pictures", Enabled: true, Delete: true}, + }, }, }, } @@ -300,10 +203,11 @@ func TestResolveConfig(t *testing.T) { resolvedCfg, err := ResolveConfig(cfg) require.NoError(t, err) - assert.Equal(t, "/home/user/Documents", resolvedCfg.Jobs[0].Source) - assert.Equal(t, "/backup/user/Documents", resolvedCfg.Jobs[0].Target) - assert.Equal(t, "/home/user/Pictures", resolvedCfg.Jobs[1].Source) - assert.Equal(t, "/backup/user/Pictures", resolvedCfg.Jobs[1].Target) + allJobs := resolvedCfg.AllJobs() + assert.Equal(t, "/home/user/Documents/", allJobs[0].Source) + assert.Equal(t, "/backup/user/Documents", allJobs[0].Target) + assert.Equal(t, "/home/user/Pictures/", allJobs[1].Source) + assert.Equal(t, "/backup/user/Pictures", allJobs[1].Target) } func TestLoadResolvedConfig(t *testing.T) { @@ -314,26 +218,29 @@ func TestLoadResolvedConfig(t *testing.T) { {name: "FileNotFound", wantErr: "failed to open config"}, {name: "InvalidYAML", config: "{{invalid yaml", wantErr: "failed to parse YAML"}, {name: "DuplicateJobNames", wantErr: "duplicate job name: dup", - config: testutil.NewConfigBuilder().Source("/src").Target("/tgt"). - AddJob("dup", "/src/a", "/tgt/a").AddJob("dup", "/src/b", "/tgt/b").Build()}, - {name: "InvalidSourcePath", wantErr: "path validation failed", - config: testutil.NewConfigBuilder().Source("/home").Target("/backup"). - AddJob("job1", "/invalid/source", "/backup/stuff").Build()}, + config: testutil.NewConfigBuilder(). + AddMapping("m", "/src", "/tgt"). + AddJobToMapping("dup", "a", "a").AddJobToMapping("dup", "b", "b").Build()}, {name: "OverlappingSourcePaths", wantErr: "job source path validation failed", - config: testutil.NewConfigBuilder().Source("/home").Target("/backup"). - AddJob("parent", "/home/user", "/backup/user"). - AddJob("child", "/home/user/docs", "/backup/docs").Build()}, + config: testutil.NewConfigBuilder(). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("parent", "user", "user"). + AddJobToMapping("child", "user/docs", "docs").Build()}, {name: "OverlappingAllowedByExclusion", wantJobs: 2, - config: testutil.NewConfigBuilder().Source("/home").Target("/backup"). - AddJob("parent", "/home/user", "/backup/user", testutil.Exclusions("docs")). - AddJob("child", "/home/user/docs", "/backup/docs").Build()}, + config: testutil.NewConfigBuilder(). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("parent", "user", "user", testutil.Exclusions("docs")). + AddJobToMapping("child", "user/docs", "docs").Build()}, {name: "OverlappingTargetPaths", wantErr: "job target path validation failed", - config: testutil.NewConfigBuilder().Source("/home").Target("/backup"). - AddJob("job1", "/home/docs", "/backup/all"). - AddJob("job2", "/home/photos", "/backup/all/photos").Build()}, + config: testutil.NewConfigBuilder(). + AddMapping("m", "/home", "/backup"). + AddJobToMapping("job1", "docs", "all"). + AddJobToMapping("job2", "photos", "all/photos").Build()}, {name: "ValidConfig", wantJobs: 1, wantTarget: "/backup/docs", - config: testutil.NewConfigBuilder().Source("/home").Target("/backup"). - Variable("base", "/backup").AddJob("docs", "/home/docs", "${base}/docs").Build()}, + config: testutil.NewConfigBuilder(). + Variable("base", "/backup"). + AddMapping("m", "/home", "${base}"). + AddJobToMapping("docs", "docs", "docs").Build()}, } for _, test := range tests { @@ -344,7 +251,6 @@ func TestLoadResolvedConfig(t *testing.T) { } cfg, err := LoadResolvedConfig(path) - if test.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), test.wantErr) @@ -354,12 +260,13 @@ func TestLoadResolvedConfig(t *testing.T) { require.NoError(t, err) + allJobs := cfg.AllJobs() if test.wantJobs > 0 { - assert.Len(t, cfg.Jobs, test.wantJobs) + assert.Len(t, allJobs, test.wantJobs) } if test.wantTarget != "" { - assert.Equal(t, test.wantTarget, cfg.Jobs[0].Target) + assert.Equal(t, test.wantTarget, allJobs[0].Target) } }) } @@ -373,9 +280,16 @@ func TestConfigApply_VersionInfoSuccess(t *testing.T) { logger := log.New(&logBuf, "", 0) cfg := Config{ - Jobs: []Job{ - {Name: "job1", Source: "/src/", Target: "/dst/", Enabled: true}, - {Name: "job2", Source: "/src2/", Target: "/dst2/", Enabled: false}, + Mappings: []Mapping{ + { + Name: "test", + Source: "/src", + Target: "/dst", + Jobs: []Job{ + {Name: "job1", Source: "/src/a/", Target: "/dst/a", Enabled: true}, + {Name: "job2", Source: "/src/b/", Target: "/dst/b", Enabled: false}, + }, + }, }, } @@ -400,8 +314,15 @@ func TestConfigApply_VersionInfoError(t *testing.T) { logger := log.New(&logBuf, "", 0) cfg := Config{ - Jobs: []Job{ - {Name: "backup", Source: "/data/", Target: "/bak/", Enabled: true}, + Mappings: []Mapping{ + { + Name: "test", + Source: "/data", + Target: "/bak", + Jobs: []Job{ + {Name: "backup", Source: "/data/stuff/", Target: "/bak/stuff", Enabled: true}, + }, + }, }, } @@ -471,16 +392,20 @@ func TestResolveVariables_CircularReference(t *testing.T) { func TestResolveConfig_ResolvesAllFields(t *testing.T) { cfg := Config{ Variables: map[string]string{ - "user": "alice", - "user_cap": "Jaap", + "user": "alice", }, - Sources: []Path{{Path: "/home/${user}/"}}, - Targets: []Path{{Path: "/mnt/backup1/${user}"}}, - Jobs: []Job{ + Mappings: []Mapping{ { - Name: "${user}_docs", - Source: "/home/${user}/Documents/", - Target: "/mnt/backup1/${user}/documents", + Name: "${user}_home", + Source: "/home/${user}", + Target: "/mnt/backup1/${user}", + Jobs: []Job{ + { + Name: "${user}_docs", + Source: "Documents", + Target: "documents", + }, + }, }, }, } @@ -488,11 +413,14 @@ func TestResolveConfig_ResolvesAllFields(t *testing.T) { resolved, err := ResolveConfig(cfg) require.NoError(t, err) - assert.Equal(t, "/home/alice/", resolved.Sources[0].Path) - assert.Equal(t, "/mnt/backup1/alice", resolved.Targets[0].Path) - assert.Equal(t, "alice_docs", resolved.Jobs[0].Name) - assert.Equal(t, "/home/alice/Documents/", resolved.Jobs[0].Source) - assert.Equal(t, "/mnt/backup1/alice/documents", resolved.Jobs[0].Target) + assert.Equal(t, "alice_home", resolved.Mappings[0].Name) + assert.Equal(t, "/home/alice", resolved.Mappings[0].Source) + assert.Equal(t, "/mnt/backup1/alice", resolved.Mappings[0].Target) + + allJobs := resolved.AllJobs() + assert.Equal(t, "alice_docs", allJobs[0].Name) + assert.Equal(t, "/home/alice/Documents/", allJobs[0].Source) + assert.Equal(t, "/mnt/backup1/alice/documents", allJobs[0].Target) } func TestResolveConfig_VariableChaining(t *testing.T) { @@ -502,13 +430,18 @@ func TestResolveConfig_VariableChaining(t *testing.T) { "source_home": "/home/${user}", "target_base": "/mnt/backup1/${user}", }, - Sources: []Path{{Path: "/home/${user}/"}}, - Targets: []Path{{Path: "/mnt/backup1/${user}"}}, - Jobs: []Job{ + Mappings: []Mapping{ { - Name: "${user}_mail", - Source: "${source_home}/.thunderbird/", - Target: "${target_base}/mail", + Name: "${user}_home", + Source: "${source_home}", + Target: "${target_base}", + Jobs: []Job{ + { + Name: "${user}_mail", + Source: ".thunderbird", + Target: "mail", + }, + }, }, }, } @@ -516,41 +449,71 @@ func TestResolveConfig_VariableChaining(t *testing.T) { resolved, err := ResolveConfig(cfg) require.NoError(t, err) - assert.Equal(t, "/home/bob/", resolved.Sources[0].Path) - assert.Equal(t, "/mnt/backup1/bob", resolved.Targets[0].Path) - assert.Equal(t, "bob_mail", resolved.Jobs[0].Name) - assert.Equal(t, "/home/bob/.thunderbird/", resolved.Jobs[0].Source) - assert.Equal(t, "/mnt/backup1/bob/mail", resolved.Jobs[0].Target) + assert.Equal(t, "/home/bob", resolved.Mappings[0].Source) + assert.Equal(t, "/mnt/backup1/bob", resolved.Mappings[0].Target) + + allJobs := resolved.AllJobs() + assert.Equal(t, "bob_mail", allJobs[0].Name) + assert.Equal(t, "/home/bob/.thunderbird/", allJobs[0].Source) + assert.Equal(t, "/mnt/backup1/bob/mail", allJobs[0].Target) +} + +func TestResolveConfig_MappingSourceMacroError(t *testing.T) { + cfg := Config{ + Mappings: []Mapping{ + { + Name: "test", + Source: "/home/@{bogus:val}", + Target: "/dst", + Jobs: []Job{{Name: "job1", Source: "a", Target: "a"}}, + }, + }, + } + + _, err := ResolveConfig(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "resolving mapping source") } -func TestResolveConfig_SourceMacroError(t *testing.T) { +func TestResolveConfig_MappingTargetMacroError(t *testing.T) { cfg := Config{ - Sources: []Path{{Path: "/home/@{bogus:val}/"}}, - Jobs: []Job{{Name: "job1", Source: "/src/", Target: "/dst/"}}, + Mappings: []Mapping{ + { + Name: "test", + Source: "/src", + Target: "/backup/@{bogus:val}", + Jobs: []Job{{Name: "job1", Source: "a", Target: "a"}}, + }, + }, } _, err := ResolveConfig(cfg) require.Error(t, err) - assert.Contains(t, err.Error(), "resolving source path") + assert.Contains(t, err.Error(), "resolving mapping target") } -func TestResolveConfig_TargetMacroError(t *testing.T) { +func TestResolveConfig_MappingNameMacroError(t *testing.T) { cfg := Config{ - Targets: []Path{{Path: "/backup/@{bogus:val}/"}}, - Jobs: []Job{{Name: "job1", Source: "/src/", Target: "/dst/"}}, + Mappings: []Mapping{ + { + Name: "@{bogus:val}", + Source: "/src", + Target: "/dst", + Jobs: []Job{{Name: "job1", Source: "a", Target: "a"}}, + }, + }, } _, err := ResolveConfig(cfg) require.Error(t, err) - assert.Contains(t, err.Error(), "resolving target path") + assert.Contains(t, err.Error(), "resolving mapping name") } func TestLoadResolvedConfig_WithOverrides(t *testing.T) { config := testutil.NewConfigBuilder(). - Source("/home/${user}"). - Target("/mnt/backup1/${user}"). Variable("user", "default"). - AddJob("${user}_docs", "/home/${user}/docs/", "/mnt/backup1/${user}/docs/"). + AddMapping("home", "/home/${user}", "/mnt/backup1/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() path := testutil.WriteConfigFile(t, config) @@ -558,16 +521,17 @@ func TestLoadResolvedConfig_WithOverrides(t *testing.T) { cfg, err := LoadResolvedConfig(path, map[string]string{"user": "alice"}) require.NoError(t, err) - assert.Equal(t, "alice_docs", cfg.Jobs[0].Name) - assert.Equal(t, "/home/alice/docs/", cfg.Jobs[0].Source) - assert.Equal(t, "/mnt/backup1/alice/docs/", cfg.Jobs[0].Target) + + allJobs := cfg.AllJobs() + assert.Equal(t, "alice_docs", allJobs[0].Name) + assert.Equal(t, "/home/alice/docs/", allJobs[0].Source) + assert.Equal(t, "/mnt/backup1/alice/docs", allJobs[0].Target) } func TestLoadResolvedConfig_OverridesNewVariable(t *testing.T) { config := testutil.NewConfigBuilder(). - Source("/home/${user}"). - Target("/mnt/backup1/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/mnt/backup1/${user}/docs/"). + AddMapping("home", "/home/${user}", "/mnt/backup1/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() path := testutil.WriteConfigFile(t, config) @@ -575,9 +539,11 @@ func TestLoadResolvedConfig_OverridesNewVariable(t *testing.T) { cfg, err := LoadResolvedConfig(path, map[string]string{"user": "bob"}) require.NoError(t, err) - assert.Equal(t, "bob_docs", cfg.Jobs[0].Name) - assert.Equal(t, "/home/bob/docs/", cfg.Jobs[0].Source) - assert.Equal(t, "/mnt/backup1/bob/docs/", cfg.Jobs[0].Target) + + allJobs := cfg.AllJobs() + assert.Equal(t, "bob_docs", allJobs[0].Name) + assert.Equal(t, "/home/bob/docs/", allJobs[0].Source) + assert.Equal(t, "/mnt/backup1/bob/docs", allJobs[0].Target) } // --- ValidateTemplateVars --- @@ -648,9 +614,9 @@ func TestValidateTemplateVars(t *testing.T) { func TestLoadResolvedConfig_TemplateVarsMissing(t *testing.T) { config := testutil.NewConfigBuilder(). TemplateVar("user").TemplateVar("user_cap"). - Source("/home/${user}").Target("/backup/${user}"). Variable("user", "alice"). - AddJob("docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("docs", "docs", "docs"). Build() path := testutil.WriteConfigFile(t, config) @@ -665,8 +631,8 @@ func TestLoadResolvedConfig_TemplateVarsMissing(t *testing.T) { func TestLoadResolvedConfig_TemplateVarsProvidedViaOverride(t *testing.T) { config := testutil.NewConfigBuilder(). TemplateVar("user").TemplateVar("user_cap"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() path := testutil.WriteConfigFile(t, config) @@ -674,15 +640,15 @@ func TestLoadResolvedConfig_TemplateVarsProvidedViaOverride(t *testing.T) { cfg, err := LoadResolvedConfig(path, map[string]string{"user": "alice", "user_cap": "Alice"}) require.NoError(t, err) - assert.Equal(t, "alice_docs", cfg.Jobs[0].Name) + assert.Equal(t, "alice_docs", cfg.AllJobs()[0].Name) } func TestLoadResolvedConfig_TemplateVarsAllInYAML(t *testing.T) { config := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). Variable("user", "bob"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() path := testutil.WriteConfigFile(t, config) @@ -690,7 +656,7 @@ func TestLoadResolvedConfig_TemplateVarsAllInYAML(t *testing.T) { cfg, err := LoadResolvedConfig(path) require.NoError(t, err) - assert.Equal(t, "bob_docs", cfg.Jobs[0].Name) + assert.Equal(t, "bob_docs", cfg.AllJobs()[0].Name) } // --- LoadResolvedConfig with include --- @@ -700,8 +666,8 @@ func TestLoadResolvedConfig_BasicInclude(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -713,10 +679,12 @@ func TestLoadResolvedConfig_BasicInclude(t *testing.T) { cfg, err := LoadResolvedConfig(mainPath) require.NoError(t, err) - require.Len(t, cfg.Jobs, 1) - assert.Equal(t, "alice_docs", cfg.Jobs[0].Name) - assert.Equal(t, "/home/alice/docs/", cfg.Jobs[0].Source) - assert.Equal(t, "/backup/alice/docs/", cfg.Jobs[0].Target) + + allJobs := cfg.AllJobs() + require.Len(t, allJobs, 1) + assert.Equal(t, "alice_docs", allJobs[0].Name) + assert.Equal(t, "/home/alice/docs/", allJobs[0].Source) + assert.Equal(t, "/backup/alice/docs", allJobs[0].Target) } func TestLoadResolvedConfig_MultipleIncludes(t *testing.T) { @@ -724,8 +692,8 @@ func TestLoadResolvedConfig_MultipleIncludes(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -738,9 +706,11 @@ func TestLoadResolvedConfig_MultipleIncludes(t *testing.T) { cfg, err := LoadResolvedConfig(mainPath) require.NoError(t, err) - require.Len(t, cfg.Jobs, 2) - assert.Equal(t, "alice_docs", cfg.Jobs[0].Name) - assert.Equal(t, "bob_docs", cfg.Jobs[1].Name) + + allJobs := cfg.AllJobs() + require.Len(t, allJobs, 2) + assert.Equal(t, "alice_docs", allJobs[0].Name) + assert.Equal(t, "bob_docs", allJobs[1].Name) } func TestLoadResolvedConfig_IncludeMissingTemplateVars(t *testing.T) { @@ -748,8 +718,8 @@ func TestLoadResolvedConfig_IncludeMissingTemplateVars(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user").TemplateVar("user_cap"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -788,8 +758,8 @@ func TestLoadResolvedConfig_NestedIncludesRejected(t *testing.T) { // inner template itself has an include (nested) inner := testutil.NewConfigBuilder(). AddInclude("other.yaml", map[string]string{"x": "y"}). - Source("/src").Target("/dst"). - AddJob("inner", "/src/a/", "/dst/a/"). + AddMapping("m", "/src", "/dst"). + AddJobToMapping("inner", "a", "a"). Build() testutil.WriteConfigFileInDir(t, dir, "inner.yaml", inner) @@ -827,9 +797,9 @@ func TestLoadResolvedConfig_IncludeWithVariableChaining(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). Variable("home", "/home/${user}"). - AddJob("${user}_mail", "${home}/.thunderbird/", "/backup/${user}/mail"). + AddMapping("home", "${home}", "/backup/${user}"). + AddJobToMapping("${user}_mail", ".thunderbird", "mail"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -841,10 +811,12 @@ func TestLoadResolvedConfig_IncludeWithVariableChaining(t *testing.T) { cfg, err := LoadResolvedConfig(mainPath) require.NoError(t, err) - require.Len(t, cfg.Jobs, 1) - assert.Equal(t, "alice_mail", cfg.Jobs[0].Name) - assert.Equal(t, "/home/alice/.thunderbird/", cfg.Jobs[0].Source) - assert.Equal(t, "/backup/alice/mail", cfg.Jobs[0].Target) + + allJobs := cfg.AllJobs() + require.Len(t, allJobs, 1) + assert.Equal(t, "alice_mail", allJobs[0].Name) + assert.Equal(t, "/home/alice/.thunderbird/", allJobs[0].Source) + assert.Equal(t, "/backup/alice/mail", allJobs[0].Target) } func TestLoadResolvedConfig_IncludeWithOverridesOnMainConfig(t *testing.T) { @@ -852,9 +824,9 @@ func TestLoadResolvedConfig_IncludeWithOverridesOnMainConfig(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/${target_root}/${user}"). Variable("target_root", "backup"). - AddJob("${user}_docs", "/home/${user}/docs/", "/${target_root}/${user}/docs/"). + AddMapping("home", "/home/${user}", "/${target_root}/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -866,34 +838,35 @@ func TestLoadResolvedConfig_IncludeWithOverridesOnMainConfig(t *testing.T) { cfg, err := LoadResolvedConfig(mainPath) require.NoError(t, err) - assert.Equal(t, "/backup/alice/docs/", cfg.Jobs[0].Target) + assert.Equal(t, "/backup/alice/docs", cfg.AllJobs()[0].Target) } -func TestLoadResolvedConfig_IncludeMergesSources(t *testing.T) { +func TestLoadResolvedConfig_IncludeMergesMappings(t *testing.T) { dir := t.TempDir() template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/${user}/docs/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) main := testutil.NewConfigBuilder(). - Source("/shared").Target("/shared-backup"). + AddMapping("shared", "/shared", "/shared-backup"). + AddJobToMapping("shared_data", "data", "data"). AddInclude("template.yaml", map[string]string{"user": "alice"}). - AddJob("shared_data", "/shared/data/", "/shared-backup/data/"). Build() mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main) cfg, err := LoadResolvedConfig(mainPath) require.NoError(t, err) - require.Len(t, cfg.Jobs, 2) - // Main config's own sources + included template's sources - assert.Len(t, cfg.Sources, 2) - assert.Len(t, cfg.Targets, 2) + allJobs := cfg.AllJobs() + require.Len(t, allJobs, 2) + + // Main config's own mappings + included template's mappings + assert.Len(t, cfg.Mappings, 2) } func TestLoadResolvedConfig_IncludeMacroError(t *testing.T) { @@ -901,8 +874,124 @@ func TestLoadResolvedConfig_IncludeMacroError(t *testing.T) { template := testutil.NewConfigBuilder(). TemplateVar("user"). - Source("/home/${user}").Target("/backup/${user}"). - AddJob("${user}_docs", "/home/@{bogus:val}/", "/backup/${user}/docs/"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "@{bogus:val}", "docs"). + Build() + testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) + + main := testutil.NewConfigBuilder(). + AddInclude("template.yaml", map[string]string{"user": "alice"}). + Build() + mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main) + + _, err := LoadResolvedConfig(mainPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "expanding includes") +} + +func TestLoadResolvedConfig_IncludeTemplateMappingSourceMacroError(t *testing.T) { + dir := t.TempDir() + + template := testutil.NewConfigBuilder(). + TemplateVar("user"). + AddMapping("home", "@{bogus:val}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). + Build() + testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) + + main := testutil.NewConfigBuilder(). + AddInclude("template.yaml", map[string]string{"user": "alice"}). + Build() + mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main) + + _, err := LoadResolvedConfig(mainPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "expanding includes") +} + +func TestLoadResolvedConfig_IncludeTemplateMappingTargetMacroError(t *testing.T) { + dir := t.TempDir() + + template := testutil.NewConfigBuilder(). + TemplateVar("user"). + AddMapping("home", "/home/${user}", "@{bogus:val}"). + AddJobToMapping("${user}_docs", "docs", "docs"). + Build() + testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) + + main := testutil.NewConfigBuilder(). + AddInclude("template.yaml", map[string]string{"user": "alice"}). + Build() + mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main) + + _, err := LoadResolvedConfig(mainPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "expanding includes") +} + +func TestLoadResolvedConfig_IncludeTemplateMappingNameMacroError(t *testing.T) { + dir := t.TempDir() + + template := testutil.NewConfigBuilder(). + TemplateVar("user"). + AddMapping("@{bogus:val}", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "docs"). + Build() + testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) + + main := testutil.NewConfigBuilder(). + AddInclude("template.yaml", map[string]string{"user": "alice"}). + Build() + mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main) + + _, err := LoadResolvedConfig(mainPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "expanding includes") +} + +func TestConfigBuilder_AddMappingWithExclusions(t *testing.T) { + yaml := testutil.NewConfigBuilder(). + AddMappingWithExclusions("m", "/src", "/tgt", "cache", "tmp"). + AddJobToMapping("j", "docs", "docs"). + Build() + + assert.Contains(t, yaml, "exclusions:") + assert.Contains(t, yaml, "cache") + assert.Contains(t, yaml, "tmp") +} + +func TestLoadResolvedConfig_IncludeTemplateJobNameMacroError(t *testing.T) { + dir := t.TempDir() + + template := testutil.NewConfigBuilder(). + TemplateVar("user"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("@{bogus:val}", "docs", "docs"). + Build() + testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) + + main := testutil.NewConfigBuilder(). + AddInclude("template.yaml", map[string]string{"user": "alice"}). + Build() + mainPath := testutil.WriteConfigFileInDir(t, dir, "main.yaml", main) + + _, err := LoadResolvedConfig(mainPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "expanding includes") +} + +func TestLoadResolvedConfig_IncludeTemplateJobTargetMacroError(t *testing.T) { + dir := t.TempDir() + + template := testutil.NewConfigBuilder(). + TemplateVar("user"). + AddMapping("home", "/home/${user}", "/backup/${user}"). + AddJobToMapping("${user}_docs", "docs", "@{bogus:val}"). Build() testutil.WriteConfigFileInDir(t, dir, "template.yaml", template) @@ -916,3 +1005,32 @@ func TestLoadResolvedConfig_IncludeMacroError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "expanding includes") } + +func TestResolveConfig_JobNameMacroError(t *testing.T) { + cfg := Config{ + Mappings: []Mapping{{Name: "m", Source: "/src", Target: "/tgt", + Jobs: []Job{{Name: "@{bogus:val}", Source: "", Target: ""}}}}, + } + + _, err := ResolveConfig(cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "resolving job") +} + +// --- AllJobs helper --- + +func TestAllJobs(t *testing.T) { + cfg := Config{ + Mappings: []Mapping{ + {Name: "m1", Source: "/s1", Target: "/t1", Jobs: []Job{{Name: "j1"}, {Name: "j2"}}}, + {Name: "m2", Source: "/s2", Target: "/t2", Jobs: []Job{{Name: "j3"}}}, + }, + } + + allJobs := cfg.AllJobs() + require.Len(t, allJobs, 3) + assert.Equal(t, "j1", allJobs[0].Name) + assert.Equal(t, "j2", allJobs[1].Name) + assert.Equal(t, "j3", allJobs[2].Name) +} diff --git a/backup/internal/test/macros_test.go b/backup/internal/test/macros_test.go index 5bcdda6..4119cc2 100644 --- a/backup/internal/test/macros_test.go +++ b/backup/internal/test/macros_test.go @@ -140,13 +140,20 @@ func TestResolveMacros_MissingColon(t *testing.T) { func TestResolveConfig_WithMacros(t *testing.T) { cfg := Config{ Variables: map[string]string{ - "user": "jaap", + "user": "alice", }, - Jobs: []Job{ + Mappings: []Mapping{ { - Name: "${user}_mail", - Source: "/home/${user}/", - Target: "/backup/@{capitalize:${user}}/mail", + Name: "home", + Source: "/home/${user}", + Target: "/backup/@{capitalize:${user}}", + Jobs: []Job{ + { + Name: "${user}_mail", + Source: "", + Target: "mail", + }, + }, }, }, } @@ -154,25 +161,32 @@ func TestResolveConfig_WithMacros(t *testing.T) { resolved, err := ResolveConfig(cfg) require.NoError(t, err) - assert.Equal(t, "jaap_mail", resolved.Jobs[0].Name) - assert.Equal(t, "/home/jaap/", resolved.Jobs[0].Source) - assert.Equal(t, "/backup/Jaap/mail", resolved.Jobs[0].Target) + jobs := resolved.AllJobs() + assert.Equal(t, "alice_mail", jobs[0].Name) + assert.Equal(t, "/home/alice/", jobs[0].Source) + assert.Equal(t, "/backup/Alice/mail", jobs[0].Target) } func TestResolveConfig_MacroError(t *testing.T) { cfg := Config{ - Jobs: []Job{ + Mappings: []Mapping{ { - Name: "job1", - Source: "/home/@{bogus:val}/", - Target: "/backup/", + Name: "m", + Source: "/home/@{bogus:val}", + Target: "/backup", + Jobs: []Job{ + { + Name: "job1", + Source: "", + Target: "", + }, + }, }, }, } _, err := ResolveConfig(cfg) require.Error(t, err) - assert.ErrorIs(t, err, ErrUnresolvedMacro) } func TestValidateNoUnresolvedMacros(t *testing.T) { @@ -184,28 +198,40 @@ func TestValidateNoUnresolvedMacros(t *testing.T) { { name: "AllResolved", cfg: Config{ - Jobs: []Job{{Name: "job1", Source: "/home/user/", Target: "/backup/user/"}}, + Mappings: []Mapping{{Name: "m", Source: "/home/user", Target: "/backup/user", + Jobs: []Job{{Name: "job1", Source: "", Target: ""}}}}, }, wantErr: false, }, { name: "UnresolvedInSource", cfg: Config{ - Jobs: []Job{{Name: "job1", Source: "/home/@{upper:user}/", Target: "/backup/user/"}}, + Mappings: []Mapping{{Name: "m", Source: "/home/user", Target: "/backup/user", + Jobs: []Job{{Name: "job1", Source: "@{upper:user}", Target: ""}}}}, }, wantErr: true, }, { name: "UnresolvedInTarget", cfg: Config{ - Jobs: []Job{{Name: "job1", Source: "/home/user/", Target: "/backup/@{lower:user}/"}}, + Mappings: []Mapping{{Name: "m", Source: "/home/user", Target: "/backup/user", + Jobs: []Job{{Name: "job1", Source: "", Target: "@{lower:user}"}}}}, }, wantErr: true, }, { name: "UnresolvedInName", cfg: Config{ - Jobs: []Job{{Name: "@{upper:job}", Source: "/home/user/", Target: "/backup/user/"}}, + Mappings: []Mapping{{Name: "m", Source: "/home/user", Target: "/backup/user", + Jobs: []Job{{Name: "@{upper:job}", Source: "", Target: ""}}}}, + }, + wantErr: true, + }, + { + name: "UnresolvedInMappingSource", + cfg: Config{ + Mappings: []Mapping{{Name: "m", Source: "/home/@{upper:user}", Target: "/backup/user", + Jobs: []Job{{Name: "job1", Source: "", Target: ""}}}}, }, wantErr: true, }, @@ -226,22 +252,22 @@ func TestValidateNoUnresolvedMacros(t *testing.T) { func TestLoadResolvedConfigWithMacros(t *testing.T) { yamlContent := testutil.NewConfigBuilder(). - Source("/home/jaap").Target("/backup"). - Variable("user", "jaap"). - AddJob("jaap_docs", "/home/jaap/docs", "/backup/@{capitalize:${user}}/docs"). + Variable("user", "alice"). + AddMapping("m", "/home/alice", "/backup/@{capitalize:${user}}"). + AddJobToMapping("alice_docs", "docs", "docs"). Build() path := testutil.WriteConfigFile(t, yamlContent) cfg, err := LoadResolvedConfig(path) require.NoError(t, err) - assert.Equal(t, "/backup/Jaap/docs", cfg.Jobs[0].Target) + assert.Equal(t, "/backup/Alice/docs", cfg.AllJobs()[0].Target) } func TestLoadResolvedConfigWithMacros_Error(t *testing.T) { yamlContent := testutil.NewConfigBuilder(). - Source("/home/jaap").Target("/backup"). - AddJob("job1", "/home/jaap/docs", "/backup/@{nonexistent:val}/docs"). + AddMapping("m", "/home/alice", "/backup/@{nonexistent:val}"). + AddJobToMapping("job1", "docs", "docs"). Build() path := testutil.WriteConfigFile(t, yamlContent) diff --git a/backup/internal/testutil/builder.go b/backup/internal/testutil/builder.go index 655f51d..b596416 100644 --- a/backup/internal/testutil/builder.go +++ b/backup/internal/testutil/builder.go @@ -17,6 +17,14 @@ type jobDef struct { exclusions []string } +type mappingDef struct { + name string + source string + target string + exclusions []string + jobs []jobDef +} + type includeDef struct { uses string with map[string]string @@ -24,10 +32,8 @@ type includeDef struct { // ConfigBuilder constructs YAML config strings declaratively. type ConfigBuilder struct { - sources []string - targets []string + mappings []mappingDef variables map[string]string - jobs []jobDef templateVars []string includes []includeDef } @@ -39,35 +45,40 @@ func NewConfigBuilder() *ConfigBuilder { } } -// Source adds a source path. -func (b *ConfigBuilder) Source(path string) *ConfigBuilder { - b.sources = append(b.sources, path) +// AddMapping adds a mapping with the given name, source, and target paths. +func (b *ConfigBuilder) AddMapping(name, source, target string) *ConfigBuilder { + b.mappings = append(b.mappings, mappingDef{name: name, source: source, target: target}) return b } -// Target adds a target path. -func (b *ConfigBuilder) Target(path string) *ConfigBuilder { - b.targets = append(b.targets, path) +// AddMappingWithExclusions adds a mapping with exclusions. +func (b *ConfigBuilder) AddMappingWithExclusions(name, source, target string, exclusions ...string) *ConfigBuilder { + b.mappings = append(b.mappings, mappingDef{name: name, source: source, target: target, exclusions: exclusions}) return b } -// Variable adds a variable for substitution. -func (b *ConfigBuilder) Variable(key, value string) *ConfigBuilder { - b.variables[key] = value - - return b -} +// AddJobToMapping adds a job to the last mapping. +func (b *ConfigBuilder) AddJobToMapping(name, source, target string, opts ...JobOpt) *ConfigBuilder { + if len(b.mappings) == 0 { + panic("AddJobToMapping called with no mappings") + } -// AddJob adds a job with the given name, source, and target paths. -func (b *ConfigBuilder) AddJob(name, source, target string, opts ...JobOpt) *ConfigBuilder { - j := jobDef{name: name, source: source, target: target} + job := jobDef{name: name, source: source, target: target} for _, opt := range opts { - opt(&j) + opt(&job) } - b.jobs = append(b.jobs, j) + lastMapping := &b.mappings[len(b.mappings)-1] + lastMapping.jobs = append(lastMapping.jobs, job) + + return b +} + +// Variable adds a variable for substitution. +func (b *ConfigBuilder) Variable(key, value string) *ConfigBuilder { + b.variables[key] = value return b } @@ -93,18 +104,6 @@ func (b *ConfigBuilder) Build() string { b.writeTemplate(&result) b.writeIncludes(&result) - result.WriteString("sources:\n") - - for _, s := range b.sources { - fmt.Fprintf(&result, " - path: %q\n", s) - } - - result.WriteString("targets:\n") - - for _, t := range b.targets { - fmt.Fprintf(&result, " - path: %q\n", t) - } - if len(b.variables) > 0 { result.WriteString("variables:\n") @@ -113,10 +112,10 @@ func (b *ConfigBuilder) Build() string { } } - result.WriteString("jobs:\n") + result.WriteString("mappings:\n") - for _, job := range b.jobs { - writeJob(&result, job) + for _, m := range b.mappings { + writeMapping(&result, m) } return result.String() @@ -154,24 +153,46 @@ func (b *ConfigBuilder) writeIncludes(writer *strings.Builder) { } } +func writeMapping(writer *strings.Builder, mapping mappingDef) { + fmt.Fprintf(writer, " - name: %q\n", mapping.name) + fmt.Fprintf(writer, " source: %q\n", mapping.source) + fmt.Fprintf(writer, " target: %q\n", mapping.target) + + if len(mapping.exclusions) > 0 { + writer.WriteString(" exclusions:\n") + + for _, e := range mapping.exclusions { + fmt.Fprintf(writer, " - %q\n", e) + } + } + + if len(mapping.jobs) > 0 { + writer.WriteString(" jobs:\n") + + for _, job := range mapping.jobs { + writeJob(writer, job) + } + } +} + func writeJob(writer *strings.Builder, job jobDef) { - fmt.Fprintf(writer, " - name: %q\n", job.name) - fmt.Fprintf(writer, " source: %q\n", job.source) - fmt.Fprintf(writer, " target: %q\n", job.target) + fmt.Fprintf(writer, " - name: %q\n", job.name) + fmt.Fprintf(writer, " source: %q\n", job.source) + fmt.Fprintf(writer, " target: %q\n", job.target) if job.delete != nil { - fmt.Fprintf(writer, " delete: %v\n", *job.delete) + fmt.Fprintf(writer, " delete: %v\n", *job.delete) } if job.enabled != nil { - fmt.Fprintf(writer, " enabled: %v\n", *job.enabled) + fmt.Fprintf(writer, " enabled: %v\n", *job.enabled) } if len(job.exclusions) > 0 { - writer.WriteString(" exclusions:\n") + writer.WriteString(" exclusions:\n") for _, e := range job.exclusions { - fmt.Fprintf(writer, " - %q\n", e) + fmt.Fprintf(writer, " - %q\n", e) } } } diff --git a/docs/configuration.md b/docs/configuration.md index ffaa9f4..bf8d683 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,38 +1,48 @@ # Configuration File Format (`sync.yaml`) -The backup tool is configured using a YAML file, typically named `sync.yaml`. This file defines the sources, targets, variables, and backup jobs. Below is a description of the structure, settings, and an example configuration. +The backup tool is configured using a YAML file, typically named `sync.yaml`. This file defines mappings (source-to-target directory pairs), variables, and backup jobs. Below is a description of the structure, settings, and an example configuration. ## Top-Level Structure ```yaml template: # (Optional) Declares required variables for this template include: # (Optional) List of template configs to instantiate -sources: # List of source paths to back up -targets: # List of target paths for backups -variables: # Key-value pairs for variable substitution -jobs: # List of backup jobs +variables: # (Optional) Key-value pairs for variable substitution +mappings: # List of source-to-target directory mappings, each with its own jobs ``` -## Sources and Targets +## Mappings -Each source and target is defined as a `Path` object: +Each mapping defines a source-to-target directory pair and owns a list of backup jobs. Job paths within a mapping are relative to the mapping's source and target. ```yaml -- path: "/path/to/source/or/target/" - exclusions: - - "/path/to/exclude/" +mappings: + - name: "home" + source: "/home/user" + target: "/mnt/backup1/user" + exclusions: # (Optional) Source-level exclusions + - "/Downloads/" + jobs: + - name: "documents" + source: "Documents" + target: "documents" ``` -- `path`: The directory path to include as a source or target. -- `exclusions` (optional): List of subpaths to exclude from backup. +- `name`: A label for identifying the mapping. +- `source`: Absolute path to the source directory for this mapping. +- `target`: Absolute path to the target directory for this mapping. +- `exclusions` (optional): List of subpaths to exclude at the source level. +- `jobs`: List of backup jobs (see below). + +During resolution, each job's relative source and target paths are joined with the mapping's base paths to produce absolute paths for rsync. For example, a job with `source: "Documents"` under a mapping with `source: "/home/user"` resolves to `/home/user/Documents/`. ## Variables -Variables are key-value pairs that can be referenced in job definitions using `${varname}` syntax. +Variables are key-value pairs that can be referenced in mapping and job fields using `${varname}` syntax. ```yaml variables: - target_base: "/mnt/backup1" + user: alice ``` ## Macros @@ -43,9 +53,14 @@ Macros apply string transformation functions to values using `@{function:argumen variables: user: alice -jobs: - - name: "${user}_docs" - target: "/backup/@{capitalize:${user}}/docs" # resolves to /backup/Alice/docs +mappings: + - name: "home" + source: "/home/${user}" + target: "/backup/@{capitalize:${user}}" + jobs: + - name: "${user}_docs" + source: "Documents" + target: "docs" ``` ## Template (Optional) @@ -77,23 +92,23 @@ include: ## Jobs -Each job defines a backup operation: +Each job defines a backup operation within a mapping. Job paths are relative to the mapping's source and target: ```yaml -- name: "job_name" # Unique name for the job - source: "/path/to/source/" # Source directory - target: "/path/to/target/" # Target directory (can use variables) - delete: true # (Optional) Delete files in target not in source (default: true) - enabled: true # (Optional) Enable/disable the job (default: true) - exclusions: # (Optional) List of subpaths to exclude +- name: "job_name" # Unique name for the job + source: "relative/src" # Relative to mapping source (use "" for root) + target: "relative/tgt" # Relative to mapping target (use "" for root) + delete: true # (Optional) Delete files in target not in source (default: true) + enabled: true # (Optional) Enable/disable the job (default: true) + exclusions: # (Optional) List of subpaths to exclude - "/subpath/to/exclude/" ``` ### Job Fields - `name`: Unique identifier for the job. -- `source`: Path to the source directory. -- `target`: Path to the target directory. Variables can be used (e.g., `${target_base}/user/home`). +- `source`: Path to the source directory, relative to the mapping's source. Use `""` to sync the entire mapping source. +- `target`: Path to the target directory, relative to the mapping's target. Use `""` to sync to the mapping target root. - `delete`: (Optional) If `true`, files deleted from the source are also deleted from the target. Defaults to `true` if omitted. - `enabled`: (Optional) If `false`, the job is skipped. Defaults to `true` if omitted. - `exclusions`: (Optional) List of subpaths to exclude from this job. @@ -101,33 +116,30 @@ Each job defines a backup operation: ## Example Configuration ```yaml -sources: - - path: "/home/user/" - exclusions: - - "/Downloads/" - - "/.cache/" -targets: - - path: "/mnt/backup1/" -variables: - target_base: "/mnt/backup1" -jobs: - - name: "user_home" - source: "/home/user/" - target: "${target_base}/user/home" +mappings: + - name: "home" + source: "/home/user" + target: "/mnt/backup1/user" exclusions: - "/Downloads/" - "/.cache/" - delete: true - enabled: true - - name: "user_documents" - source: "/home/user/Documents/" - target: "${target_base}/user/documents" + jobs: + - name: "user_home" + source: "" + target: "home" + exclusions: + - "/Downloads/" + - "/.cache/" + - name: "user_documents" + source: "Documents" + target: "documents" ``` ## Notes -- All paths should be absolute. -- Exclusions are relative to the specified source or target path. +- Mapping-level source and target paths should be absolute. +- Job-level source and target paths are relative to the mapping and are joined during resolution. +- Exclusions are relative to the specified source path. - Jobs with `enabled: false` are ignored. - If `delete` is omitted, it defaults to `true` (target files not present in source will be deleted from the destination). - For templating features (`template:`, `include:`, `--set` flags), see [templating.md](templating.md). diff --git a/docs/templating.md b/docs/templating.md index 7dd020a..02a3697 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -16,8 +16,8 @@ single template plus a lightweight orchestration file. Variables are referenced with `${variable_name}` syntax and can appear in: +- **Mapping fields**: `name`, `source`, `target` - **Job fields**: `name`, `source`, `target` -- **Source and target paths** (top-level `sources` / `targets` sections) - **Other variables** (variable-to-variable references) ### Variable Resolution Order @@ -25,28 +25,32 @@ Variables are referenced with `${variable_name}` syntax and can appear in: 1. Variables defined in the YAML `variables` section are loaded 2. CLI `--set` overrides are merged in (overwriting any matching keys) 3. Variable self-references are resolved (multi-pass, up to 10 iterations) -4. All config fields are substituted using the fully resolved variables +4. All mapping and job fields are substituted using the fully resolved variables +5. Job relative paths are joined with their mapping's base paths to produce absolute paths This means variables can reference other variables: ```yaml variables: - source_home: "/home/${user}" - target_base: "/mnt/backup1/${user}" - -jobs: - - name: "${user}_documents" - source: "${source_home}/Documents/" - target: "${target_base}/documents" + user: alice + +mappings: + - name: "home" + source: "/home/${user}" + target: "/mnt/backup1/${user}" + jobs: + - name: "${user}_documents" + source: "Documents" + target: "documents" ``` When invoked with `--set user=alice`, the resolution chain is: 1. `user` = `alice` (from CLI) -2. `source_home` = `/home/${user}` → `/home/alice` -3. `target_base` = `/mnt/backup1/${user}` → `/mnt/backup1/alice` +2. Mapping source = `/home/${user}` → `/home/alice` +3. Mapping target = `/mnt/backup1/${user}` → `/mnt/backup1/alice` 4. Job name = `${user}_documents` → `alice_documents` -5. Job source = `${source_home}/Documents/` → `/home/alice/Documents/` +5. Job source = `Documents` joined with `/home/alice` → `/home/alice/Documents/` ## Declaring Required Variables (`template:`) @@ -94,7 +98,7 @@ include: 2. The `with` values are merged into the template's `variables` map 3. Template variable validation runs (all `template.variables` must be set) 4. The template is resolved (variable substitution) -5. The resolved sources, targets, and jobs are appended to the main config +5. The resolved mappings (with their jobs) are appended to the main config 6. After all includes are expanded, the main config goes through standard validation (job names, paths, overlaps)