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

CyberDuck79 / duckfile / 18097442731

29 Sep 2025 12:46PM UTC coverage: 79.573% (+0.04%) from 79.529%
18097442731

Pull #70

github

CyberDuck79
refactor: extract writeMetadataFile helper to reduce metadata writing duplication

- Add writeMetadataFile() helper function for consistent JSON metadata writing
- Replace duplicate json.Marshal + os.WriteFile patterns in remote.go
- Reduces code duplication in template and remote metadata handling
Pull Request #70: Phase 2: Implement separated targets and remotes configurations

592 of 686 new or added lines in 9 files covered. (86.3%)

24 existing lines in 5 files now uncovered.

2758 of 3466 relevant lines covered (79.57%)

11.37 hits per line

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

82.63
/internal/config/config.go
1
package config
2

3
import (
4
        "fmt"
5
        "os"
6
        "sort"
7
        "strconv"
8
        "strings"
9

10
        "github.com/CyberDuck79/duckfile/internal/git"
11
        "github.com/CyberDuck79/duckfile/internal/log"
12
        "gopkg.in/yaml.v3"
13
)
14

15
// Delims lets a template override the default Go template delimiters.
16
type Delims struct {
17
        Left  string `yaml:"left"`
18
        Right string `yaml:"right"`
19
}
20

21
// Remote defines a shared remote repository configuration that can be referenced by multiple templates
22
type Remote struct {
23
        Repo               string `yaml:"repo"`
24
        Ref                string `yaml:"ref,omitempty"`
25
        Submodules         bool   `yaml:"submodules,omitempty"`
26
        TrackCommitHash    bool   `yaml:"trackCommitHash,omitempty"`
27
        AutoUpdateOnChange bool   `yaml:"autoUpdateOnChange,omitempty"`
28
}
29

30
// ResolvedTemplate contains the fully resolved template configuration,
31
// combining remote settings with template-specific settings
32
type ResolvedTemplate struct {
33
        Repo               string
34
        Ref                string
35
        Path               string
36
        Submodules         bool
37
        TrackCommitHash    bool
38
        AutoUpdateOnChange bool
39
        Checksum           string
40
        Delims             *Delims
41
        AllowMissing       bool
42
}
43

44
type Template struct {
45
        // Remote reference (new approach)
46
        Remote string `yaml:"remote,omitempty"`
47

48
        // Inline configuration (existing approach - mutually exclusive with Remote)
49
        Repo     string `yaml:"repo,omitempty"`
50
        Ref      string `yaml:"ref,omitempty"`
51
        Path     string `yaml:"path"`
52
        Checksum string `yaml:"checksum,omitempty"`
53

54
        // Optional delimiter override to avoid conflicts with downstream tools (e.g., Taskfile).
55
        Delims *Delims `yaml:"delims,omitempty"`
56
        // If true, missing keys render as empty strings (zero values). Default: strict error.
57
        AllowMissing bool `yaml:"allowMissing,omitempty"`
58
        // If true, enables commit hash storage and validation. Default: false.
59
        TrackCommitHash bool `yaml:"trackCommitHash,omitempty"`
60
        // If true, auto-update if commit hash changes; otherwise warn and stop. Default: false.
61
        AutoUpdateOnChange bool `yaml:"autoUpdateOnChange,omitempty"`
62
        // If true, fetch submodules with --recurse-submodules. Default: false.
63
        Submodules bool `yaml:"submodules,omitempty"`
64
}
65

66
// VarKind represents the origin/behavior of a variable value.
67
type VarKind int
68

69
const (
70
        VarLiteral VarKind = iota // plain scalar (string/number/bool)
71
        VarEnv                    // !env NAME
72
        VarCmd                    // !cmd 'sh expression'
73
        VarFile                   // !file path
74
)
75

76
// VarValue supports tagged scalars like !env, !cmd, !file as well as plain scalars.
77
// It implements yaml.Unmarshaler to capture custom tags.
78
type VarValue struct {
79
        Kind  VarKind
80
        Arg   string // tag argument (env name, command, or file path)
81
        Value any    // for literal
82
}
83

84
// MarshalYAML enables preserving custom tags when writing config files.
85
func (v VarValue) MarshalYAML() (any, error) {
1✔
86
        switch v.Kind {
1✔
87
        case VarEnv:
×
88
                n := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!env", Value: v.Arg}
×
89
                return n, nil
×
90
        case VarCmd:
×
91
                n := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!cmd", Value: v.Arg}
×
92
                return n, nil
×
93
        case VarFile:
×
94
                n := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!file", Value: v.Arg}
×
95
                return n, nil
×
96
        case VarLiteral:
1✔
97
                return v.Value, nil
1✔
98
        default:
×
99
                return v.Value, nil
×
100
        }
101
}
102

103
// UnmarshalYAML enables custom tag decoding for VarValue.
104
func (v *VarValue) UnmarshalYAML(node *yaml.Node) error {
9✔
105
        // Custom tags we accept: !env, !cmd, !file
9✔
106
        switch node.Tag {
9✔
107
        case "!env":
1✔
108
                v.Kind, v.Arg = VarEnv, node.Value
1✔
109
                return nil
1✔
110
        case "!cmd":
1✔
111
                v.Kind, v.Arg = VarCmd, node.Value
1✔
112
                return nil
1✔
113
        case "!file":
1✔
114
                v.Kind, v.Arg = VarFile, node.Value
1✔
115
                return nil
1✔
116
        }
117

118
        // Otherwise, treat as literal and parse basic YAML scalar types
119
        v.Kind = VarLiteral
6✔
120
        switch node.Tag {
6✔
121
        case "!!str", "":
2✔
122
                v.Value = node.Value
2✔
123
                return nil
2✔
124
        case "!!int":
1✔
125
                i, err := strconv.ParseInt(node.Value, 10, 64)
1✔
126
                if err != nil {
1✔
127
                        return err
×
128
                }
×
129
                v.Value = i
1✔
130
                return nil
1✔
131
        case "!!float":
1✔
132
                f, err := strconv.ParseFloat(node.Value, 64)
1✔
133
                if err != nil {
1✔
134
                        return err
×
135
                }
×
136
                v.Value = f
1✔
137
                return nil
1✔
138
        case "!!bool":
2✔
139
                switch node.Value {
2✔
140
                case "true", "True", "TRUE":
1✔
141
                        v.Value = true
1✔
142
                case "false", "False", "FALSE":
1✔
143
                        v.Value = false
1✔
144
                default:
×
145
                        return fmt.Errorf("invalid boolean literal: %q", node.Value)
×
146
                }
147
                return nil
2✔
148
        default:
×
149
                // Fallback: store as string
×
150
                v.Value = node.Value
×
151
                return nil
×
152
        }
153
}
154

155
type Target struct {
156
        // Description is an optional human readable explanation printed by `duck list`.
157
        Description  string              `yaml:"description,omitempty"`
158
        Binary       string              `yaml:"binary,omitempty"`
159
        FileFlag     string              `yaml:"fileFlag,omitempty"`
160
        Template     Template            `yaml:"template"`
161
        Variables    map[string]VarValue `yaml:"variables,omitempty"`
162
        RenderedPath string              `yaml:"renderedPath,omitempty"`
163
        CopyRendered bool                `yaml:"copyRendered,omitempty"` // If true, copy instead of symlink. RenderedPath required.
164
        Args         ArgList             `yaml:"args,omitempty"`
165
}
166

167
// Settings represents the global configuration options
168
type Settings struct {
169
        CacheDir           string `yaml:"cacheDir,omitempty"`
170
        LogLevel           string `yaml:"logLevel,omitempty"`
171
        Locked             bool   `yaml:"locked,omitempty"`
172
        TrackCommitHash    bool   `yaml:"trackCommitHash,omitempty"`
173
        AutoUpdateOnChange bool   `yaml:"autoUpdateOnChange,omitempty"`
174
}
175

176
// GetLogLevel returns the configured log level or default "info"
177
func (s *Settings) GetLogLevel() string {
5✔
178
        if s == nil || s.LogLevel == "" {
8✔
179
                return "info"
3✔
180
        }
3✔
181
        return s.LogLevel
2✔
182
}
183

184
// GetCacheDir returns the configured cache dir or default ".duck/objects"
185
func (s *Settings) GetCacheDir() string {
3✔
186
        if s == nil || s.CacheDir == "" {
5✔
187
                return ".duck/objects"
2✔
188
        }
2✔
189
        return s.CacheDir
1✔
190
}
191

192
// IsLocked returns whether locked mode is enabled
193
func (s *Settings) IsLocked() bool {
3✔
194
        if s == nil {
4✔
195
                return false
1✔
196
        }
1✔
197
        return s.Locked
2✔
198
}
199

200
// GetTrackCommitHash returns whether commit hash tracking is enabled globally
201
func (s *Settings) GetTrackCommitHash() bool {
5✔
202
        if s == nil {
5✔
203
                return false
×
204
        }
×
205
        return s.TrackCommitHash
5✔
206
}
207

208
// GetAutoUpdateOnChange returns whether auto-update on commit hash change is enabled globally
209
func (s *Settings) GetAutoUpdateOnChange() bool {
5✔
210
        if s == nil {
5✔
211
                return false
×
212
        }
×
213
        return s.AutoUpdateOnChange
5✔
214
}
215

216
type DuckConf struct {
217
        Version int `yaml:"version"`
218
        // Default is the key of the target (in Targets) executed when the user omits a target.
219
        Default  string            `yaml:"default"`
220
        Remotes  map[string]Remote `yaml:"remotes,omitempty"`
221
        Targets  map[string]Target `yaml:"targets"`
222
        Settings *Settings         `yaml:"settings,omitempty"`
223
}
224

225
// Save writes the configuration to disk as YAML.
226
func (c *DuckConf) Save(path string) error {
1✔
227
        if c.Version == 0 {
1✔
228
                c.Version = 1
×
229
        }
×
230
        if c.Targets == nil {
1✔
231
                c.Targets = map[string]Target{}
×
232
        }
×
233
        b, err := yaml.Marshal(c)
1✔
234
        if err != nil {
1✔
235
                return err
×
236
        }
×
237
        return os.WriteFile(path, b, 0o644)
1✔
238
}
239

240
// Load reads the configuration from disk as YAML.
241
func Load(path string) (*DuckConf, error) {
1✔
242
        log.Debugf("Reading configuration file: %s", path)
1✔
243
        raw, err := os.ReadFile(path)
1✔
244
        if err != nil {
1✔
245
                return nil, err
×
246
        }
×
247
        log.Debugf("Parsing YAML configuration (%d bytes)", len(raw))
1✔
248
        var cfg DuckConf
1✔
249
        if err := yaml.Unmarshal(raw, &cfg); err != nil {
1✔
250
                return nil, err
×
251
        }
×
252
        log.Debugf("Validating configuration")
1✔
253
        if err := cfg.Validate(); err != nil {
1✔
254
                return nil, err
×
255
        }
×
256
        log.Debugf("Configuration loaded successfully")
1✔
257
        return &cfg, nil
1✔
258
}
259

260
// ArgList accepts either a single string or a list of strings in YAML.
261
// Examples:
262
//
263
//        args: "--silent"           => ["--silent"]
264
//        args: ["-v", "--color"]  => ["-v","--color"]
265
type ArgList []string
266

267
// UnmarshalYAML enables custom decoding for ArgList.
268
func (a *ArgList) UnmarshalYAML(node *yaml.Node) error {
6✔
269
        switch node.Kind {
6✔
270
        case yaml.ScalarNode:
3✔
271
                // Single string value
3✔
272
                if node.Value == "" {
4✔
273
                        *a = []string{}
1✔
274
                } else {
3✔
275
                        *a = []string{node.Value}
2✔
276
                }
2✔
277
                return nil
3✔
278
        case yaml.SequenceNode:
3✔
279
                out := make([]string, 0, len(node.Content))
3✔
280
                for _, c := range node.Content {
7✔
281
                        if c.Kind != yaml.ScalarNode {
4✔
282
                                return fmt.Errorf("args array must contain strings")
×
283
                        }
×
284
                        out = append(out, c.Value)
4✔
285
                }
286
                *a = out
3✔
287
                return nil
3✔
288
        default:
×
289
                return fmt.Errorf("invalid YAML type for args: %v", node.Kind)
×
290
        }
291
}
292

293
// Validate enforces cross-field rules:
294
// - binary is optional
295
// - fileFlag and args are only allowed when binary is set
296
func (c *DuckConf) Validate() error {
10✔
297
        if strings.TrimSpace(c.Default) == "" {
11✔
298
                return fmt.Errorf("default target key must be set to one of the declared targets")
1✔
299
        }
1✔
300
        if len(c.Targets) == 0 {
10✔
301
                return fmt.Errorf("no targets declared; 'targets' mapping must contain at least the default target '%s'", c.Default)
1✔
302
        }
1✔
303

304
        // Validate settings
305
        if err := validateSettings(c.Settings); err != nil {
8✔
306
                return err
×
307
        }
×
308

309
        // Validate remotes
310
        if err := validateRemotes(c.Remotes); err != nil {
10✔
311
                return err
2✔
312
        }
2✔
313

314
        // Validate remote references in targets
315
        if err := validateRemoteReferences(c.Targets, c.Remotes); err != nil {
7✔
316
                return err
1✔
317
        }
1✔
318

319
        // Validate each target
320
        for name, t := range c.Targets {
11✔
321
                if err := validateTarget(t, name, c.Remotes); err != nil {
7✔
322
                        return err
1✔
323
                }
1✔
324
        }
325
        if _, ok := c.Targets[c.Default]; !ok {
5✔
326
                // Build list for diagnostics
1✔
327
                keys := make([]string, 0, len(c.Targets))
1✔
328
                for k := range c.Targets {
3✔
329
                        keys = append(keys, k)
2✔
330
                }
2✔
331
                sort.Strings(keys)
1✔
332
                return fmt.Errorf("default target %q not found; available targets: %s", c.Default, strings.Join(keys, ", "))
1✔
333
        }
334
        return nil
3✔
335
}
336

337
func validateSettings(s *Settings) error {
16✔
338
        if s == nil {
25✔
339
                return nil
9✔
340
        }
9✔
341

342
        if s.LogLevel != "" {
12✔
343
                validLevels := []string{"error", "warn", "info", "debug"}
5✔
344
                valid := false
5✔
345
                for _, level := range validLevels {
19✔
346
                        if s.LogLevel == level {
18✔
347
                                valid = true
4✔
348
                                break
4✔
349
                        }
350
                }
351
                if !valid {
6✔
352
                        return fmt.Errorf("invalid logLevel %q, must be one of: %s", s.LogLevel, strings.Join(validLevels, ", "))
1✔
353
                }
1✔
354
        }
355

356
        return nil
6✔
357
}
358

359
func validateRemotes(remotes map[string]Remote) error {
8✔
360
        if remotes == nil {
11✔
361
                return nil
3✔
362
        }
3✔
363

364
        for name, remote := range remotes {
10✔
365
                if strings.TrimSpace(name) == "" {
5✔
NEW
366
                        return fmt.Errorf("remote name cannot be empty")
×
NEW
367
                }
×
368

369
                if strings.TrimSpace(remote.Repo) == "" {
5✔
NEW
370
                        return fmt.Errorf("remote %q: repo is required", name)
×
NEW
371
                }
×
372

373
                // Validate commit hash tracking configuration for remote
374
                if remote.TrackCommitHash && remote.Ref != "" && git.IsCommitHash(remote.Ref) {
6✔
375
                        return fmt.Errorf("remote %q: commit hash tracking is invalid when ref is already a commit hash (%s).\n\n"+
1✔
376
                                "Commit hashes are immutable and don't change, so tracking them is unnecessary.\n\n"+
1✔
377
                                "To fix this issue, choose one of the following options:\n"+
1✔
378
                                "  • Change 'ref' to a branch name (e.g., 'main', 'develop') or tag name (e.g., 'v1.0.0')\n"+
1✔
379
                                "  • Set 'trackCommitHash: false' in your remote configuration\n"+
1✔
380
                                "  • Remove the 'trackCommitHash' setting to use the default (false)",
1✔
381
                                name, remote.Ref)
1✔
382
                }
1✔
383

384
                // If auto-update is enabled, commit hash tracking must also be enabled
385
                if remote.AutoUpdateOnChange && !remote.TrackCommitHash {
5✔
386
                        return fmt.Errorf("remote %q: autoUpdateOnChange requires trackCommitHash to be enabled.\n\n"+
1✔
387
                                "To fix this issue, add 'trackCommitHash: true' to your remote configuration.\n\n"+
1✔
388
                                "Example configuration:\n"+
1✔
389
                                "  trackCommitHash: true\n"+
1✔
390
                                "  autoUpdateOnChange: true",
1✔
391
                                name)
1✔
392
                }
1✔
393
        }
394

395
        return nil
3✔
396
}
397

398
func validateRemoteReferences(targets map[string]Target, remotes map[string]Remote) error {
6✔
399
        for targetName, target := range targets {
13✔
400
                if target.Template.Remote != "" {
10✔
401
                        if _, exists := remotes[target.Template.Remote]; !exists {
4✔
402
                                return fmt.Errorf("target %q: remote %q not found in remotes section", targetName, target.Template.Remote)
1✔
403
                        }
1✔
404
                }
405
        }
406
        return nil
5✔
407
}
408

409
func validateTemplate(template Template, targetName string, remotes map[string]Remote) error {
15✔
410
        if template.Remote != "" {
17✔
411
                // Remote reference mode - no inline settings allowed
2✔
412
                if template.Repo != "" || template.Ref != "" ||
2✔
413
                        template.Submodules || template.TrackCommitHash || template.AutoUpdateOnChange {
3✔
414
                        return fmt.Errorf("target %q: cannot specify remote settings when using remote reference %q",
1✔
415
                                targetName, template.Remote)
1✔
416
                }
1✔
417

418
                // Verify remote exists
419
                if _, exists := remotes[template.Remote]; !exists {
1✔
NEW
420
                        return fmt.Errorf("target %q: remote %q not found", targetName, template.Remote)
×
NEW
421
                }
×
422

423
                // Path is required
424
                if strings.TrimSpace(template.Path) == "" {
1✔
NEW
425
                        return fmt.Errorf("target %q: path is required", targetName)
×
NEW
426
                }
×
427
        } else {
13✔
428
                // Inline mode - existing validation logic
13✔
429
                if strings.TrimSpace(template.Repo) == "" {
13✔
NEW
430
                        return fmt.Errorf("target %q: repo is required when not using remote reference", targetName)
×
NEW
431
                }
×
432

433
                // Path is required
434
                if strings.TrimSpace(template.Path) == "" {
13✔
NEW
435
                        return fmt.Errorf("target %q: path is required", targetName)
×
NEW
436
                }
×
437

438
                // Validate commit hash tracking for inline templates
439
                if err := validateCommitHashTracking(template, targetName); err != nil {
17✔
440
                        return err
4✔
441
                }
4✔
442
        }
443

444
        return nil
10✔
445
}
446

447
func validateTarget(t Target, name string, remotes map[string]Remote) error {
19✔
448
        hasBin := strings.TrimSpace(t.Binary) != ""
19✔
449
        if !hasBin {
32✔
450
                if strings.TrimSpace(t.FileFlag) != "" {
14✔
451
                        return fmt.Errorf("target %q: fileFlag is not allowed without binary", name)
1✔
452
                }
1✔
453
                if len(t.Args) > 0 {
13✔
454
                        return fmt.Errorf("target %q: args are not allowed without binary", name)
1✔
455
                }
1✔
456
        } else { // binary present
6✔
457
                if strings.TrimSpace(t.FileFlag) == "" {
8✔
458
                        return fmt.Errorf("target %q: fileFlag is required when binary is set", name)
2✔
459
                }
2✔
460
        }
461

462
        // If CopyRendered is true, RenderedPath must be set
463
        if t.CopyRendered && strings.TrimSpace(t.RenderedPath) == "" {
15✔
464
                return fmt.Errorf("target %q: renderedPath is required when copyRendered is true", name)
×
465
        }
×
466

467
        // Validate template configuration
468
        return validateTemplate(t.Template, name, remotes)
15✔
469
}
470

471
// resolveTemplateConfig resolves a template configuration by merging remote settings
472
// with template-specific settings and global settings fallback
473
func ResolveTemplateConfig(template Template, remotes map[string]Remote, settings *Settings) (ResolvedTemplate, error) {
4✔
474
        if template.Remote != "" {
6✔
475
                // Use remote config entirely
2✔
476
                remote, exists := remotes[template.Remote]
2✔
477
                if !exists {
3✔
478
                        return ResolvedTemplate{}, fmt.Errorf("remote %q not found", template.Remote)
1✔
479
                }
1✔
480

481
                return ResolvedTemplate{
1✔
482
                        Repo:               remote.Repo,
1✔
483
                        Ref:                remote.Ref,
1✔
484
                        Path:               template.Path,
1✔
485
                        Submodules:         remote.Submodules,
1✔
486
                        TrackCommitHash:    remote.TrackCommitHash,
1✔
487
                        AutoUpdateOnChange: remote.AutoUpdateOnChange,
1✔
488
                        Checksum:           template.Checksum,
1✔
489
                        Delims:             template.Delims,
1✔
490
                        AllowMissing:       template.AllowMissing,
1✔
491
                }, nil
1✔
492
        } else {
2✔
493
                // Use inline configuration with settings fallback
2✔
494
                trackCommitHash := template.TrackCommitHash
2✔
495
                autoUpdateOnChange := template.AutoUpdateOnChange
2✔
496

2✔
497
                // Apply global settings if not set at template level
2✔
498
                if settings != nil {
3✔
499
                        if !template.TrackCommitHash && settings.GetTrackCommitHash() {
2✔
500
                                trackCommitHash = true
1✔
501
                        }
1✔
502
                        if !template.AutoUpdateOnChange && settings.GetAutoUpdateOnChange() {
2✔
503
                                autoUpdateOnChange = true
1✔
504
                        }
1✔
505
                }
506

507
                return ResolvedTemplate{
2✔
508
                        Repo:               template.Repo,
2✔
509
                        Ref:                template.Ref,
2✔
510
                        Path:               template.Path,
2✔
511
                        Submodules:         template.Submodules,
2✔
512
                        TrackCommitHash:    trackCommitHash,
2✔
513
                        AutoUpdateOnChange: autoUpdateOnChange,
2✔
514
                        Checksum:           template.Checksum,
2✔
515
                        Delims:             template.Delims,
2✔
516
                        AllowMissing:       template.AllowMissing,
2✔
517
                }, nil
2✔
518
        }
519
}
520

521
// IsReservedTargetName checks if a target name conflicts with subcommand names
522
// This is used for warnings and conflict detection, but doesn't prevent target creation
523
func IsReservedTargetName(name string) bool {
×
524
        reservedTargetNames := map[string]bool{
×
525
                "add": true, "clean": true, "exec": true, "init": true,
×
526
                "list": true, "security": true, "sync": true, "version": true, "help": true,
×
527
                "completion": true, // Also block the auto-generated completion command
×
528
        }
×
529

×
530
        return reservedTargetNames[name]
×
531
}
×
532

533
// validateCommitHashTracking checks if commit hash tracking is properly configured.
534
// If ref is already a commit hash, commit hash tracking doesn't make sense since commit hashes don't change.
535
func validateCommitHashTracking(template Template, targetName string) error {
20✔
536
        // If commit hash tracking is enabled, validate that ref is not already a commit hash
20✔
537
        if template.TrackCommitHash {
31✔
538
                if template.Ref != "" && git.IsCommitHash(template.Ref) {
15✔
539
                        return fmt.Errorf("target %q: commit hash tracking is invalid when ref is already a commit hash (%s).\n\n"+
4✔
540
                                "Commit hashes are immutable and don't change, so tracking them is unnecessary.\n\n"+
4✔
541
                                "To fix this issue, choose one of the following options:\n"+
4✔
542
                                "  • Change 'ref' to a branch name (e.g., 'main', 'develop') or tag name (e.g., 'v1.0.0')\n"+
4✔
543
                                "  • Set 'trackCommitHash: false' in your configuration\n"+
4✔
544
                                "  • Remove the 'trackCommitHash' setting to use the default (false)\n\n"+
4✔
545
                                "Example configurations:\n"+
4✔
546
                                "  ref: main                    # Use branch name\n"+
4✔
547
                                "  ref: v1.0.0                  # Use tag name\n"+
4✔
548
                                "  trackCommitHash: false       # Disable tracking",
4✔
549
                                targetName, template.Ref)
4✔
550
                }
4✔
551
        }
552

553
        // If auto-update is enabled, commit hash tracking must also be enabled
554
        if template.AutoUpdateOnChange && !template.TrackCommitHash {
18✔
555
                return fmt.Errorf("target %q: autoUpdateOnChange requires trackCommitHash to be enabled.\n\n"+
2✔
556
                        "To fix this issue, add 'trackCommitHash: true' to your template configuration.\n\n"+
2✔
557
                        "Example configuration:\n"+
2✔
558
                        "  trackCommitHash: true\n"+
2✔
559
                        "  autoUpdateOnChange: true",
2✔
560
                        targetName)
2✔
561
        }
2✔
562

563
        return nil
14✔
564
}
565

566
// NewLiteralVar helper.
567
func NewLiteralVar(val any) VarValue { return VarValue{Kind: VarLiteral, Value: val} }
2✔
568

569
// NewEnvVar helper.
570
func NewEnvVar(name string) VarValue { return VarValue{Kind: VarEnv, Arg: name} }
1✔
571

572
// NewCmdVar helper.
573
func NewCmdVar(cmd string) VarValue { return VarValue{Kind: VarCmd, Arg: cmd} }
1✔
574

575
// NewFileVar helper.
576
func NewFileVar(path string) VarValue { return VarValue{Kind: VarFile, Arg: path} }
1✔
577

578
// ValidateTarget exposes target validation rules for external callers.
579
func ValidateTarget(t Target, name string) error { return validateTarget(t, name, nil) }
5✔
580

581
// ResolveLogLevel determines the effective log level from CLI flag, environment, and config
582
// Precedence: CLI flag > Environment variable > Config file > Default ("info")
583
// Returns the log level string, caller should parse it using run.ParseLogLevel
584
func ResolveLogLevel(cliLogLevel string, cfg *DuckConf) string {
6✔
585
        // 1. CLI flag has highest precedence
6✔
586
        if cliLogLevel != "" {
7✔
587
                return cliLogLevel
1✔
588
        }
1✔
589

590
        // 2. Environment variable
591
        if envLevel := strings.TrimSpace(os.Getenv("DUCK_LOG_LEVEL")); envLevel != "" {
6✔
592
                return envLevel
1✔
593
        }
1✔
594

595
        // 3. Config file
596
        if cfg != nil && cfg.Settings != nil {
6✔
597
                return cfg.Settings.GetLogLevel()
2✔
598
        }
2✔
599

600
        // 4. Default
601
        return "info"
2✔
602
}
603

604
// ResolveTrackCommitHash determines the effective track commit hash setting from CLI flag, environment, and config
605
// Precedence: CLI flag > Environment variable > Template config > Global settings > Default (false)
606
func ResolveTrackCommitHash(cliFlag *bool, template *Template, cfg *DuckConf) bool {
16✔
607
        // 1. CLI flag has highest precedence
16✔
608
        if cliFlag != nil {
19✔
609
                return *cliFlag
3✔
610
        }
3✔
611

612
        // 2. Environment variable
613
        if envValue := strings.TrimSpace(os.Getenv("DUCK_TRACK_COMMIT_HASH")); envValue != "" {
18✔
614
                return strings.ToLower(envValue) == "true" || envValue == "1"
5✔
615
        }
5✔
616

617
        // 3. Template configuration
618
        if template != nil && template.TrackCommitHash {
10✔
619
                return true
2✔
620
        }
2✔
621

622
        // 4. Global settings
623
        if cfg != nil && cfg.Settings != nil {
9✔
624
                return cfg.Settings.GetTrackCommitHash()
3✔
625
        }
3✔
626

627
        // 5. Default
628
        return false
3✔
629
}
630

631
// ResolveAutoUpdateOnChange determines the effective auto-update setting from CLI flag, environment, and config
632
// Precedence: CLI flag > Environment variable > Template config > Global settings > Default (false)
633
func ResolveAutoUpdateOnChange(cliFlag *bool, template *Template, cfg *DuckConf) bool {
14✔
634
        // 1. CLI flag has highest precedence
14✔
635
        if cliFlag != nil {
17✔
636
                return *cliFlag
3✔
637
        }
3✔
638

639
        // 2. Environment variable
640
        if envValue := strings.TrimSpace(os.Getenv("DUCK_AUTO_UPDATE_ON_CHANGE")); envValue != "" {
15✔
641
                return strings.ToLower(envValue) == "true" || envValue == "1"
4✔
642
        }
4✔
643

644
        // 3. Template configuration
645
        if template != nil && template.AutoUpdateOnChange {
9✔
646
                return true
2✔
647
        }
2✔
648

649
        // 4. Global settings
650
        if cfg != nil && cfg.Settings != nil {
8✔
651
                return cfg.Settings.GetAutoUpdateOnChange()
3✔
652
        }
3✔
653

654
        // 5. Default
655
        return false
2✔
656
}
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

© 2025 Coveralls, Inc