From d7dced68fd626258a02d60d9f9a117da1fb9061c Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:03:10 +0000 Subject: [PATCH 1/4] refactor: Simplify Configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Mapping struct as an intermediate layer between Config and Job. Each mapping defines a source/target base path pair and owns a list of jobs with relative paths. This eliminates the verbose ${source_home} / ${target_base} variable patterns repeated in every job. Clean migration — old flat format removed entirely. --- AGENTS.md | 2 +- backup/cmd/test/commands_test.go | 88 ++-- backup/cmd/test/integration_test.go | 98 ++-- backup/internal/check.go | 20 +- backup/internal/config.go | 205 ++++++--- backup/internal/macros.go | 25 +- backup/internal/test/check_test.go | 99 ++--- backup/internal/test/config_test.go | 662 +++++++++++++++++----------- backup/internal/test/macros_test.go | 72 ++- backup/internal/testutil/builder.go | 105 +++-- docs/configuration.md | 104 +++-- docs/templating.md | 30 +- 12 files changed, 897 insertions(+), 613 deletions(-) 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..2b11fe9 100644 --- a/backup/internal/check.go +++ b/backup/internal/check.go @@ -35,9 +35,11 @@ func (c *CoverageChecker) ListUncoveredPaths(cfg Config) []string { var result []string seen := make(map[string]bool) + sources := cfg.AllSources() + allJobs := cfg.AllJobs() - for _, source := range cfg.Sources { - c.checkPath(source.Path, cfg, &result, seen) + for _, source := range sources { + c.checkPath(source.Path, sources, allJobs, &result, seen) } slices.Sort(result) // Ensure consistent ordering for test comparison @@ -75,7 +77,7 @@ func (c *CoverageChecker) isCovered(path string, jobs []Job) bool { }) } -func (c *CoverageChecker) checkPath(path string, cfg Config, result *[]string, seen map[string]bool) { +func (c *CoverageChecker) checkPath(path string, sources []Path, jobs []Job, result *[]string, seen map[string]bool) { if seen[path] { c.Logger.Printf("SKIP: Path '%s' already seen", path) @@ -85,21 +87,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, sources) { 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, jobs) { 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, sources, jobs) { c.Logger.Printf("SKIP: Path '%s' is effectively covered", path) return @@ -112,7 +114,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, sources []Path, jobs []Job) 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 +131,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, sources) || c.isCovered(child, jobs) || + c.isEffectivelyCovered(child, sources, jobs) + 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..5fbc249 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,58 @@ 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 +} + +// AllSources derives a []Path from all mapping sources and their exclusions. +func (cfg Config) AllSources() []Path { + sources := make([]Path, 0, len(cfg.Mappings)) + for _, m := range cfg.Mappings { + sources = append(sources, Path{Path: m.Source, Exclusions: m.Exclusions}) + } + + return sources +} + +// AllTargets derives a deduplicated []Path from all mapping targets. +func (cfg Config) AllTargets() []Path { + seen := make(map[string]bool) + + var targets []Path + + for _, m := range cfg.Mappings { + if !seen[m.Target] { + seen[m.Target] = true + targets = append(targets, Path{Path: m.Target}) + } + } + + return targets } func (cfg Config) String() string { @@ -65,8 +107,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 +118,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,48 +185,103 @@ func ResolveVariables(variables map[string]string) map[string]string { return resolved } -func ResolveConfig(cfg Config) (Config, error) { - resolvedCfg := cfg +// resolveTemplateVariables resolves variables and macros in a template config +// without joining job paths with mapping base paths. Used by expandIncludes so +// that path joining happens only once in the outer ResolveConfig call. +func resolveTemplateVariables(cfg Config) (Config, error) { + resolved := cfg + resolved.Variables = ResolveVariables(cfg.Variables) - resolvedCfg.Variables = ResolveVariables(cfg.Variables) + for mIdx := range resolved.Mappings { + mapping := &resolved.Mappings[mIdx] - for idx, source := range resolvedCfg.Sources { - resolved, err := resolveField(source.Path, resolvedCfg.Variables) + var err error + + mapping.Name, err = resolveField(mapping.Name, resolved.Variables) if err != nil { - return Config{}, fmt.Errorf("resolving source path %q: %w", source.Path, err) + return Config{}, fmt.Errorf("resolving mapping name %q: %w", mapping.Name, err) } - resolvedCfg.Sources[idx].Path = resolved - } + mapping.Source, err = resolveField(mapping.Source, resolved.Variables) + if err != nil { + return Config{}, fmt.Errorf("resolving mapping source %q: %w", mapping.Source, err) + } - for idx, target := range resolvedCfg.Targets { - resolved, err := resolveField(target.Path, resolvedCfg.Variables) + mapping.Target, err = resolveField(mapping.Target, resolved.Variables) if err != nil { - return Config{}, fmt.Errorf("resolving target path %q: %w", target.Path, err) + return Config{}, fmt.Errorf("resolving mapping target %q: %w", mapping.Target, err) } - resolvedCfg.Targets[idx].Path = resolved + for jIdx := range mapping.Jobs { + job := &mapping.Jobs[jIdx] + + job.Name, err = resolveField(job.Name, resolved.Variables) + if err != nil { + return Config{}, fmt.Errorf("resolving job name %q: %w", job.Name, err) + } + + job.Source, err = resolveField(job.Source, resolved.Variables) + if err != nil { + return Config{}, fmt.Errorf("resolving job source %q: %w", job.Source, err) + } + + job.Target, err = resolveField(job.Target, resolved.Variables) + if err != nil { + return Config{}, fmt.Errorf("resolving job target %q: %w", job.Target, err) + } + } } - for idx := range resolvedCfg.Jobs { - job := &resolvedCfg.Jobs[idx] + return resolved, nil +} - errs := make([]error, 0, 3) //nolint:mnd // 3 fields to resolve: Source, Target, Name +func ResolveConfig(cfg Config) (Config, error) { + resolvedCfg := cfg + + resolvedCfg.Variables = ResolveVariables(cfg.Variables) + + for mIdx := range resolvedCfg.Mappings { + mapping := &resolvedCfg.Mappings[mIdx] var err error - job.Source, err = resolveField(job.Source, resolvedCfg.Variables) - errs = append(errs, err) + mapping.Name, err = resolveField(mapping.Name, resolvedCfg.Variables) + if err != nil { + return Config{}, fmt.Errorf("resolving mapping name %q: %w", mapping.Name, err) + } - job.Target, err = resolveField(job.Target, resolvedCfg.Variables) - errs = append(errs, err) + mapping.Source, err = resolveField(mapping.Source, resolvedCfg.Variables) + if err != nil { + return Config{}, fmt.Errorf("resolving mapping source %q: %w", mapping.Source, err) + } - job.Name, err = resolveField(job.Name, resolvedCfg.Variables) - errs = append(errs, err) + mapping.Target, err = resolveField(mapping.Target, resolvedCfg.Variables) + if err != nil { + return Config{}, fmt.Errorf("resolving mapping target %q: %w", mapping.Target, err) + } + + for jIdx := range mapping.Jobs { + job := &mapping.Jobs[jIdx] + + errs := make([]error, 0, 3) //nolint:mnd // 3 fields to resolve: Source, Target, Name + + job.Name, err = resolveField(job.Name, resolvedCfg.Variables) + errs = append(errs, err) + + job.Source, err = resolveField(job.Source, resolvedCfg.Variables) + errs = append(errs, err) + + job.Target, err = resolveField(job.Target, resolvedCfg.Variables) + errs = append(errs, err) + + joined := errors.Join(errs...) + if joined != nil { + return Config{}, fmt.Errorf("resolving job %q: %w", job.Name, joined) + } - joined := errors.Join(errs...) - if joined != nil { - return Config{}, fmt.Errorf("resolving job %q: %w", job.Name, joined) + // Join relative job paths with mapping base paths + job.Source = filepath.Join(mapping.Source, job.Source) + "/" + job.Target = filepath.Join(mapping.Target, job.Target) } } @@ -219,30 +317,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 +401,12 @@ func expandIncludes(cfg *Config, configDir string) error { return fmt.Errorf("include %q: %w", inc.Uses, err) } - resolved, err := ResolveConfig(tmplCfg) + resolved, err := resolveTemplateVariables(tmplCfg) 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 +459,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/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..48cdf5f 100644 --- a/backup/internal/test/check_test.go +++ b/backup/internal/test/check_test.go @@ -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..e219af2 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_SourceMacroError(t *testing.T) { +func TestResolveConfig_MappingSourceMacroError(t *testing.T) { cfg := Config{ - Sources: []Path{{Path: "/home/@{bogus:val}/"}}, - Jobs: []Job{{Name: "job1", Source: "/src/", Target: "/dst/"}}, + 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 source path") + assert.Contains(t, err.Error(), "resolving mapping source") } -func TestResolveConfig_TargetMacroError(t *testing.T) { +func TestResolveConfig_MappingTargetMacroError(t *testing.T) { cfg := Config{ - Targets: []Path{{Path: "/backup/@{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 target path") + assert.Contains(t, err.Error(), "resolving mapping target") +} + +func TestResolveConfig_MappingNameMacroError(t *testing.T) { + cfg := Config{ + 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 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,103 @@ 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) @@ -916,3 +984,83 @@ func TestLoadResolvedConfig_IncludeMacroError(t *testing.T) { 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) + + 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 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, AllSources, AllTargets helpers --- + +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) +} + +func TestAllSources(t *testing.T) { + cfg := Config{ + Mappings: []Mapping{ + {Name: "m1", Source: "/s1", Target: "/t1", Exclusions: []string{"cache"}}, + {Name: "m2", Source: "/s2", Target: "/t2"}, + }, + } + + sources := cfg.AllSources() + require.Len(t, sources, 2) + assert.Equal(t, "/s1", sources[0].Path) + assert.Equal(t, []string{"cache"}, sources[0].Exclusions) + assert.Equal(t, "/s2", sources[1].Path) +} + +func TestAllTargets(t *testing.T) { + cfg := Config{ + Mappings: []Mapping{ + {Name: "m1", Source: "/s1", Target: "/t1"}, + {Name: "m2", Source: "/s2", Target: "/t1"}, // duplicate target + {Name: "m3", Source: "/s3", Target: "/t2"}, + }, + } + + targets := cfg.AllTargets() + require.Len(t, targets, 2) + assert.Equal(t, "/t1", targets[0].Path) + assert.Equal(t, "/t2", targets[1].Path) +} 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) From 065fe90766eedfac18d0e605b4bb2650e1fbefeb Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:33:21 +0000 Subject: [PATCH 2/4] refactor: introduce resolveFields Replaced the two near-identical functions (resolveTemplateVariables and ResolveConfig) with a shared resolveFields(cfg, joinPaths) --- backup/internal/config.go | 116 ++++++++++++++------------------------ 1 file changed, 42 insertions(+), 74 deletions(-) diff --git a/backup/internal/config.go b/backup/internal/config.go index 5fbc249..1b112c9 100644 --- a/backup/internal/config.go +++ b/backup/internal/config.go @@ -185,112 +185,80 @@ func ResolveVariables(variables map[string]string) map[string]string { return resolved } -// resolveTemplateVariables resolves variables and macros in a template config -// without joining job paths with mapping base paths. Used by expandIncludes so -// that path joining happens only once in the outer ResolveConfig call. -func resolveTemplateVariables(cfg Config) (Config, error) { +// 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 { - mapping := &resolved.Mappings[mIdx] - - var err error - - mapping.Name, err = resolveField(mapping.Name, resolved.Variables) - if err != nil { - return Config{}, fmt.Errorf("resolving mapping name %q: %w", mapping.Name, err) - } - - mapping.Source, err = resolveField(mapping.Source, resolved.Variables) + err := resolveMapping(&resolved.Mappings[mIdx], resolved.Variables, joinPaths) if err != nil { - return Config{}, fmt.Errorf("resolving mapping source %q: %w", mapping.Source, err) + return Config{}, err } + } - mapping.Target, err = resolveField(mapping.Target, resolved.Variables) + if joinPaths { + err := ValidateNoUnresolvedMacros(resolved) if err != nil { - return Config{}, fmt.Errorf("resolving mapping target %q: %w", mapping.Target, err) - } - - for jIdx := range mapping.Jobs { - job := &mapping.Jobs[jIdx] - - job.Name, err = resolveField(job.Name, resolved.Variables) - if err != nil { - return Config{}, fmt.Errorf("resolving job name %q: %w", job.Name, err) - } - - job.Source, err = resolveField(job.Source, resolved.Variables) - if err != nil { - return Config{}, fmt.Errorf("resolving job source %q: %w", job.Source, err) - } - - job.Target, err = resolveField(job.Target, resolved.Variables) - if err != nil { - return Config{}, fmt.Errorf("resolving job target %q: %w", job.Target, err) - } + return Config{}, fmt.Errorf("macro resolution incomplete: %w", err) } } return resolved, nil } -func ResolveConfig(cfg Config) (Config, error) { - resolvedCfg := cfg +func resolveMapping(mapping *Mapping, variables map[string]string, joinPaths bool) error { + var err error - resolvedCfg.Variables = ResolveVariables(cfg.Variables) + mapping.Name, err = resolveField(mapping.Name, variables) + if err != nil { + return fmt.Errorf("resolving mapping name %q: %w", mapping.Name, err) + } - for mIdx := range resolvedCfg.Mappings { - mapping := &resolvedCfg.Mappings[mIdx] + mapping.Source, err = resolveField(mapping.Source, variables) + if err != nil { + return fmt.Errorf("resolving mapping source %q: %w", mapping.Source, err) + } - var err error + mapping.Target, err = resolveField(mapping.Target, variables) + if err != nil { + return fmt.Errorf("resolving mapping target %q: %w", mapping.Target, err) + } - mapping.Name, err = resolveField(mapping.Name, resolvedCfg.Variables) + for jIdx := range mapping.Jobs { + job := &mapping.Jobs[jIdx] + + job.Name, err = resolveField(job.Name, variables) if err != nil { - return Config{}, fmt.Errorf("resolving mapping name %q: %w", mapping.Name, err) + return fmt.Errorf("resolving job name %q: %w", job.Name, err) } - mapping.Source, err = resolveField(mapping.Source, resolvedCfg.Variables) + job.Source, err = resolveField(job.Source, variables) if err != nil { - return Config{}, fmt.Errorf("resolving mapping source %q: %w", mapping.Source, err) + return fmt.Errorf("resolving job source %q: %w", job.Source, err) } - mapping.Target, err = resolveField(mapping.Target, resolvedCfg.Variables) + job.Target, err = resolveField(job.Target, variables) if err != nil { - return Config{}, fmt.Errorf("resolving mapping target %q: %w", mapping.Target, err) + return fmt.Errorf("resolving job target %q: %w", job.Target, err) } - for jIdx := range mapping.Jobs { - job := &mapping.Jobs[jIdx] - - errs := make([]error, 0, 3) //nolint:mnd // 3 fields to resolve: Source, Target, Name - - job.Name, err = resolveField(job.Name, resolvedCfg.Variables) - errs = append(errs, err) - - job.Source, err = resolveField(job.Source, resolvedCfg.Variables) - errs = append(errs, err) - - job.Target, err = resolveField(job.Target, resolvedCfg.Variables) - errs = append(errs, err) - - joined := errors.Join(errs...) - if joined != nil { - return Config{}, fmt.Errorf("resolving job %q: %w", job.Name, joined) - } - - // Join relative job paths with mapping base paths + 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 { @@ -401,7 +369,7 @@ func expandIncludes(cfg *Config, configDir string) error { return fmt.Errorf("include %q: %w", inc.Uses, err) } - resolved, err := resolveTemplateVariables(tmplCfg) + resolved, err := resolveFields(tmplCfg, false) if err != nil { return fmt.Errorf("include %q: resolving config: %w", inc.Uses, err) } From 9c6f31561a72e823840498435c191fa1b8d5e3fd Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:37:42 +0000 Subject: [PATCH 3/4] refactor: eliminate Path --- backup/internal/check.go | 30 ++++++++++++++------------- backup/internal/config.go | 26 ----------------------- backup/internal/helper.go | 6 ------ backup/internal/test/check_test.go | 8 ++++---- backup/internal/test/config_test.go | 32 +---------------------------- 5 files changed, 21 insertions(+), 81 deletions(-) diff --git a/backup/internal/check.go b/backup/internal/check.go index 2b11fe9..e088fde 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 } @@ -35,11 +36,10 @@ func (c *CoverageChecker) ListUncoveredPaths(cfg Config) []string { var result []string seen := make(map[string]bool) - sources := cfg.AllSources() allJobs := cfg.AllJobs() - for _, source := range sources { - c.checkPath(source.Path, sources, allJobs, &result, seen) + for _, mapping := range cfg.Mappings { + c.checkPath(mapping.Source, cfg.Mappings, allJobs, &result, seen) } slices.Sort(result) // Ensure consistent ordering for test comparison @@ -77,7 +77,9 @@ func (c *CoverageChecker) isCovered(path string, jobs []Job) bool { }) } -func (c *CoverageChecker) checkPath(path string, sources []Path, jobs []Job, result *[]string, seen map[string]bool) { +func (c *CoverageChecker) checkPath( + path string, mappings []Mapping, jobs []Job, result *[]string, seen map[string]bool, +) { if seen[path] { c.Logger.Printf("SKIP: Path '%s' already seen", path) @@ -87,7 +89,7 @@ func (c *CoverageChecker) checkPath(path string, sources []Path, jobs []Job, res seen[path] = true // Skip if globally excluded - if c.IsExcludedGlobally(path, sources) { + if c.IsExcludedGlobally(path, mappings) { c.Logger.Printf("SKIP: Path '%s' is globally excluded", path) return @@ -101,7 +103,7 @@ func (c *CoverageChecker) checkPath(path string, sources []Path, jobs []Job, res } // Check if it's effectively covered through descendants - if c.isEffectivelyCovered(path, sources, jobs) { + if c.isEffectivelyCovered(path, mappings, jobs) { c.Logger.Printf("SKIP: Path '%s' is effectively covered", path) return @@ -114,7 +116,7 @@ func (c *CoverageChecker) checkPath(path string, sources []Path, jobs []Job, res // isEffectivelyCovered checks if a directory is effectively covered // (all its descendants are covered or excluded). -func (c *CoverageChecker) isEffectivelyCovered(path string, sources []Path, jobs []Job) bool { +func (c *CoverageChecker) isEffectivelyCovered(path string, mappings []Mapping, jobs []Job) bool { children, err := getChildDirectories(c.Fs, path) if err != nil { c.Logger.Printf("ERROR: could not get child directories of '%s': %v", path, err) @@ -131,8 +133,8 @@ func (c *CoverageChecker) isEffectivelyCovered(path string, sources []Path, jobs allCovered := true for _, child := range children { - covered := c.IsExcludedGlobally(child, sources) || c.isCovered(child, jobs) || - c.isEffectivelyCovered(child, sources, jobs) + covered := c.IsExcludedGlobally(child, mappings) || c.isCovered(child, jobs) || + c.isEffectivelyCovered(child, mappings, jobs) if !covered { c.Logger.Printf("UNCOVERED CHILD: Path '%s' has uncovered child '%s'", path, child) diff --git a/backup/internal/config.go b/backup/internal/config.go index 1b112c9..721adca 100644 --- a/backup/internal/config.go +++ b/backup/internal/config.go @@ -62,32 +62,6 @@ func (cfg Config) AllJobs() []Job { return jobs } -// AllSources derives a []Path from all mapping sources and their exclusions. -func (cfg Config) AllSources() []Path { - sources := make([]Path, 0, len(cfg.Mappings)) - for _, m := range cfg.Mappings { - sources = append(sources, Path{Path: m.Source, Exclusions: m.Exclusions}) - } - - return sources -} - -// AllTargets derives a deduplicated []Path from all mapping targets. -func (cfg Config) AllTargets() []Path { - seen := make(map[string]bool) - - var targets []Path - - for _, m := range cfg.Mappings { - if !seen[m.Target] { - seen[m.Target] = true - targets = append(targets, Path{Path: m.Target}) - } - } - - return targets -} - func (cfg Config) String() string { out, err := yaml.Marshal(cfg) if err != nil { 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/test/check_test.go b/backup/internal/test/check_test.go index 48cdf5f..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) diff --git a/backup/internal/test/config_test.go b/backup/internal/test/config_test.go index e219af2..47a9501 100644 --- a/backup/internal/test/config_test.go +++ b/backup/internal/test/config_test.go @@ -1018,7 +1018,7 @@ func TestResolveConfig_JobNameMacroError(t *testing.T) { assert.Contains(t, err.Error(), "resolving job") } -// --- AllJobs, AllSources, AllTargets helpers --- +// --- AllJobs helper --- func TestAllJobs(t *testing.T) { cfg := Config{ @@ -1034,33 +1034,3 @@ func TestAllJobs(t *testing.T) { assert.Equal(t, "j2", allJobs[1].Name) assert.Equal(t, "j3", allJobs[2].Name) } - -func TestAllSources(t *testing.T) { - cfg := Config{ - Mappings: []Mapping{ - {Name: "m1", Source: "/s1", Target: "/t1", Exclusions: []string{"cache"}}, - {Name: "m2", Source: "/s2", Target: "/t2"}, - }, - } - - sources := cfg.AllSources() - require.Len(t, sources, 2) - assert.Equal(t, "/s1", sources[0].Path) - assert.Equal(t, []string{"cache"}, sources[0].Exclusions) - assert.Equal(t, "/s2", sources[1].Path) -} - -func TestAllTargets(t *testing.T) { - cfg := Config{ - Mappings: []Mapping{ - {Name: "m1", Source: "/s1", Target: "/t1"}, - {Name: "m2", Source: "/s2", Target: "/t1"}, // duplicate target - {Name: "m3", Source: "/s3", Target: "/t2"}, - }, - } - - targets := cfg.AllTargets() - require.Len(t, targets, 2) - assert.Equal(t, "/t1", targets[0].Path) - assert.Equal(t, "/t2", targets[1].Path) -} From 703a0875d7bc88ea6d4230b66ac0b0025419fa7e Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:41:45 +0000 Subject: [PATCH 4/4] refactor: AllJobs() flattening --- backup/internal/check.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/backup/internal/check.go b/backup/internal/check.go index e088fde..bfb1da5 100644 --- a/backup/internal/check.go +++ b/backup/internal/check.go @@ -36,10 +36,9 @@ func (c *CoverageChecker) ListUncoveredPaths(cfg Config) []string { var result []string seen := make(map[string]bool) - allJobs := cfg.AllJobs() for _, mapping := range cfg.Mappings { - c.checkPath(mapping.Source, cfg.Mappings, allJobs, &result, seen) + c.checkPath(mapping.Source, cfg.Mappings, &result, seen) } slices.Sort(result) // Ensure consistent ordering for test comparison @@ -71,14 +70,20 @@ 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, mappings []Mapping, jobs []Job, result *[]string, seen map[string]bool, + path string, mappings []Mapping, result *[]string, seen map[string]bool, ) { if seen[path] { c.Logger.Printf("SKIP: Path '%s' already seen", path) @@ -96,14 +101,14 @@ func (c *CoverageChecker) checkPath( } // Skip if covered by a job - if c.isCovered(path, 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, mappings, jobs) { + if c.isEffectivelyCovered(path, mappings) { c.Logger.Printf("SKIP: Path '%s' is effectively covered", path) return @@ -116,7 +121,7 @@ func (c *CoverageChecker) checkPath( // isEffectivelyCovered checks if a directory is effectively covered // (all its descendants are covered or excluded). -func (c *CoverageChecker) isEffectivelyCovered(path string, mappings []Mapping, jobs []Job) 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) @@ -133,8 +138,8 @@ func (c *CoverageChecker) isEffectivelyCovered(path string, mappings []Mapping, allCovered := true for _, child := range children { - covered := c.IsExcludedGlobally(child, mappings) || c.isCovered(child, jobs) || - c.isEffectivelyCovered(child, mappings, jobs) + 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)