返回 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, mutate)
28 }
29
30 /// Atomically replace `path` with `body` via a same-directory temp file and
31 /// rename. On Unix the file lands with 0o600 permissions: config.toml can
32 /// hold API keys, so this matches `ConfigStore::save` and the auth save path.
33 pub(crate) fn write_config_toml_atomic(path: &Path, body: &str) -> anyhow::Result<()> {
34 codewhale_config::create_config_document(path, body)
35 }
36
37 /// Set the value at `segments` (parent tables plus the final key), creating
38 /// missing intermediate tables. Replacing an existing value keeps its decor,
39 /// so comments above the key and trailing same-line comments survive.
40 ///
41 /// Segments are separate strings rather than one dotted key, so table names
42 /// that need quoting (`[providers."my.provider"]`) resolve correctly.
43 pub(crate) fn set_document_value(
44 doc: &mut toml_edit::DocumentMut,
45 segments: &[&str],
46 value: impl Into<toml_edit::Value>,
47 ) -> anyhow::Result<()> {
48 codewhale_config::set_config_document_value(doc, segments, value)
49 }
50
51 /// Remove the value at `segments`. Returns `Ok(true)` when an entry was
52 /// removed; missing keys and missing (or non-table) parents are a no-op.
53 pub(crate) fn unset_document_value(
54 doc: &mut toml_edit::DocumentMut,
55 segments: &[&str],
56 ) -> anyhow::Result<bool> {
57 codewhale_config::unset_config_document_value(doc, segments)
58 }
59
60 /// Remove every entry named `key` from `table` and, recursively, from nested
61 /// tables, inline tables, and arrays of tables. Used by `/logout` to strip
62 /// `api_key` everywhere without disturbing keys like `api_key_env`.
63 pub(crate) fn remove_document_key_recursive(table: &mut dyn toml_edit::TableLike, key: &str) {
64 remove_key_preserving_leading_decor(table, key);
65 for (_, item) in table.iter_mut() {
66 if let toml_edit::Item::ArrayOfTables(tables) = item {
67 for nested in tables.iter_mut() {
68 remove_document_key_recursive(nested, key);
69 }
70 } else if let Some(nested) = item.as_table_like_mut() {
71 remove_document_key_recursive(nested, key);
72 }
73 }
74 }
75
76 fn remove_key_preserving_leading_decor(table: &mut dyn toml_edit::TableLike, key: &str) -> bool {
77 let mut found = false;
78 let next_key = table.iter().find_map(|(candidate, _)| {
79 if found {
80 Some(candidate.to_owned())
81 } else {
82 found = candidate == key;
83 None
84 }
85 });
86 let leading_prefix = leading_prefix_for_key(table, key);
87 if table.remove(key).is_none() {
88 return false;
89 }
90 let Some(prefix) = leading_prefix else {
91 return true;
92 };
93 let Some(next_key) = next_key else {
94 return true;
95 };
96 if prefix.as_str() == Some("") {
97 return true;
98 }
99 if let Some(mut next_key_decor) = table.key_mut(&next_key)
100 && decor_prefix_is_empty(next_key_decor.leaf_decor())
101 {
102 next_key_decor.leaf_decor_mut().set_prefix(prefix);
103 }
104 true
105 }
106
107 fn decor_prefix_is_empty(decor: &toml_edit::Decor) -> bool {
108 match decor.prefix() {
109 Some(prefix) => prefix.as_str() == Some(""),
110 None => true,
111 }
112 }
113
114 fn leading_prefix_for_key(
115 table: &dyn toml_edit::TableLike,
116 key: &str,
117 ) -> Option<toml_edit::RawString> {
118 table
119 .key(key)
120 .and_then(|key| key.leaf_decor().prefix().cloned())
121 .or_else(|| {
122 table
123 .get(key)
124 .and_then(|item| item.as_value())
125 .and_then(|value| value.decor().prefix().cloned())
126 })
127 }
128
129 pub(crate) fn persist_status_items(items: &[StatusItem]) -> anyhow::Result<PathBuf> {
130 let path = config_toml_path(None)?;
131 let items: toml_edit::Array = items.iter().map(|item| item.key()).collect();
132 mutate_config_document(&path, |doc| {
133 set_document_value(doc, &["tui", "status_items"], items)
134 })?;
135 Ok(path)
136 }
137
138 pub(crate) fn persist_root_string_key(
139 config_path: Option<&Path>,
140 key: &str,
141 value: &str,
142 ) -> anyhow::Result<PathBuf> {
143 let path = config_toml_path(config_path)?;
144 mutate_config_document(&path, |doc| set_document_value(doc, &[key], value))?;
145 Ok(path)
146 }
147
148 pub(crate) fn persist_unset_root_key(
149 config_path: Option<&Path>,
150 key: &str,
151 ) -> anyhow::Result<PathBuf> {
152 let path = config_toml_path(config_path)?;
153 mutate_config_document(&path, |doc| unset_document_value(doc, &[key]).map(|_| ()))?;
154 Ok(path)
155 }
156
157 pub(crate) fn persist_root_bool_key(
158 config_path: Option<&Path>,
159 key: &str,
160 value: bool,
161 ) -> anyhow::Result<PathBuf> {
162 let path = config_toml_path(config_path)?;
163 mutate_config_document(&path, |doc| set_document_value(doc, &[key], value))?;
164 Ok(path)
165 }
166
167 pub(crate) fn persist_tui_integer_key(
168 config_path: Option<&Path>,
169 key: &str,
170 value: u64,
171 ) -> anyhow::Result<PathBuf> {
172 let value = i64::try_from(value).context("integer value is too large for TOML")?;
173 persist_table_value_key(config_path, "tui", key, value.into())
174 }
175
176 pub(crate) fn persist_subagents_bool_key(
177 config_path: Option<&Path>,
178 key: &str,
179 value: bool,
180 ) -> anyhow::Result<PathBuf> {
181 persist_table_value_key(config_path, "subagents", key, value.into())
182 }
183
184 pub(crate) fn persist_subagents_integer_key(
185 config_path: Option<&Path>,
186 key: &str,
187 value: u64,
188 ) -> anyhow::Result<PathBuf> {
189 let value = i64::try_from(value).context("integer value is too large for TOML")?;
190 persist_table_value_key(config_path, "subagents", key, value.into())
191 }
192
193 pub(crate) fn persist_table_bool_key(
194 config_path: Option<&Path>,
195 table_name: &str,
196 key: &str,
197 value: bool,
198 ) -> anyhow::Result<PathBuf> {
199 persist_table_value_key(config_path, table_name, key, value.into())
200 }
201
202 pub(crate) fn persist_table_string_key(
203 config_path: Option<&Path>,
204 table_name: &str,
205 key: &str,
206 value: &str,
207 ) -> anyhow::Result<PathBuf> {
208 persist_table_value_key(config_path, table_name, key, value.into())
209 }
210
211 fn persist_table_value_key(
212 config_path: Option<&Path>,
213 table_name: &str,
214 key: &str,
215 value: toml_edit::Value,
216 ) -> anyhow::Result<PathBuf> {
217 let path = config_toml_path(config_path)?;
218 mutate_config_document(&path, |doc| {
219 set_document_value(doc, &[table_name, key], value)
220 })?;
221 Ok(path)
222 }
223
224 pub(crate) fn persist_provider_base_url_key(
225 config_path: Option<&Path>,
226 provider: ApiProvider,
227 value: &str,
228 ) -> anyhow::Result<PathBuf> {
229 let provider_key = provider_base_url_table_key(provider)?;
230 let path = config_toml_path(config_path)?;
231 mutate_config_document(&path, |doc| {
232 set_document_value(doc, &["providers", provider_key, "base_url"], value)
233 })?;
234 Ok(path)
235 }
236
237 /// Persist the model for one exact provider route without rewriting the
238 /// legacy root DeepSeek fallback used by unrelated providers.
239 ///
240 /// First-party DeepSeek retains its historical `default_text_model` root key.
241 /// Every other built-in provider writes to its typed `[providers.<name>]`
242 /// table, while named custom routes use their exact user-owned table id.
243 pub(crate) fn persist_provider_model_key(
244 config_path: Option<&Path>,
245 provider: ApiProvider,
246 provider_identity: &str,
247 value: &str,
248 ) -> anyhow::Result<PathBuf> {
249 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
250 return persist_root_string_key(config_path, "default_text_model", value);
251 }
252
253 let provider_key = if provider == ApiProvider::Custom {
254 normalize_custom_provider_id(provider_identity)?
255 } else {
256 provider
257 .metadata()
258 .context("provider config metadata")?
259 .provider_config_key()
260 .to_string()
261 };
262 let path = config_toml_path(config_path)?;
263 mutate_config_document(&path, |doc| {
264 set_document_value(doc, &["providers", &provider_key, "model"], value)
265 })?;
266 Ok(path)
267 }
268
269 fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> {
270 match provider {
271 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
272 anyhow::bail!("DeepSeek uses the root base_url setting")
273 }
274 ApiProvider::DeepseekAnthropic => Ok("deepseek_anthropic"),
275 ApiProvider::NvidiaNim => Ok("nvidia_nim"),
276 ApiProvider::Openai => Ok("openai"),
277 ApiProvider::Anthropic => Ok("anthropic"),
278 ApiProvider::Atlascloud => Ok("atlascloud"),
279 ApiProvider::WanjieArk => Ok("wanjie_ark"),
280 ApiProvider::Volcengine => Ok("volcengine"),
281 ApiProvider::Openrouter => Ok("openrouter"),
282 ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"),
283 ApiProvider::Novita => Ok("novita"),
284 ApiProvider::Fireworks => Ok("fireworks"),
285 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"),
286 ApiProvider::Arcee => Ok("arcee"),
287 ApiProvider::Huggingface => Ok("huggingface"),
288 ApiProvider::Deepinfra => Ok("deepinfra"),
289 ApiProvider::Moonshot => Ok("moonshot"),
290 ApiProvider::Sglang => Ok("sglang"),
291 ApiProvider::Vllm => Ok("vllm"),
292 ApiProvider::Ollama => Ok("ollama"),
293 ApiProvider::Together => Ok("together"),
294 ApiProvider::Qianfan => Ok("qianfan"),
295 ApiProvider::OpenaiCodex => Ok("openai_codex"),
296 ApiProvider::Openmodel => Ok("openmodel"),
297 ApiProvider::Zai => Ok("zai"),
298 ApiProvider::Stepfun => Ok("stepfun"),
299 ApiProvider::Minimax => Ok("minimax"),
300 ApiProvider::MinimaxAnthropic => Ok("minimax_anthropic"),
301 ApiProvider::Sakana => Ok("sakana"),
302 ApiProvider::LongCat => Ok("longcat"),
303 ApiProvider::OpencodeGo => Ok("opencode_go"),
304 ApiProvider::OpencodeZen => Ok("opencode_zen"),
305 ApiProvider::Meta => Ok("meta"),
306 ApiProvider::Xai => Ok("xai"),
307 ApiProvider::Telecomjs => Ok("telecomjs"),
308 ApiProvider::ModelstudioTokenPlan => Ok("modelstudio_token_plan"),
309 ApiProvider::ModelstudioTokenPlanAnthropic => Ok("modelstudio_token_plan_anthropic"),
310 ApiProvider::ModelstudioCodingPlan => Ok("modelstudio_coding_plan"),
311 ApiProvider::ModelstudioCodingPlanAnthropic => Ok("modelstudio_coding_plan_anthropic"),
312 // Custom providers live under a user-chosen `[providers.<name>]` table,
313 // not a fixed key. Persisting base_url through this static-key path is
314 // out of scope for the #1519 constrained slice; users edit the named
315 // table directly.
316 ApiProvider::Custom => {
317 anyhow::bail!("custom providers store base_url in their named [providers.<name>] table")
318 }
319 }
320 }
321
322 pub(crate) fn persist_custom_provider(
323 config_path: Option<&Path>,
324 provider_id: &str,
325 base_url: &str,
326 model: Option<&str>,
327 api_key_env: Option<&str>,
328 ) -> anyhow::Result<PathBuf> {
329 let provider_id = normalize_custom_provider_id(provider_id)?;
330 let base_url = normalize_custom_provider_base_url(base_url)?;
331 let model = model.and_then(normalize_optional_custom_provider_field);
332 let api_key_env = api_key_env.and_then(normalize_optional_custom_provider_field);
333
334 let path = config_toml_path(config_path)?;
335 mutate_config_document(&path, |doc| {
336 let entry = ["providers", provider_id.as_str()];
337 set_document_value(doc, &["provider"], provider_id.as_str())?;
338 set_document_value(doc, &[entry[0], entry[1], "kind"], "openai-compatible")?;
339 set_document_value(doc, &[entry[0], entry[1], "base_url"], base_url.as_str())?;
340 match model.as_deref() {
341 Some(model) => set_document_value(doc, &[entry[0], entry[1], "model"], model)?,
342 None => {
343 unset_document_value(doc, &[entry[0], entry[1], "model"])?;
344 }
345 }
346 match api_key_env.as_deref() {
347 Some(env) => set_document_value(doc, &[entry[0], entry[1], "api_key_env"], env)?,
348 None => {
349 unset_document_value(doc, &[entry[0], entry[1], "api_key_env"])?;
350 }
351 }
352 Ok(())
353 })?;
354 Ok(path)
355 }
356
357 fn normalize_custom_provider_id(raw: &str) -> anyhow::Result<String> {
358 use anyhow::bail;
359
360 let value = raw.trim();
361 if value.is_empty() {
362 bail!("custom provider name is required");
363 }
364 if value == "__custom__" {
365 bail!("custom provider name is reserved");
366 }
367 if crate::config::ApiProvider::parse(value).is_some() {
368 bail!("custom provider name must not shadow a built-in provider");
369 }
370 if !value
371 .chars()
372 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
373 {
374 bail!("custom provider name may only use letters, numbers, '-' and '_'");
375 }
376 Ok(value.to_string())
377 }
378
379 fn normalize_custom_provider_base_url(raw: &str) -> anyhow::Result<String> {
380 use anyhow::bail;
381
382 let value = raw.trim().trim_end_matches('/');
383 if value.is_empty() {
384 bail!("custom provider base URL is required");
385 }
386 let parsed = reqwest::Url::parse(value)
387 .map_err(|err| anyhow::anyhow!("custom provider base URL is invalid: {err}"))?;
388 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
389 bail!("custom provider base URL must be an http(s) URL with a host");
390 }
391 Ok(value.to_string())
392 }
393
394 fn normalize_optional_custom_provider_field(raw: &str) -> Option<String> {
395 let value = raw.trim();
396 (!value.is_empty()).then(|| value.to_string())
397 }
398
399 pub(crate) fn persist_hotbar_bindings(
400 config_path: Option<&Path>,
401 bindings: &[codewhale_config::HotbarBindingToml],
402 ) -> anyhow::Result<PathBuf> {
403 let path = config_toml_path(config_path)?;
404 mutate_config_document(&path, |doc| {
405 let table = doc.as_table_mut();
406 table.remove("hotbar");
407 if bindings.is_empty() {
408 table.insert(
409 "hotbar",
410 toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new())),
411 );
412 } else {
413 let mut hotbar = toml_edit::ArrayOfTables::new();
414 for binding in bindings {
415 let mut entry = toml_edit::Table::new();
416 entry["slot"] = toml_edit::value(i64::from(binding.slot));
417 entry["action"] = toml_edit::value(binding.action.clone());
418 if let Some(label) = binding.label.as_deref() {
419 entry["label"] = toml_edit::value(label);
420 }
421 hotbar.push(entry);
422 }
423 table.insert("hotbar", toml_edit::Item::ArrayOfTables(hotbar));
424 }
425 Ok(())
426 })?;
427 Ok(path)
428 }
429
430 pub(crate) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<PathBuf> {
431 if let Some(path) = config_path {
432 return Ok(expand_path(path.to_string_lossy().as_ref()));
433 }
434 crate::config::resolve_load_config_path(None)?
435 .context("failed to resolve the active config.toml path")
436 }
437
438 #[cfg(test)]
439 mod tests {
440 use super::*;
441 use std::env;
442 use std::ffi::OsString;
443 use std::fs;
444 use std::path::Path;
445 use std::time::{SystemTime, UNIX_EPOCH};
446
447 struct EnvGuard {
448 home: Option<OsString>,
449 userprofile: Option<OsString>,
450 codewhale_home: Option<OsString>,
451 codewhale_config_path: Option<OsString>,
452 deepseek_config_path: Option<OsString>,
453 _lock: crate::test_support::TestEnvLock,
454 }
455
456 impl EnvGuard {
457 fn new(home: &Path) -> Self {
458 let lock = crate::test_support::lock_test_env();
459 let home_str = OsString::from(home.as_os_str());
460 let config_path = home.join(".deepseek").join("config.toml");
461 let config_str = OsString::from(config_path.as_os_str());
462 let home_prev = env::var_os("HOME");
463 let userprofile_prev = env::var_os("USERPROFILE");
464 let codewhale_home_prev = env::var_os("CODEWHALE_HOME");
465 let codewhale_config_prev = env::var_os("CODEWHALE_CONFIG_PATH");
466 let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
467
468 // Safety: test-only environment mutation guarded by process-wide mutex.
469 unsafe {
470 env::set_var("HOME", &home_str);
471 env::set_var("USERPROFILE", &home_str);
472 env::remove_var("CODEWHALE_HOME");
473 env::remove_var("CODEWHALE_CONFIG_PATH");
474 env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
475 }
476
477 Self {
478 home: home_prev,
479 userprofile: userprofile_prev,
480 codewhale_home: codewhale_home_prev,
481 codewhale_config_path: codewhale_config_prev,
482 deepseek_config_path: deepseek_config_prev,
483 _lock: lock,
484 }
485 }
486 }
487
488 impl Drop for EnvGuard {
489 fn drop(&mut self) {
490 if let Some(value) = self.home.take() {
491 // Safety: test-only environment mutation guarded by a global mutex.
492 unsafe {
493 env::set_var("HOME", value);
494 }
495 } else {
496 // Safety: test-only environment mutation guarded by a global mutex.
497 unsafe {
498 env::remove_var("HOME");
499 }
500 }
501
502 if let Some(value) = self.userprofile.take() {
503 // Safety: test-only environment mutation guarded by a global mutex.
504 unsafe {
505 env::set_var("USERPROFILE", value);
506 }
507 } else {
508 // Safety: test-only environment mutation guarded by a global mutex.
509 unsafe {
510 env::remove_var("USERPROFILE");
511 }
512 }
513
514 if let Some(value) = self.codewhale_home.take() {
515 // Safety: test-only environment mutation guarded by a global mutex.
516 unsafe {
517 env::set_var("CODEWHALE_HOME", value);
518 }
519 } else {
520 // Safety: test-only environment mutation guarded by a global mutex.
521 unsafe {
522 env::remove_var("CODEWHALE_HOME");
523 }
524 }
525
526 if let Some(value) = self.codewhale_config_path.take() {
527 // Safety: test-only environment mutation guarded by a global mutex.
528 unsafe {
529 env::set_var("CODEWHALE_CONFIG_PATH", value);
530 }
531 } else {
532 // Safety: test-only environment mutation guarded by a global mutex.
533 unsafe {
534 env::remove_var("CODEWHALE_CONFIG_PATH");
535 }
536 }
537
538 if let Some(value) = self.deepseek_config_path.take() {
539 // Safety: test-only environment mutation guarded by a global mutex.
540 unsafe {
541 env::set_var("DEEPSEEK_CONFIG_PATH", value);
542 }
543 } else {
544 // Safety: test-only environment mutation guarded by a global mutex.
545 unsafe {
546 env::remove_var("DEEPSEEK_CONFIG_PATH");
547 }
548 }
549 }
550 }
551
552 fn temp_root(prefix: &str) -> std::path::PathBuf {
553 let nanos = SystemTime::now()
554 .duration_since(UNIX_EPOCH)
555 .unwrap()
556 .as_nanos();
557 env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
558 }
559
560 #[test]
561 fn persist_status_items_writes_tui_section_to_config_toml() {
562 let temp_root = temp_root("codewhale-statusline-persist");
563 fs::create_dir_all(&temp_root).unwrap();
564 let _guard = EnvGuard::new(&temp_root);
565
566 let items = vec![
567 crate::config::StatusItem::Mode,
568 crate::config::StatusItem::Model,
569 crate::config::StatusItem::Cost,
570 ];
571
572 let path = persist_status_items(&items).expect("persist should succeed");
573 let body = fs::read_to_string(&path).expect("written file should be readable");
574 assert!(body.contains("[tui]"), "expected [tui] section in {body}");
575 assert!(
576 body.contains("status_items"),
577 "expected status_items key in {body}"
578 );
579 assert!(body.contains("\"mode\""), "expected mode key in {body}");
580 assert!(body.contains("\"cost\""), "expected cost key in {body}");
581 }
582
583 #[test]
584 fn config_toml_path_uses_codewhale_home_for_fresh_installs() {
585 let temp_root = temp_root("codewhale-config-path-fresh");
586 fs::create_dir_all(&temp_root).unwrap();
587 let _guard = EnvGuard::new(&temp_root);
588
589 unsafe {
590 env::remove_var("DEEPSEEK_CONFIG_PATH");
591 }
592
593 assert_eq!(
594 config_toml_path(None).unwrap(),
595 temp_root.join(".codewhale").join("config.toml")
596 );
597 }
598
599 #[test]
600 fn config_toml_path_preserves_legacy_config_when_it_exists() {
601 let temp_root = temp_root("codewhale-config-path-legacy");
602 let legacy_config = temp_root.join(".deepseek").join("config.toml");
603 fs::create_dir_all(legacy_config.parent().unwrap()).unwrap();
604 fs::write(&legacy_config, "").unwrap();
605 let _guard = EnvGuard::new(&temp_root);
606
607 unsafe {
608 env::remove_var("DEEPSEEK_CONFIG_PATH");
609 }
610
611 assert_eq!(config_toml_path(None).unwrap(), legacy_config);
612 }
613
614 #[test]
615 fn config_toml_path_ignores_legacy_config_when_codewhale_home_is_explicit() {
616 let temp_root = temp_root("codewhale-config-path-explicit-home");
617 let explicit_home = temp_root.join("isolated-codewhale");
618 let legacy_config = temp_root.join(".deepseek").join("config.toml");
619 fs::create_dir_all(legacy_config.parent().unwrap()).unwrap();
620 fs::write(&legacy_config, "").unwrap();
621 let _guard = EnvGuard::new(&temp_root);
622
623 unsafe {
624 env::remove_var("DEEPSEEK_CONFIG_PATH");
625 env::set_var("CODEWHALE_HOME", &explicit_home);
626 }
627
628 assert_eq!(
629 config_toml_path(None).unwrap(),
630 explicit_home.join("config.toml")
631 );
632 }
633
634 #[test]
635 fn config_toml_path_prefers_codewhale_env_over_legacy_env() {
636 let temp_root = temp_root("codewhale-config-path-env");
637 fs::create_dir_all(&temp_root).unwrap();
638 let _guard = EnvGuard::new(&temp_root);
639 let preferred = temp_root.join("preferred.toml");
640 let legacy = temp_root.join("legacy.toml");
641
642 unsafe {
643 env::set_var("CODEWHALE_CONFIG_PATH", &preferred);
644 env::set_var("DEEPSEEK_CONFIG_PATH", &legacy);
645 }
646
647 let expected = preferred
648 .parent()
649 .expect("preferred path has a parent")
650 .canonicalize()
651 .expect("preferred parent should canonicalize")
652 .join("preferred.toml");
653 assert_eq!(config_toml_path(None).unwrap(), expected);
654 }
655
656 #[test]
657 fn config_toml_path_keeps_missing_env_target_authoritative() {
658 let temp_root = temp_root("codewhale-config-path-missing-env-fallback");
659 let home_config = temp_root.join(".codewhale").join("config.toml");
660 fs::create_dir_all(home_config.parent().unwrap()).unwrap();
661 fs::write(&home_config, "# existing fallback\n").unwrap();
662 let _guard = EnvGuard::new(&temp_root);
663 let missing_env = temp_root.join("override").join("missing.toml");
664
665 unsafe {
666 env::set_var("DEEPSEEK_CONFIG_PATH", &missing_env);
667 }
668
669 assert_eq!(config_toml_path(None).unwrap(), missing_env);
670 assert!(home_config.exists());
671 assert!(!missing_env.exists());
672 }
673
674 #[test]
675 fn persist_status_items_preserves_existing_unrelated_keys() {
676 let temp_root = temp_root("codewhale-statusline-preserve");
677 fs::create_dir_all(&temp_root).unwrap();
678 let _guard = EnvGuard::new(&temp_root);
679
680 let path = temp_root.join(".deepseek").join("config.toml");
681 fs::create_dir_all(path.parent().unwrap()).unwrap();
682 fs::write(
683 &path,
684 "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n",
685 )
686 .unwrap();
687
688 let written = persist_status_items(&[crate::config::StatusItem::Mode])
689 .expect("persist should succeed");
690 let body = fs::read_to_string(&written).expect("written file should be readable");
691 assert!(
692 body.contains("api_key = \"sentinel-key\""),
693 "round-trip lost api_key: {body}"
694 );
695 assert!(
696 body.contains("model = \"deepseek-v4-pro\""),
697 "round-trip lost model: {body}"
698 );
699 assert!(
700 body.contains("status_items"),
701 "expected status_items in {body}"
702 );
703 }
704
705 #[test]
706 fn persist_bool_key_preserves_comments() {
707 let temp_root = temp_root("codewhale-persist-comments");
708 fs::create_dir_all(&temp_root).unwrap();
709 let _guard = EnvGuard::new(&temp_root);
710
711 let path = temp_root.join(".deepseek").join("config.toml");
712 fs::create_dir_all(path.parent().unwrap()).unwrap();
713 fs::write(
714 &path,
715 "# my note\nmodel = \"deepseek-v4-flash\"\n# disabled = true\n",
716 )
717 .unwrap();
718
719 let written = persist_root_bool_key(Some(&path), "allow_shell", true)
720 .expect("persist should succeed");
721 let body = fs::read_to_string(&written).expect("written file should be readable");
722 assert!(body.contains("# my note"), "prefix comment lost: {body}");
723 assert!(
724 body.contains("# disabled = true"),
725 "disabled key lost: {body}"
726 );
727 assert!(
728 body.contains("allow_shell = true"),
729 "new key not written: {body}"
730 );
731 }
732
733 #[test]
734 fn persist_table_bool_key_updates_existing_memory_enabled() {
735 let temp_root = temp_root("codewhale-persist-memory-update");
736 fs::create_dir_all(&temp_root).unwrap();
737 let _guard = EnvGuard::new(&temp_root);
738
739 let path = temp_root.join(".deepseek").join("config.toml");
740 fs::create_dir_all(path.parent().unwrap()).unwrap();
741 fs::write(&path, "allow_shell = true\n\n[memory]\nenabled = true\n").unwrap();
742
743 let written = persist_table_bool_key(Some(&path), "memory", "enabled", false)
744 .expect("persist should succeed");
745 let body = fs::read_to_string(&written).expect("written file should be readable");
746 assert!(
747 body.contains("enabled = false"),
748 "memory enabled should be false: {body}"
749 );
750 assert!(
751 !body.contains("enabled = true"),
752 "memory enabled should not still be true: {body}"
753 );
754 }
755
756 #[test]
757 fn persist_memory_enabled_round_trips_through_config_load() {
758 let temp_root = temp_root("codewhale-persist-memory-roundtrip");
759 fs::create_dir_all(&temp_root).unwrap();
760 let _guard = EnvGuard::new(&temp_root);
761
762 let path = temp_root.join(".deepseek").join("config.toml");
763 fs::create_dir_all(path.parent().unwrap()).unwrap();
764 // Initial config has memory enabled = true
765 fs::write(&path, "allow_shell = true\n\n[memory]\nenabled = true\n").unwrap();
766
767 // Verify initial state
768 let cfg0 = crate::config::Config::load(Some(path.clone()), None)
769 .expect("initial config should load");
770 assert!(cfg0.memory_enabled(), "memory should be enabled initially");
771
772 // Persist memory.enabled = false (what the GUI's set_config endpoint does)
773 persist_table_bool_key(Some(&path), "memory", "enabled", false)
774 .expect("persist should succeed");
775
776 // Reload config from disk and verify memory_enabled() reflects the change
777 let cfg1 = crate::config::Config::load(Some(path.clone()), None)
778 .expect("reloaded config should load");
779 assert!(
780 !cfg1.memory_enabled(),
781 "memory should be disabled after persisting false"
782 );
783 }
784
785 #[test]
786 fn persist_custom_provider_writes_named_openai_compatible_table() {
787 let temp_root = temp_root("codewhale-custom-provider-persist");
788 fs::create_dir_all(&temp_root).unwrap();
789 let _guard = EnvGuard::new(&temp_root);
790
791 let path = temp_root.join(".codewhale").join("config.toml");
792 let written = persist_custom_provider(
793 Some(&path),
794 "acme_ai",
795 "https://api.acme.example/v1/",
796 Some("acme/code-1"),
797 Some("ACME_API_KEY"),
798 )
799 .expect("custom provider should persist");
800 let body = fs::read_to_string(&written).expect("written file should be readable");
801
802 assert!(body.contains("provider = \"acme_ai\""), "{body}");
803 assert!(body.contains("[providers.acme_ai]"), "{body}");
804 assert!(body.contains("kind = \"openai-compatible\""), "{body}");
805 assert!(
806 body.contains("base_url = \"https://api.acme.example/v1\""),
807 "{body}"
808 );
809 assert!(body.contains("model = \"acme/code-1\""), "{body}");
810 assert!(body.contains("api_key_env = \"ACME_API_KEY\""), "{body}");
811 assert!(
812 !body.contains("sk-"),
813 "helper must not persist raw secret values: {body}"
814 );
815
816 let loaded =
817 crate::config::Config::load(Some(written.clone()), None).expect("config should load");
818 assert_eq!(loaded.provider.as_deref(), Some("acme_ai"));
819 assert_eq!(loaded.api_provider(), crate::config::ApiProvider::Custom);
820 let entry = loaded
821 .providers
822 .as_ref()
823 .and_then(|providers| providers.custom_provider_config("acme_ai"))
824 .expect("custom provider entry");
825 assert!(entry.is_openai_compatible_custom());
826 assert_eq!(
827 entry.base_url.as_deref(),
828 Some("https://api.acme.example/v1")
829 );
830 assert_eq!(entry.model.as_deref(), Some("acme/code-1"));
831 assert_eq!(entry.api_key_env.as_deref(), Some("ACME_API_KEY"));
832
833 let dispatcher = codewhale_config::ConfigStore::load(Some(written))
834 .expect("the dispatcher must parse the exact config written by the TUI");
835 assert_eq!(
836 dispatcher.config.provider,
837 codewhale_config::ProviderKind::Custom
838 );
839 assert_eq!(dispatcher.config.provider_id(), "acme_ai");
840 }
841
842 #[test]
843 fn persist_custom_provider_rejects_builtin_or_invalid_names() {
844 let temp_root = temp_root("codewhale-custom-provider-invalid");
845 fs::create_dir_all(&temp_root).unwrap();
846 let _guard = EnvGuard::new(&temp_root);
847 let path = temp_root.join(".codewhale").join("config.toml");
848
849 let builtin = persist_custom_provider(
850 Some(&path),
851 "openrouter",
852 "https://api.example.invalid/v1",
853 None,
854 None,
855 )
856 .expect_err("built-in names should be rejected");
857 assert!(builtin.to_string().contains("built-in provider"));
858
859 let bad_chars = persist_custom_provider(
860 Some(&path),
861 "my provider",
862 "https://api.example.invalid/v1",
863 None,
864 None,
865 )
866 .expect_err("space in name should be rejected");
867 assert!(bad_chars.to_string().contains("letters, numbers"));
868 }
869
870 #[test]
871 fn persist_hotbar_bindings_writes_primary_config_path_for_fresh_installs() {
872 let temp_root = temp_root("codewhale-hotbar-persist-fresh");
873 fs::create_dir_all(&temp_root).unwrap();
874 let _guard = EnvGuard::new(&temp_root);
875
876 unsafe {
877 env::remove_var("DEEPSEEK_CONFIG_PATH");
878 }
879
880 let bindings = vec![codewhale_config::HotbarBindingToml {
881 slot: 1,
882 action: "mode.plan".to_string(),
883 label: Some("Plan".to_string()),
884 }];
885 let path = persist_hotbar_bindings(None, &bindings).expect("persist should succeed");
886
887 assert_eq!(path, temp_root.join(".codewhale").join("config.toml"));
888 let body = fs::read_to_string(&path).expect("written file should be readable");
889 assert!(body.contains("[[hotbar]]"), "hotbar table missing: {body}");
890 let parsed: codewhale_config::ConfigToml =
891 toml::from_str(&body).expect("written hotbar config should parse");
892 assert_eq!(parsed.hotbar, Some(bindings));
893 }
894
895 #[test]
896 fn persist_default_hotbar_bindings_round_trips_for_hotbar_on() {
897 // #3807: `/hotbar on` persists the explicit default slots (an absent key
898 // now means hidden), and they read back as the eight recommended slots.
899 let temp_root = temp_root("codewhale-hotbar-on-defaults");
900 fs::create_dir_all(&temp_root).unwrap();
901 let _guard = EnvGuard::new(&temp_root);
902
903 let defaults = codewhale_config::default_hotbar_bindings_toml();
904 assert_eq!(defaults.len(), codewhale_config::HOTBAR_SLOT_COUNT as usize);
905
906 let path = persist_hotbar_bindings(None, &defaults).expect("persist should succeed");
907 let body = fs::read_to_string(&path).expect("written file should be readable");
908 assert!(body.contains("[[hotbar]]"), "hotbar table missing: {body}");
909
910 let parsed: codewhale_config::ConfigToml =
911 toml::from_str(&body).expect("written hotbar config should parse");
912 assert_eq!(parsed.hotbar, Some(defaults));
913
914 // The persisted defaults resolve back to all eight recommended slots.
915 let resolved = parsed.resolve_hotbar_bindings(&codewhale_config::DEFAULT_HOTBAR_ACTIONS);
916 assert_eq!(
917 resolved.bindings,
918 codewhale_config::default_hotbar_bindings()
919 );
920 }
921
922 #[test]
923 fn persist_hotbar_bindings_preserves_comments_and_replaces_existing_tables() {
924 let temp_root = temp_root("codewhale-hotbar-persist-comments");
925 fs::create_dir_all(&temp_root).unwrap();
926 let _guard = EnvGuard::new(&temp_root);
927
928 let path = temp_root.join(".codewhale").join("config.toml");
929 fs::create_dir_all(path.parent().unwrap()).unwrap();
930 fs::write(
931 &path,
932 r#"# model note
933 model = "deepseek-v4-flash"
934
935 [[hotbar]]
936 slot = 1
937 action = "mode.plan"
938 label = "Plan"
939
940 # notification note
941 [notifications]
942 enabled = true
943 "#,
944 )
945 .unwrap();
946
947 let bindings = vec![codewhale_config::HotbarBindingToml {
948 slot: 2,
949 action: "session.compact".to_string(),
950 label: Some("Compact".to_string()),
951 }];
952 let written =
953 persist_hotbar_bindings(Some(&path), &bindings).expect("persist should succeed");
954 let body = fs::read_to_string(&written).expect("written file should be readable");
955
956 assert!(body.contains("# model note"), "prefix comment lost: {body}");
957 assert!(
958 body.contains("# notification note"),
959 "section comment lost: {body}"
960 );
961 assert!(
962 !body.contains("mode.plan"),
963 "old hotbar table was not replaced: {body}"
964 );
965 assert!(body.contains("[[hotbar]]"), "hotbar table missing: {body}");
966 assert!(
967 body.contains("action = \"session.compact\""),
968 "new action missing: {body}"
969 );
970 let parsed: codewhale_config::ConfigToml =
971 toml::from_str(&body).expect("written hotbar config should parse");
972 assert_eq!(parsed.hotbar, Some(bindings));
973 }
974
975 #[test]
976 fn persist_hotbar_bindings_writes_empty_array_to_disable_defaults() {
977 let temp_root = temp_root("codewhale-hotbar-persist-empty");
978 fs::create_dir_all(&temp_root).unwrap();
979 let _guard = EnvGuard::new(&temp_root);
980
981 let path = temp_root.join(".codewhale").join("config.toml");
982 fs::create_dir_all(path.parent().unwrap()).unwrap();
983
984 let written = persist_hotbar_bindings(Some(&path), &[]).expect("persist should succeed");
985 let body = fs::read_to_string(&written).expect("written file should be readable");
986
987 assert!(body.contains("hotbar = []"), "empty hotbar missing: {body}");
988 let parsed: codewhale_config::ConfigToml =
989 toml::from_str(&body).expect("written hotbar config should parse");
990 assert_eq!(parsed.hotbar, Some(Vec::new()));
991 }
992
993 // ------------------------------------------------------------------
994 // Golden-file coverage for the shared toml_edit mutation path
995 // (findings #18/#19/#20): unrelated comments, ordering, and quoted
996 // provider tables must survive every supported mutation.
997 // ------------------------------------------------------------------
998
999 const GOLDEN_CONFIG: &str = r#"# CodeWhale golden config fixture, top note.
1000 # api_key = "sk-placeholder" (uncomment to set the key by hand)
1001 model = "deepseek-v4-pro" # pinned for release QA
1002
1003 # workspace trust note
1004 [projects."/Users/example/work"]
1005 trust_level = "trusted" # granted manually
1006
1007 # providers note
1008 [providers.openrouter]
1009 base_url = "https://openrouter.ai/api/v1" # keep in sync with docs
1010
1011 [providers."quoted.provider"]
1012 base_url = "https://quoted.example/v1"
1013
1014 [[hotbar]]
1015 slot = 1
1016 action = "mode.plan"
1017 "#;
1018
1019 fn write_golden_config(path: &Path) {
1020 fs::create_dir_all(path.parent().unwrap()).unwrap();
1021 fs::write(path, GOLDEN_CONFIG).unwrap();
1022 }
1023
1024 #[test]
1025 fn golden_replacing_existing_root_value_only_touches_that_value() {
1026 let temp_root = temp_root("codewhale-golden-root-value");
1027 fs::create_dir_all(&temp_root).unwrap();
1028 let _guard = EnvGuard::new(&temp_root);
1029 let path = temp_root.join(".deepseek").join("config.toml");
1030 write_golden_config(&path);
1031
1032 persist_root_string_key(Some(&path), "model", "deepseek-v4-flash")
1033 .expect("persist should succeed");
1034
1035 let body = fs::read_to_string(&path).unwrap();
1036 let expected = GOLDEN_CONFIG.replace(
1037 "model = \"deepseek-v4-pro\" # pinned for release QA",
1038 "model = \"deepseek-v4-flash\" # pinned for release QA",
1039 );
1040 assert_eq!(body, expected, "only the model value may change");
1041 }
1042
1043 #[test]
1044 fn golden_mutations_preserve_unrelated_comments_order_and_quoted_tables() {
1045 let temp_root = temp_root("codewhale-golden-mutations");
1046 fs::create_dir_all(&temp_root).unwrap();
1047 let _guard = EnvGuard::new(&temp_root);
1048 let path = temp_root.join(".deepseek").join("config.toml");
1049 write_golden_config(&path);
1050
1051 persist_root_bool_key(Some(&path), "allow_shell", true).unwrap();
1052 persist_tui_integer_key(Some(&path), "scrollback_lines", 4000).unwrap();
1053 persist_table_string_key(Some(&path), "memory", "backend", "sqlite").unwrap();
1054 persist_subagents_bool_key(Some(&path), "enabled", true).unwrap();
1055 persist_provider_base_url_key(
1056 Some(&path),
1057 crate::config::ApiProvider::Openrouter,
1058 "https://openrouter.example/v2",
1059 )
1060 .unwrap();
1061 persist_status_items(&[crate::config::StatusItem::Mode]).unwrap();
1062 persist_hotbar_bindings(
1063 Some(&path),
1064 &[codewhale_config::HotbarBindingToml {
1065 slot: 2,
1066 action: "session.compact".to_string(),
1067 label: None,
1068 }],
1069 )
1070 .unwrap();
1071
1072 let body = fs::read_to_string(&path).unwrap();
1073 for comment in [
1074 "# CodeWhale golden config fixture, top note.",
1075 "# api_key = \"sk-placeholder\" (uncomment to set the key by hand)",
1076 "# pinned for release QA",
1077 "# workspace trust note",
1078 "# granted manually",
1079 "# providers note",
1080 "# keep in sync with docs",
1081 ] {
1082 assert!(body.contains(comment), "comment lost: {comment}\n{body}");
1083 }
1084 // Updated in place, keeping the trailing comment on the same line.
1085 assert!(
1086 body.contains("base_url = \"https://openrouter.example/v2\" # keep in sync with docs"),
1087 "{body}"
1088 );
1089 assert!(body.contains("[providers.\"quoted.provider\"]"), "{body}");
1090 assert!(
1091 !body.contains("mode.plan"),
1092 "old hotbar entry must be replaced: {body}"
1093 );
1094
1095 // Original section order is intact.
1096 let model_at = body.find("model = ").unwrap();
1097 let projects_at = body.find("[projects.").unwrap();
1098 let providers_at = body.find("[providers.openrouter]").unwrap();
1099 assert!(
1100 model_at < projects_at && projects_at < providers_at,
1101 "{body}"
1102 );
1103
1104 let parsed: toml::Value = toml::from_str(&body).unwrap();
1105 assert_eq!(
1106 parsed.get("allow_shell").and_then(toml::Value::as_bool),
1107 Some(true)
1108 );
1109 assert_eq!(
1110 parsed
1111 .get("tui")
1112 .and_then(|t| t.get("scrollback_lines"))
1113 .and_then(toml::Value::as_integer),
1114 Some(4000)
1115 );
1116 assert_eq!(
1117 parsed
1118 .get("memory")
1119 .and_then(|t| t.get("backend"))
1120 .and_then(toml::Value::as_str),
1121 Some("sqlite")
1122 );
1123 assert_eq!(
1124 parsed
1125 .get("subagents")
1126 .and_then(|t| t.get("enabled"))
1127 .and_then(toml::Value::as_bool),
1128 Some(true)
1129 );
1130 }
1131
1132 #[test]
1133 fn set_document_value_inserts_api_key_even_when_a_comment_mentions_it() {
1134 // Finding #20 at the primitive level: the old string scan treated a
1135 // comment mentioning api_key as an existing assignment and skipped
1136 // the insert entirely.
1137 let temp_root = temp_root("codewhale-golden-api-key-comment");
1138 fs::create_dir_all(&temp_root).unwrap();
1139 let _guard = EnvGuard::new(&temp_root);
1140 let path = temp_root.join(".deepseek").join("config.toml");
1141 write_golden_config(&path);
1142
1143 mutate_config_document(&path, |doc| {
1144 set_document_value(doc, &["api_key"], "sk-fresh")
1145 })
1146 .expect("mutation should succeed");
1147
1148 let body = fs::read_to_string(&path).unwrap();
1149 assert!(
1150 body.contains("# api_key = \"sk-placeholder\""),
1151 "comment lost: {body}"
1152 );
1153 let parsed: toml::Value = toml::from_str(&body).unwrap();
1154 assert_eq!(
1155 parsed.get("api_key").and_then(toml::Value::as_str),
1156 Some("sk-fresh"),
1157 "real key must be inserted despite the comment: {body}"
1158 );
1159 }
1160
1161 #[test]
1162 fn unset_document_value_reports_removal_and_tolerates_missing_parents() {
1163 let mut doc = "model = \"deepseek-v4-pro\"\n"
1164 .parse::<toml_edit::DocumentMut>()
1165 .unwrap();
1166 assert!(!unset_document_value(&mut doc, &["providers", "openrouter", "api_key"]).unwrap());
1167 assert!(!unset_document_value(&mut doc, &["model", "nested"]).unwrap());
1168 assert!(unset_document_value(&mut doc, &["model"]).unwrap());
1169 assert!(!unset_document_value(&mut doc, &["model"]).unwrap());
1170 }
1171
1172 #[test]
1173 fn unset_last_root_value_preserves_its_leading_comment() {
1174 let mut doc = "# keep this explanation\napproval_policy = \"on-request\"\n"
1175 .parse::<toml_edit::DocumentMut>()
1176 .unwrap();
1177
1178 assert!(unset_document_value(&mut doc, &["approval_policy"]).unwrap());
1179
1180 let saved = doc.to_string();
1181 assert!(saved.contains("# keep this explanation"), "{saved:?}");
1182 assert!(!saved.contains("approval_policy"), "{saved:?}");
1183 }
1184
1185 #[test]
1186 fn set_document_value_rejects_non_table_parents() {
1187 let mut doc = "model = \"deepseek-v4-pro\"\n"
1188 .parse::<toml_edit::DocumentMut>()
1189 .unwrap();
1190 let err = set_document_value(&mut doc, &["model", "nested"], "x")
1191 .expect_err("scalar parent must be rejected");
1192 assert!(err.to_string().contains("must be a table"), "{err}");
1193 }
1194
1195 #[test]
1196 fn remove_document_key_recursive_strips_nested_and_quoted_tables() {
1197 let mut doc = r#"# root note
1198 api_key = "root"
1199 api_key_env = "KEEP_ENV"
1200
1201 [providers.openrouter]
1202 api_key = "or"
1203 base_url = "https://openrouter.ai/api/v1"
1204
1205 [providers."quoted.provider"]
1206 api_key = "quoted"
1207
1208 [[hotbar]]
1209 slot = 1
1210 "#
1211 .parse::<toml_edit::DocumentMut>()
1212 .unwrap();
1213
1214 remove_document_key_recursive(doc.as_table_mut(), "api_key");
1215
1216 let body = doc.to_string();
1217 assert!(!body.contains("api_key = "), "{body}");
1218 assert!(body.contains("# root note"), "{body}");
1219 assert!(body.contains("api_key_env = \"KEEP_ENV\""), "{body}");
1220 assert!(body.contains("base_url"), "{body}");
1221 assert!(body.contains("[[hotbar]]"), "{body}");
1222 }
1223
1224 #[test]
1225 fn persist_custom_provider_unsets_removed_optional_fields() {
1226 let temp_root = temp_root("codewhale-custom-provider-unset");
1227 fs::create_dir_all(&temp_root).unwrap();
1228 let _guard = EnvGuard::new(&temp_root);
1229 let path = temp_root.join(".codewhale").join("config.toml");
1230
1231 persist_custom_provider(
1232 Some(&path),
1233 "acme_ai",
1234 "https://api.acme.example/v1",
1235 Some("acme/code-1"),
1236 Some("ACME_API_KEY"),
1237 )
1238 .expect("first persist should succeed");
1239 persist_custom_provider(
1240 Some(&path),
1241 "acme_ai",
1242 "https://api.acme.example/v2",
1243 None,
1244 None,
1245 )
1246 .expect("second persist should succeed");
1247
1248 let body = fs::read_to_string(&path).unwrap();
1249 let parsed: toml::Value = toml::from_str(&body).unwrap();
1250 let entry = parsed
1251 .get("providers")
1252 .and_then(|providers| providers.get("acme_ai"))
1253 .expect("provider entry");
1254 assert_eq!(
1255 entry.get("base_url").and_then(toml::Value::as_str),
1256 Some("https://api.acme.example/v2")
1257 );
1258 assert!(entry.get("model").is_none(), "model must be unset: {body}");
1259 assert!(
1260 entry.get("api_key_env").is_none(),
1261 "api_key_env must be unset: {body}"
1262 );
1263 }
1264
1265 #[cfg(unix)]
1266 #[test]
1267 fn config_writes_land_with_owner_only_permissions() {
1268 use std::os::unix::fs::PermissionsExt;
1269
1270 let temp_root = temp_root("codewhale-persist-perms");
1271 fs::create_dir_all(&temp_root).unwrap();
1272 let _guard = EnvGuard::new(&temp_root);
1273 let path = temp_root.join(".deepseek").join("config.toml");
1274 write_golden_config(&path);
1275 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1276
1277 persist_root_bool_key(Some(&path), "allow_shell", true).expect("persist should succeed");
1278
1279 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1280 assert_eq!(mode, 0o600, "config.toml can hold api keys");
1281 }
1282
1283 /// Clears every model override the dispatcher's env layer reads for the
1284 /// providers exercised below, so the assertion is about config precedence
1285 /// and cannot be flipped by an ambient variable on a developer machine.
1286 struct ModelEnvGuard {
1287 saved: Vec<(&'static str, Option<OsString>)>,
1288 }
1289
1290 impl ModelEnvGuard {
1291 const VARS: &'static [&'static str] = &[
1292 "CODEWHALE_MODEL",
1293 "DEEPSEEK_MODEL",
1294 "DEEPSEEK_DEFAULT_TEXT_MODEL",
1295 "GLM_MODEL",
1296 "BIGMODEL_MODEL",
1297 "ZAI_MODEL",
1298 "XAI_MODEL",
1299 "GROK_MODEL",
1300 "OPENROUTER_MODEL",
1301 "OLLAMA_MODEL",
1302 ];
1303
1304 fn new() -> Self {
1305 let saved = Self::VARS
1306 .iter()
1307 .map(|name| (*name, env::var_os(name)))
1308 .collect();
1309 // Safety: test-only environment mutation; the caller holds the
1310 // process-wide test-env lock via `EnvGuard`.
1311 unsafe {
1312 for name in Self::VARS {
1313 env::remove_var(name);
1314 }
1315 }
1316 Self { saved }
1317 }
1318 }
1319
1320 impl Drop for ModelEnvGuard {
1321 fn drop(&mut self) {
1322 // Safety: test-only environment restoration under the same lock.
1323 unsafe {
1324 for (name, value) in &self.saved {
1325 match value {
1326 Some(value) => env::set_var(name, value),
1327 None => env::remove_var(name),
1328 }
1329 }
1330 }
1331 }
1332 }
1333
1334 /// The active model must be one answer, not two.
1335 ///
1336 /// The TUI resolves it with `Config::default_model()`, which is what
1337 /// `client.rs` puts on the wire and what `doctor` reports. The dispatcher
1338 /// resolves it independently in `codewhale-config`'s
1339 /// `resolve_runtime_options`, which is what `codewhale model resolve`
1340 /// reports and what the app-server and route descriptors consume. The two
1341 /// silently disagreed for every non-DeepSeek provider (#4832, #4838): the
1342 /// dispatcher gated root `default_text_model` behind `provider == Deepseek`
1343 /// and so reported a provider default while the wire carried the user's
1344 /// chosen model.
1345 ///
1346 /// A diagnostic that contradicts the request it is diagnosing is worse than
1347 /// no diagnostic, so this pins the two chains together by construction
1348 /// rather than asserting either one's internals.
1349 #[test]
1350 fn the_dispatcher_and_the_tui_resolve_the_same_active_model() {
1351 // (case, config body, what both chains must answer)
1352 let cases: &[(&str, &str, &str)] = &[
1353 (
1354 "a non-DeepSeek provider honours the user's chosen model",
1355 "provider = \"zai\"\ndefault_text_model = \"GLM-4.6\"\n\n[providers.zai]\napi_key = \"k\"\n",
1356 "GLM-4.6",
1357 ),
1358 (
1359 "a stale DeepSeek id must not be forwarded to a native non-DeepSeek endpoint",
1360 "provider = \"zai\"\ndefault_text_model = \"deepseek-chat\"\n\n[providers.zai]\napi_key = \"k\"\n",
1361 crate::config::DEFAULT_ZAI_MODEL,
1362 ),
1363 (
1364 "no root default falls through to the provider default",
1365 "provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
1366 crate::config::DEFAULT_ZAI_MODEL,
1367 ),
1368 (
1369 "a provider-scoped model outranks the root default",
1370 "provider = \"zai\"\ndefault_text_model = \"GLM-4.6\"\n\n[providers.zai]\napi_key = \"k\"\nmodel = \"GLM-4.5-Air\"\n",
1371 "GLM-4.5-Air",
1372 ),
1373 (
1374 "DeepSeek itself keeps honouring the root default",
1375 "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n\n[providers.deepseek]\napi_key = \"k\"\n",
1376 "deepseek-v4-pro",
1377 ),
1378 (
1379 "a vendor-locked endpoint refuses a DeepSeek id (#3227)",
1380 "provider = \"xai\"\ndefault_text_model = \"deepseek-v4-pro\"\n\n[providers.xai]\napi_key = \"k\"\n",
1381 crate::config::DEFAULT_XAI_MODEL,
1382 ),
1383 (
1384 "an aggregator legitimately serves DeepSeek ids",
1385 "provider = \"openrouter\"\ndefault_text_model = \"deepseek/deepseek-v4-pro\"\n\n[providers.openrouter]\napi_key = \"k\"\n",
1386 "deepseek/deepseek-v4-pro",
1387 ),
1388 (
1389 "a local runtime passes its own tag through",
1390 "provider = \"ollama\"\ndefault_text_model = \"qwen3-coder:30b\"\n",
1391 "qwen3-coder:30b",
1392 ),
1393 (
1394 "a custom base URL keeps full pass-through (#1519)",
1395 "provider = \"zai\"\ndefault_text_model = \"deepseek-chat\"\n\n[providers.zai]\napi_key = \"k\"\nbase_url = \"https://proxy.example.invalid/v1\"\n",
1396 "deepseek-chat",
1397 ),
1398 ];
1399
1400 for (case, body, expected) in cases {
1401 let temp_root = temp_root("codewhale-model-chain-agreement");
1402 fs::create_dir_all(&temp_root).unwrap();
1403 let _guard = EnvGuard::new(&temp_root);
1404 let _model_guard = ModelEnvGuard::new();
1405 let path = temp_root.join(".deepseek").join("config.toml");
1406 fs::create_dir_all(path.parent().unwrap()).unwrap();
1407 fs::write(&path, body).unwrap();
1408
1409 let tui = crate::config::Config::load(Some(path.clone()), None)
1410 .expect("the TUI must parse this config");
1411 let tui_model = tui.default_model();
1412
1413 let dispatcher = codewhale_config::ConfigStore::load(Some(path.clone()))
1414 .expect("the dispatcher must parse the same config");
1415 let runtime = dispatcher
1416 .config
1417 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1418
1419 assert_eq!(
1420 tui_model, *expected,
1421 "{case}: the TUI chain (what actually reaches the provider) is wrong"
1422 );
1423 assert_eq!(
1424 runtime.model, *expected,
1425 "{case}: the dispatcher chain (what `model resolve` reports) is wrong"
1426 );
1427
1428 let _ = fs::remove_dir_all(&temp_root);
1429 }
1430 }
1431 }
1432
1432 lines RUST