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