diff --git a/README.md b/README.md index 6ef471e..3fb9252 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ GitHub Releases publish: 1. **Source vs target** - `source` = manifest, source of truth (relative to config file directory) - `target` = live location under `$HOME` (relative path in config) + - both may include environment variables (`$VAR` / `${VAR}`) that are expanded at runtime + - expansion happens before normalization/validation + - missing/empty env vars are errors; expanded paths must still be relative and non-escaping 2. **Sync entries** - config has `syncs[]`, each with one `target` + one `source` @@ -70,6 +73,7 @@ GitHub Releases publish: 4. **Scoped runs** - optional `[path]` narrows commands to matching target subpaths + - matching uses post-expansion target roots 5. **Preview and safety** - `status` is preview @@ -108,6 +112,10 @@ GitHub Releases publish: syncs: - target: .config/nvim source: .config/nvim + - target: ./ + source: ./global + - target: ./ + source: "./$HOSTNAME/$USER" on: deploy: remove-unmanaged: diff --git a/docs/internal/contracts/config-schema.json b/docs/internal/contracts/config-schema.json index 3362835..59592da 100644 --- a/docs/internal/contracts/config-schema.json +++ b/docs/internal/contracts/config-schema.json @@ -20,7 +20,7 @@ "$defs": { "relativePath": { "type": "string", - "description": "Relative path only. Runtime also rejects paths that lexically escape the base directory after normalization.", + "description": "Relative path only; runtime expands `$VAR` and `${VAR}` placeholders before normalization. Missing/empty variables fail validation. Expanded paths must still be relative and non-escaping.", "allOf": [ { "minLength": 1 @@ -47,12 +47,7 @@ }, { "not": { - "pattern": "^\\s*\\$" - } - }, - { - "not": { - "pattern": "\\$\\{" + "pattern": "\\$\\{[^}]*$" } }, { diff --git a/docs/internal/contracts/validation-errors.md b/docs/internal/contracts/validation-errors.md index 71c4c4a..cae164b 100644 --- a/docs/internal/contracts/validation-errors.md +++ b/docs/internal/contracts/validation-errors.md @@ -36,8 +36,11 @@ When `--json` is set, errors are emitted via JSON envelope (`ok=false`, `error.c | `DFM_CONFIG_SCHEMA_UNKNOWN_KEY` | unknown key present | `Unknown config key: {key_path}` | | `DFM_CONFIG_SCHEMA_TYPE` | wrong type for key (e.g. string instead of list) | `Invalid type at {key_path}: expected {expected}` | | `DFM_CONFIG_SCHEMA_REQUIRED` | missing required key | `Missing required key: {key_path}` | -| `DFM_CONFIG_PATH_NOT_RELATIVE` | `syncs[].source` or `syncs[].target` is absolute / `~` / env-like | `Path must be relative: {key_path}` | -| `DFM_CONFIG_PATH_ESCAPE` | normalized config path escapes base via `..` | `Path escapes base directory: {key_path}` | +| `DFM_CONFIG_PATH_NOT_RELATIVE` | resolved `syncs[].source` or `syncs[].target` is absolute / starts with `~` / otherwise not relative after placeholder expansion | `Path must be relative: {key_path}` | +| `DFM_CONFIG_PATH_ESCAPE` | normalized config path escapes base via `..` after placeholder expansion | `Path escapes base directory: {key_path}` | +| `DFM_CONFIG_PATH_ENV_VAR_UNDEFINED` | `$VAR`/`${VAR}` placeholder references missing or empty environment variable during expansion | `Environment variable {var} required for path: {key_path}` | + +Paths that include `$VAR`/`${VAR}` placeholders are expanded using the runtime environment before the checks above; missing/empty variables raise `DFM_CONFIG_PATH_ENV_VAR_UNDEFINED` and the resulting path is subjected to the usual relative/escape validation. ## CLI path scoping @@ -78,7 +81,7 @@ When `--json` is set, errors are emitted via JSON envelope (`ok=false`, `error.c To keep errors deterministic: 1. config source resolution + path checks 2. YAML parse -3. schema + path validation +3. schema + placeholder expansion + path validation (missing/empty env vars raise `DFM_CONFIG_PATH_ENV_VAR_UNDEFINED` before other path checks) 4. `[path]` matching checks 5. runtime filesystem operations diff --git a/docs/internal/specs/cli-and-config-spec.md b/docs/internal/specs/cli-and-config-spec.md index 1c32d91..ad48051 100644 --- a/docs/internal/specs/cli-and-config-spec.md +++ b/docs/internal/specs/cli-and-config-spec.md @@ -33,7 +33,7 @@ Rules: - log level defaults to `info`; supported levels: `debug`, `info`, `warn`, `error` - warnings/errors are emitted as human-readable stderr diagnostics - `--dry-run` is valid for `deploy`/`import` only -- `[path]` narrows execution to matching target subpaths +- `[path]` narrows execution to matching target subpaths (against post-expansion target roots) ## Config surface @@ -59,9 +59,11 @@ syncs: ``` Key constraints: -- `target`: relative to `$HOME` -- `source`: relative to config file directory -- config paths are relative-only +- `target`: relative to `$HOME` after env expansion +- `source`: relative to config file directory after env expansion +- env placeholders are supported in `target`/`source`: `$VAR`, `${VAR}` +- missing or empty env values are validation errors +- expanded paths are still required to be relative-only and non-escaping - unknown keys are validation errors Machine-readable schema: @@ -81,6 +83,7 @@ Machine-readable schema: Text mode: - sync blocks always start with: - `sync[idx] target=~/ source=./` + - header uses configured path text (placeholders stay visible if present) - each command prints only non-empty phase blocks - summary line prints only non-zero categories - status uses potential-action phrases (`can create`, `can update`, `can replace type`, `can add`, `can remove`) diff --git a/docs/internal/specs/decision-matrix.md b/docs/internal/specs/decision-matrix.md index eb2d19b..95d8e0d 100644 --- a/docs/internal/specs/decision-matrix.md +++ b/docs/internal/specs/decision-matrix.md @@ -46,6 +46,12 @@ Examples: - `target: .config/nvim` + `[path]=~/.config/nvim/lua` → selected (subtree only) - `target: .config/nvim` + `[path]=~/.config` → not selected +### Env-var placeholders and CLI scope + +- `syncs[].source`/`target` may contain `$VAR` and `${VAR}` placeholders expanded from the runtime environment before lexical normalization and CLI scoping. +- The post-expansion path is what `[path]` matching uses, so scope behavior remains the same (equal target or inside target subtree). +- Missing/empty placeholders, absolute post-expansion paths, or post-expansion escape (`..`) paths fail validation before scoping. + ## 3) Per-path behavior matrix | Scenario | Deploy | Import | Status | diff --git a/docs/internal/specs/decisions.md b/docs/internal/specs/decisions.md index fbaa05d..30de6bf 100644 --- a/docs/internal/specs/decisions.md +++ b/docs/internal/specs/decisions.md @@ -29,9 +29,12 @@ It complements: | Decision | Why | |---|---| -| `source` and `target` in config are relative-only | Prevents surprising absolute-path writes and keeps configs portable. | +| `source` and `target` in config are relative-only **after env expansion** | Prevents surprising absolute-path writes and keeps configs portable. | | `source` is relative to config directory; `target` is relative to `$HOME` | Clear, stable roots for both sides. | | Lexical normalization applies (`.`, `..`, duplicate separators) | Ensures consistent matching and validation. | +| Config paths may embed env placeholders (`$VAR` and `${VAR}`) that are expanded from the runtime environment before normalization | Lets configs reuse environment-specific roots while keeping resolution deterministic. | +| Missing or empty env vars referenced by path placeholders are errors | Prevents silent fallback to unintended roots. | +| Expanded paths must still sit inside their base roots and pass relative/escape validation | Preserves safety guarantees even when runtime env is used. | | Escaping base roots via `..` is invalid | Prevents traversal outside intended scope. | | Symlinks are treated as symlink entries (no realpath-based sync semantics) | Matches “treat like git entries” model and avoids hidden path rewrites. | | CLI `[path]` accepts absolute, `~`-based, and relative forms | Convenient for both shell and scripting usage. | diff --git a/docs/internal/specs/open-questions.md b/docs/internal/specs/open-questions.md index c3a9cdf..3efede2 100644 --- a/docs/internal/specs/open-questions.md +++ b/docs/internal/specs/open-questions.md @@ -1,7 +1,7 @@ --- owner: Core Engineering status: Implementation-ready -last-updated: 2026-02-16 +last-updated: 2026-02-17 canonical-source: docs/internal/specs/open-questions.md --- @@ -27,7 +27,7 @@ Most previously open items are now resolved in: - Config is YAML-only, unknown keys are errors. - Config resolution order is `--config` → `DOTFILES_MANAGER_CONFIG` → `./.dotfiles-manager.yaml` (cwd). - Default config lookup is cwd-only (no parent search). -- Config `source`/`target` are relative-only. +- Config `source`/`target` accept env placeholders (`$VAR`, `${VAR}`) and are validated as relative-only after expansion. - Lexical path normalization; base escape via `..` is invalid. - `[path]` accepts absolute, `~`, and relative forms. - `[path]` must be target or subpath; parent-of-target does not match. diff --git a/docs/user/README.md b/docs/user/README.md index 102c0b7..190d9bf 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -29,6 +29,11 @@ Main commands: - Config format is YAML. - Default discovery is current-directory only (no parent-directory search). - Config JSON Schema is available at `../internal/contracts/config-schema.json` for editor/tooling validation. +- `syncs[].target`/`syncs[].source` support env vars (`$VAR`, `${VAR}`) with strict checks: + - expansion happens before normalization/validation + - missing/empty values are errors + - expanded paths must still be relative and non-escaping +- `[path]` scoping matches against post-expansion target roots - Unmanaged/missing behavior is opt-in by patterns; empty defaults evaluate manifest paths only. - Logs are always written to a log file. - macOS default: `~/Library/Logs/dotfiles-manager/dotfiles-manager.log` diff --git a/docs/user/commands.md b/docs/user/commands.md index 7c1bfbd..0be3a85 100644 --- a/docs/user/commands.md +++ b/docs/user/commands.md @@ -135,6 +135,8 @@ A sync is selected only when `[path]` is: - exactly the sync target, or - inside the sync target subtree +Target matching uses post-expansion target roots (after `$VAR`/`${VAR}` resolution). + If `[path]` matches no syncs, command fails. ## Output and exit codes @@ -144,6 +146,7 @@ If `[path]` matches no syncs, command fails. - text mode prints per-sync sections with exact file operations. - every sync header uses: - `sync[idx] target=~/ source=./` +- sync headers show configured path text (placeholders stay visible if present in config) - when `[path]` scopes into a subpath, header appends: - `scope=` - text mode only prints non-empty phase blocks. diff --git a/docs/user/configuration.md b/docs/user/configuration.md index 23ec5e1..fcd5e5b 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -18,6 +18,8 @@ The default config filename is `.dotfiles-manager.yaml`. syncs: - target: .config/nvim source: .config/nvim + - target: ./ + source: "./$HOSTNAME/$USER" on: deploy: remove-unmanaged: @@ -54,8 +56,14 @@ Runtime validation is still authoritative. ## `syncs[]` keys Required: -- `target` — relative to `$HOME` -- `source` — relative to config file directory +- `target` — relative to `$HOME` after env expansion +- `source` — relative to config file directory after env expansion + +Env expansion in paths: +- supported in `target` and `source`: `$VAR` and `${VAR}` +- expansion runs before path normalization and validation +- missing or empty env values are errors +- expanded paths must still be relative and must not escape base via `..` Optional behavior keys: - `on.deploy.remove-unmanaged` — patterns for unmanaged target paths removed during `deploy` @@ -83,7 +91,7 @@ Optional behavior keys: ## Path and order behavior -- Config paths are relative-only. +- Config paths are relative-only after env expansion. - Path normalization is lexical. - Paths must not escape their base via `..` after normalization. - Overlapping syncs are allowed. diff --git a/docs/user/faq.md b/docs/user/faq.md index d4304bd..e5b2d9b 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -20,6 +20,19 @@ Use `status` first to preview both sides. `source` is the manifest/source of truth. Command direction (`deploy` vs `import`) determines which side updates the other. +## Can I use environment variables in `source`/`target` paths? + +Yes. + +Supported syntax: +- `$VAR` +- `${VAR}` + +Rules: +- expansion happens before path validation +- missing or empty env values are errors +- expanded paths must still be relative and must not escape base directories via `..` + ## How does `[path]` filtering work? A command-scoped `[path]` only selects syncs where that path is the sync target or inside it. Parent-of-target paths do not match. diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index 6306dc0..954f64e 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -35,13 +35,19 @@ Use YAML (default filename: `.dotfiles-manager.yaml` in your current working dir syncs: - target: .config/nvim source: .config/nvim + - target: ./ + source: "./$HOSTNAME/$USER" ``` Path rules: -- `target` is relative to `$HOME` -- `source` is relative to the directory containing the config file -- config paths must be relative (no absolute paths) +- `target` is relative to `$HOME` after env expansion +- `source` is relative to the directory containing the config file after env expansion +- config paths must be relative after env expansion (no absolute paths) - config paths must not escape base directories via `..` after normalization +- env vars are supported in `target`/`source`: `$VAR` and `${VAR}` +- expansion happens before path validation +- missing or empty env values are errors +- expanded path must still be relative and non-escaping Optional editor schema: - `docs/internal/contracts/config-schema.json` diff --git a/internal/app/cli.go b/internal/app/cli.go index dd9a53c..81d4d6d 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -488,8 +488,17 @@ func selectSyncs(cfg *config.Config, configPath string, pathInput any, normalize pathValue, hasScope := normalizedPath.(string) for idx, syncCfg := range cfg.Syncs { - targetRoot := filepath.Clean(filepath.Join(home, syncCfg.Target)) - sourceRoot := filepath.Clean(filepath.Join(configDir, syncCfg.Source)) + targetPath, err := config.ExpandSyncPath(syncCfg.Target, fmt.Sprintf("syncs[%d].target", idx)) + if err != nil { + return nil, err + } + sourcePath, err := config.ExpandSyncPath(syncCfg.Source, fmt.Sprintf("syncs[%d].source", idx)) + if err != nil { + return nil, err + } + + targetRoot := filepath.Clean(filepath.Join(home, targetPath)) + sourceRoot := filepath.Clean(filepath.Join(configDir, sourcePath)) if !hasScope { selections = append(selections, syncSelection{ diff --git a/internal/app/cli_helpers_test.go b/internal/app/cli_helpers_test.go index 543bd91..19d23ec 100644 --- a/internal/app/cli_helpers_test.go +++ b/internal/app/cli_helpers_test.go @@ -128,6 +128,39 @@ func TestSelectSyncsMultipleMatches(t *testing.T) { require.Equal(t, "", selected[1].ScopePrefix) } +func TestSelectSyncsExpandsEnvPaths(t *testing.T) { + t.Setenv("DFM_TEST_HOST_ENV", "host-a") + t.Setenv("DFM_TEST_USER_ENV", "alice") + + cfgPath := filepath.Join(t.TempDir(), ".dotfiles-manager.yaml") + cfg := &config.Config{Syncs: []config.Sync{ + {Target: ".config/$DFM_TEST_HOST_ENV", Source: "./$DFM_TEST_USER_ENV/global"}, + }} + + selected, err := selectSyncs(cfg, cfgPath, nil, nil) + require.NoError(t, err) + require.Len(t, selected, 1) + + home, err := os.UserHomeDir() + require.NoError(t, err) + require.Equal(t, filepath.Join(home, ".config", "host-a"), selected[0].TargetRoot) + require.Equal(t, filepath.Join(filepath.Dir(cfgPath), "alice", "global"), selected[0].SourceRoot) +} + +func TestSelectSyncsRejectsMissingEnvPath(t *testing.T) { + t.Setenv("DFM_TEST_EMPTY_ENV", "") + + cfgPath := filepath.Join(t.TempDir(), ".dotfiles-manager.yaml") + cfg := &config.Config{Syncs: []config.Sync{ + {Target: ".config/nvim", Source: "./$DFM_TEST_EMPTY_ENV/global"}, + }} + + selected, err := selectSyncs(cfg, cfgPath, nil, nil) + require.Nil(t, selected) + require.Error(t, err) + require.Equal(t, dfmerr.CodeConfigPathEnvUndefined, dfmerr.MustCode(err)) +} + func TestIsWithinTarget(t *testing.T) { t.Parallel() target := filepath.Join("/tmp", "root") diff --git a/internal/config/loader.go b/internal/config/loader.go index a91be0d..6c9a1e6 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -53,6 +53,8 @@ type ResolveOptions struct { Stat func(string) (os.FileInfo, error) } +type lookupEnvFunc func(string) (string, bool) + func ResolvePath(opts ResolveOptions) (string, error) { getenv := opts.Getenv if getenv == nil { @@ -163,6 +165,10 @@ func extractUnknownKey(message string) string { } func Validate(cfg *Config) error { + return validateWithLookup(cfg, os.LookupEnv) +} + +func validateWithLookup(cfg *Config, lookup lookupEnvFunc) error { if cfg == nil { return dfmerr.New(dfmerr.CodeConfigParse, "Failed to parse YAML config: ", nil) } @@ -181,10 +187,10 @@ func Validate(cfg *Config) error { return dfmerr.New(dfmerr.CodeConfigSchemaRequired, fmt.Sprintf("Missing required key: %s", sourceKey), map[string]any{"key_path": sourceKey}) } - if err := validateRelative(sync.Target, targetKey); err != nil { + if _, err := expandAndValidateSyncPath(sync.Target, targetKey, lookup); err != nil { return err } - if err := validateRelative(sync.Source, sourceKey); err != nil { + if _, err := expandAndValidateSyncPath(sync.Source, sourceKey, lookup); err != nil { return err } } @@ -192,9 +198,130 @@ func Validate(cfg *Config) error { return nil } +func ExpandSyncPath(value string, keyPath string) (string, error) { + return expandAndValidateSyncPath(value, keyPath, os.LookupEnv) +} + +func expandAndValidateSyncPath(value string, keyPath string, lookup lookupEnvFunc) (string, error) { + expanded, err := expandPathPlaceholders(value, keyPath, lookup) + if err != nil { + return "", err + } + if err := validateRelative(expanded, keyPath); err != nil { + return "", err + } + return expanded, nil +} + +func expandPathPlaceholders(value string, keyPath string, lookup lookupEnvFunc) (string, error) { + if lookup == nil { + lookup = os.LookupEnv + } + + var out strings.Builder + out.Grow(len(value)) + + for idx := 0; idx < len(value); { + if value[idx] != '$' { + out.WriteByte(value[idx]) + idx++ + continue + } + + if idx+1 >= len(value) { + out.WriteByte('$') + idx++ + continue + } + + if value[idx+1] == '{' { + closeOffset := strings.IndexByte(value[idx+2:], '}') + if closeOffset < 0 { + return "", dfmerr.New( + dfmerr.CodeConfigSchemaType, + fmt.Sprintf("Invalid env placeholder in path: %s", keyPath), + map[string]any{"key_path": keyPath}, + ) + } + + name := value[idx+2 : idx+2+closeOffset] + if !isValidEnvName(name) { + return "", dfmerr.New( + dfmerr.CodeConfigSchemaType, + fmt.Sprintf("Invalid env placeholder in path: %s", keyPath), + map[string]any{"key_path": keyPath}, + ) + } + + val, ok := lookup(name) + if !ok || val == "" { + return "", dfmerr.New( + dfmerr.CodeConfigPathEnvUndefined, + fmt.Sprintf("Environment variable %s required for path: %s", name, keyPath), + map[string]any{"key_path": keyPath, "var": name}, + ) + } + + out.WriteString(val) + idx += closeOffset + 3 + continue + } + + next := value[idx+1] + if !isEnvVarStart(next) { + out.WriteByte('$') + idx++ + continue + } + + end := idx + 2 + for end < len(value) && isEnvVarPart(value[end]) { + end++ + } + + name := value[idx+1 : end] + val, ok := lookup(name) + if !ok || val == "" { + return "", dfmerr.New( + dfmerr.CodeConfigPathEnvUndefined, + fmt.Sprintf("Environment variable %s required for path: %s", name, keyPath), + map[string]any{"key_path": keyPath, "var": name}, + ) + } + + out.WriteString(val) + idx = end + } + + return out.String(), nil +} + +func isValidEnvName(name string) bool { + if name == "" { + return false + } + if !isEnvVarStart(name[0]) { + return false + } + for idx := 1; idx < len(name); idx++ { + if !isEnvVarPart(name[idx]) { + return false + } + } + return true +} + +func isEnvVarStart(ch byte) bool { + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch == '_' +} + +func isEnvVarPart(ch byte) bool { + return isEnvVarStart(ch) || (ch >= '0' && ch <= '9') +} + func validateRelative(value, keyPath string) error { trimmed := strings.TrimSpace(value) - if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "~") || strings.HasPrefix(trimmed, "$") || strings.Contains(trimmed, "${") { + if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "~") { return dfmerr.New(dfmerr.CodeConfigPathNotRelative, fmt.Sprintf("Path must be relative: %s", keyPath), map[string]any{"key_path": keyPath}) } diff --git a/internal/config/loader_additional_test.go b/internal/config/loader_additional_test.go index 93f7dfd..1924e0a 100644 --- a/internal/config/loader_additional_test.go +++ b/internal/config/loader_additional_test.go @@ -108,14 +108,117 @@ func TestValidateRelativeSuccess(t *testing.T) { require.NoError(t, err) } -func TestValidateRejectsTildeAndEnvLikePaths(t *testing.T) { +func TestValidateRejectsTildeAndAbsoluteEnvExpansion(t *testing.T) { t.Parallel() - err := Validate(&Config{Syncs: []Sync{{Target: "~/.config/nvim", Source: ".config/nvim"}}}) + err := validateWithLookup( + &Config{Syncs: []Sync{{Target: "~/.config/nvim", Source: ".config/nvim"}}}, + func(string) (string, bool) { return "", false }, + ) require.Error(t, err) require.Equal(t, dfmerr.CodeConfigPathNotRelative, dfmerr.MustCode(err)) - err = Validate(&Config{Syncs: []Sync{{Target: ".config/nvim", Source: "$HOME/nvim"}}}) + err = validateWithLookup( + &Config{Syncs: []Sync{{Target: ".config/nvim", Source: "$HOME/nvim"}}}, + func(key string) (string, bool) { + if key == "HOME" { + return "/Users/shpoont", true + } + return "", false + }, + ) require.Error(t, err) require.Equal(t, dfmerr.CodeConfigPathNotRelative, dfmerr.MustCode(err)) } + +func TestValidateAllowsEnvPlaceholders(t *testing.T) { + t.Parallel() + + err := validateWithLookup( + &Config{Syncs: []Sync{{Target: ".config/$HOSTNAME", Source: "./$HOSTNAME/$USER"}}}, + func(key string) (string, bool) { + switch key { + case "HOSTNAME": + return "mbp", true + case "USER": + return "alice", true + default: + return "", false + } + }, + ) + require.NoError(t, err) +} + +func TestValidateRejectsMissingOrEmptyEnvPlaceholders(t *testing.T) { + t.Parallel() + + err := validateWithLookup( + &Config{Syncs: []Sync{{Target: ".config/$HOSTNAME", Source: ".config/nvim"}}}, + func(string) (string, bool) { return "", false }, + ) + require.Error(t, err) + require.Equal(t, dfmerr.CodeConfigPathEnvUndefined, dfmerr.MustCode(err)) + + err = validateWithLookup( + &Config{Syncs: []Sync{{Target: ".config/nvim", Source: "./$USER"}}}, + func(key string) (string, bool) { + if key == "USER" { + return "", true + } + return "", false + }, + ) + require.Error(t, err) + require.Equal(t, dfmerr.CodeConfigPathEnvUndefined, dfmerr.MustCode(err)) +} + +func TestExpandSyncPathRejectsInvalidPlaceholderAndEscape(t *testing.T) { + t.Parallel() + + _, err := expandPathPlaceholders("./${HOSTNAME", "syncs[0].source", func(string) (string, bool) { return "", false }) + require.Error(t, err) + require.Equal(t, dfmerr.CodeConfigSchemaType, dfmerr.MustCode(err)) + + _, err = expandAndValidateSyncPath("./$ENV", "syncs[0].source", func(key string) (string, bool) { + if key == "ENV" { + return "../escape", true + } + return "", false + }) + require.Error(t, err) + require.Equal(t, dfmerr.CodeConfigPathEscape, dfmerr.MustCode(err)) +} + +func TestExpandSyncPathSupportsDollarAndBracedVars(t *testing.T) { + t.Setenv("DFM_EXPAND_HOST", "mbp") + t.Setenv("DFM_EXPAND_USER", "alice") + + expanded, err := ExpandSyncPath("./$DFM_EXPAND_HOST/$DFM_EXPAND_USER", "syncs[0].source") + require.NoError(t, err) + require.Equal(t, "mbp/alice", filepath.ToSlash(filepath.Clean(expanded))) + + expanded, err = ExpandSyncPath("./${DFM_EXPAND_HOST}/${DFM_EXPAND_USER}", "syncs[0].source") + require.NoError(t, err) + require.Equal(t, "mbp/alice", filepath.ToSlash(filepath.Clean(expanded))) +} + +func TestExpandSyncPathTreatsBareDollarAsLiteral(t *testing.T) { + t.Parallel() + + expanded, err := ExpandSyncPath("./foo/$/bar", "syncs[0].source") + require.NoError(t, err) + require.Equal(t, "foo/$/bar", filepath.ToSlash(filepath.Clean(expanded))) +} + +func TestExpandSyncPathRejectsInvalidBracedName(t *testing.T) { + t.Parallel() + + _, err := expandPathPlaceholders("./${1BAD}/path", "syncs[0].source", func(string) (string, bool) { return "", false }) + require.Error(t, err) + require.Equal(t, dfmerr.CodeConfigSchemaType, dfmerr.MustCode(err)) + + _, err = expandPathPlaceholders("./${BAD-NAME}/path", "syncs[0].source", func(string) (string, bool) { return "", false }) + require.Error(t, err) + require.Equal(t, dfmerr.CodeConfigSchemaType, dfmerr.MustCode(err)) +} diff --git a/internal/dfmerr/errors.go b/internal/dfmerr/errors.go index ff22d0d..ecb13df 100644 --- a/internal/dfmerr/errors.go +++ b/internal/dfmerr/errors.go @@ -17,6 +17,7 @@ const ( CodeConfigSchemaRequired Code = "DFM_CONFIG_SCHEMA_REQUIRED" CodeConfigPathNotRelative Code = "DFM_CONFIG_PATH_NOT_RELATIVE" CodeConfigPathEscape Code = "DFM_CONFIG_PATH_ESCAPE" + CodeConfigPathEnvUndefined Code = "DFM_CONFIG_PATH_ENV_VAR_UNDEFINED" CodeFlagUnsupported Code = "DFM_FLAG_UNSUPPORTED" CodeFlagInvalidValue Code = "DFM_FLAG_INVALID_VALUE"