From 68aad3ce3b85ef5ec1d1957ea52e3bb9209fa986 Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:43:04 +0000 Subject: [PATCH] feat: macros --- .golangci.yml | 3 + backup/internal/config.go | 49 +++++- backup/internal/macros.go | 229 +++++++++++++++++++++++++ backup/internal/test/config_test.go | 3 +- backup/internal/test/macros_test.go | 252 ++++++++++++++++++++++++++++ docs/configuration.md | 13 ++ docs/macros.md | 115 +++++++++++++ 7 files changed, 657 insertions(+), 7 deletions(-) create mode 100644 backup/internal/macros.go create mode 100644 backup/internal/test/macros_test.go create mode 100644 docs/macros.md diff --git a/.golangci.yml b/.golangci.yml index e8e1fc2..47a18a3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -27,6 +27,7 @@ linters: - strings - testing - time + - unicode - github.com/spf13/cobra - github.com/spf13/afero - github.com/stretchr/testify/assert @@ -45,6 +46,8 @@ linters: varnamelen: ignore-decls: - fs afero.Fs + - fn MacroFunc + - ok bool wrapcheck: ignore-package-globs: - backup-rsync/* diff --git a/backup/internal/config.go b/backup/internal/config.go index 1d853f0..f157e42 100644 --- a/backup/internal/config.go +++ b/backup/internal/config.go @@ -87,14 +87,48 @@ func SubstituteVariables(input string, variables map[string]string) string { return strings.NewReplacer(oldnew...).Replace(input) } -func ResolveConfig(cfg Config) Config { +func resolveField(input string, variables map[string]string) (string, error) { + result := SubstituteVariables(input, variables) + + resolved, err := ResolveMacros(result) + if err != nil { + return "", err + } + + return resolved, nil +} + +func ResolveConfig(cfg Config) (Config, error) { resolvedCfg := cfg - for i, job := range resolvedCfg.Jobs { - resolvedCfg.Jobs[i].Source = SubstituteVariables(job.Source, cfg.Variables) - resolvedCfg.Jobs[i].Target = SubstituteVariables(job.Target, cfg.Variables) + + for idx := range resolvedCfg.Jobs { + job := &resolvedCfg.Jobs[idx] + + errs := make([]error, 0, 3) //nolint:mnd // 3 fields to resolve: Source, Target, Name + + var err error + + job.Source, err = resolveField(job.Source, cfg.Variables) + errs = append(errs, err) + + job.Target, err = resolveField(job.Target, cfg.Variables) + errs = append(errs, err) + + job.Name, err = resolveField(job.Name, cfg.Variables) + errs = append(errs, err) + + joined := errors.Join(errs...) + if joined != nil { + return Config{}, fmt.Errorf("resolving job %q: %w", job.Name, joined) + } + } + + err := ValidateNoUnresolvedMacros(resolvedCfg) + if err != nil { + return Config{}, fmt.Errorf("macro resolution incomplete: %w", err) } - return resolvedCfg + return resolvedCfg, nil } func ValidateJobNames(jobs []Job) error { @@ -183,7 +217,10 @@ func LoadResolvedConfig(configPath string) (Config, error) { return Config{}, fmt.Errorf("job validation failed: %w", err) } - resolvedCfg := ResolveConfig(cfg) + resolvedCfg, err := ResolveConfig(cfg) + if err != nil { + return Config{}, fmt.Errorf("config resolution failed: %w", err) + } err = ValidatePaths(resolvedCfg) if err != nil { diff --git a/backup/internal/macros.go b/backup/internal/macros.go new file mode 100644 index 0000000..e84d09e --- /dev/null +++ b/backup/internal/macros.go @@ -0,0 +1,229 @@ +package internal + +import ( + "errors" + "fmt" + "strings" + "unicode" +) + +// ErrUnresolvedMacro indicates a macro could not be resolved. +var ErrUnresolvedMacro = errors.New("unresolved macro") + +// MacroFunc transforms an input string and returns the result. +type MacroFunc func(string) string + +// GetMacroFunc returns the macro function for the given name, or false if not found. +func GetMacroFunc(name string) (MacroFunc, bool) { + registry := map[string]MacroFunc{ + "upper": strings.ToUpper, + "lower": strings.ToLower, + "title": toTitleCase, + "capitalize": capitalize, + "camelcase": toCamelCase, + "pascalcase": toPascalCase, + "snakecase": toSnakeCase, + "kebabcase": toKebabCase, + "trim": strings.TrimSpace, + } + + fn, ok := registry[name] + + return fn, ok +} + +func toTitleCase(input string) string { + runes := []rune(input) + capitalizeNext := true + + for i, r := range runes { + if unicode.IsSpace(r) || r == '_' || r == '-' { + capitalizeNext = true + } else if capitalizeNext { + runes[i] = unicode.ToUpper(r) + capitalizeNext = false + } + } + + return string(runes) +} + +func capitalize(input string) string { + if input == "" { + return "" + } + + runes := []rune(input) + runes[0] = unicode.ToUpper(runes[0]) + + return string(runes) +} + +// splitWords splits a string into words by recognizing boundaries at +// underscores, hyphens, spaces, and camelCase transitions. +func isSeparator(r rune) bool { + return r == '_' || r == '-' || unicode.IsSpace(r) +} + +func isCamelBoundary(prev, current rune) bool { + return unicode.IsUpper(current) && unicode.IsLower(prev) +} + +func splitWords(input string) []string { + var words []string + + var current []rune + + runes := []rune(input) + + for idx, char := range runes { + if isSeparator(char) { + if len(current) > 0 { + words = append(words, string(current)) + current = nil + } + + continue + } + + if idx > 0 && len(current) > 0 && isCamelBoundary(runes[idx-1], char) { + words = append(words, string(current)) + current = nil + } + + current = append(current, char) + } + + if len(current) > 0 { + words = append(words, string(current)) + } + + return words +} + +func toCamelCase(input string) string { + words := splitWords(input) + + for i, word := range words { + lower := strings.ToLower(word) + if i == 0 { + words[i] = lower + } else { + words[i] = capitalize(lower) + } + } + + return strings.Join(words, "") +} + +func toPascalCase(input string) string { + words := splitWords(input) + + for i, word := range words { + words[i] = capitalize(strings.ToLower(word)) + } + + return strings.Join(words, "") +} + +func toSnakeCase(input string) string { + words := splitWords(input) + + for i, word := range words { + words[i] = strings.ToLower(word) + } + + return strings.Join(words, "_") +} + +func toKebabCase(input string) string { + words := splitWords(input) + + for i, word := range words { + words[i] = strings.ToLower(word) + } + + return strings.Join(words, "-") +} + +const macroPrefix = "@{" +const macroSuffix = "}" + +// ResolveMacros evaluates all @{function:argument} expressions in the input string. +// Macros are resolved from the innermost outward to support nesting. +func ResolveMacros(input string) (string, error) { + // Iteratively resolve innermost macros until none remain. + for { + start, end, funcName, arg, found := findInnermostMacro(input) + if !found { + break + } + + fn, ok := GetMacroFunc(funcName) + if !ok { + return "", fmt.Errorf("%w: unknown function %q in @{%s:%s}", ErrUnresolvedMacro, funcName, funcName, arg) + } + + result := fn(arg) + input = input[:start] + result + input[end:] + } + + return input, nil +} + +// findInnermostMacro finds the innermost (deepest nested) @{func:arg} in the string. +// Returns the start/end indices of the full macro expression, the function name, +// the argument, and whether a macro was found. +func findInnermostMacro(input string) (int, int, string, string, bool) { + // Find the last occurrence of "@{" before any "}" — that's the innermost. + lastStart := -1 + + for i := range len(input) - 1 { + if input[i] == '@' && input[i+1] == '{' { + lastStart = i + } + } + + if lastStart < 0 { + return 0, 0, "", "", false + } + + // Find the matching closing brace. + closeIdx := strings.Index(input[lastStart:], macroSuffix) + if closeIdx < 0 { + return 0, 0, "", "", false + } + + closeIdx += lastStart + + inner := input[lastStart+len(macroPrefix) : closeIdx] + + funcName, arg, found := strings.Cut(inner, ":") + if !found { + return 0, 0, "", "", false + } + + return lastStart, closeIdx + len(macroSuffix), funcName, arg, true +} + +// ValidateNoUnresolvedMacros checks that no @{...} patterns remain in config fields. +func ValidateNoUnresolvedMacros(cfg Config) error { + var errs []error + + for _, job := range cfg.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)) + } + } + } + + return errors.Join(errs...) +} diff --git a/backup/internal/test/config_test.go b/backup/internal/test/config_test.go index dbfe170..2cd43ca 100644 --- a/backup/internal/test/config_test.go +++ b/backup/internal/test/config_test.go @@ -297,7 +297,8 @@ func TestResolveConfig(t *testing.T) { }, } - resolvedCfg := ResolveConfig(cfg) + 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) diff --git a/backup/internal/test/macros_test.go b/backup/internal/test/macros_test.go new file mode 100644 index 0000000..5bcdda6 --- /dev/null +++ b/backup/internal/test/macros_test.go @@ -0,0 +1,252 @@ +package internal_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + . "backup-rsync/backup/internal" + "backup-rsync/backup/internal/testutil" +) + +func TestResolveMacros_StringFunctions(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + // upper + {"UpperSimple", "@{upper:hello}", "HELLO"}, + {"UpperMixed", "@{upper:Hello World}", "HELLO WORLD"}, + {"UpperEmpty", "@{upper:}", ""}, + + // lower + {"LowerSimple", "@{lower:HELLO}", "hello"}, + {"LowerMixed", "@{lower:Hello World}", "hello world"}, + + // title + {"TitleSimple", "@{title:hello world}", "Hello World"}, + {"TitleUnderscores", "@{title:hello_world}", "Hello_World"}, + {"TitleHyphens", "@{title:hello-world}", "Hello-World"}, + + // capitalize + {"CapitalizeSimple", "@{capitalize:hello}", "Hello"}, + {"CapitalizeOneChar", "@{capitalize:h}", "H"}, + {"CapitalizeEmpty", "@{capitalize:}", ""}, + {"CapitalizeSentence", "@{capitalize:hello world}", "Hello world"}, + + // camelcase + {"CamelFromSnake", "@{camelcase:hello_world}", "helloWorld"}, + {"CamelFromKebab", "@{camelcase:hello-world}", "helloWorld"}, + {"CamelFromSpace", "@{camelcase:hello world}", "helloWorld"}, + {"CamelFromPascal", "@{camelcase:HelloWorld}", "helloWorld"}, + {"CamelSingleWord", "@{camelcase:hello}", "hello"}, + + // pascalcase + {"PascalFromSnake", "@{pascalcase:hello_world}", "HelloWorld"}, + {"PascalFromKebab", "@{pascalcase:hello-world}", "HelloWorld"}, + {"PascalFromSpace", "@{pascalcase:hello world}", "HelloWorld"}, + {"PascalFromCamel", "@{pascalcase:helloWorld}", "HelloWorld"}, + {"PascalSingleWord", "@{pascalcase:hello}", "Hello"}, + + // snakecase + {"SnakeFromCamel", "@{snakecase:helloWorld}", "hello_world"}, + {"SnakeFromPascal", "@{snakecase:HelloWorld}", "hello_world"}, + {"SnakeFromKebab", "@{snakecase:hello-world}", "hello_world"}, + {"SnakeFromSpace", "@{snakecase:hello world}", "hello_world"}, + {"SnakeSingleWord", "@{snakecase:hello}", "hello"}, + + // kebabcase + {"KebabFromCamel", "@{kebabcase:helloWorld}", "hello-world"}, + {"KebabFromPascal", "@{kebabcase:HelloWorld}", "hello-world"}, + {"KebabFromSnake", "@{kebabcase:hello_world}", "hello-world"}, + {"KebabFromSpace", "@{kebabcase:hello world}", "hello-world"}, + + // trim + {"TrimSpaces", "@{trim: hello }", "hello"}, + {"TrimTabs", "@{trim:\thello\t}", "hello"}, + {"TrimNone", "@{trim:hello}", "hello"}, + + // no macros + {"NoMacros", "/home/user/docs", "/home/user/docs"}, + {"EmptyString", "", ""}, + {"VariableSyntax", "${var}", "${var}"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ResolveMacros(test.input) + require.NoError(t, err) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestResolveMacros_InContext(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"MacroInPath", "/home/@{lower:USER}/docs", "/home/user/docs"}, + {"MacroAtStart", "@{upper:hello}/world", "HELLO/world"}, + {"MacroAtEnd", "/path/@{capitalize:test}", "/path/Test"}, + {"MultipleMacros", "@{upper:hello}-@{lower:WORLD}", "HELLO-world"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ResolveMacros(test.input) + require.NoError(t, err) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestResolveMacros_Nested(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"NestedUpperTrim", "@{upper:@{trim: hello }}", "HELLO"}, + {"NestedCapitalizeLower", "@{capitalize:@{lower:HELLO}}", "Hello"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ResolveMacros(test.input) + require.NoError(t, err) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestResolveMacros_UnknownFunction(t *testing.T) { + _, err := ResolveMacros("@{unknown:hello}") + require.Error(t, err) + require.ErrorIs(t, err, ErrUnresolvedMacro) + assert.Contains(t, err.Error(), "unknown function") +} + +func TestResolveMacros_MissingColon(t *testing.T) { + // @{nocolon} has no colon separator, so it's not a valid macro — left unchanged. + result, err := ResolveMacros("@{nocolon}") + require.NoError(t, err) + assert.Equal(t, "@{nocolon}", result) +} + +func TestResolveConfig_WithMacros(t *testing.T) { + cfg := Config{ + Variables: map[string]string{ + "user": "jaap", + }, + Jobs: []Job{ + { + Name: "${user}_mail", + Source: "/home/${user}/", + Target: "/backup/@{capitalize:${user}}/mail", + }, + }, + } + + 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) +} + +func TestResolveConfig_MacroError(t *testing.T) { + cfg := Config{ + Jobs: []Job{ + { + Name: "job1", + Source: "/home/@{bogus:val}/", + Target: "/backup/", + }, + }, + } + + _, err := ResolveConfig(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnresolvedMacro) +} + +func TestValidateNoUnresolvedMacros(t *testing.T) { + tests := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "AllResolved", + cfg: Config{ + Jobs: []Job{{Name: "job1", Source: "/home/user/", Target: "/backup/user/"}}, + }, + wantErr: false, + }, + { + name: "UnresolvedInSource", + cfg: Config{ + Jobs: []Job{{Name: "job1", Source: "/home/@{upper:user}/", Target: "/backup/user/"}}, + }, + wantErr: true, + }, + { + name: "UnresolvedInTarget", + cfg: Config{ + Jobs: []Job{{Name: "job1", Source: "/home/user/", Target: "/backup/@{lower:user}/"}}, + }, + wantErr: true, + }, + { + name: "UnresolvedInName", + cfg: Config{ + Jobs: []Job{{Name: "@{upper:job}", Source: "/home/user/", Target: "/backup/user/"}}, + }, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateNoUnresolvedMacros(test.cfg) + if test.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnresolvedMacro) + } else { + assert.NoError(t, err) + } + }) + } +} + +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"). + Build() + + path := testutil.WriteConfigFile(t, yamlContent) + + cfg, err := LoadResolvedConfig(path) + require.NoError(t, err) + assert.Equal(t, "/backup/Jaap/docs", cfg.Jobs[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"). + Build() + + path := testutil.WriteConfigFile(t, yamlContent) + + _, err := LoadResolvedConfig(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "config resolution failed") +} diff --git a/docs/configuration.md b/docs/configuration.md index bc8a982..c2bf0d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -33,6 +33,19 @@ variables: target_base: "/mnt/backup1" ``` +## Macros + +Macros apply string transformation functions to values using `@{function:argument}` syntax. Variables are resolved before macros, so they compose naturally. See [macros.md](macros.md) for the full list of available functions and detailed usage. + +```yaml +variables: + user: alice + +jobs: + - name: "${user}_docs" + target: "/backup/@{capitalize:${user}}/docs" # resolves to /backup/Alice/docs +``` + ## Jobs Each job defines a backup operation: diff --git a/docs/macros.md b/docs/macros.md new file mode 100644 index 0000000..e8e9f25 --- /dev/null +++ b/docs/macros.md @@ -0,0 +1,115 @@ +# Macros + +Macros allow you to apply string transformation functions inside configuration values. They complement [variable substitution](configuration.md) by transforming strings at resolution time. + +## Syntax + +``` +@{function_name:argument} +``` + +- `@{` opens a macro call. +- `function_name` is the name of the transformation function. +- `:` separates the function name from its argument. +- `argument` is the input string to transform. +- `}` closes the macro call. + +## Resolution Order + +1. **Variable substitution** — all `${variable}` references are replaced with their values. +2. **Macro evaluation** — all `@{function:argument}` expressions are evaluated. + +This means macros can operate on resolved variable values: + +```yaml +variables: + user: alice + +jobs: + - name: "${user}_docs" + source: "/home/${user}/Documents/" + target: "/backup/@{capitalize:${user}}/Documents/" +``` + +After resolution, the target becomes `/backup/Alice/Documents/`. + +## Nesting + +Macros can be nested. Inner macros are resolved first: + +```yaml +target: "/backup/@{upper:@{trim: ${user} }}/" +``` + +Given `user: alice`, this resolves as: +1. Variable substitution: `/backup/@{upper:@{trim: alice }}/` +2. Inner macro `@{trim: alice }` → `alice` +3. Outer macro `@{upper:alice}` → `ALICE` +4. Result: `/backup/ALICE/` + +## Available Functions + +| Function | Description | Example Input | Example Output | +|---|---|---|---| +| `upper` | Convert to uppercase | `hello world` | `HELLO WORLD` | +| `lower` | Convert to lowercase | `HELLO WORLD` | `hello world` | +| `title` | Capitalize first letter of each word | `hello world` | `Hello World` | +| `capitalize` | Capitalize first character only | `hello world` | `Hello world` | +| `camelcase` | Convert to camelCase | `hello_world` | `helloWorld` | +| `pascalcase` | Convert to PascalCase | `hello_world` | `HelloWorld` | +| `snakecase` | Convert to snake_case | `helloWorld` | `hello_world` | +| `kebabcase` | Convert to kebab-case | `helloWorld` | `hello-world` | +| `trim` | Remove leading/trailing whitespace | ` hello ` | `hello` | + +### Case conversion details + +The `camelcase`, `pascalcase`, `snakecase`, and `kebabcase` functions detect word boundaries at: + +- Underscores (`_`) +- Hyphens (`-`) +- Spaces +- camelCase transitions (a lowercase letter followed by an uppercase letter) + +Examples: + +| Input | `camelcase` | `pascalcase` | `snakecase` | `kebabcase` | +|---|---|---|---|---| +| `hello_world` | `helloWorld` | `HelloWorld` | `hello_world` | `hello-world` | +| `hello-world` | `helloWorld` | `HelloWorld` | `hello_world` | `hello-world` | +| `HelloWorld` | `helloWorld` | `HelloWorld` | `hello_world` | `hello-world` | +| `helloWorld` | `helloWorld` | `HelloWorld` | `hello_world` | `hello-world` | + +## Validation + +After all variables and macros are resolved, the configuration is validated to ensure no unresolved `@{...}` expressions remain. If any macro could not be resolved (e.g., an unknown function name), the configuration is rejected with an error. + +The `config show` command always displays the fully resolved configuration with all variables substituted and all macros evaluated. + +## Practical Example + +Instead of maintaining separate `user` and `user_cap` variables: + +```yaml +# Before: redundant variables +variables: + user: alice + user_cap: Alice + +jobs: + - name: "${user}_docs" + source: "/home/${user}/Documents/" + target: "/backup/${user_cap}/Documents/" +``` + +Use a macro to derive the capitalized form: + +```yaml +# After: single variable with macro +variables: + user: alice + +jobs: + - name: "${user}_docs" + source: "/home/${user}/Documents/" + target: "/backup/@{capitalize:${user}}/Documents/" +```