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:
2026-06-04 13:18:30 +02:00
parent db7a47012e
commit 70cd530578
11 changed files with 1008 additions and 22 deletions
+7 -8
View File
@@ -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,
+96
View File
@@ -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
}
+142
View File
@@ -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
View File
@@ -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.
+4 -1
View File
@@ -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",
+8
View File
@@ -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")
}
+46
View File
@@ -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,
}
}
+60
View File
@@ -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)
}
}
+298
View File
@@ -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 / HuntSzymanski 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
}
+309
View File
@@ -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")
}
}
+24
View File
@@ -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)
}
}