| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "reflect" |
| 6 | "slices" |
| 7 | "sort" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/BurntSushi/toml" |
| 12 | ) |
| 13 | |
| 14 | // rawTOMLSet edits only the owning assignment. Unrelated text, including unknown |
| 15 | // keys and comments, is retained. Tables and quoted/dotted keys are supported. |
| 16 | func rawTOMLSet(body string, path []string, value any) (string, error) { |
| 17 | lines := strings.Split(body, "\n") |
| 18 | var section []string |
| 19 | insert := 0 |
| 20 | parentSeen := len(path) == 1 |
| 21 | for i := 0; i < len(lines); i++ { |
| 22 | line := strings.TrimSpace(lines[i]) |
| 23 | if header := tomlSectionHeader(line); header != "" { |
| 24 | section = rawTOMLKeyPath(strings.Trim(header, "[]")) |
| 25 | if reflect.DeepEqual(section, path[:len(path)-1]) { |
| 26 | insert, parentSeen = i+1, true |
| 27 | } |
| 28 | continue |
| 29 | } |
| 30 | if line == "" || strings.HasPrefix(line, "#") { |
| 31 | continue |
| 32 | } |
| 33 | eq, err := findTOMLAssignmentEquals(lines[i], 0, len(lines[i])) |
| 34 | if err != nil || eq < 0 { |
| 35 | continue |
| 36 | } |
| 37 | key := rawTOMLKeyPath(strings.TrimSpace(lines[i][:eq])) |
| 38 | full := append(append([]string{}, section...), key...) |
| 39 | if len(section) == 0 && len(path) > 1 && path[0] == "providers" { |
| 40 | full = append([]string{"providers"}, full...) |
| 41 | } |
| 42 | // Consume an entire multiline value, so embedded fake headers/keys |
| 43 | // cannot be mistaken for configuration syntax. |
| 44 | end := i |
| 45 | var decoded map[string]any |
| 46 | for ; end < len(lines); end++ { |
| 47 | if _, e := toml.Decode("v = "+strings.Join(append([]string{lines[i][eq+1:]}, lines[i+1:end+1]...), "\n"), &decoded); e == nil { |
| 48 | break |
| 49 | } |
| 50 | } |
| 51 | if end == len(lines) { |
| 52 | return body, fmt.Errorf("cannot locate TOML value for %s", strings.Join(full, ".")) |
| 53 | } |
| 54 | if len(full) <= len(path) && reflect.DeepEqual(full, path[:len(full)]) { |
| 55 | v := value |
| 56 | if len(full) < len(path) { |
| 57 | m, ok := decoded["v"].(map[string]any) |
| 58 | if !ok { |
| 59 | i = end |
| 60 | continue |
| 61 | } |
| 62 | root := m |
| 63 | for _, k := range path[len(full) : len(path)-1] { |
| 64 | nested, ok := m[k].(map[string]any) |
| 65 | if !ok { |
| 66 | nested = map[string]any{} |
| 67 | m[k] = nested |
| 68 | } |
| 69 | m = nested |
| 70 | } |
| 71 | m[path[len(path)-1]] = value |
| 72 | v = root |
| 73 | } |
| 74 | encoded, err := rawTOMLValue(v) |
| 75 | if err != nil { |
| 76 | return body, err |
| 77 | } |
| 78 | replacement := replaceTOMLScalarAssignment(lines[i], encoded) |
| 79 | // Retain comments inside an edited multiline array as adjacent |
| 80 | // comments; the rest of the document remains byte-for-byte intact. |
| 81 | var comments []string |
| 82 | for n := i + 1; n <= end; n++ { |
| 83 | if at := tomlInlineCommentIndex(lines[n]); at >= 0 { |
| 84 | comments = append(comments, lines[n][at:]) |
| 85 | } |
| 86 | } |
| 87 | out := append([]string{}, lines[:i]...) |
| 88 | out = append(out, comments...) |
| 89 | out = append(out, replacement) |
| 90 | out = append(out, lines[end+1:]...) |
| 91 | return strings.Join(out, "\n"), nil |
| 92 | } |
| 93 | i = end |
| 94 | } |
| 95 | encoded, err := rawTOMLValue(value) |
| 96 | if err != nil { |
| 97 | return body, err |
| 98 | } |
| 99 | assignment := strconv.Quote(path[len(path)-1]) + " = " + encoded |
| 100 | if parentSeen { |
| 101 | lines = append(lines[:insert], append([]string{assignment}, lines[insert:]...)...) |
| 102 | return strings.Join(lines, "\n"), nil |
| 103 | } |
| 104 | keys := make([]string, len(path)-1) |
| 105 | for i, k := range path[:len(path)-1] { |
| 106 | keys[i] = strconv.Quote(k) |
| 107 | } |
| 108 | return strings.TrimRight(body, "\n") + "\n[" + strings.Join(keys, ".") + "]\n" + assignment + "\n", nil |
| 109 | } |
| 110 | |
| 111 | func rawTOMLKeyPath(s string) []string { |
| 112 | // Let the validated TOML parser handle escapes and quoted dots. |
| 113 | var m map[string]any |
| 114 | if _, err := toml.Decode(s+" = 0", &m); err != nil { |
| 115 | return nil |
| 116 | } |
| 117 | var out []string |
| 118 | for len(m) == 1 { |
| 119 | for k, v := range m { |
| 120 | out = append(out, k) |
| 121 | m, _ = v.(map[string]any) |
| 122 | } |
| 123 | } |
| 124 | return out |
| 125 | } |
| 126 | |
| 127 | func rawTOMLValue(v any) (string, error) { |
| 128 | if v == nil { |
| 129 | return "", fmt.Errorf("cannot encode absent TOML value") |
| 130 | } |
| 131 | rv := reflect.ValueOf(v) |
| 132 | if rv.Kind() == reflect.Pointer { |
| 133 | return rawTOMLValue(rv.Elem().Interface()) |
| 134 | } |
| 135 | switch rv.Kind() { |
| 136 | case reflect.String: |
| 137 | return strconv.Quote(rv.String()), nil |
| 138 | case reflect.Bool: |
| 139 | return strconv.FormatBool(rv.Bool()), nil |
| 140 | case reflect.Int, reflect.Int64, reflect.Int32: |
| 141 | return strconv.FormatInt(rv.Int(), 10), nil |
| 142 | case reflect.Float64, reflect.Float32: |
| 143 | return strconv.FormatFloat(rv.Float(), 'g', -1, 64), nil |
| 144 | case reflect.Slice, reflect.Array: |
| 145 | var parts []string |
| 146 | for i := range rv.Len() { |
| 147 | s, err := rawTOMLValue(rv.Index(i).Interface()) |
| 148 | if err != nil { |
| 149 | return "", err |
| 150 | } |
| 151 | parts = append(parts, s) |
| 152 | } |
| 153 | return "[" + strings.Join(parts, ", ") + "]", nil |
| 154 | case reflect.Map: |
| 155 | var keys []string |
| 156 | for _, k := range rv.MapKeys() { |
| 157 | keys = append(keys, k.String()) |
| 158 | } |
| 159 | sort.Strings(keys) |
| 160 | var parts []string |
| 161 | for _, k := range keys { |
| 162 | s, err := rawTOMLValue(rv.MapIndex(reflect.ValueOf(k)).Interface()) |
| 163 | if err != nil { |
| 164 | return "", err |
| 165 | } |
| 166 | parts = append(parts, strconv.Quote(k)+" = "+s) |
| 167 | } |
| 168 | return "{ " + strings.Join(parts, ", ") + " }", nil |
| 169 | } |
| 170 | return "", fmt.Errorf("unsupported TOML edit value %T", v) |
| 171 | } |
| 172 | |
| 173 | func rewriteOpenCodeGoConfig(body string, before, after *Config, additions []openCodeGoGroup) (string, error) { |
| 174 | if reflect.DeepEqual(before.Providers, after.Providers) && reflect.DeepEqual(before.Agent, after.Agent) && reflect.DeepEqual(before.Bot, after.Bot) && reflect.DeepEqual(before.Desktop, after.Desktop) && before.DefaultModel == after.DefaultModel && len(additions) == 0 { |
| 175 | return rawTOMLSet(body, []string{"config_version"}, openCodeGoUpgradeVersion) |
| 176 | } |
| 177 | lines := strings.Split(body, "\n") |
| 178 | blocks := providerTOMLBlocks(lines) |
| 179 | if len(blocks) != len(before.Providers) { |
| 180 | expanded, err := expandOpenCodeGoInlineProviders(body) |
| 181 | if err != nil { |
| 182 | return body, err |
| 183 | } |
| 184 | return rewriteOpenCodeGoConfig(expanded, before, after, additions) |
| 185 | } |
| 186 | // Extend each provider span through its nested tables only. |
| 187 | for i := range blocks { |
| 188 | for blocks[i].end < len(lines) { |
| 189 | h := tomlSectionHeader(lines[blocks[i].end]) |
| 190 | p := rawTOMLKeyPath(strings.Trim(h, "[]")) |
| 191 | if h != "" && (len(p) < 2 || p[0] != "providers") { |
| 192 | break |
| 193 | } |
| 194 | blocks[i].end++ |
| 195 | } |
| 196 | } |
| 197 | var originals []string |
| 198 | for _, b := range blocks { |
| 199 | originals = append(originals, strings.Join(lines[b.start+1:b.end], "\n")) |
| 200 | } |
| 201 | // after.Providers holds each split group right behind its source, so the |
| 202 | // original index i maps to i plus the groups split from earlier sources. |
| 203 | afterIndex := make([]int, len(blocks)) |
| 204 | for i := range blocks { |
| 205 | if i > 0 { |
| 206 | afterIndex[i] = afterIndex[i-1] + 1 |
| 207 | for _, addition := range additions { |
| 208 | if addition.source == i-1 { |
| 209 | afterIndex[i]++ |
| 210 | } |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | for i := range slices.Backward(blocks) { |
| 215 | next, err := patchOpenCodeGoProvider(originals[i], before.Providers[i], after.Providers[afterIndex[i]]) |
| 216 | if err != nil { |
| 217 | return body, err |
| 218 | } |
| 219 | var siblings []string |
| 220 | for _, addition := range additions { |
| 221 | if addition.source != i { |
| 222 | continue |
| 223 | } |
| 224 | raw, err := patchOpenCodeGoProvider(originals[i], before.Providers[i], addition.entry) |
| 225 | if err != nil { |
| 226 | return body, err |
| 227 | } |
| 228 | siblings = append(siblings, "", "[[providers]]") |
| 229 | siblings = append(siblings, strings.Split(strings.TrimRight(raw, "\n"), "\n")...) |
| 230 | } |
| 231 | b := blocks[i] |
| 232 | replacement := strings.Split(next, "\n") |
| 233 | if len(siblings) > 0 { |
| 234 | replacement = append(append(trimTrailingBlankLines(replacement), siblings...), "") |
| 235 | } |
| 236 | lines = append(lines[:b.start+1], append(replacement, lines[b.end:]...)...) |
| 237 | } |
| 238 | body = strings.Join(lines, "\n") |
| 239 | return rewriteOpenCodeGoReferences(body, before, after) |
| 240 | } |
| 241 | |
| 242 | func trimTrailingBlankLines(lines []string) []string { |
| 243 | for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { |
| 244 | lines = lines[:len(lines)-1] |
| 245 | } |
| 246 | return lines |
| 247 | } |
| 248 | |
| 249 | // verifyOpenCodeGoRewrite proves the lexical edit still describes the planned |
| 250 | // configuration before any byte reaches disk. Every rewrite path ends here. |
| 251 | func verifyOpenCodeGoRewrite(body string, after *Config) error { |
| 252 | var check Config |
| 253 | if _, err := toml.Decode(body, &check); err != nil { |
| 254 | return fmt.Errorf("migration readback: %w", err) |
| 255 | } |
| 256 | if check.ConfigVersion != openCodeGoUpgradeVersion { |
| 257 | return fmt.Errorf("migration readback: config_version %d", check.ConfigVersion) |
| 258 | } |
| 259 | if check.DefaultModel != after.DefaultModel { |
| 260 | return fmt.Errorf("migration readback: default_model %q", check.DefaultModel) |
| 261 | } |
| 262 | if len(check.Providers) != len(after.Providers) { |
| 263 | return fmt.Errorf("provider count changed during lexical rewrite") |
| 264 | } |
| 265 | for i, p := range check.Providers { |
| 266 | want := after.Providers[i] |
| 267 | if p.Name != want.Name || p.Kind != want.Kind || p.BaseURL != want.BaseURL || !reflect.DeepEqual(p.ModelList(), want.ModelList()) { |
| 268 | return fmt.Errorf("provider %q failed migration readback", want.Name) |
| 269 | } |
| 270 | } |
| 271 | return nil |
| 272 | } |
| 273 | |
| 274 | // Expand the uncommon inline-array form lexically. Only structural separators |
| 275 | // change; string contents, unknown fields and comments remain in the document. |
| 276 | func expandOpenCodeGoInlineProviders(body string) (string, error) { |
| 277 | start, end, err := providerTOMLInlineArrayRange(body) |
| 278 | if err != nil { |
| 279 | return body, err |
| 280 | } |
| 281 | blocks, err := providerTOMLInlineBlocks(body) |
| 282 | if err != nil { |
| 283 | return body, fmt.Errorf("cannot safely map inline providers: %w", err) |
| 284 | } |
| 285 | if len(blocks) == 0 { |
| 286 | return body, fmt.Errorf("cannot safely map inline providers: no provider blocks") |
| 287 | } |
| 288 | assignment := strings.LastIndex(body[:start], "\n") + 1 |
| 289 | var tables strings.Builder |
| 290 | outside := body[start+1 : end] |
| 291 | for _, b := range slices.Backward(blocks) { |
| 292 | a, z := b.start-start-1, b.end-start |
| 293 | outside = outside[:a] + outside[z:] |
| 294 | } |
| 295 | var comments []string |
| 296 | for line := range strings.SplitSeq(outside, "\n") { |
| 297 | if at := tomlInlineCommentIndex(line); at >= 0 { |
| 298 | comments = append(comments, line[at:]) |
| 299 | } |
| 300 | } |
| 301 | for _, b := range blocks { |
| 302 | chunk := []byte(body[b.start+1 : b.end]) |
| 303 | depth := 0 |
| 304 | if err := scanTOMLOutsideStrings(string(chunk), 0, len(chunk), func(pos int, ch byte) bool { |
| 305 | switch ch { |
| 306 | case '[', '{': |
| 307 | depth++ |
| 308 | case ']', '}': |
| 309 | depth-- |
| 310 | case ',': |
| 311 | if depth == 0 { |
| 312 | chunk[pos] = '\n' |
| 313 | } |
| 314 | } |
| 315 | return true |
| 316 | }); err != nil { |
| 317 | return body, err |
| 318 | } |
| 319 | tables.WriteString("\n[[providers]]\n" + string(chunk) + "\n") |
| 320 | } |
| 321 | return body[:assignment] + strings.Join(comments, "\n") + body[end+1:] + tables.String(), nil |
| 322 | } |
| 323 | |
| 324 | func patchOpenCodeGoProvider(raw string, old, next ProviderEntry) (string, error) { |
| 325 | ov, nv := reflect.ValueOf(old), reflect.ValueOf(next) |
| 326 | for _, field := range []string{"Name", "Kind", "BaseURL", "RequestURL", "ChatURL", "Models", "Model", "Default", "PresetID", "PresetVersion", "ResponsesMode", "Effort"} { |
| 327 | a, b := ov.FieldByName(field).Interface(), nv.FieldByName(field).Interface() |
| 328 | if reflect.DeepEqual(a, b) { |
| 329 | continue |
| 330 | } |
| 331 | f, _ := ov.Type().FieldByName(field) |
| 332 | var err error |
| 333 | raw, err = rawTOMLSet(raw, []string{strings.Split(f.Tag.Get("toml"), ",")[0]}, b) |
| 334 | if err != nil { |
| 335 | return raw, err |
| 336 | } |
| 337 | } |
| 338 | for model, o := range next.ModelOverrides { |
| 339 | previous := old.ModelOverrides[model] |
| 340 | for _, pair := range []struct { |
| 341 | key string |
| 342 | a, b any |
| 343 | }{{"reasoning_protocol", previous.ReasoningProtocol, o.ReasoningProtocol}, {"supported_efforts", previous.SupportedEfforts, o.SupportedEfforts}, {"default_effort", previous.DefaultEffort, o.DefaultEffort}} { |
| 344 | if reflect.DeepEqual(pair.a, pair.b) { |
| 345 | continue |
| 346 | } |
| 347 | var err error |
| 348 | raw, err = rawTOMLSet(raw, []string{"providers", "model_overrides", model, pair.key}, pair.b) |
| 349 | if err != nil { |
| 350 | return raw, err |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | return raw, nil |
| 355 | } |
| 356 | |
| 357 | func rewriteOpenCodeGoReferences(body string, before, after *Config) (string, error) { |
| 358 | // Update only reference fields whose resolved migration changed them. |
| 359 | var err error |
| 360 | for _, pair := range []struct { |
| 361 | path []string |
| 362 | a, b any |
| 363 | }{ |
| 364 | {[]string{"config_version"}, before.ConfigVersion, openCodeGoUpgradeVersion}, |
| 365 | {[]string{"default_model"}, before.DefaultModel, after.DefaultModel}, |
| 366 | {[]string{"desktop", "provider_access"}, before.Desktop.ProviderAccess, after.Desktop.ProviderAccess}, |
| 367 | {[]string{"agent", "planner_model"}, before.Agent.PlannerModel, after.Agent.PlannerModel}, |
| 368 | {[]string{"agent", "vision_model"}, before.Agent.VisionModel, after.Agent.VisionModel}, |
| 369 | {[]string{"agent", "guardian_model"}, before.Agent.GuardianModel, after.Agent.GuardianModel}, |
| 370 | {[]string{"agent", "recovery_model"}, before.Agent.RecoveryModel, after.Agent.RecoveryModel}, |
| 371 | {[]string{"agent", "subagent_model"}, before.Agent.SubagentModel, after.Agent.SubagentModel}, |
| 372 | {[]string{"agent", "web_search_model"}, before.Agent.WebSearchModel, after.Agent.WebSearchModel}, |
| 373 | {[]string{"bot", "model"}, before.Bot.Model, after.Bot.Model}, |
| 374 | } { |
| 375 | if reflect.DeepEqual(pair.a, pair.b) { |
| 376 | continue |
| 377 | } |
| 378 | body, err = rawTOMLSet(body, pair.path, pair.b) |
| 379 | if err != nil { |
| 380 | return body, err |
| 381 | } |
| 382 | } |
| 383 | for name, ref := range after.Agent.SubagentModels { |
| 384 | if before.Agent.SubagentModels[name] != ref { |
| 385 | body, err = rawTOMLSet(body, []string{"agent", "subagent_models", name}, ref) |
| 386 | if err != nil { |
| 387 | return body, err |
| 388 | } |
| 389 | } |
| 390 | } |
| 391 | // Bot array tables need positional edits, never a global string replace. |
| 392 | botIndex, inBotConnection := -1, false |
| 393 | lines := strings.Split(body, "\n") |
| 394 | for i, line := range lines { |
| 395 | if tomlSectionHeader(line) == "bot.connections" && strings.HasPrefix(strings.TrimSpace(line), "[[") { |
| 396 | botIndex++ |
| 397 | inBotConnection = true |
| 398 | continue |
| 399 | } |
| 400 | if inBotConnection && botIndex >= 0 && botIndex < len(after.Bot.Connections) && isTOMLKeyAssignment(line, "model") && before.Bot.Connections[botIndex].Model != after.Bot.Connections[botIndex].Model { |
| 401 | lines[i] = replaceTOMLStringAssignment(line, after.Bot.Connections[botIndex].Model) |
| 402 | } |
| 403 | if h := tomlSectionHeader(line); h != "" { |
| 404 | inBotConnection = false |
| 405 | } |
| 406 | } |
| 407 | body = strings.Join(lines, "\n") |
| 408 | return body, nil |
| 409 | } |
| 410 |