feat(config): upgrade-config command + Duration pointer fix
Closes the two follow-up caveats from the 2026-06-04
config-migration follow-up plan:
Caveat 1 — Duration pointer conversion
SLM.StartupTimeout and SLM.ClassifyTimeout are now *Duration
(pointer) instead of bare Duration. nil = "use documented
default" (5s and 0s respectively); *Duration(0) = explicit
zero. ResolvedSLMSection added to the mirror so consumers
read resolved time.Duration values instead of the raw
pointer. cmd/gnoma/main.go, profile_cmd, and the SLM
startup wiring all move through the mirror. The remaining
cosmetic encoder issue (startup_timeout = 0 / classify_timeout
= 0 written even with omitempty) is fixed because the
BurntSushi encoder now sees a nil pointer when the user
didn't set the field.
ResolvedSLMSection's RegisterAsArm mirrors the existing
nil→true default-substitution semantics from the field's
doc comment; the if-nil check in main.go is collapsed to
a direct read of resolved.SLM.RegisterAsArm.
Caveat 2 — `gnoma upgrade-config` (single-file mode)
New command that cleans a config file in place: drops
pointer-converted fields whose resolved value matches the
resolved default, leaves explicit-zero pointer fields
alone (the "explicit zero preserved" contract from Phase 1),
and writes the cleaned form atomically with a
.bak-YYYYMMDD-HHMMSS backup of the original. Idempotent —
a second run on the cleaned file reports "already clean,
nothing to do" without creating a second backup.
Cleaning rules per field type (encoded in internal/config/
upgrade.go::clean):
- pointer-converted fields: null iff resolved value
equals resolved default
- non-pointer string / map / slice / numeric / bool
fields: encoder's omitempty already handles them on
rewrite; the cleaner doesn't touch them
Diff output uses a simple line-by-line algorithm (added/
removed/neutral) via splitLines + a forward scan. Adequate
for the small config files gnoma produces. A proper Myers
diff could be vendored later — pmezard/go-difflib is
already a transitive dep in go.sum.
internal/config/load.go::ProjectConfigPath is now exported
so the CLI can default the upgrade target to the project
config when no path is given.
--dry-run runs the upgrade then restores the file from the
backup so the operation is truly side-effect-free.
Scope notes
Single-file mode only. --all-projects is deferred until the
project registry (Phase 2 of the 2026-05-24 plan) lands —
the follow-up doc calls this out as the natural next slice
and it can be added as a follow-up PR without touching
upgrade-config's core semantics.
No-op test cases (TestUpgrade_NoChangesOnAlreadyCleanFile,
TestUpgrade_KeepsExplicitUserValues, TestUpgrade_Keeps-
ExplicitZeroPointerFields) assert the "resolved view is
identical before and after" contract.
Test coverage
internal/config/upgrade_test.go: 10 tests (drops, keeps,
backup, idempotency, diff, edge cases)
internal/config/resolve_test.go: +3 tests for ResolvedSLM
internal/config/write_test.go: +1 test for the Duration
emission fix
cmd/gnoma/upgrade_config_cmd_test.go: 3 tests for the CLI
Refs: docs/superpowers/plans/2026-06-04-config-migration-followups.md
This commit is contained in:
+7
-8
@@ -185,6 +185,8 @@ func main() {
|
||||
os.Exit(runProfileCommand(cliArgs[1:], cfg, profile))
|
||||
case "config":
|
||||
os.Exit(runConfigCommand(cliArgs[1:]))
|
||||
case "upgrade-config":
|
||||
os.Exit(runUpgradeConfigCommand(cliArgs[1:]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +869,7 @@ func main() {
|
||||
BaseURL: cfg.SLM.BaseURL,
|
||||
ModelURL: cfg.SLM.ModelURL,
|
||||
DataDir: cfg.SLM.DataDir,
|
||||
StartupTimeout: cfg.SLM.StartupTimeout.Duration(),
|
||||
StartupTimeout: resolved.SLM.StartupTimeout,
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "Starting SLM...")
|
||||
boot, bootErr := slm.StartBackend(context.Background(), bcfg, logger)
|
||||
@@ -881,7 +883,7 @@ func main() {
|
||||
// transport and as a router arm. Both paths route through the
|
||||
// firewall after fwRef.Set fires above.
|
||||
slmProvider := security.WrapProvider(boot.Provider, fwRef)
|
||||
lazy.set(slm.NewClassifier(slmProvider, boot.Model, time.Duration(cfg.SLM.ClassifyTimeout), logger))
|
||||
lazy.set(slm.NewClassifier(slmProvider, boot.Model, resolved.SLM.ClassifyTimeout, logger))
|
||||
// ToolUse comes from the live probe of the actual model. For
|
||||
// completion-only models (e.g. TinyLlama), the SLM arm only
|
||||
// handles knowledge-only prompts where the trivial-prompt
|
||||
@@ -895,12 +897,9 @@ func main() {
|
||||
// the correct setting for task-specialised models
|
||||
// (FunctionGemma, code-completion-tuned models, etc.) that
|
||||
// would mishandle a general prompt routed to them as the
|
||||
// answer-producing arm.
|
||||
registerAsArm := true
|
||||
if cfg.SLM.RegisterAsArm != nil {
|
||||
registerAsArm = *cfg.SLM.RegisterAsArm
|
||||
}
|
||||
if registerAsArm {
|
||||
// answer-producing arm. Resolved() applies the default-true
|
||||
// substitution; see ResolvedSLMSection in resolve.go.
|
||||
if resolved.SLM.RegisterAsArm {
|
||||
rtr.RegisterArm(&router.Arm{
|
||||
ID: router.ArmID("slm/" + string(boot.Backend)),
|
||||
Provider: slmProvider,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
gnomacfg "somegit.dev/Owlibou/gnoma/internal/config"
|
||||
)
|
||||
|
||||
// runUpgradeConfigCommand handles `gnoma upgrade-config`. Cleans
|
||||
// a single config file in place: drops fields whose value matches
|
||||
// the resolved default, leaves explicit-zero pointer fields alone,
|
||||
// writes the cleaned form atomically with a `.bak-YYYYMMDD-HHMMSS`
|
||||
// backup of the original.
|
||||
//
|
||||
// Single-file mode only. `--all-projects` is deferred to the
|
||||
// project-registry work in the 2026-05-24 config-migration plan.
|
||||
func runUpgradeConfigCommand(args []string) int {
|
||||
dryRun := false
|
||||
pathArgs := args
|
||||
for i, a := range args {
|
||||
if a == "--dry-run" {
|
||||
dryRun = true
|
||||
pathArgs = append(args[:i], args[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Default to the project config if no path given — matches
|
||||
// the convention of `gnoma config set` writing to the project
|
||||
// file by default.
|
||||
target := ""
|
||||
switch len(pathArgs) {
|
||||
case 0:
|
||||
target = gnomacfg.ProjectConfigPath()
|
||||
case 1:
|
||||
target = pathArgs[0]
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "usage: gnoma upgrade-config [--dry-run] [path]")
|
||||
return 1
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return runUpgradeConfigDryRun(target)
|
||||
}
|
||||
return runUpgradeConfigApply(target)
|
||||
}
|
||||
|
||||
func runUpgradeConfigApply(path string) int {
|
||||
res, err := gnomacfg.Upgrade(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if !res.Changed {
|
||||
fmt.Printf("%s: already clean, nothing to do\n", path)
|
||||
return 0
|
||||
}
|
||||
fmt.Printf("%s: upgraded (backup at %s)\n\n", path, res.BackupPath)
|
||||
fmt.Println(res.Diff)
|
||||
return 0
|
||||
}
|
||||
|
||||
func runUpgradeConfigDryRun(path string) int {
|
||||
// For the dry-run, snapshot the file, run Upgrade, restore
|
||||
// the original from the backup, and only print the diff.
|
||||
// (Upgrade is destructive by design — it writes the cleaned
|
||||
// form before we have a chance to inspect the diff. The
|
||||
// backup+restore dance lets us preview without committing.)
|
||||
res, err := gnomacfg.Upgrade(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if !res.Changed {
|
||||
fmt.Printf("%s: already clean, nothing to do (dry run)\n", path)
|
||||
return 0
|
||||
}
|
||||
// Restore the original from the backup so the dry-run is
|
||||
// truly side-effect-free.
|
||||
if err := os.Rename(res.BackupPath, path); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: dry-run restore failed: %v\n", err)
|
||||
} else {
|
||||
// The rename already moved the backup back to the
|
||||
// original path; nothing left to remove. The os.Remove
|
||||
// below is a no-op in the happy case and surfaces a
|
||||
// warning only when the restore failed and a stray .bak
|
||||
// remains.
|
||||
if err := os.Remove(res.BackupPath); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not remove dry-run backup %s: %v\n", res.BackupPath, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s: would upgrade (dry run; no changes written)\n\n", path)
|
||||
fmt.Println(res.Diff)
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRunUpgradeConfig_DropsDefaultPointerField exercises the
|
||||
// happy path: a project config with `max_tokens = 8192` (the
|
||||
// default) gets the field dropped and a backup created.
|
||||
func TestRunUpgradeConfig_DropsDefaultPointerField(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
|
||||
origDir, _ := os.Getwd()
|
||||
projectDir := filepath.Join(dir, "project")
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chdir(projectDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
|
||||
path := filepath.Join(projectDir, ".gnoma", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("[provider]\nmax_tokens = 8192\n"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if rc := runUpgradeConfigApply(path); rc != 0 {
|
||||
t.Fatalf("runUpgradeConfigApply rc=%d", rc)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(got), "max_tokens") {
|
||||
t.Errorf("max_tokens at default not dropped, got:\n%s", got)
|
||||
}
|
||||
// Backup file exists.
|
||||
entries, _ := os.ReadDir(filepath.Dir(path))
|
||||
backupFound := false
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "config.toml.bak-") {
|
||||
backupFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !backupFound {
|
||||
t.Errorf("no backup file created in %s", filepath.Dir(path))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunUpgradeConfig_DryRunNoSideEffects verifies that
|
||||
// --dry-run previews the diff without leaving the file modified.
|
||||
func TestRunUpgradeConfig_DryRunNoSideEffects(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
|
||||
origDir, _ := os.Getwd()
|
||||
projectDir := filepath.Join(dir, "project")
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chdir(projectDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
|
||||
path := filepath.Join(projectDir, ".gnoma", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
original := "[provider]\nmax_tokens = 8192\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if rc := runUpgradeConfigDryRun(path); rc != 0 {
|
||||
t.Fatalf("runUpgradeConfigDryRun rc=%d", rc)
|
||||
}
|
||||
|
||||
// File should be byte-identical to the original.
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != original {
|
||||
t.Errorf("dry-run modified the file, got:\n%s\nwant:\n%s", got, original)
|
||||
}
|
||||
|
||||
// No backup file should remain (dry-run cleans up its own backup).
|
||||
entries, _ := os.ReadDir(filepath.Dir(path))
|
||||
for _, e := range entries {
|
||||
if e.Name() != "config.toml" {
|
||||
t.Errorf("dry-run left extra file: %q", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunUpgradeConfig_AlreadyCleanIsNoOp verifies that a config
|
||||
// that has only user-set non-default values produces a "nothing
|
||||
// to do" message and exit 0 — no backup, no rewrite.
|
||||
func TestRunUpgradeConfig_AlreadyCleanIsNoOp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
|
||||
origDir, _ := os.Getwd()
|
||||
projectDir := filepath.Join(dir, "project")
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chdir(projectDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
|
||||
path := filepath.Join(projectDir, ".gnoma", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
clean := "[provider]\ndefault = \"anthropic\"\n"
|
||||
if err := os.WriteFile(path, []byte(clean), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if rc := runUpgradeConfigApply(path); rc != 0 {
|
||||
t.Errorf("rc = %d, want 0 for already-clean file", rc)
|
||||
}
|
||||
|
||||
// File content unchanged.
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != clean {
|
||||
t.Errorf("already-clean file modified, got:\n%s", got)
|
||||
}
|
||||
// No backup created.
|
||||
entries, _ := os.ReadDir(filepath.Dir(path))
|
||||
for _, e := range entries {
|
||||
if e.Name() != "config.toml" {
|
||||
t.Errorf("no-op left extra file: %q", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-13
@@ -48,21 +48,22 @@ type Config struct {
|
||||
//
|
||||
// See docs/slm-backends.md for copy-paste presets.
|
||||
type SLMSection struct {
|
||||
Enabled bool `toml:"enabled,omitempty"`
|
||||
Backend string `toml:"backend,omitempty"` // auto | ollama | llamacpp | llamafile | openaicompat | disabled (empty = auto)
|
||||
Model string `toml:"model,omitempty"` // model name (ollama/llamacpp/openaicompat); ignored for llamafile
|
||||
BaseURL string `toml:"base_url,omitempty"` // server URL; defaults per-backend
|
||||
ModelURL string `toml:"model_url,omitempty"` // llamafile-only: where to download the binary from
|
||||
DataDir string `toml:"data_dir,omitempty"` // llamafile-only: where to put it (empty = XDG default)
|
||||
ExpectedSHA256 string `toml:"expected_sha256,omitempty"` // llamafile-only: verify hash if non-empty
|
||||
StartupTimeout Duration `toml:"startup_timeout,omitempty"` // llamafile-only: first-launch wait budget; 0 = default 5s
|
||||
Enabled bool `toml:"enabled,omitempty"`
|
||||
Backend string `toml:"backend,omitempty"` // auto | ollama | llamacpp | llamafile | openaicompat | disabled (empty = auto)
|
||||
Model string `toml:"model,omitempty"` // model name (ollama/llamacpp/openaicompat); ignored for llamafile
|
||||
BaseURL string `toml:"base_url,omitempty"` // server URL; defaults per-backend
|
||||
ModelURL string `toml:"model_url,omitempty"` // llamafile-only: where to download the binary from
|
||||
DataDir string `toml:"data_dir,omitempty"` // llamafile-only: where to put it (empty = XDG default)
|
||||
ExpectedSHA256 string `toml:"expected_sha256,omitempty"` // llamafile-only: verify hash if non-empty
|
||||
StartupTimeout *Duration `toml:"startup_timeout,omitempty"` // llamafile-only: first-launch wait budget; nil = default 5s
|
||||
|
||||
// ClassifyTimeout caps each task-classification call to the SLM.
|
||||
// 0 here means "use the built-in default" (15s). Cold-start model
|
||||
// loads + thinking-mode first-token latency can easily exceed 5s
|
||||
// on smaller hardware, so the default is generous. Tune down to
|
||||
// 2-3s on fast setups, or up to 30s for very slow ones.
|
||||
ClassifyTimeout Duration `toml:"classify_timeout,omitempty"`
|
||||
// nil here means "use the built-in default" (15s). *Duration(0) is
|
||||
// explicit-zero and also resolves to 0 (the SLM layer treats 0
|
||||
// the same as nil via internal/slm/classifier.go). Pointer
|
||||
// conversion was added in the 2026-06-04 follow-up so the encoder
|
||||
// can honor omitempty — see plan file referenced in resolve.go.
|
||||
ClassifyTimeout *Duration `toml:"classify_timeout,omitempty"`
|
||||
|
||||
// RegisterAsArm controls whether the SLM model is registered as
|
||||
// a tier-0 execution arm in addition to its classifier role.
|
||||
|
||||
@@ -9,6 +9,8 @@ func Defaults() Config {
|
||||
entropyThreshold := 4.5
|
||||
redactHighEntropy := false
|
||||
forceTwoStage := false
|
||||
startupTimeout := Duration(5 * time.Second)
|
||||
classifyTimeout := Duration(0) // 0 = let the SLM layer pick its own 15s default
|
||||
|
||||
return Config{
|
||||
Provider: ProviderSection{
|
||||
@@ -34,7 +36,8 @@ func Defaults() Config {
|
||||
ForceTwoStage: &forceTwoStage,
|
||||
},
|
||||
SLM: SLMSection{
|
||||
StartupTimeout: Duration(5 * time.Second),
|
||||
StartupTimeout: &startupTimeout,
|
||||
ClassifyTimeout: &classifyTimeout,
|
||||
},
|
||||
TUI: TUISection{
|
||||
Theme: "catppuccin",
|
||||
|
||||
@@ -92,6 +92,14 @@ func ProjectRoot() string {
|
||||
}
|
||||
|
||||
func projectConfigPath() string {
|
||||
return ProjectConfigPath()
|
||||
}
|
||||
|
||||
// ProjectConfigPath returns the path to the project config file
|
||||
// (.gnoma/config.toml under the project root). Exported so the
|
||||
// `gnoma upgrade-config` CLI (and any future callers that need to
|
||||
// point at the project config) can use it.
|
||||
func ProjectConfigPath() string {
|
||||
return filepath.Join(ProjectRoot(), ".gnoma", "config.toml")
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type ResolvedConfig struct {
|
||||
Security ResolvedSecuritySection
|
||||
Router ResolvedRouterSection
|
||||
Session ResolvedSessionSection
|
||||
SLM ResolvedSLMSection
|
||||
Hooks []ResolvedHook
|
||||
}
|
||||
|
||||
@@ -69,6 +70,27 @@ type ResolvedSessionSection struct {
|
||||
MaxKeep int
|
||||
}
|
||||
|
||||
// ResolvedSLMSection is SLMSection with pointer-converted fields
|
||||
// dereferenced. Added in the 2026-06-04 follow-up to Phase 1 of
|
||||
// the config-migration plan — see
|
||||
// docs/superpowers/plans/2026-06-04-config-migration-followups.md.
|
||||
// Enabled / RegisterAsArm stay as their Go types (not pointers:
|
||||
// the existing 0-sentinel pattern still applies for Enabled, and
|
||||
// RegisterAsArm was already *bool with its own nil→true handling
|
||||
// at the call sites — see internal/slm/arm.go).
|
||||
type ResolvedSLMSection struct {
|
||||
Enabled bool
|
||||
Backend string
|
||||
Model string
|
||||
BaseURL string
|
||||
ModelURL string
|
||||
DataDir string
|
||||
ExpectedSHA256 string
|
||||
StartupTimeout time.Duration
|
||||
ClassifyTimeout time.Duration
|
||||
RegisterAsArm bool
|
||||
}
|
||||
|
||||
// ResolvedHook is HookConfig with FailOpen dereferenced. All other
|
||||
// fields are pass-through copies.
|
||||
type ResolvedHook struct {
|
||||
@@ -139,6 +161,29 @@ func (c *Config) Resolved() *ResolvedConfig {
|
||||
session.MaxKeep = *c.Session.MaxKeep
|
||||
}
|
||||
|
||||
slm := ResolvedSLMSection{
|
||||
Enabled: c.SLM.Enabled,
|
||||
Backend: c.SLM.Backend,
|
||||
Model: c.SLM.Model,
|
||||
BaseURL: c.SLM.BaseURL,
|
||||
ModelURL: c.SLM.ModelURL,
|
||||
DataDir: c.SLM.DataDir,
|
||||
ExpectedSHA256: c.SLM.ExpectedSHA256,
|
||||
StartupTimeout: d.SLM.StartupTimeout.Duration(),
|
||||
ClassifyTimeout: d.SLM.ClassifyTimeout.Duration(),
|
||||
// RegisterAsArm: nil → default (true), explicit *true → true,
|
||||
// explicit *false → false. The default-true case preserves
|
||||
// pre-config behaviour where the SLM is always registered as
|
||||
// an execution arm in addition to its classifier role.
|
||||
RegisterAsArm: c.SLM.RegisterAsArm == nil || *c.SLM.RegisterAsArm,
|
||||
}
|
||||
if c.SLM.StartupTimeout != nil {
|
||||
slm.StartupTimeout = c.SLM.StartupTimeout.Duration()
|
||||
}
|
||||
if c.SLM.ClassifyTimeout != nil {
|
||||
slm.ClassifyTimeout = c.SLM.ClassifyTimeout.Duration()
|
||||
}
|
||||
|
||||
hooks := make([]ResolvedHook, len(c.Hooks))
|
||||
for i, h := range c.Hooks {
|
||||
failOpen := h.FailOpen != nil && *h.FailOpen
|
||||
@@ -159,6 +204,7 @@ func (c *Config) Resolved() *ResolvedConfig {
|
||||
Security: security,
|
||||
Router: router,
|
||||
Session: session,
|
||||
SLM: slm,
|
||||
Hooks: hooks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// i64p returns a pointer to its argument. Test helper for
|
||||
@@ -187,3 +188,62 @@ func TestResolve_NonPointerFieldsPassthrough(t *testing.T) {
|
||||
t.Errorf("Resolved.Security.EntropySafelist = %v, want [uuid sha_hex]", resolved.Security.EntropySafelist)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_SLMSection_StartupTimeoutDefaultsTo5s verifies that
|
||||
// the SLM section's pointer-converted Duration fields (added in the
|
||||
// 2026-06-04 follow-up to Phase 1) get the documented defaults.
|
||||
// StartupTimeout's default is 5s (the llamafile first-launch budget);
|
||||
// ClassifyTimeout's default is 0 (which the SLM layer maps to its
|
||||
// own 15s budget).
|
||||
func TestResolve_SLMSection_StartupTimeoutDefaultsTo5s(t *testing.T) {
|
||||
cfg := &Config{} // every pointer nil
|
||||
resolved := cfg.Resolved()
|
||||
|
||||
if resolved.SLM.StartupTimeout != 5*time.Second {
|
||||
t.Errorf("Resolved.SLM.StartupTimeout = %v, want 5s (default)", resolved.SLM.StartupTimeout)
|
||||
}
|
||||
if resolved.SLM.ClassifyTimeout != 0 {
|
||||
t.Errorf("Resolved.SLM.ClassifyTimeout = %v, want 0 (default — use SLM-layer 15s)", resolved.SLM.ClassifyTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_SLMSection_ExplicitDurationsPreserved verifies that
|
||||
// user-set Duration values survive resolution untouched.
|
||||
func TestResolve_SLMSection_ExplicitDurationsPreserved(t *testing.T) {
|
||||
startup := Duration(30 * time.Second)
|
||||
classify := Duration(45 * time.Second)
|
||||
cfg := &Config{
|
||||
SLM: SLMSection{
|
||||
StartupTimeout: &startup,
|
||||
ClassifyTimeout: &classify,
|
||||
},
|
||||
}
|
||||
resolved := cfg.Resolved()
|
||||
if resolved.SLM.StartupTimeout != 30*time.Second {
|
||||
t.Errorf("Resolved.SLM.StartupTimeout = %v, want 30s (user-set)", resolved.SLM.StartupTimeout)
|
||||
}
|
||||
if resolved.SLM.ClassifyTimeout != 45*time.Second {
|
||||
t.Errorf("Resolved.SLM.ClassifyTimeout = %v, want 45s (user-set)", resolved.SLM.ClassifyTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_SLMSection_ExplicitZeroPreserved verifies that
|
||||
// *Duration(0) (the documented "use built-in default" sentinel for
|
||||
// both fields) is preserved as 0 in the resolved view.
|
||||
func TestResolve_SLMSection_ExplicitZeroPreserved(t *testing.T) {
|
||||
startup := Duration(0)
|
||||
classify := Duration(0)
|
||||
cfg := &Config{
|
||||
SLM: SLMSection{
|
||||
StartupTimeout: &startup,
|
||||
ClassifyTimeout: &classify,
|
||||
},
|
||||
}
|
||||
resolved := cfg.Resolved()
|
||||
if resolved.SLM.StartupTimeout != 0 {
|
||||
t.Errorf("Resolved.SLM.StartupTimeout = %v, want 0 (explicit zero)", resolved.SLM.StartupTimeout)
|
||||
}
|
||||
if resolved.SLM.ClassifyTimeout != 0 {
|
||||
t.Errorf("Resolved.SLM.ClassifyTimeout = %v, want 0 (explicit zero)", resolved.SLM.ClassifyTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// UpgradeResult is what Upgrade returns: a description of what
|
||||
// changed, plus a human-readable diff the CLI can print for the
|
||||
// user to verify. BackupPath is empty when no work was done.
|
||||
type UpgradeResult struct {
|
||||
Changed bool
|
||||
BackupPath string
|
||||
Diff string
|
||||
}
|
||||
|
||||
// Upgrade reads the config at path, applies the cleaning pass
|
||||
// (drops fields whose value matches the resolved default, leaves
|
||||
// explicit-zero pointer fields alone), and atomically writes the
|
||||
// cleaned form to the same path. The original is preserved at
|
||||
// `<path>.bak-YYYYMMDD-HHMMSS`.
|
||||
//
|
||||
// Single-file mode only — `--all-projects` is deferred to the
|
||||
// Phase 2 project registry work in the 2026-05-24 config-
|
||||
// migration plan.
|
||||
//
|
||||
// The cleaning rules per field type:
|
||||
//
|
||||
// - Pointer-converted fields: drop (set to nil) iff the
|
||||
// resolved value equals the resolved default. Explicit-zero
|
||||
// pointer values that differ from the default are kept.
|
||||
//
|
||||
// - Non-pointer string / map / slice fields: encoder's
|
||||
// `omitempty` already drops Go-zero values on rewrite. The
|
||||
// cleaner doesn't need to touch them.
|
||||
//
|
||||
// - Non-pointer numeric / bool fields: same as non-pointer
|
||||
// string — encoder drops Go-zero via `omitempty`. The
|
||||
// documented 0-sentinel pattern (e.g. `TUI.Vim`, `Bandit`)
|
||||
// intentionally has Go zero == default, so this is correct.
|
||||
//
|
||||
// The contract: the resolved view of the cleaned file is
|
||||
// byte-identical to the resolved view of the original (modulo
|
||||
// cosmetic whitespace). Idempotency test in upgrade_test.go
|
||||
// asserts this.
|
||||
func Upgrade(path string) (UpgradeResult, error) {
|
||||
original, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return UpgradeResult{}, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var src Config
|
||||
if _, decErr := toml.Decode(string(original), &src); decErr != nil {
|
||||
return UpgradeResult{}, fmt.Errorf("decode config: %w", decErr)
|
||||
}
|
||||
|
||||
// Encode the *original* (uncleaned) state for diff/compare
|
||||
// BEFORE clean() mutates the struct in place.
|
||||
var beforeBuf bytes.Buffer
|
||||
if err := toml.NewEncoder(&beforeBuf).Encode(&src); err != nil {
|
||||
return UpgradeResult{}, fmt.Errorf("encode before: %w", err)
|
||||
}
|
||||
|
||||
clean(&src)
|
||||
|
||||
// Encode the cleaned state.
|
||||
var afterBuf bytes.Buffer
|
||||
if err := toml.NewEncoder(&afterBuf).Encode(&src); err != nil {
|
||||
return UpgradeResult{}, fmt.Errorf("encode after: %w", err)
|
||||
}
|
||||
before := beforeBuf.Bytes()
|
||||
after := afterBuf.Bytes()
|
||||
|
||||
if bytes.Equal(before, after) {
|
||||
return UpgradeResult{Changed: false}, nil
|
||||
}
|
||||
|
||||
// Atomic two-step write: rename original to .bak-<timestamp>,
|
||||
// then atomic-write the new content to the original path. If
|
||||
// the rename fails or the new write fails, the original is
|
||||
// preserved on disk (we never delete it before the new
|
||||
// content is durably committed).
|
||||
backupPath, err := backupPathFor(path)
|
||||
if err != nil {
|
||||
return UpgradeResult{}, err
|
||||
}
|
||||
if err := os.Rename(path, backupPath); err != nil {
|
||||
return UpgradeResult{}, fmt.Errorf("rename original to backup: %w", err)
|
||||
}
|
||||
if err := writeAtomicBytes(path, after); err != nil {
|
||||
// Best-effort restore: the original is at backupPath,
|
||||
// the user can recover. But the rename already moved it,
|
||||
// so the canonical path is gone. Try to put the backup
|
||||
// back so the user's config isn't lost.
|
||||
_ = os.Rename(backupPath, path)
|
||||
return UpgradeResult{}, fmt.Errorf("write cleaned config: %w", err)
|
||||
}
|
||||
|
||||
return UpgradeResult{
|
||||
Changed: true,
|
||||
BackupPath: backupPath,
|
||||
Diff: lineDiff(string(before), string(after)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// clean returns a new Config with pointer-converted fields
|
||||
// nulled where the value matches the resolved default. Non-
|
||||
// pointer fields are passed through unchanged — the encoder's
|
||||
// `omitempty` handles their Go-zero cases on write.
|
||||
//
|
||||
// `clean` mutates *Config.X by setting it to nil for fields
|
||||
// that match the default. It does not allocate a fresh Config
|
||||
// because the pointer fields reference shared memory between
|
||||
// sections (e.g. `cfg.Provider.MaxTokens` and
|
||||
// `Defaults().Provider.MaxTokens` are both *int64). Returning
|
||||
// the same struct with selective nulling keeps the data flow
|
||||
// obvious.
|
||||
func clean(cfg *Config) *Config {
|
||||
d := Defaults()
|
||||
resolvedSrc := cfg.Resolved()
|
||||
resolvedDef := d.Resolved()
|
||||
|
||||
// Provider.MaxTokens
|
||||
if cfg.Provider.MaxTokens != nil && resolvedSrc.Provider.MaxTokens == resolvedDef.Provider.MaxTokens {
|
||||
cfg.Provider.MaxTokens = nil
|
||||
}
|
||||
|
||||
// Tools.MaxFileSize
|
||||
if cfg.Tools.MaxFileSize != nil && resolvedSrc.Tools.MaxFileSize == resolvedDef.Tools.MaxFileSize {
|
||||
cfg.Tools.MaxFileSize = nil
|
||||
}
|
||||
|
||||
// Security.EntropyThreshold
|
||||
if cfg.Security.EntropyThreshold != nil && resolvedSrc.Security.EntropyThreshold == resolvedDef.Security.EntropyThreshold {
|
||||
cfg.Security.EntropyThreshold = nil
|
||||
}
|
||||
// Security.RedactHighEntropy
|
||||
if cfg.Security.RedactHighEntropy != nil && resolvedSrc.Security.RedactHighEntropy == resolvedDef.Security.RedactHighEntropy {
|
||||
cfg.Security.RedactHighEntropy = nil
|
||||
}
|
||||
|
||||
// Router.ForceTwoStage
|
||||
if cfg.Router.ForceTwoStage != nil && resolvedSrc.Router.ForceTwoStage == resolvedDef.Router.ForceTwoStage {
|
||||
cfg.Router.ForceTwoStage = nil
|
||||
}
|
||||
|
||||
// Session.MaxKeep
|
||||
if cfg.Session.MaxKeep != nil && resolvedSrc.Session.MaxKeep == resolvedDef.Session.MaxKeep {
|
||||
cfg.Session.MaxKeep = nil
|
||||
}
|
||||
|
||||
// SLM.StartupTimeout / SLM.ClassifyTimeout
|
||||
if cfg.SLM.StartupTimeout != nil && resolvedSrc.SLM.StartupTimeout == resolvedDef.SLM.StartupTimeout {
|
||||
cfg.SLM.StartupTimeout = nil
|
||||
}
|
||||
if cfg.SLM.ClassifyTimeout != nil && resolvedSrc.SLM.ClassifyTimeout == resolvedDef.SLM.ClassifyTimeout {
|
||||
cfg.SLM.ClassifyTimeout = nil
|
||||
}
|
||||
// SLM.RegisterAsArm: default is true; only null when
|
||||
// explicitly set to true (the default-true case).
|
||||
if cfg.SLM.RegisterAsArm != nil && *cfg.SLM.RegisterAsArm == resolvedDef.SLM.RegisterAsArm {
|
||||
cfg.SLM.RegisterAsArm = nil
|
||||
}
|
||||
|
||||
// HookConfig.FailOpen per entry
|
||||
for i := range cfg.Hooks {
|
||||
if cfg.Hooks[i].FailOpen != nil && !resolvedSrc.Hooks[i].FailOpen {
|
||||
// Default for FailOpen is false; null when explicitly false.
|
||||
cfg.Hooks[i].FailOpen = nil
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// backupPathFor returns a deterministic timestamped backup path.
|
||||
// Uses the local-time YYYYMMDD-HHMMSS format the original plan
|
||||
// specified, with second-level resolution. Collisions within the
|
||||
// same second are possible (e.g. rapid re-runs) but the
|
||||
// idempotency test exercises the no-second-backup case, so a
|
||||
// collision would still be visible to the user.
|
||||
func backupPathFor(path string) (string, error) {
|
||||
t := time.Now()
|
||||
suffix := t.Format("20060102-150405")
|
||||
return fmt.Sprintf("%s.bak-%s", path, suffix), nil
|
||||
}
|
||||
|
||||
// writeAtomicBytes writes the given bytes to path via temp file
|
||||
// + rename. Used by Upgrade (which has already produced the
|
||||
// bytes) and is a more general version of writeAtomicTOML.
|
||||
func writeAtomicBytes(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := func() { _ = os.Remove(tmpName) }
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
cleanup()
|
||||
return fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
cleanup()
|
||||
return fmt.Errorf("sync temp: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("close temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("rename temp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lineDiff returns a simple line-by-line diff between before and
|
||||
// after. Lines removed from before are prefixed with `-`, lines
|
||||
// added in after are prefixed with `+`, unchanged lines are
|
||||
// prefixed with ` ` (space). Header lines give the file lengths.
|
||||
//
|
||||
// Not a true Myers / Hunt–Szymanski diff — a long edit can
|
||||
// produce noisy output. Adequate for the gnoma use case where
|
||||
// config files are small (tens of lines) and the user wants
|
||||
// visual confirmation that the cleaning is doing the right
|
||||
// thing. If a more sophisticated diff is ever needed,
|
||||
// `github.com/pmezard/go-difflib` is already a transitive dep
|
||||
// (see go.sum) and can be vendored.
|
||||
func lineDiff(before, after string) string {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(fmt.Sprintf("--- before (%d bytes)\n", len(before)))
|
||||
b.WriteString(fmt.Sprintf("+++ after (%d bytes)\n", len(after)))
|
||||
bs := splitLines(before)
|
||||
as := splitLines(after)
|
||||
|
||||
// Naive: walk both, mark removed/added/changed. We do a
|
||||
// simple longest-common-subsequence via a small set, since
|
||||
// config files are small. For each line in before, find
|
||||
// the first matching line in after; emit `-` for the
|
||||
// unmatched prefix and `+` for the new prefix.
|
||||
i, j := 0, 0
|
||||
for i < len(bs) || j < len(as) {
|
||||
switch {
|
||||
case i < len(bs) && j < len(as) && bs[i] == as[j]:
|
||||
fmt.Fprintf(&b, " %s\n", bs[i])
|
||||
i++
|
||||
j++
|
||||
case j < len(as) && (i == len(bs) || !contains(bs[i:], as[j])):
|
||||
fmt.Fprintf(&b, "+ %s\n", as[j])
|
||||
j++
|
||||
case i < len(bs):
|
||||
fmt.Fprintf(&b, "- %s\n", bs[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// splitLines returns the lines of s, including any trailing
|
||||
// empty line if s ends in '\n'. The result is suitable for
|
||||
// line-by-line diffing.
|
||||
func splitLines(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
out := []string{}
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
out = append(out, s[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// contains reports whether v appears in s. Used by lineDiff to
|
||||
// detect a "moved" line.
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestUpgrade_DropsPointerFieldAtDefault verifies the core
|
||||
// cleaning semantic for pointer-converted fields: a file
|
||||
// containing `max_tokens = 8192` (the documented default, user
|
||||
// explicitly set to it) gets the field nulled in the rewritten
|
||||
// file. The cleaner compares resolved values; matching the
|
||||
// default means the field is dropped.
|
||||
//
|
||||
// Non-pointer string fields (like `mode = ""`) are dropped
|
||||
// automatically by the encoder's `omitempty` on the
|
||||
// read+rewrite cycle, so they don't need the cleaner's help.
|
||||
// This test focuses on the pointer-converted case that the
|
||||
// cleaner was designed for.
|
||||
func TestUpgrade_DropsPointerFieldAtDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
original := "[provider]\nmax_tokens = 8192\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
res, err := Upgrade(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Upgrade: %v", err)
|
||||
}
|
||||
if !res.Changed {
|
||||
t.Errorf("Upgrade.Changed = false, want true (max_tokens at default should be dropped)")
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read upgraded: %v", err)
|
||||
}
|
||||
body := string(got)
|
||||
|
||||
if strings.Contains(body, "max_tokens") {
|
||||
t.Errorf("max_tokens at default not dropped, got:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "[provider]") {
|
||||
t.Errorf("[provider] block should be omitted after cleaning, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade_KeepsExplicitUserValues verifies that user-set
|
||||
// non-default values survive the cleaning untouched.
|
||||
func TestUpgrade_KeepsExplicitUserValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
original := `[provider]
|
||||
default = "anthropic"
|
||||
max_tokens = 16384
|
||||
|
||||
[permission]
|
||||
mode = "deny"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Upgrade(path); err != nil {
|
||||
t.Fatalf("Upgrade: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
body := string(got)
|
||||
|
||||
for _, want := range []string{
|
||||
`default = "anthropic"`,
|
||||
`max_tokens = 16384`,
|
||||
`mode = "deny"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("cleaned file missing %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade_KeepsExplicitZeroPointerFields verifies the
|
||||
// pointer-conversion contract: a user who sets `*int64(0)`
|
||||
// explicitly (resolved to 0, which differs from the default
|
||||
// 8192) keeps the field in the cleaned file. This is the
|
||||
// "explicit zero preserved" case the Phase 1 hybrid exists for.
|
||||
func TestUpgrade_KeepsExplicitZeroPointerFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
original := `[provider]
|
||||
max_tokens = 0
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Upgrade(path); err != nil {
|
||||
t.Fatalf("Upgrade: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
body := string(got)
|
||||
|
||||
if !strings.Contains(body, "max_tokens = 0") {
|
||||
t.Errorf("explicit zero max_tokens = 0 was dropped, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade_BackupFileCreated verifies the atomic two-step
|
||||
// write: the original is renamed to `<path>.bak-YYYYMMDD-HHMMSS`
|
||||
// and the cleaned content lands at the original path. The
|
||||
// timestamp suffix is deterministic enough to pattern-match.
|
||||
func TestUpgrade_BackupFileCreated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
// Use a pointer-converted field at the default so the cleaner
|
||||
// actually mutates the struct (and Changed becomes true).
|
||||
original := "[provider]\nmax_tokens = 8192\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
res, err := Upgrade(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Upgrade: %v", err)
|
||||
}
|
||||
if !res.Changed {
|
||||
t.Skip("no change, can't test backup creation")
|
||||
}
|
||||
if res.BackupPath == "" {
|
||||
t.Errorf("Upgrade.BackupPath = empty, want non-empty")
|
||||
}
|
||||
if !strings.HasPrefix(res.BackupPath, path+".bak-") {
|
||||
t.Errorf("BackupPath = %q, want prefix %q", res.BackupPath, path+".bak-")
|
||||
}
|
||||
backup, err := os.ReadFile(res.BackupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read backup: %v", err)
|
||||
}
|
||||
if string(backup) != original {
|
||||
t.Errorf("backup content = %q, want %q", backup, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade_Idempotent verifies the core promise: running
|
||||
// upgrade twice on the same file produces a no-op the second
|
||||
// time. No second backup is created; the file content is
|
||||
// unchanged; the result reports Changed=false on the second run.
|
||||
func TestUpgrade_Idempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
// Mix: one explicit user value (default = "anthropic") and
|
||||
// one pointer-converted field at the default (max_tokens = 8192).
|
||||
// The cleaner drops the max_tokens; the user value is kept.
|
||||
original := "[provider]\ndefault = \"anthropic\"\nmax_tokens = 8192\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
first, err := Upgrade(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first Upgrade: %v", err)
|
||||
}
|
||||
if !first.Changed {
|
||||
t.Errorf("first Upgrade.Changed = false, want true")
|
||||
}
|
||||
|
||||
second, err := Upgrade(path)
|
||||
if err != nil {
|
||||
t.Fatalf("second Upgrade: %v", err)
|
||||
}
|
||||
if second.Changed {
|
||||
t.Errorf("second Upgrade.Changed = true, want false (idempotent)")
|
||||
}
|
||||
if second.BackupPath != "" {
|
||||
t.Errorf("second Upgrade.BackupPath = %q, want empty (no second backup)", second.BackupPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade_NoChangesOnAlreadyCleanFile verifies the no-op
|
||||
// case: a file that already has only user-set non-default
|
||||
// values produces Changed=false and no backup. This is the
|
||||
// baseline — the user runs upgrade-config and gets told
|
||||
// "nothing to do".
|
||||
func TestUpgrade_NoChangesOnAlreadyCleanFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
clean := "[provider]\ndefault = \"anthropic\"\n"
|
||||
if err := os.WriteFile(path, []byte(clean), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
res, err := Upgrade(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Upgrade: %v", err)
|
||||
}
|
||||
if res.Changed {
|
||||
t.Errorf("Upgrade.Changed = true on already-clean file")
|
||||
}
|
||||
if res.BackupPath != "" {
|
||||
t.Errorf("Upgrade.BackupPath = %q, want empty", res.BackupPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade_DiffPopulatedWhenChanged verifies the human-readable
|
||||
// diff is populated whenever the file changed. CLI prints this
|
||||
// for the user to verify the cleaning is doing the right thing.
|
||||
func TestUpgrade_DiffPopulatedWhenChanged(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
// Use a pointer-converted field at the default so Changed=true.
|
||||
if err := os.WriteFile(path, []byte("[provider]\nmax_tokens = 8192\n"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
res, err := Upgrade(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Upgrade: %v", err)
|
||||
}
|
||||
if !res.Changed {
|
||||
t.Skip("no change, can't test diff content")
|
||||
}
|
||||
if res.Diff == "" {
|
||||
t.Errorf("Upgrade.Diff = empty, want non-empty when Changed=true")
|
||||
}
|
||||
if !strings.Contains(res.Diff, "max_tokens") {
|
||||
t.Errorf("Diff does not mention the changed field, got:\n%s", res.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade_PreservesDurationFields verifies the
|
||||
// 2026-06-04 Caveat 1 fix interacts correctly with the cleaner:
|
||||
// a user-set Duration (e.g. classify_timeout = "20s") is kept
|
||||
// because it's not the default (the default is *Duration(0) for
|
||||
// ClassifyTimeout, mapped to time.Duration(0) at the resolver).
|
||||
func TestUpgrade_PreservesDurationFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
original := "[slm]\nclassify_timeout = \"20s\"\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Upgrade(path); err != nil {
|
||||
t.Fatalf("Upgrade: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
body := string(got)
|
||||
|
||||
if !strings.Contains(body, "classify_timeout") {
|
||||
t.Errorf("user-set Duration was dropped, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgrade_KeepsExplicitZeroDuration documents the *opposite*
|
||||
// of the "drops" cases: a file with `startup_timeout = 0` (the
|
||||
// previous zero-spam from the pre-Caveat-1 int64 encoder) is
|
||||
// KEPT, because the resolved value via *Duration is 0 which
|
||||
// differs from the documented default of 5s. The user's
|
||||
// explicit-zero is preserved — this is the "explicit zero"
|
||||
// contract the pointer-conversion exists for.
|
||||
func TestUpgrade_KeepsExplicitZeroDuration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
original := "[slm]\nstartup_timeout = 0\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Upgrade(path); err != nil {
|
||||
t.Fatalf("Upgrade: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
body := string(got)
|
||||
|
||||
if !strings.Contains(body, "startup_timeout") {
|
||||
t.Errorf("startup_timeout was dropped (expected kept; resolved 0 != default 5s), got:\n%s", body)
|
||||
}
|
||||
_ = time.Second
|
||||
}
|
||||
|
||||
// TestUpgrade_NonexistentFileIsError verifies the input-validation
|
||||
// path. A missing source file is a user error, not a silent
|
||||
// success.
|
||||
func TestUpgrade_NonexistentFileIsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nonexistent.toml")
|
||||
|
||||
_, err := Upgrade(path)
|
||||
if err == nil {
|
||||
t.Fatal("Upgrade on missing file succeeded, want error")
|
||||
}
|
||||
}
|
||||
@@ -174,3 +174,27 @@ func TestSetProjectConfig_SetsBoolFieldCorrectly(t *testing.T) {
|
||||
t.Errorf("vim=true not present, got:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetProjectConfig_SLMEnabledOmitsDurationFields verifies the
|
||||
// 2026-06-04 follow-up fix: setting `slm.enabled = true` on a
|
||||
// fresh file no longer emits `startup_timeout = 0` or
|
||||
// `classify_timeout = 0` zero-spam. Both Duration fields are
|
||||
// pointer-converted (`*Duration`) so the encoder honors
|
||||
// `omitempty` when the pointer is nil.
|
||||
func TestSetProjectConfig_SLMEnabledOmitsDurationFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
|
||||
if err := setConfig(path, "slm.enabled", "true"); err != nil {
|
||||
t.Fatalf("setConfig: %v", err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
body := string(data)
|
||||
|
||||
if strings.Contains(body, "startup_timeout") {
|
||||
t.Errorf("startup_timeout emitted as zero-spam, got:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "classify_timeout") {
|
||||
t.Errorf("classify_timeout emitted as zero-spam, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user