| 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 |