返回 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::{ApiProvider, 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 codewhale_config::mutate_config_document(path, |doc| {
28 migrate_legacy_route_preferences(path, doc)?;
29 mutate(doc)
30 })
31 }
32
33 /// Commit the legacy effective startup selection with its receipt in the same
34 /// atomic config write. Settings remains untouched, so an interrupted cleanup
35 /// or an older binary cannot erase the user's historical choices. After this
36 /// marker, Config never consults those legacy route fields again.
37 pub(crate) fn migrate_legacy_route_preferences(
38 path: &Path,
39 doc: &mut toml_edit::DocumentMut,
40 ) -> anyhow::Result<()> {
41 if !crate::config::is_home_config_path(path)
42 || doc
43 .get("route_preferences_version")
44 .and_then(toml_edit::Item::as_integer)
45 .is_some()
46 {
47 return Ok(());
48 }
49 let mut config: crate::config::Config = toml::from_str(&doc.to_string()).map_err(|_| {
50 anyhow::anyhow!(
51 "Could not parse configuration for route preference migration; contents omitted"
52 )
53 })?;
54 let previous_config = config.clone();
55 let settings =
56 crate::settings::Settings::load_legacy_route_preferences_read_only().map_err(|_| {
57 anyhow::anyhow!(
58 "Could not read legacy route preferences; configuration was not changed"
59 )
60 })?;
61 // An unparsable settings.toml loads as defaults carrying `load_error`, which
62 // keeps the UI usable but is not evidence that no preferences were saved.
63 // This migration is one-way: stamping the version over defaults would retire
64 // the user's real legacy choices unread. Refuse instead, exactly as
65 // `Settings::save_to_path` refuses to overwrite an unreadable document.
66 // Contents stay omitted; the file may hold private text.
67 anyhow::ensure!(
68 settings.load_error.is_none(),
69 "Could not read legacy route preferences; configuration was not changed"
70 );
71 config.apply_saved_selection(&settings);
72 let active_identity = config.active_provider_identity(config.api_provider()).ok();
73 let selector = active_identity
74 .as_ref()
75 .and_then(|identity| {
76 identity
77 .migrated_legacy_ollama_cloud_route
78 .then(|| identity.persisted_id())
79 .flatten()
80 })
81 .or(config.provider.as_deref());
82 if let Some(provider) = selector {
83 if previous_config.provider.as_deref() != Some(provider)
84 && let Some(previous) = previous_config.provider.as_deref()
85 {
86 set_document_value(
87 doc,
88 &["route_preferences_migration", "previous_provider"],
89 previous,
90 )?;
91 }
92 set_document_value(doc, &["provider"], provider)?;
93 }
94 let mut providers: Vec<&str> = settings
95 .provider_models
96 .as_ref()
97 .map(|models| models.keys().map(String::as_str).collect())
98 .unwrap_or_default();
99 if settings.default_model.is_some() {
100 for provider in [ApiProvider::Deepseek, ApiProvider::DeepseekCN] {
101 if !providers.contains(&provider.as_str()) {
102 providers.push(provider.as_str());
103 }
104 }
105 }
106 providers.sort_unstable();
107 for provider in providers {
108 let Ok(identity) = config.legacy_selection_identity(provider) else {
109 continue;
110 };
111 let mut scoped = config.clone();
112 scoped.scope_to_provider_identity(&identity);
113 let model = if identity.provider == ApiProvider::Custom && identity.persisted_id().is_none()
114 {
115 scoped.default_text_model.as_deref()
116 } else {
117 scoped
118 .provider_config_for(identity.provider)
119 .and_then(|entry| entry.model.as_deref())
120 };
121 if let Some(model) = model {
122 let mut previous = previous_config.clone();
123 previous.scope_to_provider_identity(&identity);
124 if let Some(old_model) = previous
125 .provider_config_for(identity.provider)
126 .and_then(|entry| entry.model.as_deref())
127 .or_else(|| {
128 (previous_config.api_provider() == identity.provider)
129 .then_some(previous_config.default_text_model.as_deref())
130 .flatten()
131 })
132 && old_model != model
133 {
134 // Keep the displaced Config choice as an inert migration
135 // receipt. Nothing resolves routes from this archive.
136 set_document_value(
137 doc,
138 &[
139 "route_preferences_migration",
140 "previous_models",
141 &identity.key,
142 ],
143 old_model,
144 )?;
145 }
146 set_provider_model_document(
147 doc,
148 identity.provider,
149 identity.persisted_id().unwrap_or(&identity.key),
150 model,
151 )?;
152 }
153 }
154 set_document_value(doc, &["route_preferences_version"], 1_i64)
155 }
156
157 pub(crate) fn set_provider_model_document(
158 doc: &mut toml_edit::DocumentMut,
159 provider: ApiProvider,
160 provider_identity: &str,
161 model: &str,
162 ) -> anyhow::Result<()> {
163 anyhow::ensure!(
164 !model.trim().is_empty() && !model.chars().any(char::is_control),
165 "model must be nonempty and contain no control characters"
166 );
167 let config: crate::config::Config = toml::from_str(&doc.to_string()).map_err(|_| {
168 anyhow::anyhow!("Could not parse destination route identity; contents omitted")
169 })?;
170 let identity = config
171 .resolve_provider_pin_identity(provider_identity)
172 .map_err(anyhow::Error::msg)?;
173 anyhow::ensure!(
174 identity.provider == provider,
175 "The destination config has a different provider identity"
176 );
177 let provider_key = if provider == ApiProvider::Custom {
178 if identity.persisted_id().is_none() {
179 return set_document_value(doc, &["default_text_model"], model);
180 }
181 identity.key
182 } else if identity.migrated_legacy_ollama_cloud_route {
183 "ollama".to_string()
184 } else if provider == ApiProvider::DeepseekCN {
185 "deepseek_cn".to_string()
186 } else {
187 provider
188 .metadata()
189 .context("provider config metadata")?
190 .provider_config_key()
191 .to_string()
192 };
193 set_document_value(doc, &["providers", &provider_key, "model"], model)
194 }
195
196 /// One persistent owner and atomic write for an explicitly saved route.
197 pub(crate) fn persist_provider_selection(
198 config_path: Option<&Path>,
199 provider: ApiProvider,
200 provider_identity: &str,
201 model: Option<&str>,
202 ) -> anyhow::Result<PathBuf> {
203 let path = config_toml_path(config_path)?;
204 mutate_config_document(&path, |doc| {
205 let config: crate::config::Config = toml::from_str(&doc.to_string())
206 .map_err(|_| anyhow::anyhow!("Could not parse destination route; contents omitted"))?;
207 let identity = config
208 .resolve_provider_pin_identity(provider_identity)
209 .map_err(anyhow::Error::msg)?;
210 anyhow::ensure!(
211 identity.provider == provider,
212 "The destination config has a different provider identity"
213 );
214 if let Some(model) = model {
215 set_provider_model_document(
216 doc,
217 provider,
218 identity.persisted_id().unwrap_or(&identity.key),
219 model,
220 )?;
221 }
222 set_document_value(
223 doc,
224 &["provider"],
225 identity.persisted_id().unwrap_or(&identity.key),
226 )?;
227 reconcile_root_model_aliases(doc, &config, &identity)
228 })?;
229 Ok(path)
230 }
231
232 /// Keep a root `default_text_model` alias from stranding a route switch,
233 /// without discarding the choice it holds.
234 ///
235 /// The root alias is the *active* route's fallback: `Config` resolves it only
236 /// when the selected route has no model of its own. A switch that leaves the
237 /// incoming route without a leaf therefore hands it an alias naming the route
238 /// on its way out, and `Config::load` rejects the file the caller just wrote —
239 /// a committed switch that produces an unloadable config.
240 ///
241 /// Relocate that value onto the outgoing route's own canonical leaf, because
242 /// it is real saved state, and only then clear the alias. An alias the
243 /// incoming route already shadows with its own leaf is inert and stays:
244 /// deleting a saved choice to satisfy validation of a value nothing resolves
245 /// is data loss, not a repair. Likewise an unnamed custom route stores its
246 /// model in the alias itself and has no leaf to receive it. If the incoming
247 /// route cannot shadow that value, refuse the switch atomically and ask for an
248 /// explicit destination model instead of saving an unloadable configuration.
249 ///
250 /// `previous` is the document's configuration before the switch; `incoming` is
251 /// the identity now selected. Every route writer calls this, so no writer can
252 /// keep a private rule about which route owns the root alias.
253 pub(crate) fn reconcile_root_model_aliases(
254 doc: &mut toml_edit::DocumentMut,
255 previous: &crate::config::Config,
256 incoming: &crate::config::ProviderIdentity,
257 ) -> anyhow::Result<()> {
258 // When the *incoming* route is an unnamed custom one the root alias is its
259 // own model slot (see `set_provider_model_document`), never the outgoing
260 // route's leftovers.
261 if incoming.provider == ApiProvider::Custom && incoming.persisted_id().is_none() {
262 return Ok(());
263 }
264 // Only `default_text_model` is read here. The legacy root `model` key is
265 // never what blocks a load: `Config::default_model` already refuses to
266 // route a foreign legacy value to a provider that cannot serve it, and
267 // `Config::validate` does not consult it, so relocating it would move a
268 // value nothing is asking about.
269 const ROOT_KEY: &str = "default_text_model";
270 let Some(value) = doc
271 .get(ROOT_KEY)
272 .and_then(toml_edit::Item::as_str)
273 .map(str::to_owned)
274 else {
275 return Ok(());
276 };
277 let switched: crate::config::Config = toml::from_str(&doc.to_string())
278 .map_err(|_| anyhow::anyhow!("Could not parse switched route; contents omitted"))?;
279 // `Config::validate` is the single authority on what the incoming route can
280 // serve, so a writer cannot disagree with the loader. Act only when this
281 // alias is what the loader rejects: a document already broken for an
282 // unrelated reason is not this writer's to rewrite.
283 let mut without_alias = switched.clone();
284 without_alias.default_text_model = None;
285 if switched
286 .provider_config_for(incoming.provider)
287 .and_then(|entry| entry.model.as_deref())
288 .is_some()
289 || switched.validate().is_ok()
290 || without_alias.validate().is_err()
291 {
292 return Ok(());
293 }
294 // An empty or control-bearing alias names no saved model. Nothing to
295 // relocate, and clearing it loses nothing.
296 if value.trim().is_empty() || value.chars().any(char::is_control) {
297 unset_document_value(doc, &[ROOT_KEY])?;
298 return Ok(());
299 }
300 // No leaf can hold this value. Keep the only copy of the user's choice
301 // rather than discard it for a document the incoming route loads as soon as
302 // it saves a model of its own.
303 let outgoing = previous
304 .active_provider_identity(previous.api_provider())
305 .ok();
306 if outgoing.as_ref().is_some_and(|outgoing| {
307 outgoing != incoming
308 && outgoing.provider == ApiProvider::Custom
309 && outgoing.persisted_id().is_none()
310 }) {
311 anyhow::bail!(
312 "Choose a model for the destination provider before switching from a legacy custom connection; the saved configuration was not changed"
313 );
314 }
315 let Some(outgoing) = outgoing.as_ref().filter(|outgoing| *outgoing != incoming) else {
316 return Ok(());
317 };
318 let mut scoped = switched;
319 scoped.scope_to_provider_identity(outgoing);
320 if scoped
321 .provider_config_for(outgoing.provider)
322 .and_then(|entry| entry.model.as_deref())
323 .is_none()
324 {
325 set_provider_model_document(
326 doc,
327 outgoing.provider,
328 outgoing.persisted_id().unwrap_or(&outgoing.key),
329 &value,
330 )?;
331 }
332 // The outgoing route either already saved its own choice or has just
333 // received this one, so the alias is now a shadowed duplicate that only
334 // blocks the incoming route.
335 unset_document_value(doc, &[ROOT_KEY])?;
336 Ok(())
337 }
338
339 /// Atomically replace `path` with `body` via a same-directory temp file and
340 /// rename. On Unix the file lands with 0o600 permissions: config.toml can
341 /// hold API keys, so this matches `ConfigStore::save` and the auth save path.
342 pub(crate) fn write_config_toml_atomic(path: &Path, body: &str) -> anyhow::Result<()> {
343 codewhale_config::create_config_document(path, body)
344 }
345
346 /// Set the value at `segments` (parent tables plus the final key), creating
347 /// missing intermediate tables. Replacing an existing value keeps its decor,
348 /// so comments above the key and trailing same-line comments survive.
349 ///
350 /// Segments are separate strings rather than one dotted key, so table names
351 /// that need quoting (`[providers."my.provider"]`) resolve correctly.
352 pub(crate) fn set_document_value(
353 doc: &mut toml_edit::DocumentMut,
354 segments: &[&str],
355 value: impl Into<toml_edit::Value>,
356 ) -> anyhow::Result<()> {
357 codewhale_config::set_config_document_value(doc, segments, value)
358 }
359
360 /// Remove the value at `segments`. Returns `Ok(true)` when an entry was
361 /// removed; missing keys and missing (or non-table) parents are a no-op.
362 pub(crate) fn unset_document_value(
363 doc: &mut toml_edit::DocumentMut,
364 segments: &[&str],
365 ) -> anyhow::Result<bool> {
366 codewhale_config::unset_config_document_value(doc, segments)
367 }
368
369 /// Remove every entry named `key` from `table` and, recursively, from nested
370 /// tables, inline tables, and arrays of tables. Used by `/logout` to strip
371 /// `api_key` everywhere without disturbing keys like `api_key_env`.
372 pub(crate) fn remove_document_key_recursive(table: &mut dyn toml_edit::TableLike, key: &str) {
373 remove_key_preserving_leading_decor(table, key);
374 for (_, item) in table.iter_mut() {
375 if let toml_edit::Item::ArrayOfTables(tables) = item {
376 for nested in tables.iter_mut() {
377 remove_document_key_recursive(nested, key);
378 }
379 } else if let Some(nested) = item.as_table_like_mut() {
380 remove_document_key_recursive(nested, key);
381 }
382 }
383 }
384
385 fn remove_key_preserving_leading_decor(table: &mut dyn toml_edit::TableLike, key: &str) -> bool {
386 let mut found = false;
387 let next_key = table.iter().find_map(|(candidate, _)| {
388 if found {
389 Some(candidate.to_owned())
390 } else {
391 found = candidate == key;
392 None
393 }
394 });
395 let leading_prefix = leading_prefix_for_key(table, key);
396 if table.remove(key).is_none() {
397 return false;
398 }
399 let Some(prefix) = leading_prefix else {
400 return true;
401 };
402 let Some(next_key) = next_key else {
403 return true;
404 };
405 if prefix.as_str() == Some("") {
406 return true;
407 }
408 if let Some(mut next_key_decor) = table.key_mut(&next_key)
409 && decor_prefix_is_empty(next_key_decor.leaf_decor())
410 {
411 next_key_decor.leaf_decor_mut().set_prefix(prefix);
412 }
413 true
414 }
415
416 fn decor_prefix_is_empty(decor: &toml_edit::Decor) -> bool {
417 match decor.prefix() {
418 Some(prefix) => prefix.as_str() == Some(""),
419 None => true,
420 }
421 }
422
423 fn leading_prefix_for_key(
424 table: &dyn toml_edit::TableLike,
425 key: &str,
426 ) -> Option<toml_edit::RawString> {
427 table
428 .key(key)
429 .and_then(|key| key.leaf_decor().prefix().cloned())
430 .or_else(|| {
431 table
432 .get(key)
433 .and_then(|item| item.as_value())
434 .and_then(|value| value.decor().prefix().cloned())
435 })
436 }
437
438 pub(crate) fn persist_status_items(items: &[StatusItem]) -> anyhow::Result<PathBuf> {
439 let path = config_toml_path(None)?;
440 let items: toml_edit::Array = items.iter().map(|item| item.key()).collect();
441 mutate_config_document(&path, |doc| {
442 set_document_value(doc, &["tui", "status_items"], items)
443 })?;
444 Ok(path)
445 }
446
447 pub(crate) fn persist_root_string_key(
448 config_path: Option<&Path>,
449 key: &str,
450 value: &str,
451 ) -> anyhow::Result<PathBuf> {
452 let path = config_toml_path(config_path)?;
453 mutate_config_document(&path, |doc| set_document_value(doc, &[key], value))?;
454 Ok(path)
455 }
456
457 pub(crate) fn persist_unset_root_key(
458 config_path: Option<&Path>,
459 key: &str,
460 ) -> anyhow::Result<PathBuf> {
461 let path = config_toml_path(config_path)?;
462 mutate_config_document(&path, |doc| unset_document_value(doc, &[key]).map(|_| ()))?;
463 Ok(path)
464 }
465
466 pub(crate) fn persist_root_bool_key(
467 config_path: Option<&Path>,
468 key: &str,
469 value: bool,
470 ) -> anyhow::Result<PathBuf> {
471 let path = config_toml_path(config_path)?;
472 mutate_config_document(&path, |doc| set_document_value(doc, &[key], value))?;
473 Ok(path)
474 }
475
476 pub(crate) fn persist_tui_integer_key(
477 config_path: Option<&Path>,
478 key: &str,
479 value: u64,
480 ) -> anyhow::Result<PathBuf> {
481 let value = i64::try_from(value).context("integer value is too large for TOML")?;
482 persist_table_value_key(config_path, "tui", key, value.into())
483 }
484
485 pub(crate) fn persist_subagents_bool_key(
486 config_path: Option<&Path>,
487 key: &str,
488 value: bool,
489 ) -> anyhow::Result<PathBuf> {
490 persist_table_value_key(config_path, "subagents", key, value.into())
491 }
492
493 pub(crate) fn persist_mini_window_bool_key(
494 config_path: Option<&Path>,
495 key: &str,
496 value: bool,
497 ) -> anyhow::Result<PathBuf> {
498 persist_table_value_key(config_path, "mini_window", key, value.into())
499 }
500
501 pub(crate) fn persist_subagents_integer_key(
502 config_path: Option<&Path>,
503 key: &str,
504 value: u64,
505 ) -> anyhow::Result<PathBuf> {
506 let value = i64::try_from(value).context("integer value is too large for TOML")?;
507 persist_table_value_key(config_path, "subagents", key, value.into())
508 }
509
510 pub(crate) fn persist_table_bool_key(
511 config_path: Option<&Path>,
512 table_name: &str,
513 key: &str,
514 value: bool,
515 ) -> anyhow::Result<PathBuf> {
516 persist_table_value_key(config_path, table_name, key, value.into())
517 }
518
519 pub(crate) fn persist_table_string_key(
520 config_path: Option<&Path>,
521 table_name: &str,
522 key: &str,
523 value: &str,
524 ) -> anyhow::Result<PathBuf> {
525 persist_table_value_key(config_path, table_name, key, value.into())
526 }
527
528 fn persist_table_value_key(
529 config_path: Option<&Path>,
530 table_name: &str,
531 key: &str,
532 value: toml_edit::Value,
533 ) -> anyhow::Result<PathBuf> {
534 let path = config_toml_path(config_path)?;
535 mutate_config_document(&path, |doc| {
536 set_document_value(doc, &[table_name, key], value)
537 })?;
538 Ok(path)
539 }
540
541 pub(crate) fn persist_provider_base_url_key(
542 config_path: Option<&Path>,
543 provider: ApiProvider,
544 value: &str,
545 ) -> anyhow::Result<PathBuf> {
546 let provider_key = provider_base_url_table_key(provider)?;
547 let path = config_toml_path(config_path)?;
548 mutate_config_document(&path, |doc| {
549 set_document_value(doc, &["providers", provider_key, "base_url"], value)
550 })?;
551 Ok(path)
552 }
553
554 /// Persist the model for one exact provider route without rewriting the
555 /// legacy root DeepSeek fallback used by unrelated providers.
556 ///
557 /// Built-in providers write to their typed `[providers.<name>]` table, while
558 /// named custom routes use their exact user-owned table id. Only a legacy
559 /// literal custom route retains its root model field.
560 pub(crate) fn persist_provider_model_key(
561 config_path: Option<&Path>,
562 provider: ApiProvider,
563 provider_identity: &str,
564 value: &str,
565 ) -> anyhow::Result<PathBuf> {
566 let path = config_toml_path(config_path)?;
567 mutate_config_document(&path, |doc| {
568 set_provider_model_document(doc, provider, provider_identity, value)
569 })?;
570 Ok(path)
571 }
572
573 fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> {
574 match provider {
575 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
576 anyhow::bail!("DeepSeek uses the root base_url setting")
577 }
578 ApiProvider::DeepseekAnthropic => Ok("deepseek_anthropic"),
579 ApiProvider::NvidiaNim => Ok("nvidia_nim"),
580 ApiProvider::Openai => Ok("openai"),
581 ApiProvider::Anthropic => Ok("anthropic"),
582 ApiProvider::Atlascloud => Ok("atlascloud"),
583 ApiProvider::WanjieArk => Ok("wanjie_ark"),
584 ApiProvider::Volcengine => Ok("volcengine"),
585 ApiProvider::Openrouter => Ok("openrouter"),
586 ApiProvider::Orcarouter => Ok("orcarouter"),
587 ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"),
588 ApiProvider::Novita => Ok("novita"),
589 ApiProvider::Fireworks => Ok("fireworks"),
590 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"),
591 ApiProvider::Arcee => Ok("arcee"),
592 ApiProvider::Huggingface => Ok("huggingface"),
593 ApiProvider::Modelscope => Ok("modelscope"),
594 ApiProvider::Deepinfra => Ok("deepinfra"),
595 ApiProvider::Moonshot => Ok("moonshot"),
596 ApiProvider::Sglang => Ok("sglang"),
597 ApiProvider::Vllm => Ok("vllm"),
598 ApiProvider::Ollama => Ok("ollama"),
599 ApiProvider::OllamaCloud => Ok("ollama_cloud"),
600 ApiProvider::Together => Ok("together"),
601 ApiProvider::Qianfan => Ok("qianfan"),
602 ApiProvider::OpenaiCodex => Ok("openai_codex"),
603 ApiProvider::Openmodel => Ok("openmodel"),
604 ApiProvider::Zai => Ok("zai"),
605 ApiProvider::Stepfun => Ok("stepfun"),
606 ApiProvider::Minimax => Ok("minimax"),
607 ApiProvider::MinimaxAnthropic => Ok("minimax_anthropic"),
608 ApiProvider::Sakana => Ok("sakana"),
609 ApiProvider::LongCat => Ok("longcat"),
610 ApiProvider::OpencodeGo => Ok("opencode_go"),
611 ApiProvider::OpencodeZen => Ok("opencode_zen"),
612 ApiProvider::Meta => Ok("meta"),
613 ApiProvider::Xai => Ok("xai"),
614 ApiProvider::Mistral => Ok("mistral"),
615 ApiProvider::Google => Ok("google"),
616 ApiProvider::Antigravity => Ok("antigravity"),
617 ApiProvider::Telecomjs => Ok("telecomjs"),
618 ApiProvider::Edenai => Ok("edenai"),
619 ApiProvider::Zenmux => Ok("zenmux"),
620 ApiProvider::Csdn => Ok("csdn"),
621 ApiProvider::Concentrate => Ok("concentrate"),
622 ApiProvider::Codewhale => Ok("codewhale"),
623 ApiProvider::ModelstudioTokenPlan => Ok("modelstudio_token_plan"),
624 ApiProvider::ModelstudioTokenPlanAnthropic => Ok("modelstudio_token_plan_anthropic"),
625 ApiProvider::ModelstudioCodingPlan => Ok("modelstudio_coding_plan"),
626 ApiProvider::ModelstudioCodingPlanAnthropic => Ok("modelstudio_coding_plan_anthropic"),
627 // Custom providers live under a user-chosen `[providers.<name>]` table,
628 // not a fixed key. Persisting base_url through this static-key path is
629 // out of scope for the #1519 constrained slice; users edit the named
630 // table directly.
631 ApiProvider::Custom => {
632 anyhow::bail!("custom providers store base_url in their named [providers.<name>] table")
633 }
634 }
635 }
636
637 pub(crate) fn persist_custom_provider(
638 config_path: Option<&Path>,
639 provider_id: &str,
640 base_url: &str,
641 model: Option<&str>,
642 api_key_env: Option<&str>,
643 ) -> anyhow::Result<PathBuf> {
644 let provider_id = normalize_custom_provider_id(provider_id)?;
645 let base_url = normalize_custom_provider_base_url(base_url)?;
646 let model = model.and_then(normalize_optional_custom_provider_field);
647 let api_key_env = api_key_env.and_then(normalize_optional_custom_provider_field);
648
649 let path = config_toml_path(config_path)?;
650 mutate_config_document(&path, |doc| {
651 let entry = ["providers", provider_id.as_str()];
652 set_document_value(doc, &["provider"], provider_id.as_str())?;
653 set_document_value(doc, &[entry[0], entry[1], "kind"], "openai-compatible")?;
654 set_document_value(doc, &[entry[0], entry[1], "base_url"], base_url.as_str())?;
655 if provider_id == "ds4" && crate::config::base_url_uses_local_host(&base_url) {
656 // Match the documented starter server. DS4 explicitly requires
657 // clients not to budget beyond the server's --ctx value.
658 set_document_value(doc, &[entry[0], entry[1], "context_window"], 100_000)?;
659 }
660 match model.as_deref() {
661 Some(model) => set_document_value(doc, &[entry[0], entry[1], "model"], model)?,
662 None => {
663 unset_document_value(doc, &[entry[0], entry[1], "model"])?;
664 }
665 }
666 match api_key_env.as_deref() {
667 Some(env) => {
668 set_document_value(doc, &[entry[0], entry[1], "api_key_env"], env)?;
669 unset_document_value(doc, &[entry[0], entry[1], "auth_mode"])?;
670 }
671 None => {
672 unset_document_value(doc, &[entry[0], entry[1], "api_key_env"])?;
673 if provider_id == "ds4" && crate::config::base_url_uses_local_host(&base_url) {
674 set_document_value(doc, &[entry[0], entry[1], "auth_mode"], "none")?;
675 } else {
676 unset_document_value(doc, &[entry[0], entry[1], "auth_mode"])?;
677 }
678 }
679 }
680 Ok(())
681 })?;
682 Ok(path)
683 }
684
685 fn normalize_custom_provider_id(raw: &str) -> anyhow::Result<String> {
686 use anyhow::bail;
687
688 let value = raw.trim();
689 if value.is_empty() {
690 bail!("custom provider name is required");
691 }
692 if value == "__custom__" {
693 bail!("custom provider name is reserved");
694 }
695 if crate::config::ApiProvider::parse(value).is_some() {
696 bail!("custom provider name must not shadow a built-in provider");
697 }
698 if !value
699 .chars()
700 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
701 {
702 bail!("custom provider name may only use letters, numbers, '-' and '_'");
703 }
704 Ok(value.to_string())
705 }
706
707 fn normalize_custom_provider_base_url(raw: &str) -> anyhow::Result<String> {
708 use anyhow::bail;
709
710 let value = raw.trim().trim_end_matches('/');
711 if value.is_empty() {
712 bail!("custom provider base URL is required");
713 }
714 let parsed = reqwest::Url::parse(value)
715 .map_err(|err| anyhow::anyhow!("custom provider base URL is invalid: {err}"))?;
716 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
717 bail!("custom provider base URL must be an http(s) URL with a host");
718 }
719 Ok(value.to_string())
720 }
721
722 fn normalize_optional_custom_provider_field(raw: &str) -> Option<String> {
723 let value = raw.trim();
724 (!value.is_empty()).then(|| value.to_string())
725 }
726
727 pub(crate) fn persist_hotbar_bindings(
728 config_path: Option<&Path>,
729 bindings: &[codewhale_config::HotbarBindingToml],
730 ) -> anyhow::Result<PathBuf> {
731 let path = config_toml_path(config_path)?;
732 mutate_config_document(&path, |doc| {
733 let table = doc.as_table_mut();
734 table.remove("hotbar");
735 if bindings.is_empty() {
736 table.insert(
737 "hotbar",
738 toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new())),
739 );
740 } else {
741 let mut hotbar = toml_edit::ArrayOfTables::new();
742 for binding in bindings {
743 let mut entry = toml_edit::Table::new();
744 entry["slot"] = toml_edit::value(i64::from(binding.slot));
745 entry["action"] = toml_edit::value(binding.action.clone());
746 if let Some(label) = binding.label.as_deref() {
747 entry["label"] = toml_edit::value(label);
748 }
749 hotbar.push(entry);
750 }
751 table.insert("hotbar", toml_edit::Item::ArrayOfTables(hotbar));
752 }
753 Ok(())
754 })?;
755 Ok(path)
756 }
757
758 pub(crate) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<PathBuf> {
759 if let Some(path) = config_path {
760 return Ok(expand_path(path.to_string_lossy().as_ref()));
761 }
762 crate::config::resolve_load_config_path(None)?
763 .context("failed to resolve the active config.toml path")
764 }
765
766 #[cfg(test)]
767 mod tests {
768 use super::*;
769 use std::env;
770 use std::ffi::OsString;
771 use std::fs;
772 use std::path::Path;
773 use std::time::{SystemTime, UNIX_EPOCH};
774
775 struct EnvGuard {
776 _home: crate::test_support::EnvVarGuard,
777 _userprofile: crate::test_support::EnvVarGuard,
778 _codewhale_home: crate::test_support::EnvVarGuard,
779 _codewhale_config_path: crate::test_support::EnvVarGuard,
780 _deepseek_config_path: crate::test_support::EnvVarGuard,
781 _lock: crate::test_support::TestEnvLock,
782 }
783
784 impl EnvGuard {
785 fn new(home: &Path) -> Self {
786 let lock = crate::test_support::lock_test_env();
787 let config_path = home.join(".deepseek").join("config.toml");
788 Self {
789 _home: crate::test_support::EnvVarGuard::set("HOME", home),
790 _userprofile: crate::test_support::EnvVarGuard::set("USERPROFILE", home),
791 _codewhale_home: crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME"),
792 _codewhale_config_path: crate::test_support::EnvVarGuard::remove(
793 "CODEWHALE_CONFIG_PATH",
794 ),
795 _deepseek_config_path: crate::test_support::EnvVarGuard::set(
796 "DEEPSEEK_CONFIG_PATH",
797 &config_path,
798 ),
799 _lock: lock,
800 }
801 }
802 }
803
804 fn temp_root(prefix: &str) -> std::path::PathBuf {
805 let nanos = SystemTime::now()
806 .duration_since(UNIX_EPOCH)
807 .unwrap()
808 .as_nanos();
809 env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
810 }
811
812 #[test]
813 fn persist_status_items_writes_tui_section_to_config_toml() {
814 let temp_root = temp_root("codewhale-statusline-persist");
815 fs::create_dir_all(&temp_root).unwrap();
816 let _guard = EnvGuard::new(&temp_root);
817
818 let items = vec![
819 crate::config::StatusItem::Mode,
820 crate::config::StatusItem::Model,
821 crate::config::StatusItem::Cost,
822 ];
823
824 let path = persist_status_items(&items).expect("persist should succeed");
825 let body = fs::read_to_string(&path).expect("written file should be readable");
826 assert!(body.contains("[tui]"), "expected [tui] section in {body}");
827 assert!(
828 body.contains("status_items"),
829 "expected status_items key in {body}"
830 );
831 assert!(body.contains("\"mode\""), "expected mode key in {body}");
832 assert!(body.contains("\"cost\""), "expected cost key in {body}");
833 }
834
835 #[test]
836 fn config_toml_path_uses_codewhale_home_for_fresh_installs() {
837 let temp_root = temp_root("codewhale-config-path-fresh");
838 fs::create_dir_all(&temp_root).unwrap();
839 let _guard = EnvGuard::new(&temp_root);
840
841 unsafe {
842 env::remove_var("DEEPSEEK_CONFIG_PATH");
843 }
844
845 assert_eq!(
846 config_toml_path(None).unwrap(),
847 temp_root.join(".codewhale").join("config.toml")
848 );
849 }
850
851 #[test]
852 fn config_toml_path_preserves_legacy_config_when_it_exists() {
853 let temp_root = temp_root("codewhale-config-path-legacy");
854 let legacy_config = temp_root.join(".deepseek").join("config.toml");
855 fs::create_dir_all(legacy_config.parent().unwrap()).unwrap();
856 fs::write(&legacy_config, "").unwrap();
857 let _guard = EnvGuard::new(&temp_root);
858
859 unsafe {
860 env::remove_var("DEEPSEEK_CONFIG_PATH");
861 }
862
863 assert_eq!(config_toml_path(None).unwrap(), legacy_config);
864 }
865
866 #[test]
867 fn config_toml_path_ignores_legacy_config_when_codewhale_home_is_explicit() {
868 let temp_root = temp_root("codewhale-config-path-explicit-home");
869 let explicit_home = temp_root.join("isolated-codewhale");
870 let legacy_config = temp_root.join(".deepseek").join("config.toml");
871 fs::create_dir_all(legacy_config.parent().unwrap()).unwrap();
872 fs::write(&legacy_config, "").unwrap();
873 let _guard = EnvGuard::new(&temp_root);
874
875 unsafe {
876 env::remove_var("DEEPSEEK_CONFIG_PATH");
877 env::set_var("CODEWHALE_HOME", &explicit_home);
878 }
879
880 assert_eq!(
881 config_toml_path(None).unwrap(),
882 explicit_home.join("config.toml")
883 );
884 }
885
886 #[test]
887 fn config_toml_path_prefers_codewhale_env_over_legacy_env() {
888 let temp_root = temp_root("codewhale-config-path-env");
889 fs::create_dir_all(&temp_root).unwrap();
890 let _guard = EnvGuard::new(&temp_root);
891 let preferred = temp_root.join("preferred.toml");
892 let legacy = temp_root.join("legacy.toml");
893
894 unsafe {
895 env::set_var("CODEWHALE_CONFIG_PATH", &preferred);
896 env::set_var("DEEPSEEK_CONFIG_PATH", &legacy);
897 }
898
899 let expected = preferred
900 .parent()
901 .expect("preferred path has a parent")
902 .canonicalize()
903 .expect("preferred parent should canonicalize")
904 .join("preferred.toml");
905 assert_eq!(config_toml_path(None).unwrap(), expected);
906 }
907
908 #[test]
909 fn config_toml_path_keeps_missing_env_target_authoritative() {
910 let temp_root = temp_root("codewhale-config-path-missing-env-fallback");
911 let home_config = temp_root.join(".codewhale").join("config.toml");
912 fs::create_dir_all(home_config.parent().unwrap()).unwrap();
913 fs::write(&home_config, "# existing fallback\n").unwrap();
914 let _guard = EnvGuard::new(&temp_root);
915 let missing_env = temp_root.join("override").join("missing.toml");
916
917 unsafe {
918 env::set_var("DEEPSEEK_CONFIG_PATH", &missing_env);
919 }
920
921 assert_eq!(config_toml_path(None).unwrap(), missing_env);
922 assert!(home_config.exists());
923 assert!(!missing_env.exists());
924 }
925
926 #[test]
927 fn persist_status_items_preserves_existing_unrelated_keys() {
928 let temp_root = temp_root("codewhale-statusline-preserve");
929 fs::create_dir_all(&temp_root).unwrap();
930 let _guard = EnvGuard::new(&temp_root);
931
932 let path = temp_root.join(".deepseek").join("config.toml");
933 fs::create_dir_all(path.parent().unwrap()).unwrap();
934 fs::write(
935 &path,
936 "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n",
937 )
938 .unwrap();
939
940 let written = persist_status_items(&[crate::config::StatusItem::Mode])
941 .expect("persist should succeed");
942 let body = fs::read_to_string(&written).expect("written file should be readable");
943 assert!(
944 body.contains("api_key = \"sentinel-key\""),
945 "round-trip lost api_key: {body}"
946 );
947 assert!(
948 body.contains("model = \"deepseek-v4-pro\""),
949 "round-trip lost model: {body}"
950 );
951 assert!(
952 body.contains("status_items"),
953 "expected status_items in {body}"
954 );
955 }
956
957 #[test]
958 fn persist_bool_key_preserves_comments() {
959 let temp_root = temp_root("codewhale-persist-comments");
960 fs::create_dir_all(&temp_root).unwrap();
961 let _guard = EnvGuard::new(&temp_root);
962
963 let path = temp_root.join(".deepseek").join("config.toml");
964 fs::create_dir_all(path.parent().unwrap()).unwrap();
965 fs::write(
966 &path,
967 "# my note\nmodel = \"deepseek-v4-flash\"\n# disabled = true\n",
968 )
969 .unwrap();
970
971 let written = persist_root_bool_key(Some(&path), "allow_shell", true)
972 .expect("persist should succeed");
973 let body = fs::read_to_string(&written).expect("written file should be readable");
974 assert!(body.contains("# my note"), "prefix comment lost: {body}");
975 assert!(
976 body.contains("# disabled = true"),
977 "disabled key lost: {body}"
978 );
979 assert!(
980 body.contains("allow_shell = true"),
981 "new key not written: {body}"
982 );
983 }
984
985 #[test]
986 fn persist_table_bool_key_updates_existing_memory_enabled() {
987 let temp_root = temp_root("codewhale-persist-memory-update");
988 fs::create_dir_all(&temp_root).unwrap();
989 let _guard = EnvGuard::new(&temp_root);
990
991 let path = temp_root.join(".deepseek").join("config.toml");
992 fs::create_dir_all(path.parent().unwrap()).unwrap();
993 fs::write(&path, "allow_shell = true\n\n[memory]\nenabled = true\n").unwrap();
994
995 let written = persist_table_bool_key(Some(&path), "memory", "enabled", false)
996 .expect("persist should succeed");
997 let body = fs::read_to_string(&written).expect("written file should be readable");
998 assert!(
999 body.contains("enabled = false"),
1000 "memory enabled should be false: {body}"
1001 );
1002 assert!(
1003 !body.contains("enabled = true"),
1004 "memory enabled should not still be true: {body}"
1005 );
1006 }
1007
1008 #[test]
1009 fn persist_memory_enabled_round_trips_through_config_load() {
1010 let temp_root = temp_root("codewhale-persist-memory-roundtrip");
1011 fs::create_dir_all(&temp_root).unwrap();
1012 let _guard = EnvGuard::new(&temp_root);
1013
1014 let path = temp_root.join(".deepseek").join("config.toml");
1015 fs::create_dir_all(path.parent().unwrap()).unwrap();
1016 // Initial config has memory enabled = true
1017 fs::write(&path, "allow_shell = true\n\n[memory]\nenabled = true\n").unwrap();
1018
1019 // Verify initial state
1020 let cfg0 = crate::config::Config::load(Some(path.clone()), None)
1021 .expect("initial config should load");
1022 assert!(cfg0.memory_enabled(), "memory should be enabled initially");
1023
1024 // Persist memory.enabled = false (what the GUI's set_config endpoint does)
1025 persist_table_bool_key(Some(&path), "memory", "enabled", false)
1026 .expect("persist should succeed");
1027
1028 // Reload config from disk and verify memory_enabled() reflects the change
1029 let cfg1 = crate::config::Config::load(Some(path.clone()), None)
1030 .expect("reloaded config should load");
1031 assert!(
1032 !cfg1.memory_enabled(),
1033 "memory should be disabled after persisting false"
1034 );
1035 }
1036
1037 #[test]
1038 fn persist_custom_provider_writes_named_openai_compatible_table() {
1039 let temp_root = temp_root("codewhale-custom-provider-persist");
1040 fs::create_dir_all(&temp_root).unwrap();
1041 let _guard = EnvGuard::new(&temp_root);
1042
1043 let path = temp_root.join(".codewhale").join("config.toml");
1044 let written = persist_custom_provider(
1045 Some(&path),
1046 "acme_ai",
1047 "https://api.acme.example/v1/",
1048 Some("acme/code-1"),
1049 Some("ACME_API_KEY"),
1050 )
1051 .expect("custom provider should persist");
1052 let body = fs::read_to_string(&written).expect("written file should be readable");
1053
1054 assert!(body.contains("provider = \"acme_ai\""), "{body}");
1055 assert!(body.contains("[providers.acme_ai]"), "{body}");
1056 assert!(body.contains("kind = \"openai-compatible\""), "{body}");
1057 assert!(
1058 body.contains("base_url = \"https://api.acme.example/v1\""),
1059 "{body}"
1060 );
1061 assert!(body.contains("model = \"acme/code-1\""), "{body}");
1062 assert!(body.contains("api_key_env = \"ACME_API_KEY\""), "{body}");
1063 assert!(
1064 !body.contains("sk-"),
1065 "helper must not persist raw secret values: {body}"
1066 );
1067
1068 let loaded =
1069 crate::config::Config::load(Some(written.clone()), None).expect("config should load");
1070 assert_eq!(loaded.provider.as_deref(), Some("acme_ai"));
1071 assert_eq!(loaded.api_provider(), crate::config::ApiProvider::Custom);
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_tui_integer_key(Some(&path), "scrollback_lines", 4000).unwrap();
1335 persist_table_string_key(Some(&path), "memory", "backend", "sqlite").unwrap();
1336 persist_subagents_bool_key(Some(&path), "enabled", true).unwrap();
1337 persist_provider_base_url_key(
1338 Some(&path),
1339 crate::config::ApiProvider::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("sqlite")
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 error = persist_provider_selection(
1733 Some(&path),
1734 ApiProvider::Custom,
1735 "Missing",
1736 Some("new-model"),
1737 );
1738 assert!(error.is_err());
1739 assert_eq!(
1740 fs::read_to_string(&path).unwrap(),
1741 source,
1742 "failed save must not commit migration separately"
1743 );
1744
1745 persist_provider_selection(
1746 Some(&path),
1747 ApiProvider::Deepseek,
1748 "deepseek",
1749 Some("deepseek-v4-pro"),
1750 )
1751 .unwrap();
1752 let body = fs::read_to_string(&path).unwrap();
1753 let doc: toml::Value = toml::from_str(&body).unwrap();
1754 assert!(body.contains("# keep this comment"));
1755 assert_eq!(doc["route_preferences_version"].as_integer(), Some(1));
1756 assert_eq!(doc["provider"].as_str(), Some("deepseek"));
1757 assert_eq!(
1758 doc["providers"]["deepseek"]["model"].as_str(),
1759 Some("deepseek-v4-pro")
1760 );
1761 assert_eq!(doc["providers"]["zai"]["model"].as_str(), Some("GLM-5.3"));
1762 assert_eq!(
1763 doc["providers"]["TeamA"]["model"].as_str(),
1764 Some("New-Upper")
1765 );
1766 assert_eq!(
1767 doc["providers"]["teama"]["model"].as_str(),
1768 Some("New-Lower")
1769 );
1770 assert_eq!(
1771 doc["providers"]["TeamA"]["base_url"].as_str(),
1772 Some("https://upper.example.test/v1")
1773 );
1774 assert_eq!(
1775 doc["route_preferences_migration"]["previous_models"]["zai"].as_str(),
1776 Some("GLM-5.2")
1777 );
1778 assert_eq!(
1779 fs::read_to_string(home.path().join("settings.toml")).unwrap(),
1780 legacy
1781 );
1782
1783 persist_provider_model_key(Some(&path), ApiProvider::Zai, "zai", "GLM-5.1").unwrap();
1784 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1785 assert_eq!(doc["providers"]["zai"]["model"].as_str(), Some("GLM-5.1"));
1786 assert_eq!(
1787 doc["route_preferences_migration"]["previous_models"]["zai"].as_str(),
1788 Some("GLM-5.2")
1789 );
1790 }
1791
1792 #[test]
1793 fn switching_provider_away_and_back_preserves_every_route_selection() {
1794 use crate::test_support::{EnvVarGuard, lock_test_env};
1795 let _lock = lock_test_env();
1796 let home = tempfile::tempdir().unwrap();
1797 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1798 let _model_guard = ModelEnvGuard::new();
1799 let path = home.path().join("config.toml");
1800 for root_key in ["default_text_model", "model"] {
1801 for existing in [None, Some("GLM-4.5-Air")] {
1802 let mut source = format!(
1803 "route_preferences_version = 1\nprovider = 'zai'\n{root_key} = 'GLM-4.6'\n"
1804 );
1805 if let Some(model) = existing {
1806 source.push_str(&format!("[providers.zai]\nmodel = '{model}'\n"));
1807 }
1808 fs::write(&path, source).unwrap();
1809 persist_provider_selection(
1810 Some(&path),
1811 ApiProvider::Deepseek,
1812 "deepseek",
1813 Some("deepseek-v4-pro"),
1814 )
1815 .unwrap();
1816 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1817 // The incoming route owns its own leaf, so the outgoing root
1818 // alias is inert. It is a saved choice: deleting it to satisfy
1819 // validation of a value nothing resolves would be data loss.
1820 assert_eq!(
1821 doc[root_key].as_str(),
1822 Some("GLM-4.6"),
1823 "an inert root fallback must survive the switch"
1824 );
1825 assert_eq!(
1826 doc["providers"]["deepseek"]["model"].as_str(),
1827 Some("deepseek-v4-pro")
1828 );
1829 let restored = crate::config::Config::load(Some(path.clone()), None)
1830 .expect("saved provider switch must remain loadable");
1831 assert_eq!(restored.api_provider(), ApiProvider::Deepseek);
1832 assert_eq!(restored.default_model(), "deepseek-v4-pro");
1833
1834 // Switching back must find the choice this route had, whether
1835 // it was stored on its own leaf or in the root fallback.
1836 persist_provider_selection(Some(&path), ApiProvider::Zai, "zai", None).unwrap();
1837 let returned = crate::config::Config::load(Some(path.clone()), None)
1838 .expect("switching back must remain loadable");
1839 assert_eq!(returned.api_provider(), ApiProvider::Zai);
1840 assert_eq!(
1841 returned.default_model(),
1842 existing.unwrap_or("GLM-4.6"),
1843 "switching away and back must restore the outgoing selection"
1844 );
1845 }
1846 }
1847 }
1848
1849 #[test]
1850 fn a_bare_switch_relocates_a_root_alias_the_incoming_route_cannot_serve() {
1851 use crate::test_support::{EnvVarGuard, lock_test_env};
1852 let _lock = lock_test_env();
1853 let home = tempfile::tempdir().unwrap();
1854 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1855 let _model_guard = ModelEnvGuard::new();
1856 let path = home.path().join("config.toml");
1857 // No model argument, and the outgoing route keeps its only model in the
1858 // root fallback. Official DeepSeek cannot serve that id, so leaving it
1859 // behind would commit a switch whose config no longer loads.
1860 fs::write(
1861 &path,
1862 "route_preferences_version = 1\nprovider = 'volcengine'\ndefault_text_model = 'ark-private-id'\n",
1863 )
1864 .unwrap();
1865 persist_provider_selection(Some(&path), ApiProvider::Deepseek, "deepseek", None).unwrap();
1866
1867 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1868 assert!(
1869 doc.get("default_text_model").is_none(),
1870 "an alias the incoming route cannot serve must not be left behind"
1871 );
1872 assert_eq!(
1873 doc["providers"]["volcengine"]["model"].as_str(),
1874 Some("ark-private-id"),
1875 "the displaced choice moves onto its own route's leaf, it is not dropped"
1876 );
1877 crate::config::Config::load(Some(path.clone()), None)
1878 .expect("a bare provider switch must remain loadable");
1879
1880 persist_provider_selection(Some(&path), ApiProvider::Volcengine, "volcengine", None)
1881 .unwrap();
1882 let returned = crate::config::Config::load(Some(path.clone()), None)
1883 .expect("switching back must remain loadable");
1884 assert_eq!(returned.api_provider(), ApiProvider::Volcengine);
1885 assert_eq!(returned.default_model(), "ark-private-id");
1886 }
1887
1888 #[test]
1889 fn bare_switch_from_legacy_custom_never_commits_an_unloadable_config() {
1890 use crate::test_support::{EnvVarGuard, lock_test_env};
1891 let _lock = lock_test_env();
1892 let home = tempfile::tempdir().unwrap();
1893 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1894 let _model_guard = ModelEnvGuard::new();
1895 let path = home.path().join("config.toml");
1896 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";
1897 fs::write(&path, original).unwrap();
1898 crate::config::Config::load(Some(path.clone()), None).unwrap();
1899 let error =
1900 persist_provider_selection(Some(&path), ApiProvider::Deepseek, "deepseek", None)
1901 .unwrap_err();
1902 assert!(error.to_string().contains("Choose a model"));
1903 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1904 assert_eq!(
1905 crate::config::Config::load(Some(path.clone()), None)
1906 .unwrap()
1907 .default_model(),
1908 "proxy-wire-id"
1909 );
1910 let error = crate::route_preferences::set(&path, "provider", "deepseek").unwrap_err();
1911 assert!(error.to_string().contains("Choose a model"));
1912 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1913 }
1914
1915 #[test]
1916 fn an_unnamed_custom_route_keeps_its_root_model_across_a_switch() {
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 // An unnamed custom route stores its model in the root alias itself, so
1924 // there is no leaf to relocate it to. Dropping it would destroy the
1925 // only copy of the user's choice.
1926 fs::write(
1927 &path,
1928 "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://proxy.example.test/v1'\ndefault_text_model = 'proxy-wire-id'\n",
1929 )
1930 .unwrap();
1931 persist_provider_selection(
1932 Some(&path),
1933 ApiProvider::Deepseek,
1934 "deepseek",
1935 Some("deepseek-v4-pro"),
1936 )
1937 .unwrap();
1938 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1939 assert_eq!(
1940 doc["default_text_model"].as_str(),
1941 Some("proxy-wire-id"),
1942 "the custom route's only saved model must survive the switch"
1943 );
1944 let restored = crate::config::Config::load(Some(path.clone()), None)
1945 .expect("the switched config must remain loadable");
1946 assert_eq!(restored.default_model(), "deepseek-v4-pro");
1947
1948 // Nothing can re-select an unnamed custom route by identity once the
1949 // selector names another provider, so restoring it is a `provider`
1950 // edit. The model has to still be there when it happens.
1951 let returning = fs::read_to_string(&path)
1952 .unwrap()
1953 .replace("provider = 'deepseek'", "provider = 'custom'")
1954 .replace("provider = \"deepseek\"", "provider = 'custom'");
1955 fs::write(&path, returning).unwrap();
1956 let returned = crate::config::Config::load(Some(path.clone()), None)
1957 .expect("returning to the custom route must remain loadable");
1958 assert_eq!(returned.default_model(), "proxy-wire-id");
1959 }
1960
1961 #[test]
1962 fn canonical_model_writer_keeps_legacy_custom_shape_and_exact_named_ids() {
1963 use crate::test_support::{EnvVarGuard, lock_test_env};
1964 let _lock = lock_test_env();
1965 let home = tempfile::tempdir().unwrap();
1966 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1967 let path = home.path().join("config.toml");
1968 fs::write(&path, "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://legacy.example.test/v1'\ndefault_text_model = 'old-wire-id'\n").unwrap();
1969 persist_provider_selection(
1970 Some(&path),
1971 ApiProvider::Custom,
1972 "custom",
1973 Some("Exact-New-ID"),
1974 )
1975 .unwrap();
1976 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1977 assert_eq!(doc["default_text_model"].as_str(), Some("Exact-New-ID"));
1978 assert!(doc.get("providers").is_none());
1979
1980 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();
1981 persist_provider_selection(
1982 Some(&path),
1983 ApiProvider::Custom,
1984 "Team.A",
1985 Some("Exact-Named-ID"),
1986 )
1987 .unwrap();
1988 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1989 assert_eq!(
1990 doc["providers"]["Team.A"]["model"].as_str(),
1991 Some("Exact-Named-ID")
1992 );
1993 assert!(
1994 persist_provider_selection(Some(&path), ApiProvider::Custom, "team.a", Some("wrong"))
1995 .is_err()
1996 );
1997 persist_provider_model_key(
1998 Some(&path),
1999 ApiProvider::DeepseekCN,
2000 "deepseek-cn",
2001 "deepseek-v4-pro",
2002 )
2003 .unwrap();
2004 let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2005 assert_eq!(
2006 doc["providers"]["deepseek_cn"]["model"].as_str(),
2007 Some("deepseek-v4-pro")
2008 );
2009 }
2010
2011 #[test]
2012 fn malformed_legacy_preferences_do_not_partially_commit_a_route_save() {
2013 use crate::test_support::{EnvVarGuard, lock_test_env};
2014 let _lock = lock_test_env();
2015 let home = tempfile::tempdir().unwrap();
2016 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2017 let path = home.path().join("config.toml");
2018 let source = "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n";
2019 fs::write(&path, source).unwrap();
2020 let settings_path = home.path().join("settings.toml");
2021 let malformed = "default_provider = [\n";
2022 fs::write(&settings_path, malformed).unwrap();
2023 let error =
2024 persist_provider_selection(Some(&path), ApiProvider::Zai, "zai", Some("GLM-5.3"))
2025 .expect_err("unreadable legacy preferences must block the entire save");
2026 assert!(error.to_string().contains("configuration was not changed"));
2027 assert_eq!(fs::read_to_string(&path).unwrap(), source);
2028 assert_eq!(fs::read_to_string(&settings_path).unwrap(), malformed);
2029 }
2030 }
2031
2031 lines RUST