feat: incremental project config writes (surgical section replacement)

SaveTo now writes only delta sections/fields for project-level reasonix.toml,
preserving existing file content. User configs (full template) unchanged.

- RenderTOMLProjectDelta: generates only non-default sections/fields
- replaceTOMLSection: handles both [section] and [[section]] formats
- saveProjectIncremental merges delta into existing file
This commit is contained in:
jbts6 committed 2026-06-21 22:40:46 +08:00
1 parent 9ada141766
commit 2419ca27ea
3 files changed
+548 -2

No files matched your search

+1 -1
View File
@@ -960,7 +960,7 @@ func rememberPermissionRule(workspaceRoot, rule string) control.RememberResult {
result.Err = err
return result
}
if err := edit.SaveTo(path); err != nil {
if err := config.WritePermissionsSection(path, edit.Permissions.Allow); err != nil {
slog.Warn("save config after permission rule", "err", err)
result.Err = err
return result
+203 -1
View File
@@ -779,8 +779,17 @@ func validatePlugin(e PluginEntry) error {
// writes a sibling temp file then renames, so a crash mid-write can't leave a
// half-written reasonix.toml that fails to parse on next load. Parent directories
// are created as needed.
//
// For project configs (./reasonix.toml) the write is incremental: only sections
// and fields that differ from built-in defaults are written, so the file never
// accumulates fields that override the user's global config. User configs still
// write the full annotated template since they are the user's own settings store.
func (c *Config) SaveTo(path string) error {
return c.SaveToScope(path, renderScopeForPath(path))
scope := renderScopeForPath(path)
if scope == RenderScopeProject {
return c.saveProjectIncremental(path)
}
return c.SaveToScope(path, scope)
}
func (c *Config) SaveToScope(path string, scope RenderScope) error {
@@ -790,6 +799,94 @@ func (c *Config) SaveToScope(path string, scope RenderScope) error {
return writeConfigFile(path, RenderTOMLForScope(c, scope))
}
// saveProjectIncremental merges only the delta (non-default sections/fields)
// into the existing project config file, preserving all other content verbatim.
func (c *Config) saveProjectIncremental(path string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("save: empty config path")
}
raw, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
return err
}
raw = nil
}
body := string(raw)
isNew := body == ""
if isNew {
return writeConfigFile(path, RenderTOMLForScope(c, RenderScopeProject))
}
delta := RenderTOMLProjectDelta(c)
if strings.TrimSpace(delta) == "" {
return nil // no changes to write
}
// Parse delta into section blocks and merge each into body
body = mergeTOMLDelta(body, delta)
return writeConfigFile(path, body)
}
// mergeTOMLDelta parses delta into named TOML blocks and merges each into body
// via replaceTOMLSection. Consecutive array-of-tables entries ([[plugins]],
// [[providers]]) with the same name are merged into a single block so the
// replacement doesn't lose entries.
func mergeTOMLDelta(body, delta string) string {
lines := strings.Split(delta, "\n")
type section struct {
name string
content string
isArray bool
}
var sections []section
var curName string
var curBuf strings.Builder
curIsArray := false
flush := func() {
if curName == "" {
return
}
content := curBuf.String()
if curIsArray && len(sections) > 0 && sections[len(sections)-1].isArray && sections[len(sections)-1].name == curName {
sections[len(sections)-1].content += content
} else {
sections = append(sections, section{curName, content, curIsArray})
}
curBuf.Reset()
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "[[") && strings.HasSuffix(trimmed, "]]"):
flush()
curName = trimmed[2 : len(trimmed)-2]
curIsArray = true
curBuf.WriteString(line + "\n")
case strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") && !strings.HasPrefix(trimmed, "[["):
flush()
curName = trimmed[1 : len(trimmed)-1]
curIsArray = false
curBuf.WriteString(line + "\n")
default:
if curName != "" {
curBuf.WriteString(line + "\n")
}
}
}
flush()
for _, s := range sections {
body = replaceTOMLSection(body, s.name, s.content)
}
return body
}
// SaveMinimalProjectReasoningLanguage writes a new project config that only
// overrides [agent].reasoning_language.
func SaveMinimalProjectReasoningLanguage(path, lang string) (string, error) {
@@ -820,6 +917,111 @@ func configFilePerm(path string) os.FileMode {
return 0o644
}
// WritePermissionsSection replaces or creates the [permissions] section in a
// TOML file, preserving all other sections verbatim. When the file doesn't
// exist yet, it creates one containing only the permissions section.
func WritePermissionsSection(path string, allow []string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("write permissions: empty config path")
}
raw, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
return err
}
raw = nil
}
newBlock := fmt.Sprintf("[permissions]\nallow = %s\n", renderStringArray(allow))
body := string(raw)
if body == "" {
return writeConfigFile(path, newBlock)
}
body = replaceTOMLSection(body, "permissions", newBlock)
return writeConfigFile(path, body)
}
// replaceTOMLSection replaces the content of a named TOML section (including
// its header line) with newContent. It handles both [section] and [[section]]
// array-of-tables headers. If the section doesn't exist, newContent is appended
// at the end.
func replaceTOMLSection(body, sectionName, newContent string) string {
headerBare := "[" + sectionName + "]"
headerArr := "[[" + sectionName + "]]"
sectionStart := strings.Index(body, headerArr)
var header string
var headerLen int
if sectionStart >= 0 {
header = headerArr
headerLen = len(headerArr)
} else {
sectionStart = strings.Index(body, headerBare)
if sectionStart < 0 {
return strings.TrimRight(body, "\n") + "\n\n" + newContent
}
header = headerBare
headerLen = len(headerBare)
}
// For [[array-of-tables]], find the LAST consecutive entry of the same type
// and replace everything from the first entry through the last.
if strings.HasPrefix(header, "[[") {
start := sectionStart
// Find first preceding entry of the same array
for {
prev := strings.LastIndex(body[:start], "\n"+headerArr)
if prev < 0 {
prev = strings.LastIndex(body[:start], headerArr)
}
if prev < 0 {
break
}
// Check it's on its own line (preceded by newline or at start)
if prev == 0 || body[prev-1] == '\n' {
start = prev
} else {
break
}
}
sectionStart = start
// Find the last consecutive entry and then the next section after it
scan := body[sectionStart:]
lastEntryEnd := 0
for {
idx := strings.Index(scan, "\n"+headerArr)
if idx < 0 {
break
}
lastEntryEnd += idx + 1 + len(headerArr)
scan = scan[idx+1+len(headerArr):]
}
rest := scan
nextSection := strings.Index(rest, "\n[")
if nextSection >= 0 {
sectionEnd := sectionStart + len(body) - len(scan) + nextSection
return body[:sectionStart] + strings.TrimRight(newContent, "\n") + body[sectionEnd:]
}
return body[:sectionStart] + strings.TrimRight(newContent, "\n") + "\n"
}
rest := body[sectionStart+headerLen:]
nextSection := strings.Index(rest, "\n[")
var sectionEnd int
if nextSection >= 0 {
sectionEnd = sectionStart + headerLen + nextSection
} else {
sectionEnd = len(body)
}
return body[:sectionStart] + newContent + body[sectionEnd:]
}
func renderScopeForPath(path string) RenderScope {
if isUserConfigPath(path) {
return RenderScopeUser
+344
View File
@@ -522,6 +522,350 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
return b.String()
}
// RenderTOMLProjectDelta generates TOML containing only the sections and fields
// that differ from built-in defaults. Unlike RenderTOMLForScope (which renders
// the full config with comments), this emits clean TOML that can be surgically
// merged into an existing project config file via replaceTOMLSection.
func RenderTOMLProjectDelta(c *Config) string {
if c == nil {
return ""
}
d := Default()
var b strings.Builder
// Top-level scalar fields
if c.DefaultModel != d.DefaultModel {
fmt.Fprintf(&b, "default_model = %q\n", c.DefaultModel)
}
if c.Language != "" && c.Language != d.Language {
fmt.Fprintf(&b, "language = %q\n", c.Language)
}
// [ui] section — whole-section comparison
if !reflect.DeepEqual(c.UI, d.UI) {
b.WriteString("[ui]\n")
if c.UI.Theme != d.UI.Theme {
fmt.Fprintf(&b, "theme = %q\n", c.UITheme())
}
if s := c.UIThemeStyle(); s != "" && s != d.UIThemeStyle() {
fmt.Fprintf(&b, "theme_style = %q\n", s)
}
if l := c.UIShortcutLayout(); l != "classic" {
fmt.Fprintf(&b, "shortcut_layout = %q\n", l)
}
if c.UI.CloseBehavior != d.UI.CloseBehavior {
fmt.Fprintf(&b, "close_behavior = %q\n", c.DesktopCloseBehavior())
}
if c.UI.ShowReasoning != d.UI.ShowReasoning {
fmt.Fprintf(&b, "show_reasoning = %v\n", c.UI.ShowReasoning)
}
b.WriteString("\n")
}
// [network] section
if !reflect.DeepEqual(c.Network, d.Network) {
b.WriteString("[network]\n")
if c.Network.ProxyMode != d.Network.ProxyMode {
fmt.Fprintf(&b, "proxy_mode = %q\n", c.NetworkProxyMode())
}
if c.Network.ProxyURL != "" {
fmt.Fprintf(&b, "proxy_url = %q\n", c.Network.ProxyURL)
}
if c.Network.NoProxy != "" {
fmt.Fprintf(&b, "no_proxy = %q\n", c.Network.NoProxy)
}
if c.Network.Proxy.Type != "" || c.Network.Proxy.Server != "" || c.Network.Proxy.Port > 0 || c.Network.Proxy.Username != "" || c.Network.Proxy.Password != "" {
b.WriteString("[network.proxy]\n")
pt := c.Network.Proxy.Type
if pt == "" {
pt = "socks5"
}
fmt.Fprintf(&b, "type = %q\n", pt)
if c.Network.Proxy.Server != "" {
fmt.Fprintf(&b, "server = %q\n", c.Network.Proxy.Server)
}
if c.Network.Proxy.Port > 0 {
fmt.Fprintf(&b, "port = %d\n", c.Network.Proxy.Port)
}
if c.Network.Proxy.Username != "" {
fmt.Fprintf(&b, "username = %q\n", c.Network.Proxy.Username)
}
if c.Network.Proxy.Password != "" {
fmt.Fprintf(&b, "password = %q\n", c.Network.Proxy.Password)
}
}
b.WriteString("\n")
}
// [agent] section — per-field comparison
var agentBuf strings.Builder
anyAgent := false
if sp := strings.TrimSpace(c.Agent.SystemPrompt); sp != "" && sp != d.Agent.SystemPrompt {
agentBuf.WriteString("system_prompt = \"\"\"\n")
agentBuf.WriteString(sp)
agentBuf.WriteString("\"\"\"\n")
anyAgent = true
}
if c.Agent.SystemPromptFile != "" && c.Agent.SystemPromptFile != d.Agent.SystemPromptFile {
fmt.Fprintf(&agentBuf, "system_prompt_file = %q\n", c.Agent.SystemPromptFile)
anyAgent = true
}
if c.Agent.Temperature != d.Agent.Temperature {
fmt.Fprintf(&agentBuf, "temperature = %s\n", formatFloat(c.Agent.Temperature))
anyAgent = true
}
if c.Agent.ReasoningLanguage != d.Agent.ReasoningLanguage {
if l := c.ReasoningLanguage(); l != "auto" {
fmt.Fprintf(&agentBuf, "reasoning_language = %q\n", l)
anyAgent = true
}
}
if c.Agent.AutoPlanClassifier != "" && c.Agent.AutoPlanClassifier != d.Agent.AutoPlanClassifier {
fmt.Fprintf(&agentBuf, "auto_plan_classifier = %q\n", c.Agent.AutoPlanClassifier)
anyAgent = true
}
if c.Agent.SoftCompactRatio != d.Agent.SoftCompactRatio {
fmt.Fprintf(&agentBuf, "soft_compact_ratio = %s\n", formatFloat(c.Agent.SoftCompactRatio))
anyAgent = true
}
if c.Agent.CompactRatio != d.Agent.CompactRatio {
fmt.Fprintf(&agentBuf, "compact_ratio = %s\n", formatFloat(c.Agent.CompactRatio))
anyAgent = true
}
if c.Agent.CompactForceRatio != d.Agent.CompactForceRatio {
fmt.Fprintf(&agentBuf, "compact_force_ratio = %s\n", formatFloat(c.Agent.CompactForceRatio))
anyAgent = true
}
if c.Agent.Keep != nil && !reflect.DeepEqual(c.Agent.Keep, d.Agent.Keep) {
fmt.Fprintf(&agentBuf, "keep = %s\n", renderStringArray(c.Agent.Keep))
anyAgent = true
}
if c.Agent.RecentKeep > 0 && c.Agent.RecentKeep != d.Agent.RecentKeep {
fmt.Fprintf(&agentBuf, "recent_keep = %d\n", c.Agent.RecentKeep)
anyAgent = true
}
if c.Agent.ColdResumePrune != d.Agent.ColdResumePrune {
fmt.Fprintf(&agentBuf, "cold_resume_prune = %v\n", c.ColdResumePruneEnabled())
anyAgent = true
}
if c.Agent.PlannerModel != "" && c.Agent.PlannerModel != d.Agent.PlannerModel {
fmt.Fprintf(&agentBuf, "planner_model = %q\n", c.Agent.PlannerModel)
anyAgent = true
}
if c.Agent.SubagentModel != "" && c.Agent.SubagentModel != d.Agent.SubagentModel {
fmt.Fprintf(&agentBuf, "subagent_model = %q\n", c.Agent.SubagentModel)
anyAgent = true
}
if len(c.Agent.SubagentModels) > 0 && !reflect.DeepEqual(c.Agent.SubagentModels, d.Agent.SubagentModels) {
fmt.Fprintf(&agentBuf, "subagent_models = %s\n", renderStringMap(c.Agent.SubagentModels))
anyAgent = true
}
if c.Agent.SubagentEffort != "" && c.Agent.SubagentEffort != d.Agent.SubagentEffort {
fmt.Fprintf(&agentBuf, "subagent_effort = %q\n", c.Agent.SubagentEffort)
anyAgent = true
}
if len(c.Agent.SubagentEfforts) > 0 && !reflect.DeepEqual(c.Agent.SubagentEfforts, d.Agent.SubagentEfforts) {
fmt.Fprintf(&agentBuf, "subagent_efforts = %s\n", renderStringMap(c.Agent.SubagentEfforts))
anyAgent = true
}
if c.Agent.OutputStyle != "" && c.Agent.OutputStyle != d.Agent.OutputStyle {
fmt.Fprintf(&agentBuf, "output_style = %q\n", c.Agent.OutputStyle)
anyAgent = true
}
if anyAgent {
b.WriteString("[agent]\n")
b.WriteString(agentBuf.String())
b.WriteString("\n")
}
// [[providers]] — include user-defined providers that aren't built-in
proj := projectScopedConfigForRender(c)
if proj != nil && len(proj.Providers) > 0 && !reflect.DeepEqual(proj.Providers, d.Providers) {
for _, p := range proj.Providers {
b.WriteString("[[providers]]\n")
fmt.Fprintf(&b, "name = %q\n", p.Name)
fmt.Fprintf(&b, "kind = %q\n", p.Kind)
fmt.Fprintf(&b, "base_url = %q\n", p.BaseURL)
if len(p.Models) > 0 {
fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models))
if p.Default != "" {
fmt.Fprintf(&b, "default = %q\n", p.Default)
}
} else if p.Model != "" {
fmt.Fprintf(&b, "model = %q\n", p.Model)
}
if p.ModelsURL != "" {
fmt.Fprintf(&b, "models_url = %q\n", p.ModelsURL)
}
fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv)
if p.BalanceURL != "" {
fmt.Fprintf(&b, "balance_url = %q\n", p.BalanceURL)
}
if p.ContextWindow > 0 {
fmt.Fprintf(&b, "context_window = %d\n", p.ContextWindow)
}
if p.Price != nil {
fmt.Fprintf(&b, "price = %s\n", renderPricingInline(p.Price))
}
if len(p.Prices) > 0 {
fmt.Fprintf(&b, "prices = %s\n", renderPricingMap(p.Prices))
}
if p.Thinking != "" {
fmt.Fprintf(&b, "thinking = %q\n", p.Thinking)
}
if p.Effort != "" {
fmt.Fprintf(&b, "effort = %q\n", p.Effort)
}
if p.Vision {
b.WriteString("vision = true\n")
}
if p.VisionModels != nil {
fmt.Fprintf(&b, "vision_models = %s\n", renderStringArray(p.VisionModels))
}
if p.VisionDetail != "" {
fmt.Fprintf(&b, "vision_detail = %q\n", p.VisionDetail)
}
if p.ReasoningProtocol != "" {
fmt.Fprintf(&b, "reasoning_protocol = %q\n", p.ReasoningProtocol)
}
if len(p.SupportedEfforts) > 0 {
fmt.Fprintf(&b, "supported_efforts = %s\n", renderStringArray(p.SupportedEfforts))
}
if p.DefaultEffort != "" {
fmt.Fprintf(&b, "default_effort = %q\n", p.DefaultEffort)
}
if p.NoProxy {
b.WriteString("no_proxy = true\n")
}
b.WriteString("\n")
}
}
// [tools]
if !reflect.DeepEqual(c.Tools, d.Tools) {
b.WriteString("[tools]\n")
if len(c.Tools.Enabled) > 0 {
fmt.Fprintf(&b, "enabled = %s\n", renderStringArray(c.Tools.Enabled))
}
if c.Tools.BashTimeoutSeconds != nil && *c.Tools.BashTimeoutSeconds != 0 {
fmt.Fprintf(&b, "bash_timeout_seconds = %d\n", *c.Tools.BashTimeoutSeconds)
}
b.WriteString("\n")
}
// [tools.background_jobs]
if c.Tools.BackgroundJobs != d.Tools.BackgroundJobs {
if c.Tools.BackgroundJobs.StalledWarningSeconds != nil && *c.Tools.BackgroundJobs.StalledWarningSeconds > 0 {
b.WriteString("[tools.background_jobs]\n")
fmt.Fprintf(&b, "stalled_warning_seconds = %d\n", *c.Tools.BackgroundJobs.StalledWarningSeconds)
b.WriteString("\n")
}
}
// [lsp]
if !reflect.DeepEqual(c.LSP, d.LSP) {
renderLSPConfig(&b, c.LSP)
}
// [skills]
if !reflect.DeepEqual(c.Skills, d.Skills) {
b.WriteString("[skills]\n")
if len(c.Skills.Paths) > 0 {
fmt.Fprintf(&b, "paths = %s\n", renderStringArray(c.Skills.Paths))
}
if len(c.Skills.ExcludedPaths) > 0 {
fmt.Fprintf(&b, "excluded_paths = %s\n", renderStringArray(c.Skills.ExcludedPaths))
}
if c.Skills.MaxDepth != 0 {
fmt.Fprintf(&b, "max_depth = %d\n", c.SkillMaxDepth())
}
if disabled := c.DisabledSkillNames(); len(disabled) > 0 {
fmt.Fprintf(&b, "disabled_skills = %s\n\n", renderStringArray(disabled))
}
}
// [permissions]
if !reflect.DeepEqual(c.Permissions, d.Permissions) {
b.WriteString("[permissions]\n")
mode := c.Permissions.Mode
if mode == "" {
mode = "ask"
}
if mode != "ask" {
fmt.Fprintf(&b, "mode = %q\n", mode)
}
if len(c.Permissions.Deny) > 0 {
fmt.Fprintf(&b, "deny = %s\n", renderStringArray(c.Permissions.Deny))
}
if len(c.Permissions.Allow) > 0 {
fmt.Fprintf(&b, "allow = %s\n", renderStringArray(c.Permissions.Allow))
}
if len(c.Permissions.Ask) > 0 {
fmt.Fprintf(&b, "ask = %s\n", renderStringArray(c.Permissions.Ask))
}
b.WriteString("\n")
}
// [sandbox]
if !reflect.DeepEqual(c.Sandbox, d.Sandbox) {
b.WriteString("[sandbox]\n")
if c.Sandbox.WorkspaceRoot != "" {
fmt.Fprintf(&b, "workspace_root = %q\n", c.Sandbox.WorkspaceRoot)
}
if len(c.Sandbox.AllowWrite) > 0 {
fmt.Fprintf(&b, "allow_write = %s\n", renderStringArray(c.Sandbox.AllowWrite))
}
if c.BashMode() != "enforce" {
fmt.Fprintf(&b, "bash = %q\n", c.BashMode())
}
if c.Sandbox.Network != d.Sandbox.Network {
fmt.Fprintf(&b, "network = %v\n", c.Sandbox.Network)
}
b.WriteString("\n")
}
// [statusline]
if !reflect.DeepEqual(c.Statusline, d.Statusline) {
b.WriteString("[statusline]\n")
if c.Statusline.Command != "" {
fmt.Fprintf(&b, "command = %q\n", c.Statusline.Command)
}
b.WriteString("\n")
}
// [[plugins]] — always include when set; replaces all existing entries
for _, pl := range c.Plugins {
b.WriteString("[[plugins]]\n")
fmt.Fprintf(&b, "name = %q\n", pl.Name)
if pl.Type != "" {
fmt.Fprintf(&b, "type = %q\n", pl.Type)
}
if pl.Command != "" {
fmt.Fprintf(&b, "command = %q\n", pl.Command)
}
if len(pl.Args) > 0 {
fmt.Fprintf(&b, "args = %s\n", renderStringArray(pl.Args))
}
if pl.URL != "" {
fmt.Fprintf(&b, "url = %q\n", pl.URL)
}
if len(pl.Headers) > 0 {
fmt.Fprintf(&b, "headers = %s\n", renderStringMap(pl.Headers))
}
if len(pl.Env) > 0 {
fmt.Fprintf(&b, "env = %s\n", renderStringMap(pl.Env))
}
if pl.AutoStart != nil {
fmt.Fprintf(&b, "auto_start = %v\n", *pl.AutoStart)
}
b.WriteString("\n")
}
return b.String()
}
func renderPricingInline(p *provider.Pricing) string {
if p == nil {
return "{}"