• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / tg-spam / 24966212061

26 Apr 2026 08:23PM UTC coverage: 83.078% (+0.1%) from 82.966%
24966212061

Pull #294

github

web-flow
fix: preserve InstanceID across loadConfigFromDB swap; rename collided subtests (#397)

* fix: preserve InstanceID across loadConfigFromDB swap; rename collided subtests

`loadConfigFromDB` does `*settings = *dbSettings` which clobbers the
CLI/env-supplied `InstanceID` with whatever the persisted blob carries.
External orchestrators that write per-instance config blobs without
embedding `instance_id` (e.g. tg-spam-manager) leave it empty, so after
the swap `settings.InstanceID == ""`. Initial load works because the
short-lived store inside loadConfigFromDB was already keyed by the
still-CLI value, but every subsequent `makeDB` call in `activateServer`
opens with `gid=""` — including the runtime SettingsStore.

Symptoms: `POST /config/reload` returns
`500 "no settings found in database: sql: no rows in result set"` from a
clean state. The first UI Save with `saveToDb=true` then writes a fresh
row under `gid=""`, so subsequent reloads succeed against a
manager-orphaned row, leaving manager-side updates and bot-side updates
on different rows.

Fix: snapshot `settings.InstanceID` before the swap and restore it when
the loaded blob's value is empty. A blob that does carry its own
non-empty `InstanceID` (saved by tg-spam itself) is still trusted, so
existing single-binary deployments are unaffected.

Same patch also gives unique names to the three subtests in
`TestDetector_CheckOpenAI` that collided post-master-merge — Go was
running them under `#01` suffixes which made `-run` filtering ambiguous.
Two are byte-identical to their earlier siblings; the third differs
slightly (uses `spam` instead of `viagra` and asserts the LoadStopWords
result), and the new name reflects that.

* add WARN on InstanceID divergence + regression test for empty-blob branch

Address review:

- log a [WARN] when the persisted blob carries a non-empty InstanceID
  that differs from the CLI/env value. Behaviour is unchanged (blob
  still wins) but the divergence shifts the runtime gi... (continued)
Pull Request #294: Implement database configuration support

1290 of 1591 new or added lines in 7 files covered. (81.08%)

10 existing lines in 2 files now uncovered.

8174 of 9839 relevant lines covered (83.08%)

232.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.33
/app/config/settings.go
1
// Package config provides the application settings domain model, a database-backed
2
// store, and a field-level crypter for sensitive values. It is the single source
3
// of truth for configuration consumed by the app regardless of whether values
4
// originate from CLI flags, environment variables, or the database.
5
package config
6

7
import (
8
        "reflect"
9
        "time"
10
)
11

12
// Settings represents application configuration independent of source (CLI, DB, etc)
13
type Settings struct {
14
        // core settings
15
        InstanceID string `json:"instance_id" yaml:"instance_id" db:"instance_id"`
16

17
        // group settings by domain
18
        Telegram      TelegramSettings      `json:"telegram" yaml:"telegram" db:"telegram"`
19
        Admin         AdminSettings         `json:"admin" yaml:"admin" db:"admin"`
20
        History       HistorySettings       `json:"history" yaml:"history" db:"history"`
21
        Logger        LoggerSettings        `json:"logger" yaml:"logger" db:"logger"`
22
        CAS           CASSettings           `json:"cas" yaml:"cas" db:"cas"`
23
        Meta          MetaSettings          `json:"meta" yaml:"meta" db:"meta"`
24
        OpenAI        OpenAISettings        `json:"openai" yaml:"openai" db:"openai"`
25
        Gemini        GeminiSettings        `json:"gemini" yaml:"gemini" db:"gemini"`
26
        LLM           LLMSettings           `json:"llm" yaml:"llm" db:"llm"`
27
        LuaPlugins    LuaPluginsSettings    `json:"lua_plugins" yaml:"lua_plugins" db:"lua_plugins"`
28
        AbnormalSpace AbnormalSpaceSettings `json:"abnormal_spacing" yaml:"abnormal_spacing" db:"abnormal_spacing"`
29
        Files         FilesSettings         `json:"files" yaml:"files" db:"files"`
30
        Message       MessageSettings       `json:"message" yaml:"message" db:"message"`
31
        Server        ServerSettings        `json:"server" yaml:"server" db:"server"`
32
        Delete        DeleteSettings        `json:"delete" yaml:"delete" db:"delete"`
33
        Duplicates    DuplicatesSettings    `json:"duplicates" yaml:"duplicates" db:"duplicates"`
34
        Reactions     ReactionsSettings     `json:"reactions" yaml:"reactions" db:"reactions"`
35
        Report        ReportSettings        `json:"report" yaml:"report" db:"report"`
36

37
        // spam detection settings
38
        SimilarityThreshold float64 `json:"similarity_threshold" yaml:"similarity_threshold" db:"similarity_threshold"`
39
        MinMsgLen           int     `json:"min_msg_len" yaml:"min_msg_len" db:"min_msg_len"`
40
        MaxEmoji            int     `json:"max_emoji" yaml:"max_emoji" db:"max_emoji"`
41
        MinSpamProbability  float64 `json:"min_spam_probability" yaml:"min_spam_probability" db:"min_spam_probability"`
42
        MultiLangWords      int     `json:"multi_lang_words" yaml:"multi_lang_words" db:"multi_lang_words"`
43

44
        // bot behavior settings
45
        NoSpamReply         bool `json:"no_spam_reply" yaml:"no_spam_reply" db:"no_spam_reply"`
46
        SuppressJoinMessage bool `json:"suppress_join_message" yaml:"suppress_join_message" db:"suppress_join_message"`
47

48
        // cleanup settings
49
        AggressiveCleanup      bool `json:"aggressive_cleanup" yaml:"aggressive_cleanup" db:"aggressive_cleanup"`
50
        AggressiveCleanupLimit int  `json:"aggressive_cleanup_limit" yaml:"aggressive_cleanup_limit" db:"aggressive_cleanup_limit"`
51

52
        // detection mode settings
53
        ParanoidMode       bool `json:"paranoid_mode" yaml:"paranoid_mode" db:"paranoid_mode"`
54
        FirstMessagesCount int  `json:"first_messages_count" yaml:"first_messages_count" db:"first_messages_count"`
55

56
        // operation mode settings
57
        Training   bool   `json:"training" yaml:"training" db:"training_mode"`
58
        SoftBan    bool   `json:"soft_ban" yaml:"soft_ban" db:"soft_ban_mode"`
59
        Convert    string `json:"convert" yaml:"convert" db:"convert_mode"`
60
        MaxBackups int    `json:"max_backups" yaml:"max_backups" db:"max_backups"`
61
        Dry        bool   `json:"dry" yaml:"dry" db:"dry_mode"`
62

63
        // transient fields that should never be stored
64
        Transient TransientSettings `json:"-" yaml:"-"`
65
}
66

67
// TelegramSettings contains Telegram-specific settings
68
type TelegramSettings struct {
69
        Group        string        `json:"group" yaml:"group" db:"telegram_group"`
70
        IdleDuration time.Duration `json:"idle_duration" yaml:"idle_duration" db:"telegram_idle_duration"`
71
        Timeout      time.Duration `json:"timeout" yaml:"timeout" db:"telegram_timeout"`
72
        Token        string        `json:"token" yaml:"token" db:"telegram_token"`
73
}
74

75
// AdminSettings contains admin-related settings
76
type AdminSettings struct {
77
        AdminGroup              string   `json:"admin_group" yaml:"admin_group" db:"admin_group"`
78
        DisableAdminSpamForward bool     `json:"disable_admin_spam_forward" yaml:"disable_admin_spam_forward" db:"disable_admin_spam_forward"`
79
        TestingIDs              []int64  `json:"testing_ids" yaml:"testing_ids" db:"testing_ids"`
80
        SuperUsers              []string `json:"super_users" yaml:"super_users" db:"super_users"`
81
}
82

83
// HistorySettings contains history-related settings
84
type HistorySettings struct {
85
        Duration time.Duration `json:"duration" yaml:"duration" db:"history_duration"`
86
        MinSize  int           `json:"min_size" yaml:"min_size" db:"history_min_size"`
87
        Size     int           `json:"size" yaml:"size" db:"history_size"`
88
}
89

90
// LoggerSettings contains logging-related settings
91
type LoggerSettings struct {
92
        Enabled    bool   `json:"enabled" yaml:"enabled" db:"logger_enabled"`
93
        FileName   string `json:"file_name" yaml:"file_name" db:"logger_file_name"`
94
        MaxSize    string `json:"max_size" yaml:"max_size" db:"logger_max_size"`
95
        MaxBackups int    `json:"max_backups" yaml:"max_backups" db:"logger_max_backups"`
96
}
97

98
// CASSettings contains Combot Anti-Spam System settings
99
type CASSettings struct {
100
        API       string        `json:"api" yaml:"api" db:"cas_api"`
101
        Timeout   time.Duration `json:"timeout" yaml:"timeout" db:"cas_timeout"`
102
        UserAgent string        `json:"user_agent" yaml:"user_agent" db:"cas_user_agent"`
103
}
104

105
// MetaSettings contains message metadata check settings
106
type MetaSettings struct {
107
        LinksLimit      int    `json:"links_limit" yaml:"links_limit" db:"meta_links_limit"`
108
        MentionsLimit   int    `json:"mentions_limit" yaml:"mentions_limit" db:"meta_mentions_limit"`
109
        ImageOnly       bool   `json:"image_only" yaml:"image_only" db:"meta_image_only"`
110
        LinksOnly       bool   `json:"links_only" yaml:"links_only" db:"meta_links_only"`
111
        VideosOnly      bool   `json:"videos_only" yaml:"videos_only" db:"meta_videos_only"`
112
        AudiosOnly      bool   `json:"audios_only" yaml:"audios_only" db:"meta_audios_only"`
113
        Forward         bool   `json:"forward" yaml:"forward" db:"meta_forward"`
114
        Keyboard        bool   `json:"keyboard" yaml:"keyboard" db:"meta_keyboard"`
115
        UsernameSymbols string `json:"username_symbols" yaml:"username_symbols" db:"meta_username_symbols"`
116
        ContactOnly     bool   `json:"contact_only" yaml:"contact_only" db:"meta_contact_only"`
117
        Giveaway        bool   `json:"giveaway" yaml:"giveaway" db:"meta_giveaway"`
118
}
119

120
// OpenAISettings contains OpenAI integration settings
121
type OpenAISettings struct {
122
        APIBase            string   `json:"api_base" yaml:"api_base" db:"openai_api_base"`
123
        Veto               bool     `json:"veto" yaml:"veto" db:"openai_veto"`
124
        Prompt             string   `json:"prompt" yaml:"prompt" db:"openai_prompt"`
125
        CustomPrompts      []string `json:"custom_prompts" yaml:"custom_prompts" db:"openai_custom_prompts"`
126
        Model              string   `json:"model" yaml:"model" db:"openai_model"`
127
        Token              string   `json:"token" yaml:"token" db:"openai_token"`
128
        MaxTokensResponse  int      `json:"max_tokens_response" yaml:"max_tokens_response" db:"openai_max_tokens_response"`
129
        MaxTokensRequest   int      `json:"max_tokens_request" yaml:"max_tokens_request" db:"openai_max_tokens_request"`
130
        MaxSymbolsRequest  int      `json:"max_symbols_request" yaml:"max_symbols_request" db:"openai_max_symbols_request"`
131
        RetryCount         int      `json:"retry_count" yaml:"retry_count" db:"openai_retry_count"`
132
        HistorySize        int      `json:"history_size" yaml:"history_size" db:"openai_history_size"`
133
        ReasoningEffort    string   `json:"reasoning_effort" yaml:"reasoning_effort" db:"openai_reasoning_effort"`
134
        CheckShortMessages bool     `json:"check_short_messages" yaml:"check_short_messages" db:"openai_check_short_messages"`
135
}
136

137
// GeminiSettings contains Gemini LLM integration settings
138
type GeminiSettings struct {
139
        Token              string   `json:"token" yaml:"token" db:"gemini_token"`
140
        Veto               bool     `json:"veto" yaml:"veto" db:"gemini_veto"`
141
        Prompt             string   `json:"prompt" yaml:"prompt" db:"gemini_prompt"`
142
        CustomPrompts      []string `json:"custom_prompts" yaml:"custom_prompts" db:"gemini_custom_prompts"`
143
        Model              string   `json:"model" yaml:"model" db:"gemini_model"`
144
        MaxTokensResponse  int32    `json:"max_tokens_response" yaml:"max_tokens_response" db:"gemini_max_tokens_response"`
145
        MaxSymbolsRequest  int      `json:"max_symbols_request" yaml:"max_symbols_request" db:"gemini_max_symbols_request"`
146
        RetryCount         int      `json:"retry_count" yaml:"retry_count" db:"gemini_retry_count"`
147
        HistorySize        int      `json:"history_size" yaml:"history_size" db:"gemini_history_size"`
148
        CheckShortMessages bool     `json:"check_short_messages" yaml:"check_short_messages" db:"gemini_check_short_messages"`
149
}
150

151
// LLMSettings contains shared LLM orchestration settings
152
type LLMSettings struct {
153
        Consensus      string        `json:"consensus" yaml:"consensus" db:"llm_consensus"`
154
        RequestTimeout time.Duration `json:"request_timeout" yaml:"request_timeout" db:"llm_request_timeout"`
155
}
156

157
// DeleteSettings contains settings for automatic deletion of service messages
158
type DeleteSettings struct {
159
        JoinMessages  bool `json:"join_messages" yaml:"join_messages" db:"delete_join_messages"`
160
        LeaveMessages bool `json:"leave_messages" yaml:"leave_messages" db:"delete_leave_messages"`
161
}
162

163
// DuplicatesSettings contains duplicate-message detection settings
164
type DuplicatesSettings struct {
165
        Threshold int           `json:"threshold" yaml:"threshold" db:"duplicates_threshold"`
166
        Window    time.Duration `json:"window" yaml:"window" db:"duplicates_window"`
167
}
168

169
// ReactionsSettings contains reaction-spam detection settings
170
type ReactionsSettings struct {
171
        MaxReactions int           `json:"max_reactions" yaml:"max_reactions" db:"reactions_max_reactions"`
172
        Window       time.Duration `json:"window" yaml:"window" db:"reactions_window"`
173
}
174

175
// ReportSettings contains user-report feature settings
176
type ReportSettings struct {
177
        Enabled          bool          `json:"enabled" yaml:"enabled" db:"report_enabled"`
178
        Threshold        int           `json:"threshold" yaml:"threshold" db:"report_threshold"`
179
        AutoBanThreshold int           `json:"auto_ban_threshold" yaml:"auto_ban_threshold" db:"report_auto_ban_threshold"`
180
        RateLimit        int           `json:"rate_limit" yaml:"rate_limit" db:"report_rate_limit"`
181
        RatePeriod       time.Duration `json:"rate_period" yaml:"rate_period" db:"report_rate_period"`
182
}
183

184
// LuaPluginsSettings contains Lua plugins settings
185
type LuaPluginsSettings struct {
186
        Enabled        bool     `json:"enabled" yaml:"enabled" db:"lua_plugins_enabled"`
187
        PluginsDir     string   `json:"plugins_dir" yaml:"plugins_dir" db:"lua_plugins_dir"`
188
        EnabledPlugins []string `json:"enabled_plugins" yaml:"enabled_plugins" db:"lua_enabled_plugins"`
189
        DynamicReload  bool     `json:"dynamic_reload" yaml:"dynamic_reload" db:"lua_dynamic_reload"`
190
}
191

192
// AbnormalSpaceSettings contains abnormal spacing detection settings
193
type AbnormalSpaceSettings struct {
194
        Enabled                 bool    `json:"enabled" yaml:"enabled" db:"abnormal_spacing_enabled"`
195
        SpaceRatioThreshold     float64 `json:"space_ratio_threshold" yaml:"space_ratio_threshold" db:"abnormal_spacing_ratio"`
196
        ShortWordRatioThreshold float64 `json:"short_word_ratio_threshold" yaml:"short_word_ratio_threshold" db:"abnormal_spacing_short_ratio"`
197
        ShortWordLen            int     `json:"short_word_len" yaml:"short_word_len" db:"abnormal_spacing_short_word"`
198
        MinWords                int     `json:"min_words" yaml:"min_words" db:"abnormal_spacing_min_words"`
199
}
200

201
// FilesSettings contains file location settings
202
type FilesSettings struct {
203
        SamplesDataPath string `json:"samples_data_path" yaml:"samples_data_path" db:"files_samples_path"`
204
        DynamicDataPath string `json:"dynamic_data_path" yaml:"dynamic_data_path" db:"files_dynamic_path"`
205
        WatchInterval   int    `json:"watch_interval_secs" yaml:"watch_interval_secs" db:"files_watch_interval_secs"`
206
}
207

208
// MessageSettings contains message customization settings
209
type MessageSettings struct {
210
        Startup string `json:"startup" yaml:"startup" db:"message_startup"`
211
        Spam    string `json:"spam" yaml:"spam" db:"message_spam"`
212
        Dry     string `json:"dry" yaml:"dry" db:"message_dry"`
213
        Warn    string `json:"warn" yaml:"warn" db:"message_warn"`
214
}
215

216
// ServerSettings contains web server settings
217
type ServerSettings struct {
218
        Enabled    bool   `json:"enabled" yaml:"enabled" db:"server_enabled"`
219
        ListenAddr string `json:"listen_addr" yaml:"listen_addr" db:"server_listen_addr"`
220
        AuthUser   string `json:"auth_user,omitempty" yaml:"auth_user,omitempty" db:"server_auth_user"`
221
        AuthHash   string `json:"auth_hash" yaml:"auth_hash" db:"server_auth_hash"`
222
}
223

224
// TransientSettings contains settings that should never be persisted
225
type TransientSettings struct {
226
        // connection parameters
227
        DataBaseURL    string        `json:"-" yaml:"-"`
228
        StorageTimeout time.Duration `json:"-" yaml:"-"`
229

230
        // control flags
231
        ConfigDB bool `json:"-" yaml:"-"`
232
        Dbg      bool `json:"-" yaml:"-"`
233
        TGDbg    bool `json:"-" yaml:"-"`
234

235
        // encryption for database stored configuration
236
        ConfigDBEncryptKey string `json:"-" yaml:"-"`
237

238
        // temporary auth password (used only to generate hash)
239
        WebAuthPasswd string `json:"-" yaml:"-"`
240

241
        // AuthFromCLI marks web auth (hash or password) as held in memory rather
242
        // than authoritative in the database. It is set by applyCLIOverrides for
243
        // explicit --server.auth/--server.auth-hash overrides, and by
244
        // applyAutoAuthFallback for the auto-generated password safety net. When
245
        // true, loadConfigHandler preserves the in-memory auth state across
246
        // reloads so the override survives; when false, reload picks up fresh
247
        // DB values.
248
        AuthFromCLI bool `json:"-" yaml:"-"`
249
}
250

251
// New creates a new settings instance
252
func New() *Settings {
75✔
253
        return &Settings{}
75✔
254
}
75✔
255

256
// IsOpenAIEnabled returns true if OpenAI integration is enabled
257
func (s *Settings) IsOpenAIEnabled() bool {
4✔
258
        return s.OpenAI.APIBase != "" || s.OpenAI.Token != ""
4✔
259
}
4✔
260

261
// IsMetaEnabled returns true if any meta check is enabled
262
func (s *Settings) IsMetaEnabled() bool {
13✔
263
        return s.Meta.ImageOnly ||
13✔
264
                s.Meta.LinksLimit >= 0 ||
13✔
265
                s.Meta.MentionsLimit >= 0 ||
13✔
266
                s.Meta.LinksOnly ||
13✔
267
                s.Meta.VideosOnly ||
13✔
268
                s.Meta.AudiosOnly ||
13✔
269
                s.Meta.Forward ||
13✔
270
                s.Meta.Keyboard ||
13✔
271
                s.Meta.UsernameSymbols != "" ||
13✔
272
                s.Meta.ContactOnly ||
13✔
273
                s.Meta.Giveaway
13✔
274
}
13✔
275

276
// IsCASEnabled returns true if CAS integration is enabled
277
func (s *Settings) IsCASEnabled() bool {
2✔
278
        return s.CAS.API != ""
2✔
279
}
2✔
280

281
// IsStartupMessageEnabled returns true if a startup message is configured
282
func (s *Settings) IsStartupMessageEnabled() bool {
2✔
283
        return s.Message.Startup != ""
2✔
284
}
2✔
285

286
// zeroAwarePaths lists Settings field paths where the zero value is a meaningful
287
// operator choice (typically "disabled" or a special semantic) rather than a
288
// missing JSON key. ApplyDefaults must NOT overwrite zero on these fields,
289
// because doing so would silently re-enable a feature the operator intentionally
290
// disabled by saving zero into the DB blob.
291
//
292
// Each entry is documented with the runtime check (file:line) that interprets
293
// zero as a real value. If a new such check is added, the corresponding field
294
// path must be added here.
295
var zeroAwarePaths = map[string]bool{
296
        // "disabled when zero/negative" semantics
297
        "Meta.LinksLimit":         true, // app/main.go:799 (>= 0); app/config/settings.go IsMetaEnabled (>= 0)
298
        "Meta.MentionsLimit":      true, // app/main.go:803 (>= 0); app/config/settings.go IsMetaEnabled (>= 0)
299
        "MaxEmoji":                true, // lib/tgspam/detector.go:249 (>= 0): -1 disables, 0 = no emojis allowed
300
        "MultiLangWords":          true, // lib/tgspam/detector.go:268 (> 0): 0 disables
301
        "MaxBackups":              true, // app/main.go:546 (> 0): description says "set 0 to disable"
302
        "Reactions.MaxReactions":  true, // app/main.go:724 (> 0): 0 disables
303
        "Duplicates.Threshold":    true, // app/main.go:717 (> 0): 0 disables
304
        "Report.AutoBanThreshold": true, // app/main.go:336, app/events/reports.go:191 (> 0): 0 disables
305
        "Report.RateLimit":        true, // app/events/reports.go:154 (<= 0): 0 disables rate limiting
306
        "OpenAI.HistorySize":      true, // lib/tgspam/detector.go:409 (> 0): 0 disables history
307
        "Gemini.HistorySize":      true, // lib/tgspam/detector.go:409 (> 0): 0 disables history
308
        "FirstMessagesCount":      true, // app/main.go:703, lib/tgspam/detector.go:205,208 (> 0): 0 disables
309
        "SimilarityThreshold":     true, // lib/tgspam/detector.go:302 (> 0): 0 disables similarity check
310
        "MinSpamProbability":      true, // lib/tgspam/detector.go:1014 (== 0): 0 = always classify spam
311
}
312

313
// ApplyDefaults fills zero-valued fields in s with the corresponding values from
314
// template. Fields listed in zeroAwarePaths are never overwritten regardless of
315
// their current value, because zero on those fields is a meaningful operator
316
// choice rather than missing data.
317
//
318
// The Transient group is intentionally skipped — it is fed from CLI options
319
// elsewhere (optToSettings, applyCLIOverrides) and must not inherit template
320
// values built from CLI struct tags.
321
//
322
// Slice fields with no default tag in the CLI struct (which is currently every
323
// slice in the options struct) end up as nil in the template, so ApplyDefaults
324
// will not overwrite an empty slice in the target.
325
func (s *Settings) ApplyDefaults(template *Settings) {
19✔
326
        if template == nil {
20✔
327
                return
1✔
328
        }
1✔
329
        applyDefaultsRecursive(reflect.ValueOf(s).Elem(), reflect.ValueOf(template).Elem(), "")
18✔
330
}
331

332
// applyDefaultsRecursive walks two parallel struct values and fills zero leaf
333
// fields in target from the corresponding leaf fields in template. Skips the
334
// Transient group and any field path listed in zeroAwarePaths.
335
func applyDefaultsRecursive(target, template reflect.Value, path string) {
342✔
336
        if target.Kind() != reflect.Struct {
342✔
NEW
337
                return
×
NEW
338
        }
×
339
        tt := target.Type()
342✔
340
        for i := 0; i < target.NumField(); i++ {
2,520✔
341
                ft := tt.Field(i)
2,178✔
342
                if !ft.IsExported() {
2,178✔
NEW
343
                        continue
×
344
                }
345
                // the Transient group is CLI-fed elsewhere; defaults must not flow into it
346
                if ft.Name == "Transient" {
2,196✔
347
                        continue
18✔
348
                }
349
                fieldPath := ft.Name
2,160✔
350
                if path != "" {
3,690✔
351
                        fieldPath = path + "." + ft.Name
1,530✔
352
                }
1,530✔
353
                targetField := target.Field(i)
2,160✔
354
                templateField := template.Field(i)
2,160✔
355

2,160✔
356
                // recurse into nested settings structs (time.Duration is int64-aliased,
2,160✔
357
                // so its Kind is Int64, not Struct — handled by the leaf branch below)
2,160✔
358
                if targetField.Kind() == reflect.Struct {
2,484✔
359
                        applyDefaultsRecursive(targetField, templateField, fieldPath)
324✔
360
                        continue
324✔
361
                }
362

363
                // leaf field: respect zero-aware paths regardless of current value
364
                if zeroAwarePaths[fieldPath] {
2,088✔
365
                        continue
252✔
366
                }
367

368
                if !targetField.CanSet() {
1,584✔
NEW
369
                        continue
×
370
                }
371
                if targetField.IsZero() && !templateField.IsZero() {
1,599✔
372
                        targetField.Set(templateField)
15✔
373
                }
15✔
374
        }
375
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc