返回 CodeWhale
route_preferences.rs
根目录 / crates / tui / src / route_preferences.rs
1 //! Durable CLI route edits use the same Config owner as Runtime and the TUI.
2 //! `model` and the legacy `default_text_model` address the saved active route;
3 //! `default_model` addresses DeepSeek (CN while that route is active). Project root keys retain their scope.
4
5 use std::path::Path;
6
7 use anyhow::{Context, Result, ensure};
8
9 use crate::config::{ApiProvider, Config, ProviderIdentity};
10 use crate::config_persistence as persistence;
11
12 /// Whether a config key names a provider or model selection.
13 pub fn is_route_key(key: &str) -> bool {
14 matches!(
15 key,
16 "provider" | "model" | "default_model" | "default_text_model"
17 ) || provider_model_id(key).is_some()
18 }
19
20 fn provider_model_id(key: &str) -> Option<&str> {
21 key.strip_prefix("providers.")?
22 .strip_suffix(".model")
23 .filter(|id| !id.is_empty())
24 }
25
26 fn parse_config(body: &str) -> Result<Config> {
27 toml::from_str(body)
28 .map_err(|_| anyhow::anyhow!("Could not parse route configuration; contents omitted"))
29 }
30
31 fn model_identity(config: &Config, key: &str) -> Result<ProviderIdentity> {
32 if key == "default_model" {
33 let provider = if config.api_provider() == ApiProvider::DeepseekCN {
34 ApiProvider::DeepseekCN
35 } else {
36 ApiProvider::Deepseek
37 };
38 config.resolve_provider_pin_identity(provider.as_str())
39 } else if let Some(id) = provider_model_id(key) {
40 // Leaf keys name TOML tables, whose canonical spelling can differ
41 // from the public provider selector. Exact custom tables still win.
42 let selector = if config
43 .providers
44 .as_ref()
45 .and_then(|providers| providers.custom_provider_config(id))
46 .is_some()
47 {
48 id
49 } else if id == "deepseek_cn" {
50 ApiProvider::DeepseekCN.as_str()
51 } else {
52 ApiProvider::all()
53 .iter()
54 .find(|provider| {
55 provider
56 .metadata()
57 .is_some_and(|metadata| metadata.provider_config_key() == id)
58 })
59 .map_or(id, |provider| provider.as_str())
60 };
61 config.resolve_provider_pin_identity(selector)
62 } else {
63 config.active_provider_identity(config.api_provider())
64 }
65 .map_err(anyhow::Error::msg)
66 }
67
68 fn model_slot(identity: &ProviderIdentity) -> Result<Vec<&str>> {
69 if identity.provider == ApiProvider::Custom && identity.persisted_id().is_none() {
70 return Ok(vec!["default_text_model"]);
71 }
72 let key = match identity.provider {
73 ApiProvider::Custom => identity.key.as_str(),
74 ApiProvider::DeepseekCN => "deepseek_cn",
75 ApiProvider::OllamaCloud if identity.migrated_legacy_ollama_cloud_route => "ollama",
76 provider => provider
77 .metadata()
78 .context("provider model table")?
79 .provider_config_key(),
80 };
81 Ok(vec!["providers", key, "model"])
82 }
83
84 /// Locate the canonical model leaf in a saved document without applying
85 /// device preferences or launch overrides. Export uses this same identity
86 /// resolution to omit only root aliases shadowed by that leaf.
87 pub fn model_slot_for_document(body: &str, key: &str) -> Result<Vec<String>> {
88 ensure!(
89 is_route_key(key) && key != "provider",
90 "Not a model preference key: {key}"
91 );
92 let config = parse_config(body)?;
93 let identity = model_identity(&config, key)?;
94 Ok(model_slot(&identity)?
95 .into_iter()
96 .map(str::to_string)
97 .collect())
98 }
99
100 fn document_slot_value(document: &toml::value::Table, slot: &[String]) -> Option<String> {
101 let [root, provider, field] = slot else {
102 return None;
103 };
104 document
105 .get(root)?
106 .as_table()?
107 .get(provider)?
108 .as_table()?
109 .get(field)?
110 .as_str()
111 .map(str::trim)
112 .filter(|model| !model.is_empty())
113 .map(str::to_string)
114 }
115
116 fn insert_document_slot_value(
117 document: &mut toml::value::Table,
118 slot: &[String],
119 value: &str,
120 ) -> bool {
121 let [root, provider, field] = slot else {
122 return false;
123 };
124 let Some(providers) = document
125 .entry(root.clone())
126 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
127 .as_table_mut()
128 else {
129 return false;
130 };
131 let Some(entry) = providers
132 .entry(provider.clone())
133 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
134 .as_table_mut()
135 else {
136 return false;
137 };
138 entry.insert(field.clone(), toml::Value::String(value.to_string()));
139 true
140 }
141
142 /// Scrub root model aliases from a serialized config document for export.
143 ///
144 /// Root `model`/`default_text_model` address the *active* route on import and
145 /// `default_model` addresses DeepSeek (CN when that route is active), so a raw root alias exported next to
146 /// the canonical `[providers.<id>].model` leaf fails the importer's replay
147 /// check. This rewrites `document` in place:
148 ///
149 /// - A root alias the active route still consumes is shadowed state once the
150 /// route's canonical leaf is exported; it is dropped.
151 /// - A root alias the active route ignores but DeepSeek recognizes is
152 /// DeepSeek's saved fallback; it moves to `providers.deepseek.model` unless
153 /// that slot already carries a value. Any other unrecognized value is dead
154 /// state that would only conflict on import and is dropped.
155 /// - `default_model` folds into the selected DeepSeek region's model slot when
156 /// nothing live occupies that slot, and is dropped otherwise.
157 ///
158 /// Only route-selection keys are parsed as `Config` here. Export documents
159 /// intentionally preserve unknown or differently typed local-authority
160 /// extras, and reparsing them merely to locate the model slot would fail the
161 /// whole export.
162 pub fn scrub_root_model_aliases_for_export(document: &mut toml::value::Table) -> Result<()> {
163 let mut scratch = toml::value::Table::new();
164 for key in [
165 "provider",
166 "model",
167 "default_text_model",
168 "defaultTextModel",
169 "base_url",
170 "baseUrl",
171 "providers",
172 ] {
173 if let Some(value) = document.get(key) {
174 scratch.insert(key.to_string(), value.clone());
175 }
176 }
177 let body = toml::to_string(&toml::Value::Table(scratch))
178 .context("serializing route selection for export")?;
179 // Slot resolution reuses the exact document identity rules; the extra
180 // parse below only exists so the ownership test can scope a Config clone.
181 let active_slot = model_slot_for_document(&body, "model")?;
182 let deepseek_slot = model_slot_for_document(&body, "default_model")?;
183 let config = parse_config(&body)?;
184 let identity = model_identity(&config, "model")?;
185
186 if document_slot_value(document, &active_slot).is_some() {
187 for root_key in ["model", "default_text_model"] {
188 let Some(value) = document
189 .get(root_key)
190 .and_then(toml::Value::as_str)
191 .map(str::to_owned)
192 else {
193 continue;
194 };
195 // Same ownership test as `unset`: scope to the active route with
196 // its canonical leaf cleared and ask whether this root value is
197 // what the route would then resolve. A foreign DeepSeek root
198 // ignored by the active vendor remains DeepSeek's fallback.
199 let mut scoped = config.clone();
200 scoped.scope_to_provider_identity(&identity);
201 scoped.set_provider_model_override(identity.provider, None);
202 scoped.legacy_model = None;
203 scoped.default_text_model = Some(value.to_string());
204 let wire_model = crate::config::wire_model_for_provider_route(
205 identity.provider,
206 &scoped.active_route_base_url(),
207 &value,
208 );
209 if scoped.default_model() == wire_model {
210 document.remove(root_key);
211 continue;
212 }
213 if crate::config::normalize_model_name(&value).is_none() {
214 document.remove(root_key);
215 continue;
216 }
217 if document_slot_value(document, &deepseek_slot).is_some() {
218 document.remove(root_key);
219 continue;
220 }
221 ensure!(
222 insert_document_slot_value(document, &deepseek_slot, &value),
223 "Cannot export a root model alias into a non-table provider slot"
224 );
225 document.remove(root_key);
226 }
227 }
228
229 match document.get("default_model").and_then(toml::Value::as_str) {
230 Some(value) => {
231 let value = value.trim().to_string();
232 // A root alias the DeepSeek route still consumes lands in this
233 // same slot on import; the live choice wins over the dead alias.
234 let root_covers_slot = active_slot == deepseek_slot
235 && ["model", "default_text_model"]
236 .iter()
237 .any(|key| document.get(*key).and_then(toml::Value::as_str).is_some());
238 let folded = !value.is_empty()
239 && !root_covers_slot
240 && document_slot_value(document, &deepseek_slot).is_none();
241 if folded {
242 ensure!(
243 insert_document_slot_value(document, &deepseek_slot, &value),
244 "Cannot export default_model into a non-table provider slot"
245 );
246 }
247 document.remove("default_model");
248 }
249 None => {
250 ensure!(
251 !document.contains_key("default_model"),
252 "Cannot export a non-string default_model without losing its value"
253 );
254 }
255 }
256 Ok(())
257 }
258
259 fn project_root_key<'a>(path: &Path, key: &'a str) -> Option<&'a str> {
260 (codewhale_config::config_path_is_workspace_scoped(path)
261 && matches!(key, "model" | "default_text_model"))
262 .then_some(key)
263 }
264
265 fn saved_config(store: &codewhale_config::ConfigStore) -> Result<Config> {
266 let rendered;
267 let body = if let Some(original) = store.original_body() {
268 original
269 } else {
270 rendered = store.rendered_body()?;
271 &rendered
272 };
273 let mut config = parse_config(body)?;
274 if config.route_preferences_version.is_none()
275 && crate::config::is_home_config_path(store.path())
276 {
277 config.apply_saved_selection(
278 &crate::settings::Settings::load_legacy_route_preferences_read_only()?,
279 );
280 }
281 Ok(config)
282 }
283
284 /// Read the saved route without applying launch overrides or credentials.
285 pub fn get(path: &Path, key: &str) -> Result<Option<String>> {
286 ensure!(is_route_key(key), "Not a route preference key: {key}");
287 let store = codewhale_config::ConfigStore::load(Some(path.to_path_buf()))?;
288 if let Some(key) = project_root_key(store.path(), key) {
289 return Ok(store.config.get_value(key));
290 }
291 let mut config = saved_config(&store)?;
292 if key == "provider" {
293 return Ok(Some(
294 config.provider.unwrap_or_else(|| "deepseek".to_string()),
295 ));
296 }
297 let identity = model_identity(&config, key)?;
298 config.scope_to_provider_identity(&identity);
299 Ok(config
300 .provider_config_for(identity.provider)
301 .and_then(|entry| entry.model.clone())
302 .or_else(|| {
303 (provider_model_id(key).is_none()
304 && (config.default_text_model.is_some() || config.legacy_model.is_some()))
305 .then(|| config.default_model())
306 }))
307 }
308
309 /// One saved snapshot for CLI route reports, including exact legacy identities.
310 /// The source distinguishes an explicit model from the provider default.
311 pub fn selected_route(path: &Path) -> Result<(String, String, codewhale_config::ModelSource)> {
312 let store = codewhale_config::ConfigStore::load(Some(path.to_path_buf()))?;
313 let config = saved_config(&store)?;
314 let identity = config
315 .active_provider_identity(config.api_provider())
316 .map_err(anyhow::Error::msg)?;
317 let provider = identity.key;
318 let source = if config
319 .provider_config_for(identity.provider)
320 .and_then(|entry| entry.model.as_ref())
321 .is_some()
322 {
323 codewhale_config::ModelSource::ProviderConfig
324 } else if config.default_text_model.is_some() || config.legacy_model.is_some() {
325 if store.config.default_text_model.is_none() && store.config.model.is_some() {
326 codewhale_config::ModelSource::RootModel
327 } else {
328 codewhale_config::ModelSource::RootDefaultTextModel
329 }
330 } else {
331 codewhale_config::ModelSource::ProviderDefault
332 };
333 Ok((provider, config.default_model(), source))
334 }
335
336 /// Save one explicit route preference after atomically adopting legacy choices.
337 pub fn set(path: &Path, key: &str, value: &str) -> Result<()> {
338 persistence::mutate_config_document(path, |doc| set_document(path, doc, key, value))
339 }
340
341 /// Prepare a validated snapshot for a caller's preview and atomic save.
342 /// Migration changes only this document; this function never writes a file.
343 pub fn prepare_document(path: &Path, raw: &str) -> Result<toml_edit::DocumentMut> {
344 let mut doc = raw
345 .parse::<toml_edit::DocumentMut>()
346 .map_err(|_| anyhow::anyhow!("Could not parse route configuration; contents omitted"))?;
347 parse_config(raw)?;
348 persistence::migrate_legacy_route_preferences(path, &mut doc)?;
349 Ok(doc)
350 }
351
352 /// Edit one route selection in an already-prepared candidate without saving.
353 /// Callers must prepare migration first and atomically save the final snapshot.
354 pub fn set_document(
355 path: &Path,
356 doc: &mut toml_edit::DocumentMut,
357 key: &str,
358 value: &str,
359 ) -> Result<()> {
360 ensure!(is_route_key(key), "Not a route preference key: {key}");
361 let value = value.trim();
362 ensure!(
363 !value.is_empty() && !value.chars().any(char::is_control),
364 "Route preference must be nonempty and contain no control characters"
365 );
366 if let Some(key) = project_root_key(path, key) {
367 return persistence::set_document_value(doc, &[key], value);
368 }
369 let config = parse_config(&doc.to_string())?;
370 if key == "provider" {
371 let identity = config
372 .resolve_provider_pin_identity(value)
373 .map_err(anyhow::Error::msg)?;
374 persistence::set_document_value(
375 doc,
376 &["provider"],
377 identity.persisted_id().unwrap_or(&identity.key),
378 )?;
379 // Same root-alias authority as the Runtime/TUI provider writer: a CLI
380 // switch must not leave the incoming route holding the outgoing one's
381 // fallback, and must not delete a choice to get there.
382 return persistence::reconcile_root_model_aliases(doc, &config, &identity);
383 }
384 let identity = model_identity(&config, key)?;
385 persistence::set_provider_model_document(
386 doc,
387 identity.provider,
388 identity.persisted_id().unwrap_or(&identity.key),
389 value,
390 )
391 }
392
393 /// Clear a canonical selection without allowing archived Settings to restore it.
394 pub fn unset(path: &Path, key: &str) -> Result<()> {
395 ensure!(is_route_key(key), "Not a route preference key: {key}");
396 persistence::mutate_config_document(path, |doc| {
397 if key == "provider" || project_root_key(path, key).is_some() {
398 persistence::unset_document_value(doc, &[key])?;
399 return Ok(());
400 }
401 let config = parse_config(&doc.to_string())?;
402 let identity = model_identity(&config, key)?;
403 persistence::unset_document_value(doc, &model_slot(&identity)?)?;
404 // Clear relevant legacy fallbacks as well, or deleting the canonical
405 // leaf would restore an older choice on reload. Root fields belong to
406 // the active route, with DeepSeek's historical default as an exception.
407 if matches!(
408 identity.provider,
409 ApiProvider::Deepseek | ApiProvider::DeepseekCN
410 ) || config
411 .active_provider_identity(config.api_provider())
412 .is_ok_and(|active| active == identity)
413 {
414 let mut scoped = config.clone();
415 scoped.scope_to_provider_identity(&identity);
416 scoped.set_provider_model_override(identity.provider, None);
417 scoped.legacy_model = None;
418 for root_key in ["default_text_model", "model"] {
419 let Some(model) = doc.get(root_key).and_then(toml_edit::Item::as_str) else {
420 continue;
421 };
422 scoped.default_text_model = Some(model.to_string());
423 let wire_model = crate::config::wire_model_for_provider_route(
424 identity.provider,
425 &scoped.active_route_base_url(),
426 model,
427 );
428 // Reuse Config's root-model guards: a foreign DeepSeek root
429 // ignored by the active vendor remains that provider's fallback.
430 if scoped.default_model() == wire_model {
431 persistence::unset_document_value(doc, &[root_key])?;
432 }
433 }
434 }
435 Ok(())
436 })
437 }
438
439 #[cfg(test)]
440 mod tests {
441 use super::*;
442 use crate::test_support::{EnvVarGuard, lock_test_env};
443
444 fn document(path: &Path) -> toml::Value {
445 toml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
446 }
447
448 #[test]
449 fn route_edits_adopt_legacy_selection_once_and_preserve_settings() -> Result<()> {
450 let _env = lock_test_env();
451 let home = tempfile::tempdir()?;
452 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
453 let _path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
454 let _legacy_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
455 let path = home.path().join("config.toml");
456 std::fs::write(
457 &path,
458 "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n[providers.zai]\nmodel = \"GLM-5.2\"\n",
459 )?;
460 let settings_path = home.path().join("settings.toml");
461 let settings = "default_provider = \"zai\"\n[provider_models]\nzai = \"GLM-5.3\"\n";
462 std::fs::write(&settings_path, settings)?;
463 let before = std::fs::read(&path)?;
464 assert_eq!(get(&path, "provider")?.as_deref(), Some("zai"));
465 assert_eq!(get(&path, "model")?.as_deref(), Some("GLM-5.3"));
466 assert_eq!(std::fs::read(&path)?, before);
467
468 let mut candidate = prepare_document(&path, &std::fs::read_to_string(&path)?)?;
469 set_document(&path, &mut candidate, "model", "GLM-5.2")?;
470 assert_eq!(std::fs::read(&path)?, before);
471 set(&path, "model", "GLM-5.2")?;
472 assert_eq!(
473 document(&path),
474 toml::from_str::<toml::Value>(&candidate.to_string())?
475 );
476 assert_eq!(
477 document(&path)["route_preferences_version"].as_integer(),
478 Some(1)
479 );
480 assert_eq!(
481 get(&path, "default_text_model")?.as_deref(),
482 Some("GLM-5.2")
483 );
484 assert_eq!(
485 get(&path, "providers.zai.model")?.as_deref(),
486 Some("GLM-5.2")
487 );
488 set(&path, "default_model", "deepseek-v4-flash")?;
489 assert_eq!(
490 get(&path, "default_model")?.as_deref(),
491 Some("deepseek-v4-flash")
492 );
493 set(&path, "provider", "deepseek")?;
494 assert_eq!(get(&path, "model")?.as_deref(), Some("deepseek-v4-flash"));
495 unset(&path, "providers.deepseek.model")?;
496 assert!(get(&path, "default_model")?.is_none());
497 unset(&path, "providers.zai.model")?;
498 assert!(get(&path, "providers.zai.model")?.is_none());
499 unset(&path, "provider")?;
500 assert_eq!(get(&path, "provider")?.as_deref(), Some("deepseek"));
501 assert_eq!(std::fs::read_to_string(settings_path)?, settings);
502 // An unrelated typed store write must preserve the migration receipt.
503 let mut store = codewhale_config::ConfigStore::load(Some(path.clone()))?;
504 store.config.set_value("verbosity", "quiet")?;
505 store.save()?;
506 assert_eq!(
507 document(&path)["route_preferences_version"].as_integer(),
508 Some(1)
509 );
510 Ok(())
511 }
512
513 #[test]
514 fn route_edits_keep_exact_named_provider_keys_and_reject_unknown_routes() -> Result<()> {
515 let _env = lock_test_env();
516 let home = tempfile::tempdir()?;
517 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
518 let _path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
519 let _legacy_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
520 let path = home.path().join("config.toml");
521 std::fs::write(
522 &path,
523 r#"provider = "Team.A"
524 [providers."Team.A"]
525 kind = "openai-compatible"
526 base_url = "http://127.0.0.1:9/v1"
527 model = "Model-X"
528 [providers."team.a"]
529 kind = "openai-compatible"
530 base_url = "http://127.0.0.1:10/v1"
531 model = "Other-X"
532 "#,
533 )?;
534 set(&path, "providers.Team.A.model", "Model-Y")?;
535 assert_eq!(get(&path, "model")?.as_deref(), Some("Model-Y"));
536 assert_eq!(
537 get(&path, "providers.team.a.model")?.as_deref(),
538 Some("Other-X")
539 );
540 let before = std::fs::read(&path)?;
541 assert!(set(&path, "providers.TEAM.A.model", "Model-Z").is_err());
542 assert!(set(&path, "provider", "unconfigured-route").is_err());
543 assert_eq!(std::fs::read(&path)?, before);
544 unset(&path, "providers.Team.A.model")?;
545 assert!(get(&path, "providers.Team.A.model")?.is_none());
546 assert_eq!(
547 document(&path)["providers"]["team.a"]["model"].as_str(),
548 Some("Other-X")
549 );
550 Ok(())
551 }
552
553 #[test]
554 fn cli_provider_edits_share_the_runtime_writer_root_alias_authority() -> Result<()> {
555 let _env = lock_test_env();
556 let home = tempfile::tempdir()?;
557 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
558 let _path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
559 let _legacy_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
560 let _cloud = EnvVarGuard::set("CODEWHALE_DISABLE_CLOUD_FACTS", "1");
561 let _overrides: Vec<_> = [
562 "CODEWHALE_MODEL",
563 "DEEPSEEK_MODEL",
564 "DEEPSEEK_DEFAULT_TEXT_MODEL",
565 "CODEWHALE_PROVIDER",
566 "DEEPSEEK_PROVIDER",
567 "CODEWHALE_BASE_URL",
568 "DEEPSEEK_BASE_URL",
569 "CODEWHALE_PROFILE",
570 "DEEPSEEK_PROFILE",
571 "ZAI_MODEL",
572 "ZAI_BASE_URL",
573 ]
574 .into_iter()
575 .map(EnvVarGuard::remove)
576 .collect();
577 let path = home.path().join("config.toml");
578
579 // The incoming route owns its own leaf, so the outgoing root fallback
580 // is inert saved state. A CLI switch must not delete it, and switching
581 // back must still find it.
582 std::fs::write(
583 &path,
584 "route_preferences_version = 1\nprovider = \"zai\"\ndefault_text_model = \"GLM-4.6\"\n[providers.deepseek]\nmodel = \"deepseek-v4-pro\"\n",
585 )?;
586 set(&path, "provider", "deepseek")?;
587 assert_eq!(
588 document(&path)["default_text_model"].as_str(),
589 Some("GLM-4.6")
590 );
591 let switched = Config::load(Some(path.clone()), None)
592 .expect("a CLI provider switch must remain loadable");
593 assert_eq!(switched.api_provider(), ApiProvider::Deepseek);
594 assert_eq!(switched.default_model(), "deepseek-v4-pro");
595 set(&path, "provider", "zai")?;
596 assert_eq!(
597 Config::load(Some(path.clone()), None)
598 .expect("switching back must remain loadable")
599 .default_model(),
600 "GLM-4.6"
601 );
602
603 // With no leaf on the incoming route the alias is what `Config::load`
604 // rejects. Move it to the route that owns it rather than drop it.
605 std::fs::write(
606 &path,
607 "route_preferences_version = 1\nprovider = \"volcengine\"\ndefault_text_model = \"ark-private-id\"\n",
608 )?;
609 set(&path, "provider", "deepseek")?;
610 let doc = document(&path);
611 assert!(doc.get("default_text_model").is_none());
612 assert_eq!(
613 doc["providers"]["volcengine"]["model"].as_str(),
614 Some("ark-private-id")
615 );
616 Config::load(Some(path.clone()), None)
617 .expect("a CLI switch must not commit an unloadable config");
618 set(&path, "provider", "volcengine")?;
619 assert_eq!(
620 Config::load(Some(path), None)
621 .expect("switching back must remain loadable")
622 .default_model(),
623 "ark-private-id"
624 );
625 Ok(())
626 }
627
628 #[test]
629 fn project_model_edits_keep_root_fields_and_skip_device_migration() -> Result<()> {
630 let _env = lock_test_env();
631 let root = tempfile::tempdir()?;
632 let home = root.path().join("home");
633 std::fs::create_dir_all(&home)?;
634 let _home = EnvVarGuard::set("CODEWHALE_HOME", &home);
635 let _path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
636 let _legacy_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
637 std::fs::write(home.join("settings.toml"), "default_provider = \"zai\"\n")?;
638 let project = root.path().join("project/.codewhale");
639 std::fs::create_dir_all(&project)?;
640 // An absolute config outside the process workspace is project-scoped
641 // only when its parent is a checkout, not merely named `.codewhale`.
642 std::fs::create_dir(root.path().join("project/.git"))?;
643 let path = project.join("config.toml");
644 std::fs::write(&path, "model = \"project-old\"\n")?;
645 set(&path, "model", "project-new")?;
646 assert_eq!(get(&path, "model")?.as_deref(), Some("project-new"));
647 assert!(document(&path).get("providers").is_none());
648 assert!(document(&path).get("route_preferences_version").is_none());
649 unset(&path, "model")?;
650 assert!(document(&path).get("model").is_none());
651 Ok(())
652 }
653
654 #[test]
655 fn saved_routes_preserve_regional_and_legacy_table_identity() -> Result<()> {
656 let _env = lock_test_env();
657 let home = tempfile::tempdir()?;
658 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
659 let _path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
660 let _legacy_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
661 let _cloud = EnvVarGuard::set("CODEWHALE_DISABLE_CLOUD_FACTS", "1");
662 let _overrides: Vec<_> = [
663 "CODEWHALE_MODEL",
664 "DEEPSEEK_MODEL",
665 "DEEPSEEK_DEFAULT_TEXT_MODEL",
666 "CODEWHALE_PROVIDER",
667 "DEEPSEEK_PROVIDER",
668 "CODEWHALE_BASE_URL",
669 "DEEPSEEK_BASE_URL",
670 "CODEWHALE_PROFILE",
671 "DEEPSEEK_PROFILE",
672 "ZAI_MODEL",
673 "ZAI_BASE_URL",
674 "OLLAMA_MODEL",
675 "OLLAMA_CLOUD_MODEL",
676 "OLLAMA_BASE_URL",
677 "OLLAMA_CLOUD_BASE_URL",
678 ]
679 .into_iter()
680 .map(EnvVarGuard::remove)
681 .collect();
682 let path = home.path().join("config.toml");
683 for (provider, table, endpoint, model) in [
684 (
685 "deepseek-cn",
686 "deepseek_cn",
687 "https://api.deepseek.cn",
688 "deepseek-v4-flash",
689 ),
690 (
691 "ollama",
692 "ollama",
693 "https://ollama.com/v1",
694 "saved-cloud-model",
695 ),
696 ] {
697 std::fs::write(
698 &path,
699 format!(
700 "route_preferences_version = 1\nprovider = '{provider}'\n[providers.{table}]\nbase_url = '{endpoint}'\n"
701 ),
702 )?;
703 set(&path, "model", model)?;
704 assert_eq!(document(&path)["provider"].as_str(), Some(provider));
705 assert_eq!(
706 document(&path)["providers"][table]["model"].as_str(),
707 Some(model)
708 );
709 assert_eq!(get(&path, "model")?.as_deref(), Some(model));
710 assert_eq!(
711 selected_route(&path)?,
712 (
713 if provider == "ollama" {
714 "ollama-cloud"
715 } else {
716 provider
717 }
718 .to_string(),
719 model.to_string(),
720 codewhale_config::ModelSource::ProviderConfig
721 )
722 );
723 unset(&path, "model")?;
724 assert!(document(&path)["providers"][table].get("model").is_none());
725 let leaf = format!("providers.{table}.model");
726 set(&path, &leaf, model)?;
727 assert_eq!(get(&path, &leaf)?.as_deref(), Some(model));
728 assert_eq!(
729 Config::load(Some(path.clone()), None)?.default_model(),
730 model
731 );
732 assert_eq!(document(&path)["provider"].as_str(), Some(provider));
733 unset(&path, &leaf)?;
734 assert!(get(&path, &leaf)?.is_none());
735 assert!(document(&path)["providers"][table].get("model").is_none());
736 }
737
738 std::fs::write(
739 &path,
740 "route_preferences_version = 1\nprovider = 'zai'\nmodel = 'GLM-5.3'\n",
741 )?;
742 assert_eq!(get(&path, "model")?.as_deref(), Some("GLM-5.3"));
743 assert_eq!(
744 Config::load(Some(path.clone()), None)?.default_model(),
745 "GLM-5.3"
746 );
747 assert_eq!(
748 selected_route(&path)?.2,
749 codewhale_config::ModelSource::RootModel
750 );
751 // A foreign active-route root must never become the DeepSeek default.
752 assert_eq!(
753 get(&path, "default_model")?.as_deref(),
754 Some(crate::config::DEFAULT_TEXT_MODEL)
755 );
756 unset(&path, "providers.zai.model")?;
757 assert!(document(&path).get("model").is_none());
758 assert_eq!(
759 Config::load(Some(path.clone()), None)?.default_model(),
760 selected_route(&path)?.1
761 );
762
763 for (provider, root, leaf) in [
764 (
765 "deepseek",
766 "default_text_model = 'deepseek-v4-flash'\nmodel = 'deepseek-v4-flash-vision-exp'",
767 "deepseek-v4-pro",
768 ),
769 (
770 "zai",
771 "default_text_model = 'GLM-5.1'\nmodel = 'GLM-5.2'",
772 "GLM-5.3",
773 ),
774 ] {
775 std::fs::write(
776 &path,
777 format!(
778 "route_preferences_version = 1\nprovider = '{provider}'\n{root}\n[providers.{provider}]\nmodel = '{leaf}'\n"
779 ),
780 )?;
781 assert_eq!(
782 Config::load(Some(path.clone()), None)?.default_model(),
783 leaf
784 );
785 unset(&path, &format!("providers.{provider}.model"))?;
786 let doc = document(&path);
787 assert!(doc.get("model").is_none());
788 assert!(doc.get("default_text_model").is_none());
789 assert!(doc["providers"][provider].get("model").is_none());
790 let loaded = Config::load(Some(path.clone()), None)?;
791 assert_eq!(loaded.default_model(), selected_route(&path)?.1);
792 assert!(loaded.legacy_model.is_none());
793 }
794
795 // Clearing Z.ai cannot erase the independent DeepSeek root fallback.
796 std::fs::write(
797 &path,
798 "route_preferences_version = 1\nprovider = 'zai'\ndefault_text_model = 'deepseek-v4-flash'\nmodel = 'GLM-5.1'\n[providers.zai]\nmodel = 'GLM-5.2'\n",
799 )?;
800 unset(&path, "providers.zai.model")?;
801 assert_eq!(
802 document(&path)["default_text_model"].as_str(),
803 Some("deepseek-v4-flash")
804 );
805 assert!(document(&path).get("model").is_none());
806 let loaded = Config::load(Some(path.clone()), None)?;
807 assert_ne!(loaded.default_model(), "GLM-5.1");
808 assert_eq!(loaded.default_model(), selected_route(&path)?.1);
809 Ok(())
810 }
811 }
812
812 lines RUST