From 64866e9f44acbc0afc5e81d178c66e872e0f8798 Mon Sep 17 00:00:00 2001 From: mochi-yu Date: Sun, 22 Mar 2026 23:03:01 +0900 Subject: [PATCH 1/2] feat: add YAML configuration file support (--config) Load options from a YAML file and merge into flags; CLI flags override file values when both are set. Add JSON Schema, example YAML, README section, and tests for loadConfigFile. --- README.md | 40 +++++ config.example.yaml | 14 ++ config.go | 86 ++++++++++ config.schema.json | 48 ++++++ config_test.go | 385 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- main.go | 5 + 7 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 config.example.yaml create mode 100644 config.go create mode 100644 config.schema.json create mode 100644 config_test.go diff --git a/README.md b/README.md index 6660491..164b798 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ one or more structs have circular references. To change a method name of deep copying, use `--method` option. +To use a configuration file instead of command-line flags, use `--config` option. +The configuration file should be in YAML format. See `config.example.yaml` for an example. + ## Usage Pass either path to the folder containing the types or the module name: @@ -62,10 +65,12 @@ deep-copy /path/to/package/containing/type deep-copy github.com/globusdigital/deep-copy deep-copy github.com/globusdigital/deep-copy/some/sub/packages ``` + Here is the full set of supported flags: ```bash deep-copy \ + [--config /path/to/config.yaml] \ [-o /output/path.go] \ [--method DeepCopy] \ [--pointer-receiver] \ @@ -75,6 +80,41 @@ deep-copy \ /path/to/package/containing/type ``` +### Configuration File + +Instead of using command-line flags, you can use a YAML configuration file: + +```bash +deep-copy --config config.yaml /path/to/package/containing/type +``` + +Example configuration file (`config.example.yaml`): + +```yaml +pointer-receiver: true +maxdepth: 5 +method: DeepCopy +type: + - MyType +skip: + - Field1 + - Field2 +output: output.go +build-tags: + - custom + - build +``` + +All fields in the configuration file are optional. + +**Priority order**: When both a configuration file and command-line flags are provided, the priority is as follows (highest to lowest): + +1. **Command-line flags** (highest priority) +2. Configuration file values +3. Default values (lowest priority) + +For example, if `config.yaml` contains `method: DeepCopy` and you run `deep-copy --config config.yaml --method Clone`, the `--method Clone` flag will take precedence, and `Clone` will be used as the method name. + ## Example Given the following types: diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..538587a --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=config.schema.json + +pointer-receiver: true +maxdepth: 5 +method: DeepCopy +type: + - MyType +skip: + - Field1 + - Field2 +output: output.go +build-tags: + - custom + - build diff --git a/config.go b/config.go new file mode 100644 index 0000000..f5690d2 --- /dev/null +++ b/config.go @@ -0,0 +1,86 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type config struct { + PointerReceiver *bool `yaml:"pointer-receiver,omitempty"` + MaxDepth *int `yaml:"maxdepth,omitempty"` + Method *string `yaml:"method,omitempty"` + + Types []string `yaml:"type,omitempty"` + Skips []string `yaml:"skip,omitempty"` + OutputPath *string `yaml:"output,omitempty"` + BuildTags []string `yaml:"build-tags,omitempty"` +} + +func loadConfig() error { + flagsSetOnCLI := make(map[string]struct{}) + flag.Visit(func(f *flag.Flag) { flagsSetOnCLI[f.Name] = struct{}{} }) + return loadConfigFile(strings.TrimSpace(*configFileF), flagsSetOnCLI) +} + +// loadConfigFile reads YAML from path and merges into package-level flags. +// flagsSetOnCLI is the set of flag names that appeared on the command line; those are not overwritten from the file. +// An empty path is a no-op. +func loadConfigFile(configPath string, flagsSetOnCLI map[string]struct{}) error { + if strings.TrimSpace(configPath) == "" { + return nil + } + + file, err := os.Open(configPath) + if err != nil { + return fmt.Errorf("opening config file: %w", err) + } + defer file.Close() + + var cfg config + decoder := yaml.NewDecoder(file) + if err := decoder.Decode(&cfg); err != nil { + return fmt.Errorf("decoding config file: %w", err) + } + + mergePtr(flagsSetOnCLI, "pointer-receiver", cfg.PointerReceiver, pointerReceiverF) + mergePtr(flagsSetOnCLI, "maxdepth", cfg.MaxDepth, maxDepthF) + mergePtr(flagsSetOnCLI, "method", cfg.Method, methodF) + + if len(cfg.Types) > 0 && !flagWasSetOnCLI(flagsSetOnCLI, "type") { + typesF = typesVal(cfg.Types) + } + if len(cfg.Skips) > 0 && !flagWasSetOnCLI(flagsSetOnCLI, "skip") { + skipsF = skipsVal{} + for _, skip := range cfg.Skips { + if err := skipsF.Set(skip); err != nil { + return fmt.Errorf("parsing skip value: %w", err) + } + } + } + if cfg.OutputPath != nil && !flagWasSetOnCLI(flagsSetOnCLI, "o") { + if err := outputF.Set(*cfg.OutputPath); err != nil { + return fmt.Errorf("setting output: %w", err) + } + } + if len(cfg.BuildTags) > 0 && !flagWasSetOnCLI(flagsSetOnCLI, "tags") { + buildTagsF = buildTagsVal(cfg.BuildTags) + } + + return nil +} + +func flagWasSetOnCLI(flagsSetOnCLI map[string]struct{}, name string) bool { + _, ok := flagsSetOnCLI[name] + return ok +} + +func mergePtr[T any](flagsSetOnCLI map[string]struct{}, name string, src, dst *T) { + if src == nil || flagWasSetOnCLI(flagsSetOnCLI, name) { + return + } + *dst = *src +} diff --git a/config.schema.json b/config.schema.json new file mode 100644 index 0000000..645c865 --- /dev/null +++ b/config.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/globusdigital/deep-copy/config.schema.json", + "title": "Deep Copy Configuration Schema", + "description": "Configuration schema for deep-copy tool", + "type": "object", + "properties": { + "pointer-receiver": { + "type": "boolean", + "description": "Specify whether to use a pointer receiver for the generated method. The flag also governs whether the return type is a pointer as well." + }, + "maxdepth": { + "type": "integer", + "description": "Specify the maximum depth of deep copying. It stops deep copying at a given depth, with a warning message indicating where the deep copying has been stopped. This is especially useful when one or more structs have circular references.", + "minimum": 0 + }, + "method": { + "type": "string", + "description": "Change the method name of deep copying. Defaults to 'DeepCopy'." + }, + "type": { + "type": "array", + "description": "List of type names to generate deep copy methods for. Multiple types can be specified for the given package.", + "items": { + "type": "string" + } + }, + "skip": { + "type": "array", + "description": "field/slice/map selectors to shallow copy instead of deep copy, one YAML string per entry. Within each string, use commas to separate multiple selectors (same as repeated --skip flags on the CLI). Match the number of entries to the number of types when using multiple types. Use field selectors like 'B' to skip a field, 'B.I' to skip an inner field, '[i]' for slice members, and '[k]' for map members.", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "description": "Output file path for the generated code. Defaults to STDOUT if not specified." + }, + "build-tags": { + "type": "array", + "description": "Build tags to add to the generated code file (one tag per array element; same as repeating --tags on the CLI).", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..4f8d915 --- /dev/null +++ b/config_test.go @@ -0,0 +1,385 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func closeOpenOutputIfNotStdout() { + if outputF.file != nil && outputF.file != os.Stdout { + _ = outputF.file.Close() + } +} + +type globalsSnapshot struct { + pointerReceiver bool + maxDepth int + method string + types typesVal + skips skipsVal + buildTags buildTagsVal + output outputVal +} + +func captureGlobals() globalsSnapshot { + return globalsSnapshot{ + pointerReceiver: *pointerReceiverF, + maxDepth: *maxDepthF, + method: *methodF, + types: append(typesVal(nil), typesF...), + skips: cloneSkips(skipsF), + buildTags: append(buildTagsVal(nil), buildTagsF...), + output: outputF, + } +} + +func restoreGlobals(s globalsSnapshot) { + closeOpenOutputIfNotStdout() + *pointerReceiverF = s.pointerReceiver + *maxDepthF = s.maxDepth + *methodF = s.method + typesF = append(typesVal(nil), s.types...) + skipsF = cloneSkips(s.skips) + buildTagsF = append(buildTagsVal(nil), s.buildTags...) + outputF = s.output +} + +func resetGlobalsForConfigTest() { + closeOpenOutputIfNotStdout() + *pointerReceiverF = false + *maxDepthF = 0 + *methodF = "DeepCopy" + typesF = nil + skipsF = nil + buildTagsF = nil + outputF = outputVal{} +} + +// configTestCLI is the simulated CLI state (package-level flags) before merging the config file. +type configTestCLI struct { + PointerReceiver *bool + MaxDepth *int + Method *string + Types typesVal + Skips skipsVal + BuildTags buildTagsVal + OutputBasename string // basename under t.TempDir(); used when "o" is in flagsSetOnCLI +} + +// configTestWant is the expected flag state after loadConfigFile. +type configTestWant struct { + Pointer *bool + MaxDepth *int + Method *string + Types typesVal + Skips skipsVal + BuildTags buildTagsVal + OutputName string // empty = stdout +} + +func cloneSkips(s skipsVal) skipsVal { + if len(s) == 0 { + return nil + } + out := make(skipsVal, len(s)) + for i, m := range s { + out[i] = make(map[string]struct{}, len(m)) + for k := range m { + out[i][k] = struct{}{} + } + } + return out +} + +func applyConfigTestCLI(t *testing.T, flags map[string]struct{}, cli configTestCLI) (wantOutputName string) { + t.Helper() + if len(flags) == 0 { + return "" + } + if flagWasSetOnCLI(flags, "pointer-receiver") && cli.PointerReceiver != nil { + *pointerReceiverF = *cli.PointerReceiver + } + if flagWasSetOnCLI(flags, "maxdepth") && cli.MaxDepth != nil { + *maxDepthF = *cli.MaxDepth + } + if flagWasSetOnCLI(flags, "method") && cli.Method != nil { + *methodF = *cli.Method + } + if flagWasSetOnCLI(flags, "type") && len(cli.Types) > 0 { + typesF = append(typesVal(nil), cli.Types...) + } + if flagWasSetOnCLI(flags, "skip") && len(cli.Skips) > 0 { + skipsF = cloneSkips(cli.Skips) + } + if flagWasSetOnCLI(flags, "tags") && len(cli.BuildTags) > 0 { + buildTagsF = append(buildTagsVal(nil), cli.BuildTags...) + } + if flagWasSetOnCLI(flags, "o") && cli.OutputBasename != "" { + p := filepath.Join(t.TempDir(), cli.OutputBasename) + if err := outputF.Set(p); err != nil { + t.Fatal(err) + } + return p + } + return "" +} + +type configTestExtra struct { + configPath string + wantMethod *string + wantOutputName string +} + +// cliFlagsSet returns a set of flag names as loadConfigFile expects for flagsSetOnCLI. +func cliFlagsSet(names ...string) map[string]struct{} { + m := make(map[string]struct{}, len(names)) + for _, n := range names { + m[n] = struct{}{} + } + return m +} + +func writeTempConfigYAML(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "cfg.yaml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func assertGlobalsMatchWant(t *testing.T, want configTestWant, wantOutputName string) { + t.Helper() + if want.Pointer != nil && *pointerReceiverF != *want.Pointer { + t.Errorf("pointerReceiverF = %v, want %v", *pointerReceiverF, *want.Pointer) + } + if want.MaxDepth != nil && *maxDepthF != *want.MaxDepth { + t.Errorf("maxDepthF = %v, want %v", *maxDepthF, *want.MaxDepth) + } + if want.Method != nil && *methodF != *want.Method { + t.Errorf("methodF = %v, want %v", *methodF, *want.Method) + } + if want.Types != nil { + if diff := cmp.Diff(typesF, want.Types); diff != "" { + t.Errorf("typesF (-got +want):\n%s", diff) + } + } + if want.Skips != nil { + if diff := cmp.Diff(skipsF, want.Skips); diff != "" { + t.Errorf("skipsF (-got +want):\n%s", diff) + } + } + if want.BuildTags != nil { + if diff := cmp.Diff(buildTagsF, want.BuildTags); diff != "" { + t.Errorf("buildTagsF (-got +want):\n%s", diff) + } + } + if wantOutputName != "" && outputF.name != wantOutputName { + t.Errorf("outputF.name = %q, want %q", outputF.name, wantOutputName) + } +} + +func ptr[T any](v T) *T { + return &v +} + +func Test_loadConfigFile(t *testing.T) { + tests := []struct { + name string + configYAML string + configPath string // use as-is when configYAML == "" and non-empty (missing file) + flagsSetOnCLI map[string]struct{} + cli configTestCLI + prepare func(t *testing.T) configTestExtra // optional: dynamic config path / want overrides + wantErr bool + want configTestWant + }{ + { + name: "empty path is no-op", + wantErr: false, + }, + { + name: "missing file", + configPath: filepath.Join(t.TempDir(), "nope.yaml"), + wantErr: true, + }, + { + name: "invalid YAML", + configYAML: "invalid: yaml: [", + wantErr: true, + }, + { + name: "applies all fields when no CLI flags", + configYAML: `pointer-receiver: true +maxdepth: 5 +method: Clone +type: + - A + - B +skip: + - Field1,Field2 + - Field3 +build-tags: + - t1 + - t2`, + wantErr: false, + want: configTestWant{ + Pointer: ptr(true), + MaxDepth: ptr(5), + Method: ptr("Clone"), + Types: typesVal{"A", "B"}, + Skips: skipsVal{ + {"Field1": {}, "Field2": {}}, + {"Field3": {}}, + }, + BuildTags: buildTagsVal{"t1", "t2"}, + }, + }, + { + name: "CLI method flag is not overwritten by config", + configYAML: `method: FromConfig +maxdepth: 3`, + flagsSetOnCLI: cliFlagsSet("method"), + cli: configTestCLI{Method: ptr("KeepCLI")}, + wantErr: false, + want: configTestWant{ + MaxDepth: ptr(3), + Method: ptr("KeepCLI"), + }, + }, + { + name: "CLI maxdepth flag is not overwritten by config", + configYAML: `maxdepth: 99 +method: M`, + flagsSetOnCLI: cliFlagsSet("maxdepth"), + cli: configTestCLI{MaxDepth: ptr(7)}, + wantErr: false, + want: configTestWant{ + Method: ptr("M"), + MaxDepth: ptr(7), + }, + }, + { + name: "CLI type flag is not overwritten by config", + configYAML: `type: + - FromYAML +pointer-receiver: true`, + flagsSetOnCLI: cliFlagsSet("type"), + cli: configTestCLI{Types: typesVal{"FromCLI"}}, + wantErr: false, + want: configTestWant{ + Pointer: ptr(true), + Types: typesVal{"FromCLI"}, + }, + }, + { + name: "CLI skip flag is not overwritten by config", + configYAML: `skip: + - X +pointer-receiver: true`, + flagsSetOnCLI: cliFlagsSet("skip"), + cli: configTestCLI{Skips: skipsVal{{"Y": {}}}}, + wantErr: false, + want: configTestWant{ + Pointer: ptr(true), + Skips: skipsVal{{"Y": {}}}, + }, + }, + { + name: "CLI tags flag is not overwritten by config", + configYAML: `build-tags: + - fromyaml +method: Z`, + flagsSetOnCLI: cliFlagsSet("tags"), + cli: configTestCLI{BuildTags: buildTagsVal{"fromcli"}}, + wantErr: false, + want: configTestWant{ + Method: ptr("Z"), + BuildTags: buildTagsVal{"fromcli"}, + }, + }, + { + name: "CLI o flag is not overwritten by config", + configYAML: `output: "/yaml-only-path/should-be-ignored.go" +method: FromYAML`, + flagsSetOnCLI: cliFlagsSet("o"), + cli: configTestCLI{OutputBasename: "from_cli.go"}, + wantErr: false, + want: configTestWant{ + Method: ptr("FromYAML"), + }, + }, + { + name: "output path under temp dir", + configYAML: "", + prepare: func(t *testing.T) configTestExtra { + out := filepath.Join(t.TempDir(), "out.go") + cfg := filepath.Join(t.TempDir(), "cfg.yaml") + yaml := fmt.Sprintf("output: %q\nmethod: OutTest\n", out) + if err := os.WriteFile(cfg, []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + return configTestExtra{ + configPath: cfg, + wantMethod: ptr("OutTest"), + wantOutputName: out, + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + snap := captureGlobals() + defer restoreGlobals(snap) + + resetGlobalsForConfigTest() + + var path string + switch { + case tt.configYAML != "": + path = writeTempConfigYAML(t, tt.configYAML) + case tt.configPath != "": + path = tt.configPath + default: + path = "" + } + + extra := configTestExtra{} + if tt.prepare != nil { + extra = tt.prepare(t) + } + loadPath := path + if extra.configPath != "" { + loadPath = extra.configPath + } + + want := tt.want + if extra.wantMethod != nil { + want.Method = extra.wantMethod + } + wantOutputName := want.OutputName + if extra.wantOutputName != "" { + wantOutputName = extra.wantOutputName + } + if out := applyConfigTestCLI(t, tt.flagsSetOnCLI, tt.cli); out != "" { + wantOutputName = out + } + + err := loadConfigFile(loadPath, tt.flagsSetOnCLI) + if (err != nil) != tt.wantErr { + t.Fatalf("loadConfigFile() err = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + + assertGlobalsMatchWant(t, want, wantOutputName) + }) + } +} diff --git a/go.mod b/go.mod index a707762..d7ea0a8 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/go-cmp v0.6.0 github.com/stretchr/testify v1.9.0 golang.org/x/tools v0.36.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -13,5 +14,4 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/sync v0.16.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/main.go b/main.go index 9aec793..c758285 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( ) var ( + configFileF = flag.String("config", "", "path to config file (YAML format)") pointerReceiverF = flag.Bool("pointer-receiver", false, "the generated receiver type") maxDepthF = flag.Int("maxdepth", 0, "max depth of deep copying") methodF = flag.String("method", "DeepCopy", "deep copy method name") @@ -128,6 +129,10 @@ func init() { func main() { flag.Parse() + if err := loadConfig(); err != nil { + log.Fatalf("Error loading configuration: %v", err) + } + if len(typesF) == 0 || typesF[0] == "" { log.Fatalln("no type given") } From 7e66e25449f4e3e4bb133ceef2f1e36e82f78899 Mon Sep 17 00:00:00 2001 From: mochi-yu Date: Sun, 22 Mar 2026 23:07:03 +0900 Subject: [PATCH 2/2] chore: update README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 164b798..c5ab38e 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ deep-copy \ [-o /output/path.go] \ [--method DeepCopy] \ [--pointer-receiver] \ - [--skip Selector1,Selector.Two --skip Selector2[i], Selector.Three[k]] - [--type Type1 --type Type2\ \ - [--tags mytag,anotherTag ] \ \ + [--skip Selector1,Selector.Two --skip Selector2[i],Selector.Three[k]] \ + [--type Type1 --type Type2] \ + [--tags mytag,anotherTag] \ /path/to/package/containing/type ```