返回 CodeWhale
config_keys.rs
根目录 / crates / tui / src / config_keys.rs
1 //! Which file owns a `codewhale config set` key, and which config.toml keys
2 //! nothing reads (#6563).
3 //!
4 //! `config set` used to write any key it did not recognise into config.toml,
5 //! so `config set calm_mode flase` and `config set totally_bogus_key 42` both
6 //! exited 0 and changed nothing that runs: `calm_mode` lives in settings.toml.
7 //! This module answers the one question both `config set` and `config doctor`
8 //! need — is this key read from config.toml, from settings.toml, or by
9 //! nothing — from the structs that actually read the files, so the answer
10 //! cannot drift from a hand-kept list.
11
12 use std::path::PathBuf;
13
14 use anyhow::Result;
15 use codewhale_config::ConfigToml;
16 use codewhale_config::settings_schema::SETTINGS_SCHEMA;
17
18 use crate::config::Config;
19 use crate::settings::Settings;
20
21 /// Root keys read from config.toml outside both the TUI [`Config`] struct and
22 /// the dispatcher's [`ConfigToml`] typed fields. Each has a named reader:
23 /// profile overlays (`ConfigFile::profiles`), per-project trust
24 /// (`config::project_trust_*`), user workspace entries
25 /// (`merge_user_workspace_config_from_doc` reads `[workspace.'<path>']` and
26 /// the legacy `[projects]` spelling), the route-preference migration
27 /// (`config_persistence`), the MCP stdio dispatcher's literal JSON key, the
28 /// stream-timeout fallbacks in `ConfigToml::stream_chunk_timeout_secs`, and
29 /// the legacy top-level `base_url` / `api_key`, which
30 /// `codewhale_config::legacy_root` moves into `[providers.<name>]` (#6394).
31 const OTHER_READER_ROOT_KEYS: &[&str] = &[
32 "profiles",
33 "base_url",
34 "baseUrl",
35 "api_key",
36 "apiKey",
37 "projects",
38 "workspace",
39 "route_preferences_version",
40 "route_preferences_migration",
41 "mcp.server_definitions",
42 "stream_chunk_timeout_secs",
43 "tui.stream_chunk_timeout_secs",
44 ];
45
46 /// [`OTHER_READER_ROOT_KEYS`] whose value is a table of user entries
47 /// (`[profiles.<name>]`, `[projects.'<path>']`, `[workspace.'<path>']`).
48 /// `config doctor` counts them as read, but `config set <key> <value>` would
49 /// replace the whole table with a string, so a scalar write is refused.
50 const TABLE_ROOT_KEYS: &[&str] = &["profiles", "projects", "workspace"];
51
52 /// Where a key a user asked `config set` to write is read from.
53 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 pub enum ConfigKeyHome {
55 /// config.toml: a typed field, a table something deserializes, or a
56 /// dotted path whose own validation lives in `ConfigToml::set_value`.
57 ConfigToml,
58 /// settings.toml: a key `Settings::set` accepts.
59 SettingsToml,
60 /// Nothing reads it.
61 Unknown,
62 }
63
64 /// Classify `key` for `codewhale config set`. config.toml wins when both
65 /// files know a spelling (`sandbox_mode`, `reasoning_effort`), which keeps
66 /// every key that already took effect writing where it did before.
67 #[must_use]
68 pub fn config_key_home(key: &str) -> ConfigKeyHome {
69 let key = key.trim();
70 if key.contains('.') || is_config_toml_root_key(key) {
71 ConfigKeyHome::ConfigToml
72 } else if Settings::canonical_key(key).is_some() {
73 ConfigKeyHome::SettingsToml
74 } else {
75 ConfigKeyHome::Unknown
76 }
77 }
78
79 /// Whether some reader consumes `key` at the root of config.toml.
80 fn is_config_toml_root_key(key: &str) -> bool {
81 tui_config_fields().contains(&key)
82 || OTHER_READER_ROOT_KEYS.contains(&key)
83 || is_config_toml_typed_field(key)
84 }
85
86 /// Root field names of the TUI [`Config`], taken from serde's own field table
87 /// (renames and aliases included) rather than restated here.
88 fn tui_config_fields() -> &'static [&'static str] {
89 static FIELDS: std::sync::OnceLock<&'static [&'static str]> = std::sync::OnceLock::new();
90 FIELDS.get_or_init(struct_fields::<Config>)
91 }
92
93 /// Whether [`ConfigToml`] deserializes `key` into a typed field. Its unknown
94 /// keys flatten into `extras`, so a probe document either fails to type-check
95 /// against the field or lands somewhere other than `extras`; both mean a
96 /// typed field owns the key.
97 fn is_config_toml_typed_field(key: &str) -> bool {
98 let mut probe = toml::Table::new();
99 probe.insert(key.to_string(), toml::Value::Integer(0));
100 match toml::Value::Table(probe).try_into::<ConfigToml>() {
101 Ok(config) => !config.extras.contains_key(key),
102 Err(_) => true,
103 }
104 }
105
106 /// The field list a derived `Deserialize` hands to `deserialize_struct`.
107 /// Returns an empty slice for types that deserialize any other way.
108 fn struct_fields<T: serde::de::DeserializeOwned>() -> &'static [&'static str] {
109 struct Probe(Option<&'static [&'static str]>);
110
111 impl<'de> serde::Deserializer<'de> for &mut Probe {
112 type Error = serde::de::value::Error;
113
114 fn deserialize_any<V: serde::de::Visitor<'de>>(
115 self,
116 _visitor: V,
117 ) -> Result<V::Value, Self::Error> {
118 Err(serde::de::Error::custom("field probe"))
119 }
120
121 fn deserialize_struct<V: serde::de::Visitor<'de>>(
122 self,
123 _name: &'static str,
124 fields: &'static [&'static str],
125 _visitor: V,
126 ) -> Result<V::Value, Self::Error> {
127 self.0 = Some(fields);
128 Err(serde::de::Error::custom("field probe"))
129 }
130
131 serde::forward_to_deserialize_any! {
132 bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string
133 bytes byte_buf option unit unit_struct newtype_struct seq tuple
134 tuple_struct map enum identifier ignored_any
135 }
136 }
137
138 let mut probe = Probe(None);
139 let _ = T::deserialize(&mut probe);
140 probe.0.unwrap_or(&[])
141 }
142
143 /// Every key `config set` can write, for did-you-mean: the declared settings
144 /// (`SETTINGS_SCHEMA`) that resolve to a file, plus config.toml's own roots.
145 fn settable_keys() -> impl Iterator<Item = &'static str> {
146 SETTINGS_SCHEMA
147 .iter()
148 .map(|def| def.key)
149 .filter(|key| {
150 codewhale_config::notifications::in_namespace(key)
151 || key.starts_with("stream.")
152 || (!key.contains('.') && config_key_home(key) != ConfigKeyHome::Unknown)
153 })
154 // serde's field table carries the camelCase aliases too; suggest the
155 // documented snake_case spelling only.
156 .chain(
157 tui_config_fields()
158 .iter()
159 .copied()
160 .filter(|key| !key.chars().any(|c| c.is_ascii_uppercase())),
161 )
162 .chain(
163 OTHER_READER_ROOT_KEYS
164 .iter()
165 .copied()
166 .filter(|key| !key.contains('.') && !TABLE_ROOT_KEYS.contains(key)),
167 )
168 }
169
170 /// The closest of `candidates` to `query`, if any is close enough to suggest.
171 pub(crate) fn nearest_key<'a>(
172 query: &str,
173 candidates: impl IntoIterator<Item = &'a str>,
174 ) -> Option<&'a str> {
175 candidates
176 .into_iter()
177 .filter_map(|candidate| {
178 crate::commands::best_suggestion_score(query, [candidate])
179 .map(|score| (score, candidate))
180 })
181 .min_by_key(|(score, _)| *score)
182 .map(|(_, candidate)| candidate)
183 }
184
185 /// The refusal `config set` gives for a key nothing reads.
186 #[must_use]
187 pub fn unknown_config_key_message(key: &str) -> String {
188 let hint = nearest_key(key, settable_keys())
189 .map(|candidate| format!(" Did you mean `{candidate}`?"))
190 .unwrap_or_default();
191 format!(
192 "unknown config key `{key}`: nothing reads it, so it was not saved.{hint} \
193 Run `codewhale config list` for config.toml keys or `/settings text` for settings."
194 )
195 }
196
197 /// Effective stream values for dispatcher diagnostics, resolved by the same
198 /// Config accessors as Engine/client construction. Start from the unredacted
199 /// document to parse its types; return only this non-secret table. The caller
200 /// keeps the existing redaction path for every other config value.
201 pub fn resolved_stream_config(config: &ConfigToml) -> Result<toml::Value> {
202 let document = toml::Value::try_from(config)?;
203 // Parse only the two owning tables: dispatcher-specific root values must
204 // not change their existing dump behavior just to inspect transport.
205 let mut transport = toml::Table::new();
206 for key in ["stream", "tui"] {
207 if let Some(value) = document.get(key) {
208 transport.insert(key.to_string(), value.clone());
209 }
210 }
211 let config: Config = toml::Value::Table(transport).try_into()?;
212 Ok(toml::Value::try_from(config.resolved_stream_settings())?)
213 }
214
215 /// Write one settings.toml key through the same validator and locked
216 /// transaction as `/settings`. Returns the file written.
217 pub fn set_settings_value(key: &str, value: &str) -> Result<PathBuf> {
218 Settings::transact(|settings| settings.set(key, value))?;
219 Settings::path()
220 }
221
222 /// The saved settings.toml value for `key`, without terminal or environment
223 /// overlays, so `config get` reports what `config set` wrote.
224 pub fn settings_value(key: &str) -> Result<Option<String>> {
225 Ok(Settings::load_persisted()?.value(key))
226 }
227
228 /// The canonical settings.toml keys: each declared setting that `/set`
229 /// accepts under its own name. Internal flags, actions, receipts and retired
230 /// schema defs are not settable and are left out, so `/config <key>` and its
231 /// did-you-mean only ever name a key a user can change.
232 pub(crate) fn settings_toml_keys() -> impl Iterator<Item = &'static str> {
233 SETTINGS_SCHEMA
234 .iter()
235 .map(|def| def.key)
236 .filter(|key| Settings::canonical_key(key) == Some(*key))
237 }
238
239 /// The TOML value `codewhale config set` stores for a config.toml root key
240 /// whose reader needs more than `ConfigToml::set_value`'s string fallthrough,
241 /// or `Ok(None)` when that fallthrough is already right.
242 ///
243 /// - `approval_policy`, `sandbox_mode` and `verbosity` are refused unless
244 /// the value is one [`codewhale_config::config_toml_choices`] names, since
245 /// the TUI loader rejects the whole file otherwise.
246 /// - `reasoning_effort` is checked against its reader,
247 /// [`ReasoningEffort::parse_strict`], and stored in canonical spelling.
248 /// - A root field the TUI `Config` reads but `SETTINGS_SCHEMA` does not
249 /// declare (`yolo`, `max_subagents`, `mcp_oauth_callback_port`, ...) is
250 /// typed by that field's own deserializer: a string in a boolean or
251 /// integer field fails the TUI's strict parse of the whole file, so the
252 /// value is stored as the first of string, boolean, integer or number the
253 /// field accepts, and refused when it accepts none.
254 ///
255 /// [`ReasoningEffort::parse_strict`]: crate::reasoning_preference::ReasoningEffort::parse_strict
256 pub fn config_toml_value(key: &str, value: &str) -> Result<Option<toml::Value>> {
257 let key = key.trim();
258 if TABLE_ROOT_KEYS.contains(&key) {
259 anyhow::bail!(
260 "`{key}` is a table of entries, so `config set {key}` would replace all of \
261 them; nothing was saved. Edit the [{key}.<name>] table in config.toml instead."
262 );
263 }
264 codewhale_config::check_config_toml_choice(key, value)?;
265 if key == "reasoning_effort" {
266 let effort = crate::reasoning_preference::ReasoningEffort::parse_strict(value)
267 .map_err(|error| anyhow::anyhow!("invalid value for '{key}': {error}"))?;
268 return Ok(Some(toml::Value::String(effort.as_setting().to_string())));
269 }
270 if key.contains('.')
271 || codewhale_config::setting(key).is_some()
272 || !tui_config_fields().contains(&key)
273 || is_config_toml_typed_field(key)
274 {
275 return Ok(None);
276 }
277 let text = toml::Value::String(value.to_string());
278 if tui_config_accepts(key, &text) {
279 return Ok(None);
280 }
281 let trimmed = value.trim();
282 let candidates = [
283 crate::settings::parse_bool(trimmed)
284 .ok()
285 .map(toml::Value::Boolean),
286 trimmed.parse::<i64>().ok().map(toml::Value::Integer),
287 trimmed
288 .parse::<f64>()
289 .ok()
290 .filter(|number| number.is_finite())
291 .map(toml::Value::Float),
292 ];
293 candidates
294 .into_iter()
295 .flatten()
296 .find(|candidate| tui_config_accepts(key, candidate))
297 .map(Some)
298 .ok_or_else(|| {
299 anyhow::anyhow!(
300 "invalid value '{value}' for '{key}': its config.toml reader does not accept \
301 it. Edit `{key}` in config.toml with a TOML value of the documented type. \
302 No value was changed."
303 )
304 })
305 }
306
307 /// Whether the TUI [`Config`] deserializes `value` at root key `key`.
308 fn tui_config_accepts(key: &str, value: &toml::Value) -> bool {
309 let mut probe = toml::Table::new();
310 probe.insert(key.to_string(), value.clone());
311 toml::Value::Table(probe).try_into::<Config>().is_ok()
312 }
313
314 /// One line per config.toml root key that nothing reads, for `config doctor`.
315 /// A settings.toml key is named as misplaced so the fix is obvious.
316 #[must_use]
317 pub fn unread_config_keys<'a>(root_keys: impl IntoIterator<Item = &'a str>) -> Vec<String> {
318 root_keys
319 .into_iter()
320 .filter(|key| !is_config_toml_root_key(key))
321 .map(|key| {
322 if Settings::canonical_key(key).is_some() {
323 format!(
324 "`{key}` belongs in settings.toml; config.toml's copy is never read \
325 (move it: `codewhale config set {key} <value>`, then \
326 `codewhale config unset {key}`)"
327 )
328 } else {
329 let hint = nearest_key(key, settable_keys())
330 .map(|candidate| format!("; did you mean `{candidate}`?"))
331 .unwrap_or_default();
332 format!("`{key}` is not read by anything{hint}")
333 }
334 })
335 .collect()
336 }
337
338 #[cfg(test)]
339 mod tests {
340 use super::*;
341
342 #[test]
343 fn config_key_home_routes_by_reader() {
344 // settings.toml keys, including an alias `Settings::set` accepts.
345 assert_eq!(config_key_home("calm_mode"), ConfigKeyHome::SettingsToml);
346 assert_eq!(config_key_home("calm"), ConfigKeyHome::SettingsToml);
347 assert_eq!(
348 config_key_home("tool_collapse"),
349 ConfigKeyHome::SettingsToml
350 );
351 // config.toml: dispatcher-typed, TUI-typed, and other named readers.
352 for key in [
353 "provider",
354 "api_key",
355 "verbosity",
356 "log_level",
357 "hook_sinks",
358 "allow_shell",
359 "max_subagents",
360 "skills_dir",
361 "tui",
362 "features",
363 "profiles",
364 "projects",
365 "stream_chunk_timeout_secs",
366 ] {
367 assert_eq!(config_key_home(key), ConfigKeyHome::ConfigToml, "{key}");
368 }
369 // Both files know these spellings; config.toml keeps winning.
370 assert_eq!(config_key_home("sandbox_mode"), ConfigKeyHome::ConfigToml);
371 assert_eq!(
372 config_key_home("reasoning_effort"),
373 ConfigKeyHome::ConfigToml
374 );
375 // Dotted keys keep their validation in `ConfigToml::set_value`.
376 assert_eq!(
377 config_key_home("providers.deepseek.model"),
378 ConfigKeyHome::ConfigToml
379 );
380 assert_eq!(config_key_home("totally_bogus_key"), ConfigKeyHome::Unknown);
381 }
382
383 #[test]
384 fn every_shipped_example_root_key_is_read_by_something() {
385 let example: toml::Table =
386 toml::from_str(include_str!("../../../config.example.toml")).expect("example parses");
387 let unread = unread_config_keys(example.keys().map(String::as_str));
388 assert!(unread.is_empty(), "{unread:#?}");
389 }
390
391 #[test]
392 fn unknown_config_key_message_suggests_from_current_keys() {
393 let message = unknown_config_key_message("calm_mod");
394 assert!(message.contains("Did you mean `calm_mode`?"), "{message}");
395 assert!(message.contains("not saved"), "{message}");
396
397 let message = unknown_config_key_message("zzqqxxyy");
398 assert!(!message.contains("Did you mean"), "{message}");
399 }
400
401 #[test]
402 fn config_toml_value_types_fields_by_their_reader() {
403 // Typed TUI fields the schema does not declare get their field's type.
404 assert_eq!(
405 config_toml_value("yolo", "true").unwrap(),
406 Some(toml::Value::Boolean(true))
407 );
408 assert_eq!(
409 config_toml_value("strict_tool_mode", "off").unwrap(),
410 Some(toml::Value::Boolean(false))
411 );
412 assert_eq!(
413 config_toml_value("max_subagents", " 4 ").unwrap(),
414 Some(toml::Value::Integer(4))
415 );
416 assert_eq!(
417 config_toml_value("mcp_oauth_callback_port", "8765").unwrap(),
418 Some(toml::Value::Integer(8765))
419 );
420 // A value the field cannot hold is refused, not stored as text.
421 for (key, value) in [
422 ("yolo", "flase"),
423 ("max_subagents", "lots"),
424 ("mcp_oauth_callback_port", "70000"),
425 ] {
426 let error = config_toml_value(key, value).expect_err(key);
427 assert!(
428 format!("{error:#}").contains(&format!("invalid value '{value}' for '{key}'")),
429 "{error:#}"
430 );
431 }
432 // String fields, schema-declared keys and dotted keys keep
433 // `ConfigToml::set_value`.
434 assert_eq!(
435 config_toml_value("skills_dir", "/tmp/skills").unwrap(),
436 None
437 );
438 assert_eq!(config_toml_value("allow_shell", "on").unwrap(), None);
439 assert_eq!(
440 config_toml_value("providers.deepseek.model", "x").unwrap(),
441 None
442 );
443
444 // `reasoning_effort` accepts its reader's aliases, canonicalized.
445 for (alias, canonical) in [("none", "off"), ("mid", "medium"), ("maximum", "max")] {
446 assert_eq!(
447 config_toml_value("reasoning_effort", alias).unwrap(),
448 Some(toml::Value::String(canonical.into())),
449 "{alias}"
450 );
451 }
452 assert!(config_toml_value("reasoning_effort", "bogus").is_err());
453 }
454
455 #[test]
456 fn config_toml_value_refuses_values_the_loader_rejects() {
457 for (key, value) in [
458 ("approval_policy", " On-Request "),
459 ("approval_policy", "never"),
460 ("sandbox_mode", "workspace-write"),
461 ("verbosity", "concise"),
462 ] {
463 assert_eq!(
464 config_toml_value(key, value).unwrap(),
465 None,
466 "{key}={value}"
467 );
468 }
469 for (key, value, fix) in [
470 ("approval_policy", "ask", "on-request"),
471 ("sandbox_mode", "full", "read-only"),
472 ("verbosity", "quiet", "normal"),
473 ] {
474 let error = format!("{:#}", config_toml_value(key, value).expect_err(key));
475 assert!(
476 error.contains(&format!("invalid value '{value}' for '{key}'")),
477 "{error}"
478 );
479 assert!(
480 error.contains(&format!("fix: codewhale config set {key} {fix}")),
481 "{error}"
482 );
483 }
484 let error = format!(
485 "{:#}",
486 config_toml_value("approval_policy", "ask").unwrap_err()
487 );
488 assert!(error.contains("on-request, untrusted, never"), "{error}");
489 assert!(error.contains("settings.toml"), "{error}");
490 }
491
492 #[test]
493 fn settings_toml_keys_are_user_settable_only() {
494 let keys: Vec<&str> = settings_toml_keys().collect();
495 assert!(keys.contains(&"calm_mode"), "{keys:?}");
496 assert!(keys.contains(&"auto_compact"), "{keys:?}");
497 for internal in [
498 "feature_intro_shown",
499 "yolo_deprecation_shown",
500 "mcp_open",
501 "fast_model",
502 "effective_auto_compact",
503 ] {
504 assert!(!keys.contains(&internal), "{internal} is not settable");
505 }
506 }
507
508 #[test]
509 fn scalar_set_of_a_table_root_key_is_refused() {
510 // `config set workspace /path` used to store `workspace = "/path"`,
511 // replacing every `[workspace.'<path>']` entry on save.
512 for key in TABLE_ROOT_KEYS {
513 assert_eq!(config_key_home(key), ConfigKeyHome::ConfigToml, "{key}");
514 let error = config_toml_value(key, "/some/path").expect_err(key);
515 assert!(error.to_string().contains("nothing was saved"), "{error}");
516 }
517 }
518
519 #[test]
520 fn config_set_and_loader_accept_the_same_closed_vocabulary() {
521 for (key, value, valid) in [
522 ("approval_policy", " ON-REQUEST ", true),
523 ("approval_policy", "ask", false),
524 ("verbosity", " CONCISE ", true),
525 ("verbosity", "quiet", false),
526 ("sandbox_mode", " WORKSPACE-WRITE ", true),
527 ("sandbox_mode", "full", false),
528 ] {
529 let mut config = crate::config::Config::default();
530 match key {
531 "approval_policy" => config.approval_policy = Some(value.to_string()),
532 "verbosity" => config.verbosity = Some(value.to_string()),
533 "sandbox_mode" => config.sandbox_mode = Some(value.to_string()),
534 _ => unreachable!(),
535 }
536 assert_eq!(config_toml_value(key, value).is_ok(), valid, "{key}");
537 assert_eq!(config.validate().is_ok(), valid, "{key}");
538 }
539 }
540
541 #[test]
542 fn loader_choice_errors_redact_pasted_keys_but_keep_ordinary_typos() {
543 let token = ["sk-live-", "Z7qX4mNb2Vc9Lk3PwR8t"].concat();
544 for (key, correction) in [
545 ("approval_policy", "on-request"),
546 ("verbosity", "normal"),
547 ("sandbox_mode", "workspace-write"),
548 ] {
549 for value in [token.as_str(), "misspelled-choice"] {
550 let mut config = crate::config::Config::default();
551 match key {
552 "approval_policy" => config.approval_policy = Some(value.to_string()),
553 "verbosity" => config.verbosity = Some(value.to_string()),
554 "sandbox_mode" => config.sandbox_mode = Some(value.to_string()),
555 _ => unreachable!(),
556 }
557 let error = config.validate().expect_err("invalid choice");
558 let diagnostic = crate::config::SafeConfigDiagnostic::find_in(&error)
559 .expect("shared choice validation keeps the structured diagnostic");
560 let shareable = diagnostic.display_message();
561 assert!(shareable.starts_with(&format!("Invalid {key} (value not shown):")));
562 assert!(!shareable.contains(value), "{shareable}");
563 let fix = diagnostic
564 .fix()
565 .expect("invalid choices keep a recovery action");
566 assert!(
567 fix.starts_with(&format!("codewhale config set {key} {correction} (if ")),
568 "{fix}"
569 );
570 assert!(
571 fix.contains("profile") && fix.contains("managed config"),
572 "{fix}"
573 );
574 if key != "verbosity" {
575 assert!(
576 fix.contains(&format!("CODEWHALE_{}", key.to_ascii_uppercase())),
577 "{fix}"
578 );
579 }
580 let error = error.to_string();
581 assert!(!error.contains(&token));
582 assert!(error.contains("expected"));
583 assert!(error.contains(if value == token {
584 "[redacted]"
585 } else {
586 "misspelled-choice"
587 }));
588 }
589 }
590 }
591
592 #[test]
593 fn documented_user_workspace_entry_is_read() {
594 // The block docs/CONFIGURATION.md gives under "User workspace entries".
595 let documented: toml::Table =
596 toml::from_str("[workspace.'/absolute/path/to/project']\nallow_shell = true\n")
597 .expect("documented block parses");
598 let unread = unread_config_keys(documented.keys().map(String::as_str));
599 assert!(unread.is_empty(), "{unread:#?}");
600 }
601
602 #[test]
603 fn unread_config_keys_names_misplaced_settings_and_unknown_keys() {
604 let findings = unread_config_keys(["verbosity", "calm_mode", "totally_bogus_key", "tui"]);
605 assert_eq!(findings.len(), 2, "{findings:#?}");
606 assert!(
607 findings[0].contains("`calm_mode` belongs in settings.toml"),
608 "{findings:#?}"
609 );
610 assert!(
611 findings[1].contains("`totally_bogus_key` is not read by anything"),
612 "{findings:#?}"
613 );
614 }
615 #[test]
616 fn stream_dump_uses_runtime_resolution_without_exposing_other_config() {
617 let _env = crate::test_support::lock_test_env();
618 let _open =
619 crate::test_support::EnvVarGuard::set("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "88");
620 let _pin = crate::test_support::EnvVarGuard::set("CODEWHALE_FORCE_HTTP1", "1");
621 let config: ConfigToml = toml::from_str("[providers.deepseek]\napi_key='fixture-secret'\n[tui]\nstream_max_resumes=6\n[stream]\nhttp2_keep_alive_timeout_secs=0\n").unwrap();
622 let stream = resolved_stream_config(&config).unwrap();
623 assert_eq!(stream["open_timeout_secs"].as_integer(), Some(88));
624 assert_eq!(stream["max_resumes"].as_integer(), Some(6));
625 assert_eq!(stream["force_http1"].as_bool(), Some(true));
626 assert_eq!(
627 stream["http2_keep_alive_timeout_secs"].as_integer(),
628 Some(20)
629 );
630 assert!(!stream.to_string().contains("fixture-secret"));
631 assert!(unread_config_keys(["stream"]).is_empty());
632 for key in stream.as_table().unwrap().keys() {
633 assert!(
634 codewhale_config::setting(&format!("stream.{key}")).is_some(),
635 "{key}"
636 );
637 }
638 assert_eq!(
639 config.extras["stream"].as_table().unwrap().len(),
640 1,
641 "dump never persists resolved defaults"
642 );
643 }
644 }
645
645 lines RUST