From 92995a851c236b0cf6286b59d7d17abd3f13c7ba Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 24 Apr 2026 03:03:08 -0500 Subject: [PATCH] feat(overlays): make source optional in file-add overlays When the destination filename matches the source filename (the common case), users no longer need to specify both `file` and `source`. For `file-add` overlays, `source` now defaults to the value of `file` when omitted. Closes #30 --- docs/user/reference/config/overlays.md | 14 ++++++- internal/projectconfig/overlay.go | 17 +++++--- internal/projectconfig/overlay_test.go | 56 ++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/docs/user/reference/config/overlays.md b/docs/user/reference/config/overlays.md index da5ca9eb..caf7f60e 100644 --- a/docs/user/reference/config/overlays.md +++ b/docs/user/reference/config/overlays.md @@ -42,7 +42,7 @@ successfully makes a replacement to at least one matching file. |------|-------------|-----------------|--------------------------------| | `file-prepend-lines` | Prepends lines to a file | `file`, `lines` | Glob pattern for files to transform | | `file-search-replace` | Regex-based search and replace on a file | `file`, `regex` | Glob pattern for files to transform | -| `file-add` | Copies a new file from a source location; **fails if destination already exists** | `file`, `source` | Name of destination file | +| `file-add` | Copies a new file from a source location; **fails if destination already exists** | `file` (required); `source` (optional, defaults to `file`) | Name of destination file | | `file-remove` | Removes a file | `file` | Glob pattern for files to remove | | `file-rename` | Renames a file within the same directory | `file`, `replacement` | Name of file to rename | @@ -60,7 +60,7 @@ successfully makes a replacement to at least one matching file. | Replacement | `replacement` | Literal replacement text; capture group references like `$1` are **not** expanded. Omit or leave empty to delete matched text. | `spec-search-replace`, `file-search-replace`, `file-rename` | | Lines | `lines` | Array of text lines to insert | `spec-prepend-lines`, `spec-append-lines`, `file-prepend-lines` | | File | `file` | The name of the non-spec file to modify or add | `file-prepend-lines`, `file-search-replace`, `file-add`, `file-remove`, `file-rename`, `patch-add` (optional), `patch-remove` | -| Source | `source` | Path to source file for `file-add` and `patch-add`; relative paths are relative to the config file | `file-add`, `patch-add` | +| Source | `source` | Path to source file for `file-add` and `patch-add`; relative paths are relative to the config file. For `file-add`, defaults to the value of `file` when omitted. | `file-add` (optional), `patch-add` | > **Note:** For `file-rename`, the `replacement` field is a **filename only** (not a path). The file is renamed within its current directory. @@ -201,6 +201,16 @@ source = "files/mypackage/extra-config.conf" description = "Add custom configuration file" ``` +When the destination filename matches the source filename, `source` can be +omitted and defaults to the value of `file`: + +```toml +[[components.mypackage.overlays]] +type = "file-add" +file = "extra-config.conf" +description = "Add custom configuration file" +``` + ### Removing a File ```toml diff --git a/internal/projectconfig/overlay.go b/internal/projectconfig/overlay.go index 2c603b9e..8c18d816 100644 --- a/internal/projectconfig/overlay.go +++ b/internal/projectconfig/overlay.go @@ -37,8 +37,9 @@ type ComponentOverlay struct { // For overlays that reference lines of text, the lines of text to use. Lines []string `toml:"lines,omitempty" json:"lines,omitempty" jsonschema:"title=Lines,description=The lines of text to use"` // For overlays that require a source file as input, indicates a path to that file; relative paths are relative to - // the config file that defines the overlay. - Source string `toml:"source,omitempty" json:"source,omitempty" jsonschema:"title=Source,description=For overlays that require a source file as input, indicates a path to that file; relative paths are relative to the config file that defines the overlay"` + // the config file that defines the overlay. For `file-add` overlays, this defaults to the value of `file` when + // omitted. + Source string `toml:"source,omitempty" json:"source,omitempty" jsonschema:"title=Source,description=For overlays that require a source file as input, indicates a path to that file; relative paths are relative to the config file that defines the overlay. For file-add overlays, defaults to the value of file when omitted."` } // WithAbsolutePaths returns a copy of the overlay with config-relative file paths converted to absolute @@ -53,6 +54,13 @@ func (c *ComponentOverlay) WithAbsolutePaths(referenceDir string) (result *Compo // here. result = deep.MustCopy(c) + // For `file-add` overlays, `source` defaults to the value of `file` when omitted, so that + // users don't have to repeat the same value when the destination filename matches the + // source filename (the common case). Apply the default before absolutizing. + if result.Type == ComponentOverlayAddFile && result.Source == "" { + result.Source = result.Filename + } + // Fix up paths. result.Source = makeAbsolute(referenceDir, result.Source) @@ -231,9 +239,8 @@ func (c *ComponentOverlay) Validate() error { return err } - if c.Source == "" { - return missingField("source") - } + // `source` is optional and defaults to the value of `file` when omitted + // (see ComponentOverlay.WithAbsolutePaths). case ComponentOverlayRemoveFile: if err := requireRelativePath("file", c.Filename); err != nil { return err diff --git a/internal/projectconfig/overlay_test.go b/internal/projectconfig/overlay_test.go index 4dd9f5a7..940a5897 100644 --- a/internal/projectconfig/overlay_test.go +++ b/internal/projectconfig/overlay_test.go @@ -254,13 +254,12 @@ func TestComponentOverlay_Validate(t *testing.T) { errorContains: "file", }, { - name: "file-add missing source", + name: "file-add missing source is valid (defaults to file)", overlay: projectconfig.ComponentOverlay{ Type: projectconfig.ComponentOverlayAddFile, Filename: "new-file.txt", }, - errorExpected: true, - errorContains: "source", + errorExpected: false, }, // Description included in error { @@ -441,3 +440,54 @@ func TestComponentOverlay_ModifiesSpec(t *testing.T) { }) } } + +func TestComponentOverlay_WithAbsolutePaths(t *testing.T) { + const testRefDir = "/ref/dir" + + t.Run("file-add uses explicit source when provided", func(t *testing.T) { + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayAddFile, + Filename: "dest.txt", + Source: "custom/source.txt", + } + + result := overlay.WithAbsolutePaths(testRefDir) + + assert.Equal(t, "/ref/dir/custom/source.txt", result.Source) + assert.Equal(t, "dest.txt", result.Filename) + }) + + t.Run("file-add defaults source to file when omitted", func(t *testing.T) { + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayAddFile, + Filename: "dest.txt", + } + + result := overlay.WithAbsolutePaths(testRefDir) + + assert.Equal(t, "/ref/dir/dest.txt", result.Source) + assert.Equal(t, "dest.txt", result.Filename) + }) + + t.Run("file-add source default does not mutate original overlay", func(t *testing.T) { + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayAddFile, + Filename: "dest.txt", + } + + _ = overlay.WithAbsolutePaths(testRefDir) + + assert.Empty(t, overlay.Source, "original overlay should not be mutated") + }) + + t.Run("non file-add overlays do not default source from file", func(t *testing.T) { + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlayAddPatch, + Filename: "dest.patch", + } + + result := overlay.WithAbsolutePaths(testRefDir) + + assert.Empty(t, result.Source, "patch-add should not default source from file") + }) +}