返回 CodeWhale
config_persistence.rs
根目录 / crates / tui / src / config_persistence.rs
1 //! Config file path resolution and TOML persistence helpers.
2 //!
3 //! These helpers are used by command handlers and non-command UI code, so
4 //! persistence lives outside the command tree.
5 //!
6 //! Every `config.toml` mutation funnels through [`mutate_config_document`]:
7 //! the file is edited in place with `toml_edit` so unrelated comments,
8 //! ordering, and formatting survive, and the result is replaced atomically
9 //! (same-directory temp file + rename) with owner-only permissions.
10
11 use std::path::{Path, PathBuf};
12
13 use anyhow::Context;
14
15 use crate::config::{ProviderIdentity, ProviderKind, StatusItem, expand_path};
16
17 /// Parse the TOML document at `path` (an absent or empty file yields an empty
18 /// document), apply `mutate`, and atomically persist the result.
19 ///
20 /// This is the single write path for TUI config mutations: `toml_edit` keeps
21 /// user comments and formatting intact, and the temp-file + rename write can
22 /// never leave a half-written config behind.
23 pub(crate) fn mutate_config_document<F>(path: &Path, mutate: F) -> anyhow::Result<()>
24 where
25 F: FnOnce(&mut toml_edit::DocumentMut) -> anyhow::Result<()>,
26 {
27 mutate_config_document_with_migration(path, |doc, _| mutate(doc))
28 }
29
30 /// [`mutate_config_document`], also handing `mutate` the receipt of the legacy
31 /// top-level `base_url` / `api_key` move this same write made (#6394).
32 pub(crate) fn mutate_config_document_with_migration<F>(path: &Path, mutate: F) -> anyhow::Result<()>
33 where
34 F: FnOnce(
35 &mut toml_edit::DocumentMut,
36 &codewhale_config::legacy_root::LegacyRootMigration,
37 ) -> anyhow::Result<()>,
38 {
39 codewhale_config::mutate_config_document_with_migration(path, |doc, moved| {
40 migrate_legacy_route_preferences(path, doc)?;
41 mutate(doc, moved)
42 })
43 }
44
45 /// Commit the legacy effective startup selection with its receipt in the same
46 /// atomic config write. Settings remains untouched, so an interrupted cleanup
47 /// or an older binary cannot erase the user's historical choices. After this
48 /// marker, Config never consults those legacy route fields again.
49 pub(crate) fn migrate_legacy_route_preferences(
50 path: &Path,
51 doc: &mut toml_edit::DocumentMut,
52 ) -> anyhow::Result<()> {
53 if !crate::config::is_home_config_path(path)
54 || doc
55 .get("route_preferences_version")
56 .and_then(toml_edit::Item::as_integer)
57 .is_some()
58 {
59 return Ok(());
60 }
61 let mut config = crate::config::parse_config_base(&doc.to_string()).map_err(|_| {
62 anyhow::anyhow!(
63 "Could not parse configuration for route preference migration; contents omitted"
64 )
65 })?;
66 let previous_config = config.clone();
67 let settings =
68 crate::settings::Settings::load_legacy_route_preferences_read_only().map_err(|_| {
69 anyhow::anyhow!(
70 "Could not read legacy route preferences; configuration was not changed"
71 )
72 })?;
73 // An unparsable settings.toml loads as defaults carrying `load_error`, which
74 // keeps the UI usable but is not evidence that no preferences were saved.
75 // This migration is one-way: stamping the version over defaults would retire
76 // the user's real legacy choices unread. Refuse instead, exactly as
77 // `Settings::save_to_path` refuses to overwrite an unreadable document.
78 // Contents stay omitted; the file may hold private text.
79 anyhow::ensure!(
80 settings.load_error.is_none(),
81 "Could not read legacy route preferences; configuration was not changed"
82 );
83 config.apply_saved_selection(&settings);
84 let active_identity = config.active_provider_identity().ok();
85 let selector = active_identity
86 .as_ref()
87 .and_then(|identity| {
88 identity
89 .migrated_legacy_ollama_cloud_route
90 .then(|| identity.persisted_id())
91 .flatten()
92 })
93 .or(config.provider.as_deref());
94 if let Some(provider) = selector {
95 if previous_config.provider.as_deref() != Some(provider)
96 && let Some(previous) = previous_config.provider.as_deref()
97 {
98 set_document_value(
99 doc,
100 &["route_preferences_migration", "previous_provider"],
101 previous,
102 )?;
103 }
104 set_document_value(doc, &["provider"], provider)?;
105 }
106 let mut providers: Vec<&str> = settings
107 .provider_models
108 .as_ref()
109 .map(|models| models.keys().map(String::as_str).collect())
110 .unwrap_or_default();
111 if settings.default_model.is_some() {
112 for provider in [
113 ProviderKind::Deepseek.as_str(),
114 codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id,
115 ] {
116 if !providers.contains(&provider) {
117 providers.push(provider);
118 }
119 }
120 }
121 providers.sort_unstable();
122 for provider in providers {
123 let Ok(identity) = config.legacy_selection_identity(provider) else {
124 continue;
125 };
126 let mut scoped = config.clone();
127 scoped
128 .scope_to_provider_identity(&identity)
129 .map_err(anyhow::Error::msg)?;
130 let model =
131 if identity.provider == ProviderKind::Custom && identity.persisted_id().is_none() {
132 scoped.default_text_model.as_deref()
133 } else {
134 scoped
135 .provider_config_for(&identity)
136 .and_then(|entry| entry.model.as_deref())
137 };
138 if let Some(model) = model {
139 let mut previous = previous_config.clone();
140 previous
141 .scope_to_provider_identity(&identity)
142 .map_err(anyhow::Error::msg)?;
143 if let Some(old_model) = previous
144 .provider_config_for(&identity)
145 .and_then(|entry| entry.model.as_deref())
146 .or_else(|| {
147 (previous_config.active_provider_identity().ok().as_ref() == Some(&identity))
148 .then_some(previous_config.default_text_model.as_deref())
149 .flatten()
150 })
151 && old_model != model
152 {
153 // Keep the displaced Config choice as an inert migration
154 // receipt. Nothing resolves routes from this archive.
155 set_document_value(
156 doc,
157 &[
158 "route_preferences_migration",
159 "previous_models",
160 identity.key.as_str(),
161 ],
162 old_model,
163 )?;
164 }
165 set_provider_model_document(doc, &identity, model)?;
166 }
167 }
168 set_document_value(doc, &["route_preferences_version"], 1_i64)
169 }
170
171 pub(crate) fn set_provider_model_document(
172 doc: &mut toml_edit::DocumentMut,
173 identity: &ProviderIdentity,
174 model: &str,
175 ) -> anyhow::Result<()> {
176 let config = crate::config::parse_config_base(&doc.to_string()).map_err(|_| {
177 anyhow::anyhow!("Could not parse destination route identity; contents omitted")
178 })?;
179 set_verified_provider_model_document(doc, &config, identity, model)
180 }
181
182 // `config` is the freshly parsed same-lock document, before any intended delta.
183 // Reuse that projection so a root receipt is not dropped by a second parse of
184 // the canonicalized document. Every path verifies before writing the delta.
185 fn set_verified_provider_model_document(
186 doc: &mut toml_edit::DocumentMut,
187 config: &crate::config::Config,
188 identity: &ProviderIdentity,
189 model: &str,
190 ) -> anyhow::Result<()> {
191 anyhow::ensure!(
192 !model.trim().is_empty() && !model.chars().any(char::is_control),
193 "model must be nonempty and contain no control characters"
194 );
195 config
196 .verify_provider_identity(identity)
197 .map_err(anyhow::Error::msg)?;
198 let provider_key = identity.config_table_key()?;
199 set_document_value(doc, &["providers", provider_key, "model"], model)
200 }
201
202 /// One persistent owner and atomic write for an explicitly saved route.
203 ///
204 /// Also returns the undo for exactly this write, taken under the config lock,
205 /// so a caller whose follow-up apply step is rejected can take back only the
206 /// switch.
207 pub(crate) fn persist_provider_selection(
208 config_path: Option<&Path>,
209 identity: &ProviderIdentity,
210 model: Option<&str>,
211 ) -> anyhow::Result<(PathBuf, codewhale_config::ConfigDocumentUndo)> {
212 let path = config_toml_path(config_path)?;
213 let ((), undo) =
214 codewhale_config::mutate_config_document_undoable_with_migration(&path, |doc, moved| {
215 migrate_legacy_route_preferences(&path, doc)?;
216 let config =
217 crate::config::parse_config_after_locked_migration(&doc.to_string(), moved)
218 .map_err(|_| {
219 anyhow::anyhow!("Could not parse destination route; contents omitted")
220 })?;
221 config
222 .verify_provider_identity(identity)
223 .map_err(anyhow::Error::msg)?;
224 if let Some(model) = model {
225 set_verified_provider_model_document(doc, &config, identity, model)?;
226 }
227 set_document_value(
228 doc,
229 &["provider"],
230 identity.persisted_id().unwrap_or(identity.key.as_str()),
231 )?;
232 reconcile_root_model_aliases(doc, &config, identity)
233 })?;
234 Ok((path, undo))
235 }
236
237 /// Keep a root `default_text_model` alias from stranding a route switch,
238 /// without discarding the choice it holds.
239 ///
240 /// The root alias is the *active* route's fallback: `Config` resolves it only
241 /// when the selected route has no model of its own. A switch that leaves the
242 /// incoming route without a leaf therefore hands it an alias naming the route
243 /// on its way out, and `Config::load` rejects the file the caller just wrote —
244 /// a committed switch that produces an unloadable config — or, on a
245 /// pass-through incoming route that loads it, a model the incoming route never
246 /// chose while the outgoing route comes back on its catalog default.
247 ///
248 /// When the outgoing route has no leaf and was resolving the alias, relocate
249 /// that value onto the outgoing route's own canonical leaf, because it is real
250 /// saved state, and only then clear the alias. Any other alias the incoming
251 /// route already shadows with its own leaf is inert and stays: deleting a
252 /// saved choice to satisfy validation of a value nothing resolves is data
253 /// loss, not a repair. Likewise an unnamed custom route stores its
254 /// model in the alias itself and has no leaf to receive it. If the incoming
255 /// route cannot shadow that value, refuse the switch atomically and ask for an
256 /// explicit destination model instead of saving an unloadable configuration.
257 ///
258 /// `previous` is the document's configuration before the switch; `incoming` is
259 /// the identity now selected. Every route writer calls this, so no writer can
260 /// keep a private rule about which route owns the root alias.
261 pub(crate) fn reconcile_root_model_aliases(
262 doc: &mut toml_edit::DocumentMut,
263 previous: &crate::config::Config,
264 incoming: &crate::config::ProviderIdentity,
265 ) -> anyhow::Result<()> {
266 // A freshly admitted id-less root keeps its legacy alias as a receipt.
267 // Its effective model is written to the canonical leaf created by this
268 // same-lock migration; the alias is never another route's saved choice.
269 if incoming.provider == ProviderKind::Custom && incoming.persisted_id().is_none() {
270 return Ok(());
271 }
272 // The outgoing route was resolving the alias as its own model. Move it
273 // onto that route's leaf even when the incoming route could load it: a
274 // pass-through incoming route would otherwise inherit a model it never
275 // chose, and the outgoing route would come back on its catalog default.
276 if let Some((outgoing, value)) = previous.root_model_alias_owned_by_outgoing(incoming) {
277 set_verified_provider_model_document(doc, previous, &outgoing, &value)?;
278 unset_root_model_aliases(doc)?;
279 return Ok(());
280 }
281 // Below, only `default_text_model` is read. The legacy root `model` key is
282 // never what blocks a load: `Config::default_model` already refuses to
283 // route a foreign legacy value to a provider that cannot serve it, and
284 // `Config::validate` does not consult it, so relocating it would move a
285 // value nothing is asking about. It is still cleared together with the
286 // alias that shadows it (`unset_root_model_aliases`).
287 const ROOT_KEY: &str = "default_text_model";
288 let Some(value) = doc
289 .get(ROOT_KEY)
290 .and_then(toml_edit::Item::as_str)
291 .map(str::to_owned)
292 else {
293 return Ok(());
294 };
295 let switched = crate::config::parse_config_base(&doc.to_string())
296 .map_err(|_| anyhow::anyhow!("Could not parse switched route; contents omitted"))?;
297 // `Config::validate` is the single authority on what the incoming route can
298 // serve, so a writer cannot disagree with the loader. Act only when this
299 // alias is what the loader rejects: a document already broken for an
300 // unrelated reason is not this writer's to rewrite.
301 let mut without_alias = switched.clone();
302 without_alias.default_text_model = None;
303 if switched
304 .provider_config_for(incoming)
305 .and_then(|entry| entry.model.as_deref())
306 .is_some()
307 || switched.validate().is_ok()
308 || without_alias.validate().is_err()
309 {
310 return Ok(());
311 }
312 // An empty or control-bearing alias names no saved model. Nothing to
313 // relocate, and clearing it loses nothing.
314 if value.trim().is_empty() || value.chars().any(char::is_control) {
315 unset_root_model_aliases(doc)?;
316 return Ok(());
317 }
318 // No leaf can hold this value. Keep the only copy of the user's choice
319 // rather than discard it for a document the incoming route loads as soon as
320 // it saves a model of its own.
321 let outgoing = previous.active_provider_identity().ok();
322 if outgoing.as_ref().is_some_and(|outgoing| {
323 outgoing != incoming
324 && outgoing.provider == ProviderKind::Custom
325 && outgoing.persisted_id().is_none()
326 }) {
327 // This same-lock canonicalizer copied the root model before moving
328 // its endpoint. Clear an alias only when that exact outgoing leaf
329 // proves the choice survives; otherwise retain the refusal.
330 if outgoing.as_ref().is_some_and(|outgoing| {
331 previous
332 .provider_config_for(outgoing)
333 .and_then(|entry| entry.model.as_deref())
334 == Some(value.as_str())
335 }) {
336 return unset_root_model_aliases(doc);
337 }
338 anyhow::bail!(
339 "Choose a model for the destination provider before switching from a legacy custom connection; the saved configuration was not changed"
340 );
341 }
342 let Some(outgoing) = outgoing.as_ref().filter(|outgoing| *outgoing != incoming) else {
343 return Ok(());
344 };
345 let mut scoped = switched;
346 scoped
347 .scope_to_provider_identity(outgoing)
348 .map_err(anyhow::Error::msg)?;
349 if scoped
350 .provider_config_for(outgoing)
351 .and_then(|entry| entry.model.as_deref())
352 .is_none()
353 {
354 set_verified_provider_model_document(doc, previous, outgoing, &value)?;
355 }
356 // The outgoing route either already saved its own choice or has just
357 // received this one, so the alias is now a shadowed duplicate that only
358 // blocks the incoming route.
359 unset_root_model_aliases(doc)?;
360 Ok(())
361 }
362
363 /// Clear the root model alias: `default_text_model` and the legacy root
364 /// `model` that `Config::default_model` falls back to when the former is
365 /// unset. While `default_text_model` is present the legacy key resolves on no
366 /// route, so removing only the alias would resurrect a stale legacy model on
367 /// the incoming route (a pass-through route would then use a model nobody
368 /// chose for it). When the legacy key *is* the alias, the caller has already
369 /// moved its value onto the outgoing route's leaf.
370 fn unset_root_model_aliases(doc: &mut toml_edit::DocumentMut) -> anyhow::Result<()> {
371 unset_document_value(doc, &["default_text_model"])?;
372 unset_document_value(doc, &["model"])?;
373 Ok(())
374 }
375
376 /// Atomically replace `path` with `body` via a same-directory temp file and
377 /// rename. On Unix the file lands with 0o600 permissions: config.toml can
378 /// hold API keys, so this matches `ConfigStore::save` and the auth save path.
379 pub(crate) fn write_config_toml_atomic(path: &Path, body: &str) -> anyhow::Result<()> {
380 codewhale_config::create_config_document(path, body)
381 }
382
383 /// Set the value at `segments` (parent tables plus the final key), creating
384 /// missing intermediate tables. Replacing an existing value keeps its decor,
385 /// so comments above the key and trailing same-line comments survive.
386 ///
387 /// Segments are separate strings rather than one dotted key, so table names
388 /// that need quoting (`[providers."my.provider"]`) resolve correctly.
389 pub(crate) fn set_document_value(
390 doc: &mut toml_edit::DocumentMut,
391 segments: &[&str],
392 value: impl Into<toml_edit::Value>,
393 ) -> anyhow::Result<()> {
394 codewhale_config::set_config_document_value(doc, segments, value)
395 }
396
397 /// Remove the value at `segments`. Returns `Ok(true)` when an entry was
398 /// removed; missing keys and missing (or non-table) parents are a no-op.
399 pub(crate) fn unset_document_value(
400 doc: &mut toml_edit::DocumentMut,
401 segments: &[&str],
402 ) -> anyhow::Result<bool> {
403 codewhale_config::unset_config_document_value(doc, segments)
404 }
405
406 /// Remove every entry named `key` from `table` and, recursively, from nested
407 /// tables, inline tables, and arrays of tables. Used by `/logout` to strip
408 /// `api_key` everywhere without disturbing keys like `api_key_env`.
409 pub(crate) fn remove_document_key_recursive(table: &mut dyn toml_edit::TableLike, key: &str) {
410 remove_key_preserving_leading_decor(table, key);
411 for (_, item) in table.iter_mut() {
412 if let toml_edit::Item::ArrayOfTables(tables) = item {
413 for nested in tables.iter_mut() {
414 remove_document_key_recursive(nested, key);
415 }
416 } else if let Some(nested) = item.as_table_like_mut() {
417 remove_document_key_recursive(nested, key);
418 }
419 }
420 }
421
422 fn remove_key_preserving_leading_decor(table: &mut dyn toml_edit::TableLike, key: &str) -> bool {
423 let mut found = false;
424 let next_key = table.iter().find_map(|(candidate, _)| {
425 if found {
426 Some(candidate.to_owned())
427 } else {
428 found = candidate == key;
429 None
430 }
431 });
432 let leading_prefix = leading_prefix_for_key(table, key);
433 if table.remove(key).is_none() {
434 return false;
435 }
436 let Some(prefix) = leading_prefix else {
437 return true;
438 };
439 let Some(next_key) = next_key else {
440 return true;
441 };
442 if prefix.as_str() == Some("") {
443 return true;
444 }
445 if let Some(mut next_key_decor) = table.key_mut(&next_key)
446 && decor_prefix_is_empty(next_key_decor.leaf_decor())
447 {
448 next_key_decor.leaf_decor_mut().set_prefix(prefix);
449 }
450 true
451 }
452
453 fn decor_prefix_is_empty(decor: &toml_edit::Decor) -> bool {
454 match decor.prefix() {
455 Some(prefix) => prefix.as_str() == Some(""),
456 None => true,
457 }
458 }
459
460 fn leading_prefix_for_key(
461 table: &dyn toml_edit::TableLike,
462 key: &str,
463 ) -> Option<toml_edit::RawString> {
464 table
465 .key(key)
466 .and_then(|key| key.leaf_decor().prefix().cloned())
467 .or_else(|| {
468 table
469 .get(key)
470 .and_then(|item| item.as_value())
471 .and_then(|value| value.decor().prefix().cloned())
472 })
473 }
474
475 pub(crate) fn persist_status_items(items: &[StatusItem]) -> anyhow::Result<PathBuf> {
476 let path = config_toml_path(None)?;
477 let items: toml_edit::Array = items.iter().map(|item| item.key()).collect();
478 mutate_config_document(&path, |doc| {
479 set_document_value(doc, &["tui", "status_items"], items)
480 })?;
481 Ok(path)
482 }
483
484 pub(crate) fn persist_root_string_key(
485 config_path: Option<&Path>,
486 key: &str,
487 value: &str,
488 ) -> anyhow::Result<PathBuf> {
489 let path = config_toml_path(config_path)?;
490 mutate_config_document(&path, |doc| set_document_value(doc, &[key], value))?;
491 Ok(path)
492 }
493
494 pub(crate) fn persist_unset_root_key(
495 config_path: Option<&Path>,
496 key: &str,
497 ) -> anyhow::Result<PathBuf> {
498 let path = config_toml_path(config_path)?;
499 mutate_config_document(&path, |doc| unset_document_value(doc, &[key]).map(|_| ()))?;
500 Ok(path)
501 }
502
503 pub(crate) fn persist_root_bool_key(
504 config_path: Option<&Path>,
505 key: &str,
506 value: bool,
507 ) -> anyhow::Result<PathBuf> {
508 let path = config_toml_path(config_path)?;
509 mutate_config_document(&path, |doc| set_document_value(doc, &[key], value))?;
510 Ok(path)
511 }
512
513 pub(crate) fn persist_subagents_bool_key(
514 config_path: Option<&Path>,
515 key: &str,
516 value: bool,
517 ) -> anyhow::Result<PathBuf> {
518 persist_table_value_key(config_path, "subagents", key, value.into())
519 }
520
521 pub(crate) fn persist_mini_window_bool_key(
522 config_path: Option<&Path>,
523 key: &str,
524 value: bool,
525 ) -> anyhow::Result<PathBuf> {
526 persist_table_value_key(config_path, "mini_window", key, value.into())
527 }
528
529 pub(crate) fn persist_subagents_integer_key(
530 config_path: Option<&Path>,
531 key: &str,
532 value: u64,
533 ) -> anyhow::Result<PathBuf> {
534 let value = i64::try_from(value).context("integer value is too large for TOML")?;
535 persist_table_value_key(config_path, "subagents", key, value.into())
536 }
537
538 pub(crate) fn persist_table_bool_key(
539 config_path: Option<&Path>,
540 table_name: &str,
541 key: &str,
542 value: bool,
543 ) -> anyhow::Result<PathBuf> {
544 persist_table_value_key(config_path, table_name, key, value.into())
545 }
546
547 pub(crate) fn persist_table_string_key(
548 config_path: Option<&Path>,
549 table_name: &str,
550 key: &str,
551 value: &str,
552 ) -> anyhow::Result<PathBuf> {
553 persist_table_value_key(config_path, table_name, key, value.into())
554 }
555
556 pub(crate) fn persist_table_value_key(
557 config_path: Option<&Path>,
558 table_name: &str,
559 key: &str,
560 value: toml_edit::Value,
561 ) -> anyhow::Result<PathBuf> {
562 let path = config_toml_path(config_path)?;
563 mutate_config_document(&path, |doc| {
564 set_document_value(doc, &[table_name, key], value)
565 })?;
566 Ok(path)
567 }
568
569 /// Persist the endpoint that `GET /v1/config` reports as `base_url`.
570 ///
571 /// That value belongs to exactly one route, and the route keeps it in exactly
572 /// one place: its own `[providers.<table>]` table — the typed table for a
573 /// built-in, the exact `[providers.<name>]` table for a custom route. There is
574 /// no top-level `base_url` any more (#6394); writing one used to hand a
575 /// DeepSeek endpoint to every route that inherited it.
576 pub(crate) fn persist_route_base_url(
577 config_path: Option<&Path>,
578 identity: &ProviderIdentity,
579 value: &str,
580 ) -> anyhow::Result<PathBuf> {
581 let path = config_toml_path(config_path)?;
582 mutate_config_document_with_migration(&path, |doc, moved| {
583 let config = crate::config::parse_config_after_locked_migration(&doc.to_string(), moved)
584 .map_err(|_| anyhow::anyhow!("Could not parse destination route; contents omitted"))?;
585 config
586 .verify_provider_identity(identity)
587 .map_err(anyhow::Error::msg)?;
588 let table = if identity.provider == ProviderKind::Custom {
589 identity.key.as_str()
590 } else {
591 provider_base_url_table_key(identity)?
592 };
593 set_document_value(doc, &["providers", table, "base_url"], value)
594 })?;
595 Ok(path)
596 }
597
598 /// Persist the model for one exact provider route without rewriting the
599 /// legacy root DeepSeek fallback used by unrelated providers.
600 ///
601 /// Built-in providers write to their typed `[providers.<name>]` table, while
602 /// named custom routes use their exact user-owned table id. The locked writer first preserves legacy root provenance, then writes the
603 /// canonical leaf created by its one-way migration.
604 pub(crate) fn persist_provider_model_key(
605 config_path: Option<&Path>,
606 identity: &ProviderIdentity,
607 value: &str,
608 ) -> anyhow::Result<PathBuf> {
609 let path = config_toml_path(config_path)?;
610 mutate_config_document_with_migration(&path, |doc, moved| {
611 let config = crate::config::parse_config_after_locked_migration(&doc.to_string(), moved)
612 .map_err(|_| anyhow::anyhow!("Could not parse destination route; contents omitted"))?;
613 set_verified_provider_model_document(doc, &config, identity, value)
614 })?;
615 Ok(path)
616 }
617
618 fn provider_base_url_table_key(identity: &ProviderIdentity) -> anyhow::Result<&'static str> {
619 identity
620 .compatibility()
621 .map(|row| row.base_url_config_key)
622 .context("custom providers store base_url in their named [providers.<name>] table")
623 }
624
625 pub(crate) fn persist_custom_provider(
626 config_path: Option<&Path>,
627 provider_id: &str,
628 base_url: &str,
629 model: Option<&str>,
630 api_key_env: Option<&str>,
631 ) -> anyhow::Result<PathBuf> {
632 let provider_id = normalize_custom_provider_id(provider_id)?;
633 let base_url = normalize_custom_provider_base_url(base_url)?;
634 let model = model.and_then(normalize_optional_custom_provider_field);
635 let api_key_env = api_key_env.and_then(normalize_optional_custom_provider_field);
636
637 let path = config_toml_path(config_path)?;
638 mutate_config_document(&path, |doc| {
639 let entry = ["providers", provider_id.as_str()];
640 set_document_value(doc, &["provider"], provider_id.as_str())?;
641 set_document_value(doc, &[entry[0], entry[1], "kind"], "openai-compatible")?;
642 set_document_value(doc, &[entry[0], entry[1], "base_url"], base_url.as_str())?;
643 if provider_id == "ds4" && crate::config::base_url_uses_local_host(&base_url) {
644 // Match the documented starter server. DS4 explicitly requires
645 // clients not to budget beyond the server's --ctx value.
646 set_document_value(doc, &[entry[0], entry[1], "context_window"], 100_000)?;
647 }
648 match model.as_deref() {
649 Some(model) => set_document_value(doc, &[entry[0], entry[1], "model"], model)?,
650 None => {
651 unset_document_value(doc, &[entry[0], entry[1], "model"])?;
652 }
653 }
654 match api_key_env.as_deref() {
655 Some(env) => {
656 set_document_value(doc, &[entry[0], entry[1], "api_key_env"], env)?;
657 unset_document_value(doc, &[entry[0], entry[1], "auth_mode"])?;
658 }
659 None => {
660 unset_document_value(doc, &[entry[0], entry[1], "api_key_env"])?;
661 if provider_id == "ds4" && crate::config::base_url_uses_local_host(&base_url) {
662 set_document_value(doc, &[entry[0], entry[1], "auth_mode"], "none")?;
663 } else {
664 unset_document_value(doc, &[entry[0], entry[1], "auth_mode"])?;
665 }
666 }
667 }
668 Ok(())
669 })?;
670 Ok(path)
671 }
672
673 fn normalize_custom_provider_id(raw: &str) -> anyhow::Result<String> {
674 use anyhow::bail;
675
676 let value = raw.trim();
677 if value.is_empty() {
678 bail!("custom provider name is required");
679 }
680 if value == "__custom__" {
681 bail!("custom provider name is reserved");
682 }
683 if crate::config::ProviderKind::parse(value).is_some() {
684 bail!("custom provider name must not shadow a built-in provider");
685 }
686 if !value
687 .chars()
688 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
689 {
690 bail!("custom provider name may only use letters, numbers, '-' and '_'");
691 }
692 Ok(value.to_string())
693 }
694
695 fn normalize_custom_provider_base_url(raw: &str) -> anyhow::Result<String> {
696 use anyhow::bail;
697
698 let value = raw.trim().trim_end_matches('/');
699 if value.is_empty() {
700 bail!("custom provider base URL is required");
701 }
702 let parsed = reqwest::Url::parse(value)
703 .map_err(|err| anyhow::anyhow!("custom provider base URL is invalid: {err}"))?;
704 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
705 bail!("custom provider base URL must be an http(s) URL with a host");
706 }
707 Ok(value.to_string())
708 }
709
710 fn normalize_optional_custom_provider_field(raw: &str) -> Option<String> {
711 let value = raw.trim();
712 (!value.is_empty()).then(|| value.to_string())
713 }
714
715 pub(crate) fn persist_hotbar_bindings(
716 config_path: Option<&Path>,
717 bindings: &[codewhale_config::HotbarBindingToml],
718 ) -> anyhow::Result<PathBuf> {
719 let path = config_toml_path(config_path)?;
720 mutate_config_document(&path, |doc| {
721 let table = doc.as_table_mut();
722 table.remove("hotbar");
723 if bindings.is_empty() {
724 table.insert(
725 "hotbar",
726 toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new())),
727 );
728 } else {
729 let mut hotbar = toml_edit::ArrayOfTables::new();
730 for binding in bindings {
731 let mut entry = toml_edit::Table::new();
732 entry["slot"] = toml_edit::value(i64::from(binding.slot));
733 entry["action"] = toml_edit::value(binding.action.clone());
734 if let Some(label) = binding.label.as_deref() {
735 entry["label"] = toml_edit::value(label);
736 }
737 hotbar.push(entry);
738 }
739 table.insert("hotbar", toml_edit::Item::ArrayOfTables(hotbar));
740 }
741 Ok(())
742 })?;
743 Ok(path)
744 }
745
746 pub(crate) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<PathBuf> {
747 if let Some(path) = config_path {
748 return Ok(expand_path(path.to_string_lossy().as_ref()));
749 }
750 crate::config::resolve_load_config_path(None)?
751 .context("failed to resolve the active config.toml path")
752 }
753
754 #[cfg(test)]
755 mod tests {
756 // The fixture resolves the exact declaration from its destination document.
757 // Persistence still re-reads and validates under the shared write lock.
758 fn selection_identity(path: &Path, id: &str) -> crate::config::ProviderIdentity {
759 crate::config::parse_config_base(&fs::read_to_string(path).unwrap())
760 .unwrap()
761 .resolve_provider_pin_identity(id)
762 .unwrap()
763 }
764
765 use super::*;
766 use std::env;
767 use std::ffi::OsString;
768 use std::fs;
769 use std::path::Path;
770 use std::time::{SystemTime, UNIX_EPOCH};
771
772 struct EnvGuard {
773 _home: crate::test_support::EnvVarGuard,
774 _userprofile: crate::test_support::EnvVarGuard,
775 _codewhale_home: crate::test_support::EnvVarGuard,
776 _codewhale_config_path: crate::test_support::EnvVarGuard,
777 _deepseek_config_path: crate::test_support::EnvVarGuard,
778 _lock: crate::test_support::TestEnvLock,
779 }
780
781 impl EnvGuard {
782 fn new(home: &Path) -> Self {
783 let lock = crate::test_support::lock_test_env();
784 let config_path = home.join(".deepseek").join("config.toml");
785 Self {
786 _home: crate::test_support::EnvVarGuard::set("HOME", home),
787 _userprofile: crate::test_support::EnvVarGuard::set("USERPROFILE", home),
788 _codewhale_home: crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME"),
789 _codewhale_config_path: crate::test_support::EnvVarGuard::remove(
790 "CODEWHALE_CONFIG_PATH",
791 ),
792 _deepseek_config_path: crate::test_support::EnvVarGuard::set(
793 "DEEPSEEK_CONFIG_PATH",
794 &config_path,
795 ),
796 _lock: lock,
797 }
798 }
799 }
800
801 fn temp_root(prefix: &str) -> std::path::PathBuf {
802 let nanos = SystemTime::now()
803 .duration_since(UNIX_EPOCH)
804 .unwrap()
805 .as_nanos();
806 env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
807 }
808
809 #[test]
810 fn persist_status_items_writes_tui_section_to_config_toml() {
811 let temp_root = temp_root("codewhale-statusline-persist");
812 fs::create_dir_all(&temp_root).unwrap();
813 let _guard = EnvGuard::new(&temp_root);
814
815 let items = vec![
816 crate::config::StatusItem::Mode,
817 crate::config::StatusItem::Model,
818 crate::config::StatusItem::Cost,
819 ];
820
821 let path = persist_status_items(&items).expect("persist should succeed");
822 let body = fs::read_to_string(&path).expect("written file should be readable");
823 assert!(body.contains("[tui]"), "expected [tui] section in {body}");
824 assert!(
825 body.contains("status_items"),
826 "expected status_items key in {body}"
827 );
828 assert!(body.contains("\"mode\""), "expected mode key in {body}");
829 assert!(body.contains("\"cost\""), "expected cost key in {body}");
830 }
831
832 #[test]
833 fn config_toml_path_uses_codewhale_home_for_fresh_installs() {
834 let temp_root = temp_root("codewhale-config-path-fresh");
835 fs::create_dir_all(&temp_root).unwrap();
836 let _guard = EnvGuard::new(&temp_root);
837
838 unsafe {
839 env::remove_var("DEEPSEEK_CONFIG_PATH");
840 }
841
842 assert_eq!(
843 config_toml_path(None).unwrap(),
844 temp_root.join(".codewhale").join("config.toml")
845 );
846 }
847
848 #[test]
849 fn config_toml_path_preserves_legacy_config_when_it_exists() {
850 let temp_root = temp_root("codewhale-config-path-legacy");
851 let legacy_config = temp_root.join(".deepseek").join("config.toml");
852 fs::create_dir_all(legacy_config.parent().unwrap()).unwrap();
853 fs::write(&legacy_config, "").unwrap();
854 let _guard = EnvGuard::new(&temp_root);
855
856 unsafe {
857 env::remove_var("DEEPSEEK_CONFIG_PATH");
858 }
859
860 assert_eq!(config_toml_path(None).unwrap(), legacy_config);
861 }
862
863 #[test]
864 fn config_toml_path_ignores_legacy_config_when_codewhale_home_is_explicit() {
865 let temp_root = temp_root("codewhale-config-path-explicit-home");
866 let explicit_home = temp_root.join("isolated-codewhale");
867 let legacy_config = temp_root.join(".deepseek").join("config.toml");
868 fs::create_dir_all(legacy_config.parent().unwrap()).unwrap();
869 fs::write(&legacy_config, "").unwrap();
870 let _guard = EnvGuard::new(&temp_root);
871
872 unsafe {
873 env::remove_var("DEEPSEEK_CONFIG_PATH");
874 env::set_var("CODEWHALE_HOME", &explicit_home);
875 }
876
877 assert_eq!(
878 config_toml_path(None).unwrap(),
879 explicit_home.join("config.toml")
880 );
881 }
882
883 #[test]
884 fn config_toml_path_prefers_codewhale_env_over_legacy_env() {
885 let temp_root = temp_root("codewhale-config-path-env");
886 fs::create_dir_all(&temp_root).unwrap();
887 let _guard = EnvGuard::new(&temp_root);
888 let preferred = temp_root.join("preferred.toml");
889 let legacy = temp_root.join("legacy.toml");
890
891 unsafe {
892 env::set_var("CODEWHALE_CONFIG_PATH", &preferred);
893 env::set_var("DEEPSEEK_CONFIG_PATH", &legacy);
894 }
895
896 let expected = preferred
897 .parent()
898 .expect("preferred path has a parent")
899 .canonicalize()
900 .expect("preferred parent should canonicalize")
901 .join("preferred.toml");
902 assert_eq!(config_toml_path(None).unwrap(), expected);
903 }
904
905 #[test]
906 fn config_toml_path_keeps_missing_env_target_authoritative() {
907 let temp_root = temp_root("codewhale-config-path-missing-env-fallback");
908 let home_config = temp_root.join(".codewhale").join("config.toml");
909 fs::create_dir_all(home_config.parent().unwrap()).unwrap();
910 fs::write(&home_config, "# existing fallback\n").unwrap();
911 let _guard = EnvGuard::new(&temp_root);
912 let missing_env = temp_root.join("override").join("missing.toml");
913
914 unsafe {
915 env::set_var("DEEPSEEK_CONFIG_PATH", &missing_env);
916 }
917
918 assert_eq!(config_toml_path(None).unwrap(), missing_env);
919 assert!(home_config.exists());
920 assert!(!missing_env.exists());
921 }
922
923 #[test]
924 fn persist_status_items_preserves_existing_unrelated_keys() {
925 let temp_root = temp_root("codewhale-statusline-preserve");
926 fs::create_dir_all(&temp_root).unwrap();
927 let _guard = EnvGuard::new(&temp_root);
928
929 let path = temp_root.join(".deepseek").join("config.toml");
930 fs::create_dir_all(path.parent().unwrap()).unwrap();
931 fs::write(
932 &path,
933 "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n",
934 )
935 .unwrap();
936
937 let written = persist_status_items(&[crate::config::StatusItem::Mode])
938 .expect("persist should succeed");
939 let body = fs::read_to_string(&written).expect("written file should be readable");
940 assert!(
941 body.contains("api_key = \"sentinel-key\""),
942 "round-trip lost api_key: {body}"
943 );
944 assert!(
945 body.contains("model = \"deepseek-v4-pro\""),
946 "round-trip lost model: {body}"
947 );
948 assert!(
949 body.contains("status_items"),
950 "expected status_items in {body}"
951 );
952 }
953
954 #[test]
955 fn persist_bool_key_preserves_comments() {
956 let temp_root = temp_root("codewhale-persist-comments");
957 fs::create_dir_all(&temp_root).unwrap();
958 let _guard = EnvGuard::new(&temp_root);
959
960 let path = temp_root.join(".deepseek").join("config.toml");
961 fs::create_dir_all(path.parent().unwrap()).unwrap();
962 fs::write(
963 &path,
964 "# my note\nmodel = \"deepseek-v4-flash\"\n# disabled = true\n",
965 )
966 .unwrap();
967
968 let written = persist_root_bool_key(Some(&path), "allow_shell", true)
969 .expect("persist should succeed");
970 let body = fs::read_to_string(&written).expect("written file should be readable");
971 assert!(body.contains("# my note"), "prefix comment lost: {body}");
972 assert!(
973 body.contains("# disabled = true"),
974 "disabled key lost: {body}"
975 );
976 assert!(
977 body.contains("allow_shell = true"),
978 "new key not written: {body}"
979 );
980 }
981
982 #[test]
983 fn persist_table_bool_key_updates_existing_memory_enabled() {
984 let temp_root = temp_root("codewhale-persist-memory-update");
985 fs::create_dir_all(&temp_root).unwrap();
986 let _guard = EnvGuard::new(&temp_root);
987
988 let path = temp_root.join(".deepseek").join("config.toml");
989 fs::create_dir_all(path.parent().unwrap()).unwrap();
990 fs::write(&path, "allow_shell = true\n\n[memory]\nenabled = true\n").unwrap();
991
992 let written = persist_table_bool_key(Some(&path), "memory", "enabled", false)
993 .expect("persist should succeed");
994 let body = fs::read_to_string(&written).expect("written file should be readable");
995 assert!(
996 body.contains("enabled = false"),
997 "memory enabled should be false: {body}"
998 );
999 assert!(
1000 !body.contains("enabled = true"),
1001 "memory enabled should not still be true: {body}"
1002 );
1003 }
1004
1005 #[test]
1006 fn persist_memory_enabled_round_trips_through_config_load() {
1007 let temp_root = temp_root("codewhale-persist-memory-roundtrip");
1008 fs::create_dir_all(&temp_root).unwrap();
1009 let _guard = EnvGuard::new(&temp_root);
1010
1011 let path = temp_root.join(".deepseek").join("config.toml");
1012 fs::create_dir_all(path.parent().unwrap()).unwrap();
1013 // Initial config has memory enabled = true
1014 fs::write(&path, "allow_shell = true\n\n[memory]\nenabled = true\n").unwrap();
1015
1016 // Verify initial state
1017 let cfg0 = crate::config::Config::load(Some(path.clone()), None)
1018 .expect("initial config should load");
1019 assert!(cfg0.memory_enabled(), "memory should be enabled initially");
1020
1021 // Persist memory.enabled = false (what the GUI's set_config endpoint does)
1022 persist_table_bool_key(Some(&path), "memory", "enabled", false)
1023 .expect("persist should succeed");
1024
1025 // Reload config from disk and verify memory_enabled() reflects the change
1026 let cfg1 = crate::config::Config::load(Some(path.clone()), None)
1027 .expect("reloaded config should load");
1028 assert!(
1029 !cfg1.memory_enabled(),
1030 "memory should be disabled after persisting false"
1031 );
1032 }
1033
1034 #[test]
1035 fn persist_custom_provider_writes_named_openai_compatible_table() {
1036 let temp_root = temp_root("codewhale-custom-provider-persist");
1037 fs::create_dir_all(&temp_root).unwrap();
1038 let _guard = EnvGuard::new(&temp_root);
1039
1040 let path = temp_root.join(".codewhale").join("config.toml");
1041 let written = persist_custom_provider(
1042 Some(&path),
1043 "acme_ai",
1044 "https://api.acme.example/v1/",
1045 Some("acme/code-1"),
1046 Some("ACME_API_KEY"),
1047 )
1048 .expect("custom provider should persist");
1049 let body = fs::read_to_string(&written).expect("written file should be readable");
1050
1051 assert!(body.contains("provider = \"acme_ai\""), "{body}");
1052 assert!(body.contains("[providers.acme_ai]"), "{body}");
1053 assert!(body.contains("kind = \"openai-compatible\""), "{body}");
1054 assert!(
1055 body.contains("base_url = \"https://api.acme.example/v1\""),
1056 "{body}"
1057 );
1058 assert!(body.contains("model = \"acme/code-1\""), "{body}");
1059 assert!(body.contains("api_key_env = \"ACME_API_KEY\""), "{body}");
1060 assert!(
1061 !body.contains("sk-"),
1062 "helper must not persist raw secret values: {body}"
1063 );
1064
1065 let loaded =
1066 crate::config::Config::load(Some(written.clone()), None).expect("config should load");
1067 assert_eq!(loaded.provider.as_deref(), Some("acme_ai"));
1068 assert_eq!(
1069 loaded.active_provider_identity().unwrap().provider,
1070 crate::config::ProviderKind::Custom
1071 );
1072 let entry = loaded
1073 .providers
1074 .as_ref()
1075 .and_then(|providers| providers.custom_provider_config("acme_ai"))
1076 .expect("custom provider entry");
1077 assert!(entry.is_openai_compatible_custom());
1078 assert_eq!(
1079 entry.base_url.as_deref(),
1080 Some("https://api.acme.example/v1")
1081 );
1082 assert_eq!(entry.model.as_deref(), Some("acme/code-1"));
1083 assert_eq!(entry.api_key_env.as_deref(), Some("ACME_API_KEY"));
1084
1085 let dispatcher = codewhale_config::ConfigStore::load(Some(written))
1086 .expect("the dispatcher must parse the exact config written by the TUI");
1087 assert_eq!(
1088 dispatcher.config.provider,
1089 codewhale_config::ProviderKind::Custom
1090 );
1091 assert_eq!(dispatcher.config.provider_id(), "acme_ai");
1092 }
1093
1094 #[test]
1095 fn persist_custom_provider_rejects_builtin_or_invalid_names() {
1096 let temp_root = temp_root("codewhale-custom-provider-invalid");
1097 fs::create_dir_all(&temp_root).unwrap();
1098 let _guard = EnvGuard::new(&temp_root);
1099 let path = temp_root.join(".codewhale").join("config.toml");
1100
1101 let builtin = persist_custom_provider(
1102 Some(&path),
1103 "openrouter",
1104 "https://api.example.invalid/v1",
1105 None,
1106 None,
1107 )
1108 .expect_err("built-in names should be rejected");
1109 assert!(builtin.to_string().contains("built-in provider"));
1110
1111 let bad_chars = persist_custom_provider(
1112 Some(&path),
1113 "my provider",
1114 "https://api.example.invalid/v1",
1115 None,
1116 None,
1117 )
1118 .expect_err("space in name should be rejected");
1119 assert!(bad_chars.to_string().contains("letters, numbers"));
1120 }
1121
1122 #[test]
1123 fn persist_local_custom_provider_records_keyless_auth() {
1124 let temp_root = temp_root("codewhale-custom-provider-local-keyless");
1125 fs::create_dir_all(&temp_root).unwrap();
1126 let _guard = EnvGuard::new(&temp_root);
1127 let path = temp_root.join(".codewhale").join("config.toml");
1128
1129 let written = persist_custom_provider(
1130 Some(&path),
1131 "ds4",
1132 "http://127.0.0.1:8000/v1",
1133 Some("deepseek-v4-flash"),
1134 None,
1135 )
1136 .expect("DS4 preset should persist");
1137 let body = fs::read_to_string(&written).expect("written config");
1138
1139 assert!(body.contains("provider = \"ds4\""), "{body}");
1140 assert!(body.contains("auth_mode = \"none\""), "{body}");
1141 assert!(body.contains("context_window = 100000"), "{body}");
1142 assert!(!body.contains("api_key"), "{body}");
1143 }
1144
1145 #[test]
1146 fn persist_hotbar_bindings_writes_primary_config_path_for_fresh_installs() {
1147 let temp_root = temp_root("codewhale-hotbar-persist-fresh");
1148 fs::create_dir_all(&temp_root).unwrap();
1149 let _guard = EnvGuard::new(&temp_root);
1150
1151 unsafe {
1152 env::remove_var("DEEPSEEK_CONFIG_PATH");
1153 }
1154
1155 let bindings = vec![codewhale_config::HotbarBindingToml {
1156 slot: 1,
1157 action: "mode.plan".to_string(),
1158 label: Some("Plan".to_string()),
1159 }];
1160 let path = persist_hotbar_bindings(None, &bindings).expect("persist should succeed");
1161
1162 assert_eq!(path, temp_root.join(".codewhale").join("config.toml"));
1163 let body = fs::read_to_string(&path).expect("written file should be readable");
1164 assert!(body.contains("[[hotbar]]"), "hotbar table missing: {body}");
1165 let parsed: codewhale_config::ConfigToml =
1166 toml::from_str(&body).expect("written hotbar config should parse");
1167 assert_eq!(parsed.hotbar, Some(bindings));
1168 }
1169
1170 #[test]
1171 fn persist_default_hotbar_bindings_round_trips_for_hotbar_on() {
1172 // #3807: `/hotbar on` persists the explicit default slots (an absent key
1173 // now means hidden), and they read back as the eight recommended slots.
1174 let temp_root = temp_root("codewhale-hotbar-on-defaults");
1175 fs::create_dir_all(&temp_root).unwrap();
1176 let _guard = EnvGuard::new(&temp_root);
1177
1178 let defaults = codewhale_config::default_hotbar_bindings_toml();
1179 assert_eq!(defaults.len(), codewhale_config::HOTBAR_SLOT_COUNT as usize);
1180
1181 let path = persist_hotbar_bindings(None, &defaults).expect("persist should succeed");
1182 let body = fs::read_to_string(&path).expect("written file should be readable");
1183 assert!(body.contains("[[hotbar]]"), "hotbar table missing: {body}");
1184
1185 let parsed: codewhale_config::ConfigToml =
1186 toml::from_str(&body).expect("written hotbar config should parse");
1187 assert_eq!(parsed.hotbar, Some(defaults));
1188
1189 // The persisted defaults resolve back to all eight recommended slots.
1190 let resolved = parsed.resolve_hotbar_bindings(&codewhale_config::DEFAULT_HOTBAR_ACTIONS);
1191 assert_eq!(
1192 resolved.bindings,
1193 codewhale_config::default_hotbar_bindings()
1194 );
1195 }
1196
1197 #[test]
1198 fn persist_hotbar_bindings_preserves_comments_and_replaces_existing_tables() {
1199 let temp_root = temp_root("codewhale-hotbar-persist-comments");
1200 fs::create_dir_all(&temp_root).unwrap();
1201 let _guard = EnvGuard::new(&temp_root);
1202
1203 let path = temp_root.join(".codewhale").join("config.toml");
1204 fs::create_dir_all(path.parent().unwrap()).unwrap();
1205 fs::write(
1206 &path,
1207 r#"# model note
1208 model = "deepseek-v4-flash"
1209
1210 [[hotbar]]
1211 slot = 1
1212 action = "mode.plan"
1213 label = "Plan"
1214
1215 # notification note
1216 [notifications]
1217 enabled = true
1218 "#,
1219 )
1220 .unwrap();
1221
1222 let bindings = vec![codewhale_config::HotbarBindingToml {
1223 slot: 2,
1224 action: "session.compact".to_string(),
1225 label: Some("Compact".to_string()),
1226 }];
1227 let written =
1228 persist_hotbar_bindings(Some(&path), &bindings).expect("persist should succeed");
1229 let body = fs::read_to_string(&written).expect("written file should be readable");
1230
1231 assert!(body.contains("# model note"), "prefix comment lost: {body}");
1232 assert!(
1233 body.contains("# notification note"),
1234 "section comment lost: {body}"
1235 );
1236 assert!(
1237 !body.contains("mode.plan"),
1238 "old hotbar table was not replaced: {body}"
1239 );
1240 assert!(body.contains("[[hotbar]]"), "hotbar table missing: {body}");
1241 assert!(
1242 body.contains("action = \"session.compact\""),
1243 "new action missing: {body}"
1244 );
1245 let parsed: codewhale_config::ConfigToml =
1246 toml::from_str(&body).expect("written hotbar config should parse");
1247 assert_eq!(parsed.hotbar, Some(bindings));
1248 }
1249
1250 #[test]
1251 fn persist_hotbar_bindings_writes_empty_array_to_disable_defaults() {
1252 let temp_root = temp_root("codewhale-hotbar-persist-empty");
1253 fs::create_dir_all(&temp_root).unwrap();
1254 let _guard = EnvGuard::new(&temp_root);
1255
1256 let path = temp_root.join(".codewhale").join("config.toml");
1257 fs::create_dir_all(path.parent().unwrap()).unwrap();
1258
1259 let written = persist_hotbar_bindings(Some(&path), &[]).expect("persist should succeed");
1260 let body = fs::read_to_string(&written).expect("written file should be readable");
1261
1262 assert!(body.contains("hotbar = []"), "empty hotbar missing: {body}");
1263 let parsed: codewhale_config::ConfigToml =
1264 toml::from_str(&body).expect("written hotbar config should parse");
1265 assert_eq!(parsed.hotbar, Some(Vec::new()));
1266 }
1267
1268 // ------------------------------------------------------------------
1269 // Golden-file coverage for the shared toml_edit mutation path
1270 // (findings #18/#19/#20): unrelated comments, ordering, and quoted
1271 // provider tables must survive every supported mutation.
1272 // ------------------------------------------------------------------
1273
1274 const GOLDEN_CONFIG: &str = r#"# CodeWhale golden config fixture, top note.
1275 # api_key = "sk-placeholder" (uncomment to set the key by hand)
1276 model = "deepseek-v4-pro" # pinned for release QA
1277
1278 # workspace trust note
1279 [projects."/Users/example/work"]
1280 trust_level = "trusted" # granted manually
1281
1282 # providers note
1283 [providers.openrouter]
1284 base_url = "https://openrouter.ai/api/v1" # keep in sync with docs
1285
1286 [providers."quoted.provider"]
1287 base_url = "https://quoted.example/v1"
1288
1289 [[hotbar]]
1290 slot = 1
1291 action = "mode.plan"
1292 "#;
1293
1294 fn write_golden_config(path: &Path) {
1295 fs::create_dir_all(path.parent().unwrap()).unwrap();
1296 fs::write(path, GOLDEN_CONFIG).unwrap();
1297 }
1298
1299 #[test]
1300 fn golden_replacing_existing_root_value_only_touches_that_value() {
1301 let temp_root = temp_root("codewhale-golden-root-value");
1302 fs::create_dir_all(&temp_root).unwrap();
1303 let _guard = EnvGuard::new(&temp_root);
1304 let path = temp_root.join(".deepseek").join("config.toml");
1305 write_golden_config(&path);
1306
1307 persist_root_string_key(Some(&path), "model", "deepseek-v4-flash")
1308 .expect("persist should succeed");
1309
1310 let body = fs::read_to_string(&path).unwrap();
1311 // The fixture is a pre-migration home config, so this first write also
1312 // commits the one-way route-preference migration receipt in the same
1313 // atomic replacement. That stamp and the model value are the only two
1314 // permitted edits: comments, ordering and quoted tables stay byte-exact.
1315 let expected = GOLDEN_CONFIG.replace(
1316 "model = \"deepseek-v4-pro\" # pinned for release QA",
1317 "model = \"deepseek-v4-flash\" # pinned for release QA\nroute_preferences_version = 1",
1318 );
1319 assert_eq!(
1320 body, expected,
1321 "only the model value and the migration stamp may change"
1322 );
1323 }
1324
1325 #[test]
1326 fn golden_mutations_preserve_unrelated_comments_order_and_quoted_tables() {
1327 let temp_root = temp_root("codewhale-golden-mutations");
1328 fs::create_dir_all(&temp_root).unwrap();
1329 let _guard = EnvGuard::new(&temp_root);
1330 let path = temp_root.join(".deepseek").join("config.toml");
1331 write_golden_config(&path);
1332
1333 persist_root_bool_key(Some(&path), "allow_shell", true).unwrap();
1334 persist_table_value_key(Some(&path), "tui", "scrollback_lines", 4000_i64.into()).unwrap();
1335 persist_table_string_key(Some(&path), "memory", "backend", "native").unwrap();
1336 persist_subagents_bool_key(Some(&path), "enabled", true).unwrap();
1337 persist_route_base_url(
1338 Some(&path),
1339 &selection_identity(&path, "openrouter"),
1340 "https://openrouter.example/v2",
1341 )
1342 .unwrap();
1343 persist_status_items(&[crate::config::StatusItem::Mode]).unwrap();
1344 persist_hotbar_bindings(
1345 Some(&path),
1346 &[codewhale_config::HotbarBindingToml {
1347 slot: 2,
1348 action: "session.compact".to_string(),
1349 label: None,
1350 }],
1351 )
1352 .unwrap();
1353
1354 let body = fs::read_to_string(&path).unwrap();
1355 for comment in [
1356 "# CodeWhale golden config fixture, top note.",
1357 "# api_key = \"sk-placeholder\" (uncomment to set the key by hand)",
1358 "# pinned for release QA",
1359 "# workspace trust note",
1360 "# granted manually",
1361 "# providers note",
1362 "# keep in sync with docs",
1363 ] {
1364 assert!(body.contains(comment), "comment lost: {comment}\n{body}");
1365 }
1366 // Updated in place, keeping the trailing comment on the same line.
1367 assert!(
1368 body.contains("base_url = \"https://openrouter.example/v2\" # keep in sync with docs"),
1369 "{body}"
1370 );
1371 assert!(body.contains("[providers.\"quoted.provider\"]"), "{body}");
1372 assert!(
1373 !body.contains("mode.plan"),
1374 "old hotbar entry must be replaced: {body}"
1375 );
1376
1377 // Original section order is intact.
1378 let model_at = body.find("model = ").unwrap();
1379 let projects_at = body.find("[projects.").unwrap();
1380 let providers_at = body.find("[providers.openrouter]").unwrap();
1381 assert!(
1382 model_at < projects_at && projects_at < providers_at,
1383 "{body}"
1384 );
1385
1386 let parsed: toml::Value = toml::from_str(&body).unwrap();
1387 assert_eq!(
1388 parsed.get("allow_shell").and_then(toml::Value::as_bool),
1389 Some(true)
1390 );
1391 assert_eq!(
1392 parsed
1393 .get("tui")
1394 .and_then(|t| t.get("scrollback_lines"))
1395 .and_then(toml::Value::as_integer),
1396 Some(4000)
1397 );
1398 assert_eq!(
1399 parsed
1400 .get("memory")
1401 .and_then(|t| t.get("backend"))
1402 .and_then(toml::Value::as_str),
1403 Some("native")
1404 );
1405 assert_eq!(
1406 parsed
1407 .get("subagents")
1408 .and_then(|t| t.get("enabled"))
1409 .and_then(toml::Value::as_bool),
1410 Some(true)
1411 );
1412 }
1413
1414 #[test]
1415 fn set_document_value_inserts_api_key_even_when_a_comment_mentions_it() {
1416 // Finding #20 at the primitive level: the old string scan treated a
1417 // comment mentioning api_key as an existing assignment and skipped
1418 // the insert entirely.
1419 let temp_root = temp_root("codewhale-golden-api-key-comment");
1420 fs::create_dir_all(&temp_root).unwrap();
1421 let _guard = EnvGuard::new(&temp_root);
1422 let path = temp_root.join(".deepseek").join("config.toml");
1423 write_golden_config(&path);
1424
1425 mutate_config_document(&path, |doc| {
1426 set_document_value(doc, &["api_key"], "sk-fresh")
1427 })
1428 .expect("mutation should succeed");
1429
1430 let body = fs::read_to_string(&path).unwrap();
1431 assert!(
1432 body.contains("# api_key = \"sk-placeholder\""),
1433 "comment lost: {body}"
1434 );
1435 let parsed: toml::Value = toml::from_str(&body).unwrap();
1436 assert_eq!(
1437 parsed.get("api_key").and_then(toml::Value::as_str),
1438 Some("sk-fresh"),
1439 "real key must be inserted despite the comment: {body}"
1440 );
1441 }
1442
1443 #[test]
1444 fn unset_document_value_reports_removal_and_tolerates_missing_parents() {
1445 let mut doc = "model = \"deepseek-v4-pro\"\n"
1446 .parse::<toml_edit::DocumentMut>()
1447 .unwrap();
1448 assert!(!unset_document_value(&mut doc, &["providers", "openrouter", "api_key"]).unwrap());
1449 assert!(!unset_document_value(&mut doc, &["model", "nested"]).unwrap());
1450 assert!(unset_document_value(&mut doc, &["model"]).unwrap());
1451 assert!(!unset_document_value(&mut doc, &["model"]).unwrap());
1452 }
1453
1454 #[test]
1455 fn unset_last_root_value_preserves_its_leading_comment() {
1456 let mut doc = "# keep this explanation\napproval_policy = \"on-request\"\n"
1457 .parse::<toml_edit::DocumentMut>()
1458 .unwrap();
1459
1460 assert!(unset_document_value(&mut doc, &["approval_policy"]).unwrap());
1461
1462 let saved = doc.to_string();
1463 assert!(saved.contains("# keep this explanation"), "{saved:?}");
1464 assert!(!saved.contains("approval_policy"), "{saved:?}");
1465 }
1466
1467 #[test]
1468 fn set_document_value_rejects_non_table_parents() {
1469 let mut doc = "model = \"deepseek-v4-pro\"\n"
1470 .parse::<toml_edit::DocumentMut>()
1471 .unwrap();
1472 let err = set_document_value(&mut doc, &["model", "nested"], "x")
1473 .expect_err("scalar parent must be rejected");
1474 assert!(err.to_string().contains("must be a table"), "{err}");
1475 }
1476
1477 #[test]
1478 fn remove_document_key_recursive_strips_nested_and_quoted_tables() {
1479 let mut doc = r#"# root note
1480 api_key = "root"
1481 api_key_env = "KEEP_ENV"
1482
1483 [providers.openrouter]
1484 api_key = "or"
1485 base_url = "https://openrouter.ai/api/v1"
1486
1487 [providers."quoted.provider"]
1488 api_key = "quoted"
1489
1490 [[hotbar]]
1491 slot = 1
1492 "#
1493 .parse::<toml_edit::DocumentMut>()
1494 .unwrap();
1495
1496 remove_document_key_recursive(doc.as_table_mut(), "api_key");
1497
1498 let body = doc.to_string();
1499 assert!(!body.contains("api_key = "), "{body}");
1500 assert!(body.contains("# root note"), "{body}");
1501 assert!(body.contains("api_key_env = \"KEEP_ENV\""), "{body}");
1502 assert!(body.contains("base_url"), "{body}");
1503 assert!(body.contains("[[hotbar]]"), "{body}");
1504 }
1505
1506 #[test]
1507 fn persist_custom_provider_unsets_removed_optional_fields() {
1508 let temp_root = temp_root("codewhale-custom-provider-unset");
1509 fs::create_dir_all(&temp_root).unwrap();
1510 let _guard = EnvGuard::new(&temp_root);
1511 let path = temp_root.join(".codewhale").join("config.toml");
1512
1513 persist_custom_provider(
1514 Some(&path),
1515 "acme_ai",
1516 "https://api.acme.example/v1",
1517 Some("acme/code-1"),
1518 Some("ACME_API_KEY"),
1519 )
1520 .expect("first persist should succeed");
1521 persist_custom_provider(
1522 Some(&path),
1523 "acme_ai",
1524 "https://api.acme.example/v2",
1525 None,
1526 None,
1527 )
1528 .expect("second persist should succeed");
1529
1530 let body = fs::read_to_string(&path).unwrap();
1531 let parsed: toml::Value = toml::from_str(&body).unwrap();
1532 let entry = parsed
1533 .get("providers")
1534 .and_then(|providers| providers.get("acme_ai"))
1535 .expect("provider entry");
1536 assert_eq!(
1537 entry.get("base_url").and_then(toml::Value::as_str),
1538 Some("https://api.acme.example/v2")
1539 );
1540 assert!(entry.get("model").is_none(), "model must be unset: {body}");
1541 assert!(
1542 entry.get("api_key_env").is_none(),
1543 "api_key_env must be unset: {body}"
1544 );
1545 }
1546
1547 #[cfg(unix)]
1548 #[test]
1549 fn config_writes_land_with_owner_only_permissions() {
1550 use std::os::unix::fs::PermissionsExt;
1551
1552 let temp_root = temp_root("codewhale-persist-perms");
1553 fs::create_dir_all(&temp_root).unwrap();
1554 let _guard = EnvGuard::new(&temp_root);
1555 let path = temp_root.join(".deepseek").join("config.toml");
1556 write_golden_config(&path);
1557 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1558
1559 persist_root_bool_key(Some(&path), "allow_shell", true).expect("persist should succeed");
1560
1561 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1562 assert_eq!(mode, 0o600, "config.toml can hold api keys");
1563 }
1564
1565 /// Clears every model override the dispatcher's env layer reads for the
1566 /// providers exercised below, so the assertion is about config precedence
1567 /// and cannot be flipped by an ambient variable on a developer machine.
1568 struct ModelEnvGuard {
1569 saved: Vec<(&'static str, Option<OsString>)>,
1570 }
1571
1572 impl ModelEnvGuard {
1573 const VARS: &'static [&'static str] = &[
1574 "CODEWHALE_MODEL",
1575 "DEEPSEEK_MODEL",
1576 "DEEPSEEK_DEFAULT_TEXT_MODEL",
1577 "GLM_MODEL",
1578 "BIGMODEL_MODEL",
1579 "ZAI_MODEL",
1580 "XAI_MODEL",
1581 "GROK_MODEL",
1582 "OPENROUTER_MODEL",
1583 "OLLAMA_MODEL",
1584 ];
1585
1586 fn new() -> Self {
1587 let saved = Self::VARS
1588 .iter()
1589 .map(|name| (*name, env::var_os(name)))
1590 .collect();
1591 // Safety: test-only environment mutation; the caller holds the
1592 // process-wide test-env lock via `EnvGuard`.
1593 unsafe {
1594 for name in Self::VARS {
1595 env::remove_var(name);
1596 }
1597 }
1598 Self { saved }
1599 }
1600 }
1601
1602 impl Drop for ModelEnvGuard {
1603 fn drop(&mut self) {
1604 // Safety: test-only environment restoration under the same lock.
1605 unsafe {
1606 for (name, value) in &self.saved {
1607 match value {
1608 Some(value) => env::set_var(name, value),
1609 None => env::remove_var(name),
1610 }
1611 }
1612 }
1613 }
1614 }
1615
1616 /// The active model must be one answer, not two.
1617 ///
1618 /// The TUI resolves it with `Config::default_model()`, which is what
1619 /// `client.rs` puts on the wire and what `doctor` reports. The dispatcher
1620 /// resolves it independently in `codewhale-config`'s
1621 /// `resolve_runtime_options`, which is what `codewhale model resolve`
1622 /// reports and what the app-server and route descriptors consume. The two
1623 /// silently disagreed for every non-DeepSeek provider (#4832, #4838): the
1624 /// dispatcher gated root `default_text_model` behind `provider == Deepseek`
1625 /// and so reported a provider default while the wire carried the user's
1626 /// chosen model.
1627 ///
1628 /// A diagnostic that contradicts the request it is diagnosing is worse than
1629 /// no diagnostic, so this pins the two chains together by construction
1630 /// rather than asserting either one's internals.
1631 #[test]
1632 fn the_dispatcher_and_the_tui_resolve_the_same_active_model() {
1633 // (case, config body, what both chains must answer)
1634 let cases: &[(&str, &str, &str)] = &[
1635 (
1636 "a non-DeepSeek provider honours the user's chosen model",
1637 "provider = \"zai\"\ndefault_text_model = \"GLM-4.6\"\n\n[providers.zai]\napi_key = \"k\"\n",
1638 "GLM-4.6",
1639 ),
1640 (
1641 "a stale DeepSeek id must not be forwarded to a native non-DeepSeek endpoint",
1642 "provider = \"zai\"\ndefault_text_model = \"deepseek-chat\"\n\n[providers.zai]\napi_key = \"k\"\n",
1643 crate::config::DEFAULT_ZAI_MODEL,
1644 ),
1645 (
1646 "no root default falls through to the provider default",
1647 "provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
1648 crate::config::DEFAULT_ZAI_MODEL,
1649 ),
1650 (
1651 "a provider-scoped model outranks the root default",
1652 "provider = \"zai\"\ndefault_text_model = \"GLM-4.6\"\n\n[providers.zai]\napi_key = \"k\"\nmodel = \"GLM-4.5-Air\"\n",
1653 "GLM-4.5-Air",
1654 ),
1655 (
1656 "DeepSeek itself keeps honouring the root default",
1657 "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n\n[providers.deepseek]\napi_key = \"k\"\n",
1658 "deepseek-v4-pro",
1659 ),
1660 (
1661 "a vendor-locked endpoint refuses a DeepSeek id (#3227)",
1662 "provider = \"xai\"\ndefault_text_model = \"deepseek-v4-pro\"\n\n[providers.xai]\napi_key = \"k\"\n",
1663 crate::config::DEFAULT_XAI_MODEL,
1664 ),
1665 (
1666 "an aggregator legitimately serves DeepSeek ids",
1667 "provider = \"openrouter\"\ndefault_text_model = \"deepseek/deepseek-v4-pro\"\n\n[providers.openrouter]\napi_key = \"k\"\n",
1668 "deepseek/deepseek-v4-pro",
1669 ),
1670 (
1671 "a local runtime passes its own tag through",
1672 "provider = \"ollama\"\ndefault_text_model = \"qwen3-coder:30b\"\n",
1673 "qwen3-coder:30b",
1674 ),
1675 (
1676 "a custom base URL keeps full pass-through (#1519)",
1677 "provider = \"zai\"\ndefault_text_model = \"deepseek-chat\"\n\n[providers.zai]\napi_key = \"k\"\nbase_url = \"https://proxy.example.invalid/v1\"\n",
1678 "deepseek-chat",
1679 ),
1680 ];
1681
1682 for (case, body, expected) in cases {
1683 let temp_root = temp_root("codewhale-model-chain-agreement");
1684 fs::create_dir_all(&temp_root).unwrap();
1685 let _guard = EnvGuard::new(&temp_root);
1686 let _model_guard = ModelEnvGuard::new();
1687 let path = temp_root.join(".deepseek").join("config.toml");
1688 fs::create_dir_all(path.parent().unwrap()).unwrap();
1689 fs::write(&path, body).unwrap();
1690
1691 let tui = crate::config::Config::load(Some(path.clone()), None)
1692 .expect("the TUI must parse this config");
1693 let tui_model = tui.default_model();
1694
1695 let dispatcher = codewhale_config::ConfigStore::load(Some(path.clone()))
1696 .expect("the dispatcher must parse the same config");
1697 let runtime = dispatcher
1698 .config
1699 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1700
1701 assert_eq!(
1702 tui_model, *expected,
1703 "{case}: the TUI chain (what actually reaches the provider) is wrong"
1704 );
1705 assert_eq!(
1706 runtime.model, *expected,
1707 "{case}: the dispatcher chain (what `model resolve` reports) is wrong"
1708 );
1709
1710 let _ = fs::remove_dir_all(&temp_root);
1711 }
1712 }
1713 #[test]
1714 fn route_migration_is_atomic_preserves_conflicts_and_ignores_scoped_settings() {
1715 use crate::test_support::{EnvVarGuard, lock_test_env};
1716 let _lock = lock_test_env();
1717 let home = tempfile::tempdir().unwrap();
1718 let project = tempfile::tempdir().unwrap();
1719 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1720 let _scope = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", project.path().join("config.toml"));
1721 let path = home.path().join("config.toml");
1722 let source = "# keep this comment\nprovider = 'deepseek'\n[providers.zai]\nmodel = 'GLM-5.2'\n[providers.TeamA]\nkind = 'openai-compatible'\nbase_url = 'https://upper.example.test/v1'\nmodel = 'Old-Upper'\n[providers.teama]\nkind = 'openai-compatible'\nbase_url = 'https://lower.example.test/v1'\nmodel = 'Old-Lower'\n";
1723 let legacy = "default_provider = 'zai'\n[provider_models]\nzai = 'GLM-5.3'\nTeamA = 'New-Upper'\nteama = 'New-Lower'\n";
1724 fs::write(&path, source).unwrap();
1725 fs::write(home.path().join("settings.toml"), legacy).unwrap();
1726 fs::write(
1727 project.path().join("settings.toml"),
1728 "default_provider = 'openai'\n[provider_models]\nzai = 'wrong-scoped-model'\n",
1729 )
1730 .unwrap();
1731
1732 let admitted_source = format!(
1733 "{source}\n[providers.Missing]\nkind = 'openai-compatible'\nbase_url = 'https://missing.example.test/v1'\n"
1734 );
1735 let captured_missing = crate::config::parse_config_base(&admitted_source)
1736 .unwrap()
1737 .resolve_provider_pin_identity("Missing")
1738 .unwrap();
1739 let error = persist_provider_selection(Some(&path), &captured_missing, Some("new-model"));
1740 assert!(error.is_err());
1741 assert_eq!(
1742 fs::read_to_string(&path).unwrap(),
1743 source,
1744 "failed save must not commit migration separately"
1745 );
1746
1747 persist_provider_selection(
1748 Some(&path),
1749 &selection_identity(&path, "deepseek"),
1750 Some("deepseek-v4-pro"),
1751 )
1752 .unwrap();
1753 let body = fs::read_to_string(&path).unwrap();
1754 let doc: toml::Value = toml::from_str(&body).unwrap();
1755 assert!(body.contains("# keep this comment"));
1756 assert_eq!(doc["route_preferences_version"].as_integer(), Some(1));
1757 assert_eq!(doc["provider"].as_str(), Some("deepseek"));
1758 assert_eq!(
1759 doc["providers"]["deepseek"]["model"].as_str(),
1760 Some("deepseek-v4-pro")
1761 );
1762 assert_eq!(doc["providers"]["zai"]["model"].as_str(), Some("GLM-5.3"));
1763 assert_eq!(
1764 doc["providers"]["TeamA"]["model"].as_str(),
1765 Some("New-Upper")
1766 );
1767 assert_eq!(
1768 doc["providers"]["teama"]["model"].as_str(),
1769 Some("New-Lower")
1770 );
1771 assert_eq!(
1772 doc["providers"]["TeamA"]["base_url"].as_str(),
1773 Some("https://upper.example.test/v1")
1774 );
1775 assert_eq!(
1776 doc["route_preferences_migration"]["previous_models"]["zai"].as_str(),
1777 Some("GLM-5.2")
1778 );
1779 assert_eq!(
1780 fs::read_to_string(home.path().join("settings.toml")).unwrap(),
1781 legacy
1782 );
1783
1784 persist_provider_model_key(Some(&path), &selection_identity(&path, "zai"), "GLM-5.1")
1785 .unwrap();
1786 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1787 assert_eq!(doc["providers"]["zai"]["model"].as_str(), Some("GLM-5.1"));
1788 assert_eq!(
1789 doc["route_preferences_migration"]["previous_models"]["zai"].as_str(),
1790 Some("GLM-5.2")
1791 );
1792 }
1793
1794 #[test]
1795 fn moving_root_default_off_the_root_does_not_resurrect_a_shadowed_legacy_model() {
1796 use crate::test_support::{EnvVarGuard, lock_test_env};
1797 let _lock = lock_test_env();
1798 let home = tempfile::tempdir().unwrap();
1799 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1800 let _model_guard = ModelEnvGuard::new();
1801 let path = home.path().join("config.toml");
1802 // `default_text_model` shadows the legacy root `model` on every route.
1803 // Moving the alias onto openai's leaf must not un-shadow the legacy
1804 // value for the pass-through incoming route.
1805 fs::write(
1806 &path,
1807 "route_preferences_version = 1\nprovider = 'openai'\nmodel = 'deepseek-v4-flash'\ndefault_text_model = 'gpui-fixture'\n",
1808 )
1809 .unwrap();
1810 persist_provider_selection(Some(&path), &selection_identity(&path, "openrouter"), None)
1811 .unwrap();
1812 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1813 assert!(doc.get("default_text_model").is_none(), "{doc:?}");
1814 assert!(doc.get("model").is_none(), "{doc:?}");
1815 assert_eq!(
1816 doc["providers"]["openai"]["model"].as_str(),
1817 Some("gpui-fixture")
1818 );
1819 let switched = crate::config::Config::load(Some(path.clone()), None).unwrap();
1820 assert_eq!(
1821 switched.active_provider_identity().unwrap().provider,
1822 ProviderKind::Openrouter
1823 );
1824 // OpenRouter normalizes the stale value to `deepseek/deepseek-v4-flash`.
1825 assert!(
1826 !switched.default_model().contains("deepseek-v4-flash"),
1827 "{}",
1828 switched.default_model()
1829 );
1830 assert_ne!(switched.default_model(), "gpui-fixture");
1831
1832 persist_provider_selection(Some(&path), &selection_identity(&path, "openai"), None)
1833 .unwrap();
1834 let returned = crate::config::Config::load(Some(path.clone()), None).unwrap();
1835 assert_eq!(
1836 returned.active_provider_identity().unwrap().provider,
1837 ProviderKind::Openai
1838 );
1839 assert_eq!(returned.default_model(), "gpui-fixture");
1840 }
1841
1842 #[test]
1843 fn switching_provider_away_and_back_preserves_every_route_selection() {
1844 use crate::test_support::{EnvVarGuard, lock_test_env};
1845 let _lock = lock_test_env();
1846 let home = tempfile::tempdir().unwrap();
1847 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1848 let _model_guard = ModelEnvGuard::new();
1849 let path = home.path().join("config.toml");
1850 for root_key in ["default_text_model", "model"] {
1851 for existing in [None, Some("GLM-4.5-Air")] {
1852 let mut source = format!(
1853 "route_preferences_version = 1\nprovider = 'zai'\n{root_key} = 'GLM-4.6'\n"
1854 );
1855 if let Some(model) = existing {
1856 source.push_str(&format!("[providers.zai]\nmodel = '{model}'\n"));
1857 }
1858 fs::write(&path, source).unwrap();
1859 persist_provider_selection(
1860 Some(&path),
1861 &selection_identity(&path, "deepseek"),
1862 Some("deepseek-v4-pro"),
1863 )
1864 .unwrap();
1865 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1866 // The outgoing route was resolving the root alias only when it
1867 // had no leaf; then the choice moves onto that leaf. Otherwise
1868 // the alias is inert saved state and stays. Neither case may
1869 // delete it.
1870 if existing.is_none() {
1871 assert!(doc.get(root_key).is_none(), "{doc:?}");
1872 assert_eq!(
1873 doc["providers"]["zai"]["model"].as_str(),
1874 Some("GLM-4.6"),
1875 "the outgoing route's choice must follow it onto its leaf"
1876 );
1877 } else {
1878 assert_eq!(
1879 doc[root_key].as_str(),
1880 Some("GLM-4.6"),
1881 "an inert root fallback must survive the switch"
1882 );
1883 }
1884 assert_eq!(
1885 doc["providers"]["deepseek"]["model"].as_str(),
1886 Some("deepseek-v4-pro")
1887 );
1888 let restored = crate::config::Config::load(Some(path.clone()), None)
1889 .expect("saved provider switch must remain loadable");
1890 assert_eq!(
1891 restored.active_provider_identity().unwrap().provider,
1892 ProviderKind::Deepseek
1893 );
1894 assert_eq!(restored.default_model(), "deepseek-v4-pro");
1895
1896 // Switching back must find the choice this route had, whether
1897 // it was stored on its own leaf or in the root fallback.
1898 persist_provider_selection(Some(&path), &selection_identity(&path, "zai"), None)
1899 .unwrap();
1900 let returned = crate::config::Config::load(Some(path.clone()), None)
1901 .expect("switching back must remain loadable");
1902 assert_eq!(
1903 returned.active_provider_identity().unwrap().provider,
1904 ProviderKind::Zai
1905 );
1906 assert_eq!(
1907 returned.default_model(),
1908 existing.unwrap_or("GLM-4.6"),
1909 "switching away and back must restore the outgoing selection"
1910 );
1911 }
1912 }
1913 }
1914
1915 #[test]
1916 fn a_bare_switch_relocates_a_root_alias_the_incoming_route_cannot_serve() {
1917 use crate::test_support::{EnvVarGuard, lock_test_env};
1918 let _lock = lock_test_env();
1919 let home = tempfile::tempdir().unwrap();
1920 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1921 let _model_guard = ModelEnvGuard::new();
1922 let path = home.path().join("config.toml");
1923 // No model argument, and the outgoing route keeps its only model in the
1924 // root fallback. Official DeepSeek cannot serve that id, so leaving it
1925 // behind would commit a switch whose config no longer loads.
1926 fs::write(
1927 &path,
1928 "route_preferences_version = 1\nprovider = 'volcengine'\ndefault_text_model = 'ark-private-id'\n",
1929 )
1930 .unwrap();
1931 persist_provider_selection(Some(&path), &selection_identity(&path, "deepseek"), None)
1932 .unwrap();
1933
1934 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1935 assert!(
1936 doc.get("default_text_model").is_none(),
1937 "an alias the incoming route cannot serve must not be left behind"
1938 );
1939 assert_eq!(
1940 doc["providers"]["volcengine"]["model"].as_str(),
1941 Some("ark-private-id"),
1942 "the displaced choice moves onto its own route's leaf, it is not dropped"
1943 );
1944 crate::config::Config::load(Some(path.clone()), None)
1945 .expect("a bare provider switch must remain loadable");
1946
1947 persist_provider_selection(Some(&path), &selection_identity(&path, "volcengine"), None)
1948 .unwrap();
1949 let returned = crate::config::Config::load(Some(path.clone()), None)
1950 .expect("switching back must remain loadable");
1951 assert_eq!(
1952 returned.active_provider_identity().unwrap().provider,
1953 ProviderKind::Volcengine
1954 );
1955 assert_eq!(returned.default_model(), "ark-private-id");
1956 }
1957
1958 #[test]
1959 fn bare_switch_from_legacy_custom_commits_a_loadable_config() {
1960 use crate::test_support::{EnvVarGuard, lock_test_env};
1961 let _lock = lock_test_env();
1962 let home = tempfile::tempdir().unwrap();
1963 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1964 let _model_guard = ModelEnvGuard::new();
1965 let path = home.path().join("config.toml");
1966 let original = "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://proxy.example.test/v1'\ndefault_text_model = 'proxy-wire-id'\n[providers.deepseek]\nbase_url = 'https://api.deepseek.com/beta'\n";
1967 fs::write(&path, original).unwrap();
1968 crate::config::Config::load(Some(path.clone()), None).unwrap();
1969 // The write moves the top-level route into `[providers.custom]`,
1970 // model included (#6394), so the custom model is no longer the
1971 // switch's only copy and a bare switch commits a loadable config.
1972 persist_provider_selection(Some(&path), &selection_identity(&path, "deepseek"), None)
1973 .unwrap();
1974 let switched = crate::config::Config::load(Some(path.clone()), None)
1975 .expect("a bare provider switch must remain loadable");
1976 assert_eq!(
1977 switched.active_provider_identity().unwrap().provider,
1978 ProviderKind::Deepseek
1979 );
1980 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1981 assert_eq!(
1982 doc["providers"]["custom"]["model"].as_str(),
1983 Some("proxy-wire-id")
1984 );
1985 assert_eq!(
1986 doc["providers"]["custom"]["base_url"].as_str(),
1987 Some("https://proxy.example.test/v1")
1988 );
1989 }
1990
1991 #[test]
1992 fn an_unnamed_custom_route_keeps_its_root_model_across_a_switch() {
1993 use crate::test_support::{EnvVarGuard, lock_test_env};
1994 let _lock = lock_test_env();
1995 let home = tempfile::tempdir().unwrap();
1996 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1997 let _model_guard = ModelEnvGuard::new();
1998 let path = home.path().join("config.toml");
1999 // The one-way root migration copies the model to the exact custom
2000 // leaf. A switch with its own model retains the historical root alias
2001 // too; both choices survive without granting missing-id table authority.
2002 fs::write(
2003 &path,
2004 "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://proxy.example.test/v1'\ndefault_text_model = 'proxy-wire-id'\n",
2005 )
2006 .unwrap();
2007 persist_provider_selection(
2008 Some(&path),
2009 &selection_identity(&path, "deepseek"),
2010 Some("deepseek-v4-pro"),
2011 )
2012 .unwrap();
2013 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2014 assert_eq!(
2015 doc["default_text_model"].as_str(),
2016 Some("proxy-wire-id"),
2017 "the custom route's only saved model must survive the switch"
2018 );
2019 let restored = crate::config::Config::load(Some(path.clone()), None)
2020 .expect("the switched config must remain loadable");
2021 assert_eq!(restored.default_model(), "deepseek-v4-pro");
2022
2023 // Nothing can re-select an unnamed custom route by identity once the
2024 // selector names another provider, so restoring it is a `provider`
2025 // edit. The model has to still be there when it happens.
2026 let returning = fs::read_to_string(&path)
2027 .unwrap()
2028 .replace("provider = 'deepseek'", "provider = 'custom'")
2029 .replace("provider = \"deepseek\"", "provider = 'custom'");
2030 fs::write(&path, returning).unwrap();
2031 let returned = crate::config::Config::load(Some(path.clone()), None)
2032 .expect("returning to the custom route must remain loadable");
2033 assert_eq!(returned.default_model(), "proxy-wire-id");
2034 }
2035
2036 #[test]
2037 fn canonical_model_writer_targets_the_literal_custom_table_and_exact_named_ids() {
2038 use crate::test_support::{EnvVarGuard, lock_test_env};
2039 let _lock = lock_test_env();
2040 let home = tempfile::tempdir().unwrap();
2041 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2042 let path = home.path().join("config.toml");
2043 fs::write(&path, "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://legacy.example.test/v1'\ndefault_text_model = 'old-wire-id'\n").unwrap();
2044 persist_provider_selection(
2045 Some(&path),
2046 &selection_identity(&path, "custom"),
2047 Some("Exact-New-ID"),
2048 )
2049 .unwrap();
2050 // The literal route is the `[providers.custom]` table since #6394.
2051 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2052 assert_eq!(
2053 doc["providers"]["custom"]["model"].as_str(),
2054 Some("Exact-New-ID")
2055 );
2056 assert!(doc.get("base_url").is_none());
2057
2058 fs::write(&path, "route_preferences_version = 1\nprovider = 'Team.A'\n[providers.'Team.A']\nkind = 'openai-compatible'\nbase_url = 'https://named.example.test/v1'\nmodel = 'old'\n").unwrap();
2059 persist_provider_selection(
2060 Some(&path),
2061 &selection_identity(&path, "Team.A"),
2062 Some("Exact-Named-ID"),
2063 )
2064 .unwrap();
2065 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2066 assert_eq!(
2067 doc["providers"]["Team.A"]["model"].as_str(),
2068 Some("Exact-Named-ID")
2069 );
2070 let admitted_lower = crate::config::parse_config_base(
2071 "provider = 'team.a'\n[providers.'team.a']\nkind = 'openai-compatible'\nbase_url = 'https://lower.example.test/v1'\n"
2072 ).unwrap().resolve_provider_pin_identity("team.a").unwrap();
2073 let before_wrong_case = fs::read_to_string(&path).unwrap();
2074 assert!(persist_provider_selection(Some(&path), &admitted_lower, Some("wrong")).is_err());
2075 assert_eq!(fs::read_to_string(&path).unwrap(), before_wrong_case);
2076 persist_provider_model_key(
2077 Some(&path),
2078 &selection_identity(&path, "deepseek-cn"),
2079 "deepseek-v4-pro",
2080 )
2081 .unwrap();
2082 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2083 assert_eq!(
2084 doc["providers"]["deepseek_cn"]["model"].as_str(),
2085 Some("deepseek-v4-pro")
2086 );
2087 }
2088
2089 #[test]
2090 fn malformed_legacy_preferences_do_not_partially_commit_a_route_save() {
2091 use crate::test_support::{EnvVarGuard, lock_test_env};
2092 let _lock = lock_test_env();
2093 let home = tempfile::tempdir().unwrap();
2094 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2095 let path = home.path().join("config.toml");
2096 let source = "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n";
2097 fs::write(&path, source).unwrap();
2098 let settings_path = home.path().join("settings.toml");
2099 let malformed = "default_provider = [\n";
2100 fs::write(&settings_path, malformed).unwrap();
2101 let error = persist_provider_selection(
2102 Some(&path),
2103 &selection_identity(&path, "zai"),
2104 Some("GLM-5.3"),
2105 )
2106 .expect_err("unreadable legacy preferences must block the entire save");
2107 assert!(error.to_string().contains("configuration was not changed"));
2108 assert_eq!(fs::read_to_string(&path).unwrap(), source);
2109 assert_eq!(fs::read_to_string(&settings_path).unwrap(), malformed);
2110 }
2111 #[test]
2112 fn fresh_locked_root_migration_admits_only_the_captured_table_and_keeps_undo_cas() {
2113 let _lock = crate::test_support::lock_test_env();
2114 let home = tempfile::tempdir().unwrap();
2115 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2116 let path = home.path().join("config.toml");
2117 let original = "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\n";
2118 fs::write(&path, original).unwrap();
2119 let captured = crate::config::parse_config_base(original)
2120 .unwrap()
2121 .active_provider_identity()
2122 .unwrap();
2123 assert!(captured.persisted_id().is_none());
2124 let (_, undo) =
2125 persist_provider_selection(Some(&path), &captured, Some("selected-model")).unwrap();
2126 let saved = fs::read_to_string(&path).unwrap();
2127 let parsed = crate::config::parse_config_base(&saved).unwrap();
2128 assert_eq!(parsed.default_model(), "selected-model");
2129 assert_eq!(
2130 parsed
2131 .provider_config_for(&parsed.active_provider_identity().unwrap())
2132 .unwrap()
2133 .model
2134 .as_deref(),
2135 Some("selected-model")
2136 );
2137 let newer = saved.replace("selected-model", "newer-model");
2138 fs::write(&path, &newer).unwrap();
2139 assert!(!undo.undo().unwrap());
2140 assert_eq!(fs::read_to_string(&path).unwrap(), newer);
2141 }
2142
2143 #[test]
2144 fn locked_root_write_refuses_table_only_profile_conflict_and_changed_generation() {
2145 let _lock = crate::test_support::lock_test_env();
2146 let home = tempfile::tempdir().unwrap();
2147 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
2148 let path = home.path().join("config.toml");
2149 let root = "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\n";
2150 let captured = crate::config::parse_config_base(root)
2151 .unwrap()
2152 .active_provider_identity()
2153 .unwrap();
2154 for source in [
2155 "route_preferences_version = 1\nprovider = 'custom'\n[providers.custom]\nbase_url = 'http://127.0.0.1:18180/v1'\nmodel = 'old-model'\n",
2156 "route_preferences_version = 1\nprovider = 'deepseek'\n[profiles.dev]\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\n",
2157 "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\n[providers.custom]\nbase_url = 'http://127.0.0.1:19191/v1'\n",
2158 "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'changed-model'\n",
2159 "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\napi_key = 'changed-key'\n",
2160 ] {
2161 fs::write(&path, source).unwrap();
2162 assert!(
2163 persist_provider_selection(Some(&path), &captured, Some("must-not-save")).is_err()
2164 );
2165 assert_eq!(fs::read_to_string(&path).unwrap(), source);
2166 assert!(persist_provider_model_key(Some(&path), &captured, "must-not-save").is_err());
2167 assert_eq!(fs::read_to_string(&path).unwrap(), source);
2168 assert!(
2169 persist_route_base_url(Some(&path), &captured, "http://127.0.0.1:22222/v1")
2170 .is_err()
2171 );
2172 assert_eq!(fs::read_to_string(&path).unwrap(), source);
2173 }
2174 }
2175 }
2176
2176 lines RUST