From b0f93fd437582708e79c7c4718a9ed29670fdd74 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Wed, 21 Oct 2026 16:22:45 +0100 Subject: [PATCH] config init now leaves out the test sources unless given --all A section tagged init:"hidden" isn't written to the file config init generates; fts_mock and sleeper are. config init --all writes them too. They still load, and the config commands read and set them as before. --- README.md | 2 +- internal/cli/config/config_test.go | 26 +++++++++++++++++++ internal/cli/config/init.go | 37 ++++++++++++++++----------- internal/config/README.md | 7 ++++- internal/config/conf_sections_test.go | 20 +++++++++++++++ internal/config/conf_sources.go | 6 +++-- internal/config/config.go | 24 +++++++++++++++++ internal/config/section.go | 30 ++++++++++++++++++++-- 8 files changed, 131 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9848734..90ff21d 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Global flags: `--config`/`-c` (config file), `--debug`/`-d` and | Command | Purpose | |---|---| -| `job-search config init [path]` | Create a config file | +| `job-search config init [path]` | Create a config file holding the defaults (`--all` also writes the test sources, `fts_mock` and `sleeper`) | | `job-search config show` | Print the configuration (`--json` or `--yaml` for those formats) | | `job-search config get ` | Print one value, e.g. `logging.level` | | `job-search config set ` | Validate and write one value to the config file | diff --git a/internal/cli/config/config_test.go b/internal/cli/config/config_test.go index de28a19..5836cc6 100644 --- a/internal/cli/config/config_test.go +++ b/internal/cli/config/config_test.go @@ -214,6 +214,32 @@ func TestConfigInit(t *testing.T) { } } +// The test sources are left out of the file unless --all is given. +func TestConfigInitTestSources(t *testing.T) { + cmdtest.Setup(t, "") + + mustRun(t, "config", "init", "./default.yaml") + written := cmdtest.ReadFile(t, "default.yaml") + cmdtest.Contains(t, written, "find_a_tender:", "eu_supply:") + for _, hidden := range []string{"fts_mock", "sleeper"} { + if strings.Contains(written, hidden) { + t.Errorf("init wrote %s without --all:\n%s", hidden, written) + } + } + + mustRun(t, "config", "init", "--all", "./all.yaml") + cmdtest.Contains( + t, cmdtest.ReadFile(t, "all.yaml"), "fts_mock:", "sleeper:", + ) + + // The test sources still load and are set as usual. + mustRun( + t, "--config", "default.yaml", + "config", "set", "sources.sleeper.enabled", "true", + ) + cmdtest.Contains(t, cmdtest.ReadFile(t, "default.yaml"), "sleeper:") +} + // With no path (or a blank one), init searches the default locations. The // working directory is one, so its config.yaml is found. (Not finding one // would write to /etc/job-search, which a test can't do.) diff --git a/internal/cli/config/init.go b/internal/cli/config/init.go index 67164cf..483ae48 100644 --- a/internal/cli/config/init.go +++ b/internal/cli/config/init.go @@ -17,7 +17,11 @@ func commandInit() *cobra.Command { cmd := &cobra.Command{ Use: "init [config_path]", Args: cobra.MatchAll(cobra.MinimumNArgs(0), cobra.MaximumNArgs(1)), - Short: "Create an empty configuration file", + Short: "Create a configuration file holding the defaults", + Long: `Create a configuration file holding the defaults, at config_path or, +without one, the first config.yaml found in /etc/job-search then the working +directory (/etc/job-search/config.yaml if there is none). The test sources +(fts_mock and sleeper) are left out unless --all is given.`, PersistentPreRun: func(cmd *cobra.Command, args []string) { slog.Debug("command persistent pre-run", "command", "config.init") }, @@ -64,26 +68,29 @@ func commandInit() *cobra.Command { return err } - // Load default configuration - config.LoadDefaultConfig() - - if viper.ConfigFileUsed() == "" { - viper.SetConfigFile(config.EtcConfigPath + "/config.yaml") + path := viper.ConfigFileUsed() + if path == "" { + path = config.EtcConfigPath + "/config.yaml" } - fmt.Print( - "writing default configuration to file: " + - viper.ConfigFileUsed() + - "\n", - ) - - if err := viper.WriteConfig(); err != nil { - return err + // A Viper of its own, holding only the keys to write: the global + // one would write every default, the hidden sections' included. + all, _ := cmd.Flags().GetBool("all") + generated := viper.New() + for key, value := range config.InitDefaults(all) { + generated.Set(key, value) } + generated.SetConfigFile(path) - return nil + fmt.Print("writing default configuration to file: " + path + "\n") + + return generated.WriteConfig() }, } + cmd.Flags().Bool( + "all", false, "also write the test sources (fts_mock and sleeper)", + ) + // Config flag should be hidden as the config path should be provided cmd.SetHelpFunc(func(command *cobra.Command, args []string) { command.Flags().MarkHidden("config") //nolint:errcheck,gosec // a root flag, so it always exists diff --git a/internal/config/README.md b/internal/config/README.md index ba4b73d..27e1584 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -267,6 +267,10 @@ per key: of the configuration. - `default:"value"` is the field's default, parsed as if it were a string passed to `SetValue` (e.g. `"10m"`, `"true"`). +- `init:"hidden"` on a nested section's field leaves the section out of the + file `config init` writes, unless `--all` is given (`InitDefaults`). The + test sources, `fts_mock` and `sleeper`, have it. The section is otherwise + as any other: loaded, and read and set by the `config` commands. - A tagged field whose type is a struct is a nested section, addressed as `section.key`. An embedded struct has its fields promoted, as Go does; every source embeds `JSCSourcesSource` for `enabled` and `interval`. @@ -324,7 +328,8 @@ To add a new section: `JSCRateLimitRule`, has no constant and passes `""` to `flattenOf`. 2. Add the struct as a tagged field of its parent: `JobSearchConfig` in `config.go`, or `JSCSources` in `conf_sources.go` for a source, - etc. The field's `cfg` tag must match the `JSCK*` constant. + etc. The field's `cfg` tag must match the `JSCK*` constant. Add + `init:"hidden"` if it's only for testing. Nothing in `section.go` needs changing. diff --git a/internal/config/conf_sections_test.go b/internal/config/conf_sections_test.go index 9b8ffaa..2162df6 100644 --- a/internal/config/conf_sections_test.go +++ b/internal/config/conf_sections_test.go @@ -288,6 +288,26 @@ func TestConcreteSourceSections(t *testing.T) { } } +// InitDefaults leaves out the sections tagged init:"hidden" unless asked. +func TestInitDefaults(t *testing.T) { + all := InitDefaults(true) + shown := InitDefaults(false) + if all["sources.sleeper.enabled"] != false || + all["sources.fts_mock.interval"] != "10m0s" { + t.Errorf("all = %v; want the test sources' defaults", all) + } + for key := range all { + hidden := strings.HasPrefix(key, "sources.fts_mock.") || + strings.HasPrefix(key, "sources.sleeper.") + if _, found := shown[key]; found == hidden { + t.Errorf("%s: in the generated defaults = %v", key, found) + } + } + if shown["sources.eu_supply.base_url"] != "https://uk.eu-supply.com" { + t.Errorf("eu_supply missing: %v", shown) + } +} + func TestEUSupplySection(t *testing.T) { conf := defaultConfig(t) got := conf.Sources.EUSupply diff --git a/internal/config/conf_sources.go b/internal/config/conf_sources.go index ccddf4c..c6dc900 100644 --- a/internal/config/conf_sources.go +++ b/internal/config/conf_sources.go @@ -13,15 +13,17 @@ const JSCKSources string = "sources" // JSCSources configures each source of opportunities, one field // per source. Each field's cfg tag must match the section's JSCK* constant. +// The test sources (FTSMock and Sleeper) are tagged init:"hidden", so +// `config init` only writes them with --all. // SkipRulesFile is the JSON file of rules that mark new opportunities // Skipped (see internal/skiprules), read again when it changes; empty means // none. type JSCSources struct { SkipRulesFile string `cfg:"skip_rules_file"` FindATender JSCSourceFindATender `cfg:"find_a_tender"` - FTSMock JSCSourceFTSMock `cfg:"fts_mock"` + FTSMock JSCSourceFTSMock `cfg:"fts_mock" init:"hidden"` EUSupply JSCSourceEUSupply `cfg:"eu_supply"` - Sleeper JSCSourceSleeper `cfg:"sleeper"` + Sleeper JSCSourceSleeper `cfg:"sleeper" init:"hidden"` } // ---------- Section methods (see Section) ---------- diff --git a/internal/config/config.go b/internal/config/config.go index a6c84fe..756d296 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,30 @@ func LoadDefaultConfig() { LoadToViper(conf) } +// InitDefaults returns the default configuration as dotted keys, as +// Flatten gives it, for `config init` to write. Sections tagged +// init:"hidden" (the test sources) are left out unless includeHidden is set. +func InitDefaults(includeHidden bool) map[string]any { + var conf JobSearchConfig + if err := applyDefaults(&conf); err != nil { + panic(fmt.Sprintf("config: %v", err)) + } + flat := conf.Flatten() + if includeHidden { + return flat + } + + hidden := hiddenFromInitKeys("", reflect.TypeFor[JobSearchConfig]()) + for _, section := range hidden { + for key := range flat { + if strings.HasPrefix(key, section+".") { + delete(flat, key) + } + } + } + return flat +} + // ---------- Section methods (see Section) ---------- // Map implements Section. diff --git a/internal/config/section.go b/internal/config/section.go index 2f77d61..fb96cee 100644 --- a/internal/config/section.go +++ b/internal/config/section.go @@ -50,6 +50,10 @@ const ( keyTag = "cfg" // defaultTag holds a field's default value. defaultTag = "default" + // initTag set to initHidden on a section field leaves the section out of + // the file `config init` writes (see InitDefaults), as for test sources. + initTag = "init" + initHidden = "hidden" ) // The unknown key error and the field types section.go handles specially. @@ -109,6 +113,8 @@ type field struct { section bool defaultValue string hasDefault bool + // hiddenFromInit is set by initTag (see InitDefaults). + hiddenFromInit bool } // isSectionList reports whether listType is a list of sections, []S for a @@ -146,8 +152,9 @@ func fieldsOf(structType reflect.Type) []field { fieldType: structField.Type, section: structField.Type.Kind() == reflect.Struct && structField.Type != timeType, - defaultValue: defaultValue, - hasDefault: hasDefault, + defaultValue: defaultValue, + hasDefault: hasDefault, + hiddenFromInit: structField.Tag.Get(initTag) == initHidden, }) } @@ -244,6 +251,25 @@ func flattenInto(flat map[string]any, prefix string, section reflect.Value) { } } +// hiddenFromInitKeys returns the dotted keys of the sections under section +// (prefixed with prefix, if non-empty) whose field has initTag set to +// initHidden, descending into nested sections. +func hiddenFromInitKeys(prefix string, sectionType reflect.Type) []string { + var hidden []string + for _, configField := range fieldsOf(sectionType) { + if !configField.section { + continue + } + key := joinKey(prefix, configField.key) + if configField.hiddenFromInit { + hidden = append(hidden, key) + continue + } + hidden = append(hidden, hiddenFromInitKeys(key, configField.fieldType)...) + } + return hidden +} + // exportValue returns a field's value as Map, Flatten and ValueOf give it // out: durations and byte sizes become strings such as "1m30s" and "32KiB", // so values agree with kindOf and can be passed back to SetValue; []string is