1use std::fs::{self, OpenOptions};
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7#[cfg(unix)]
8use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
9
10pub const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
11pub const DEFAULT_API_KEY_ENV: &str = "OPENAI_API_KEY";
12pub const GENERATED_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
13pub const CODEX_SUBSCRIPTION_PROVIDER: &str = "codex_subscription";
14pub const OPENROUTER_PROVIDER: &str = "openrouter";
15pub const CODEX_API_KEY_ENV_SENTINEL: &str = "LUCY_CODEX_SUBSCRIPTION_TOKEN";
16pub const DEFAULT_SYSTEM_PROMPT: &str = "You can access computer resources. Use the provided tools to achieve the user's requirements. When needed, use cmd to read a relevant skill's SKILL.md.";
17
18const GENERATED_CONFIG: &str = r#"system_prompt = "You can access computer resources. Use the provided tools to achieve the user's requirements. When needed, use cmd to read a relevant skill's SKILL.md."
19
20[auth]
21provider = "openrouter"
22api_key_env = "OPENROUTER_API_KEY"
23
24[llm]
25base_url = "https://openrouter.ai/api/v1"
26model = ""
27# Optional reasoning effort sent as the OpenAI Chat Completions "reasoning_effort"
28# field, e.g. "low", "medium", "high". Omit or leave unset to send no effort.
29# Use a value your provider and model support; an unsupported value fails at runtime.
30# effort = "medium"
31"#;
32
33#[derive(Debug)]
34pub struct ConfigError(String);
35
36impl ConfigError {
37 fn new(message: impl Into<String>) -> Self {
38 Self(message.into())
39 }
40}
41
42impl std::fmt::Display for ConfigError {
43 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 formatter.write_str(&self.0)
45 }
46}
47
48impl std::error::Error for ConfigError {}
49
50impl From<io::Error> for ConfigError {
51 fn from(_error: io::Error) -> Self {
52 Self::new("configuration file error")
53 }
54}
55
56#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
57pub struct Config {
58 #[serde(default = "default_system_prompt")]
59 pub system_prompt: String,
60 #[serde(default)]
61 pub auth: AuthConfig,
62 #[serde(default)]
63 pub llm: LlmConfig,
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
67pub struct AuthConfig {
68 #[serde(default)]
69 pub provider: AuthProvider,
70 #[serde(default)]
71 pub api_key_env: Option<String>,
72}
73
74#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
75#[serde(rename_all = "snake_case")]
76pub enum AuthProvider {
77 #[default]
78 Openrouter,
79 CodexSubscription,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct AuthSettings {
84 pub provider: AuthProvider,
85 pub api_key_env: Option<String>,
86}
87
88#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
89pub struct LlmConfig {
90 #[serde(default = "default_base_url")]
91 pub base_url: String,
92 #[serde(default)]
93 pub model: String,
94 #[serde(default)]
95 pub api_key_env: Option<String>,
96 #[serde(default)]
97 pub effort: Option<String>,
98}
99
100#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
101pub struct LlmSettings {
102 pub base_url: String,
103 pub model: String,
104 pub api_key_env: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub effort: Option<String>,
107}
108
109impl Default for Config {
110 fn default() -> Self {
111 Self {
112 system_prompt: DEFAULT_SYSTEM_PROMPT.to_owned(),
113 auth: AuthConfig::default(),
114 llm: LlmConfig::default(),
115 }
116 }
117}
118
119impl Default for AuthConfig {
120 fn default() -> Self {
121 Self {
122 provider: AuthProvider::Openrouter,
123 api_key_env: None,
124 }
125 }
126}
127
128impl Default for LlmConfig {
129 fn default() -> Self {
130 Self {
131 base_url: DEFAULT_BASE_URL.to_owned(),
132 model: String::new(),
133 api_key_env: None,
134 effort: None,
135 }
136 }
137}
138
139fn default_system_prompt() -> String {
140 DEFAULT_SYSTEM_PROMPT.to_owned()
141}
142
143fn default_base_url() -> String {
144 DEFAULT_BASE_URL.to_owned()
145}
146
147impl Config {
148 pub fn load_or_create(home: &Path) -> Result<Self, ConfigError> {
149 Self::ensure_exists(home)?;
150 Self::load_from_path(&config_path(home))
151 }
152
153 pub fn ensure_exists(home: &Path) -> Result<(), ConfigError> {
154 let path = config_path(home);
155 ensure_private_dir(&config_dir(home))?;
156 ensure_not_symlink(&path)?;
157
158 if !path.exists() {
159 migrate_legacy_config(home, &path)?;
160 }
161 if path.exists() {
162 ensure_private_file(&path)?;
163 return Ok(());
164 }
165 if generated_config_contains_active_key() {
166 return Err(ConfigError::new("configuration bootstrap rejected"));
167 }
168
169 let mut options = OpenOptions::new();
170 options.write(true).create_new(true);
171 #[cfg(unix)]
172 options.mode(0o600);
173 match options.open(&path) {
174 Ok(mut file) => {
175 file.write_all(GENERATED_CONFIG.as_bytes())?;
176 file.flush()?;
177 ensure_private_file(&path)?;
178 Ok(())
179 }
180 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
181 ensure_private_file(&path)?;
182 Ok(())
183 }
184 Err(_error) => Err(ConfigError::new("unable to create config.toml")),
185 }
186 }
187
188 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
189 ensure_not_symlink(path)
190 .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
191 let bytes = fs::read_to_string(path)
192 .map_err(|_error| ConfigError::new("unable to read config.toml"))?;
193 ensure_private_file(path)
194 .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
195 toml::from_str(&bytes)
196 .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))
197 }
198
199 pub fn save_selection(
201 home: &Path,
202 model: &str,
203 effort: Option<&str>,
204 ) -> Result<(), ConfigError> {
205 let path = config_path(home);
206 let source = fs::read_to_string(&path)
207 .map_err(|_| ConfigError::new("unable to read config.toml"))?;
208 let mut document: toml::Value = toml::from_str(&source)
209 .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))?;
210 let llm = document
211 .as_table_mut()
212 .and_then(|root| root.get_mut("llm"))
213 .and_then(toml::Value::as_table_mut)
214 .ok_or_else(|| ConfigError::new("config.toml is missing [llm]"))?;
215 llm.insert("model".to_owned(), toml::Value::String(model.to_owned()));
216 match effort.filter(|value| !value.trim().is_empty()) {
217 Some(value) => {
218 llm.insert(
219 "effort".to_owned(),
220 toml::Value::String(value.trim().to_owned()),
221 );
222 }
223 None => {
224 llm.remove("effort");
225 }
226 }
227 let rendered = toml::to_string_pretty(&document)
228 .map_err(|_| ConfigError::new("unable to write config.toml"))?;
229 fs::write(&path, rendered).map_err(|_| ConfigError::new("unable to write config.toml"))?;
230 ensure_private_file(&path).map_err(|_| ConfigError::new("unable to secure config.toml"))
231 }
232
233 pub fn resolved_auth(&self) -> Result<AuthSettings, ConfigError> {
234 let legacy_api_key_env = self.llm.api_key_env.as_deref().map(str::trim);
235 let configured_api_key_env = self.auth.api_key_env.as_deref().map(str::trim);
236 if configured_api_key_env.is_some_and(str::is_empty) {
237 return Err(ConfigError::new("auth.api_key_env must not be empty"));
238 }
239 if legacy_api_key_env.is_some_and(str::is_empty) {
240 return Err(ConfigError::new("llm.api_key_env must not be empty"));
241 }
242 match self.auth.provider {
243 AuthProvider::Openrouter => {
244 if configured_api_key_env.is_some()
245 && legacy_api_key_env.is_some()
246 && configured_api_key_env != legacy_api_key_env
247 {
248 return Err(ConfigError::new(
249 "auth.api_key_env and llm.api_key_env must match",
250 ));
251 }
252 let api_key_env = configured_api_key_env
253 .or(legacy_api_key_env)
254 .unwrap_or(DEFAULT_API_KEY_ENV)
255 .to_owned();
256 if api_key_env == CODEX_API_KEY_ENV_SENTINEL {
257 return Err(ConfigError::new(
258 "auth.api_key_env is reserved for Codex subscription authentication",
259 ));
260 }
261 Ok(AuthSettings {
262 provider: AuthProvider::Openrouter,
263 api_key_env: Some(api_key_env),
264 })
265 }
266 AuthProvider::CodexSubscription => {
267 if configured_api_key_env.is_some() || legacy_api_key_env.is_some() {
268 return Err(ConfigError::new(
269 "codex_subscription cannot be combined with an API-key environment",
270 ));
271 }
272 Ok(AuthSettings {
273 provider: AuthProvider::CodexSubscription,
274 api_key_env: None,
275 })
276 }
277 }
278 }
279
280 pub fn resolved_llm(&self) -> Result<LlmSettings, ConfigError> {
281 let base_url = self.llm.base_url.trim().to_owned();
282 if base_url.is_empty() {
283 return Err(ConfigError::new("llm.base_url must not be empty"));
284 }
285
286 let auth = self.resolved_auth()?;
287 let api_key_env = match auth.provider {
288 AuthProvider::Openrouter => auth
289 .api_key_env
290 .unwrap_or_else(|| DEFAULT_API_KEY_ENV.to_owned()),
291 AuthProvider::CodexSubscription => CODEX_API_KEY_ENV_SENTINEL.to_owned(),
292 };
293
294 let effort = self.llm.effort.as_deref().map(str::trim).map(str::to_owned);
295
296 Ok(LlmSettings {
297 base_url,
298 model: self.llm.model.trim().to_owned(),
299 api_key_env,
300 effort,
301 })
302 }
303}
304
305pub fn config_dir(home: &Path) -> PathBuf {
308 config_dir_from_xdg_home(home, std::env::var_os("XDG_CONFIG_HOME").as_deref())
309}
310
311fn config_dir_from_xdg_home(home: &Path, xdg_config_home: Option<&std::ffi::OsStr>) -> PathBuf {
312 xdg_config_home
313 .filter(|path| !path.is_empty())
314 .map(PathBuf::from)
315 .filter(|path| path.is_absolute())
316 .unwrap_or_else(|| home.join(".config"))
317 .join("lucy")
318}
319
320pub fn lucy_dir(home: &Path) -> PathBuf {
323 home.join(".lucy")
324}
325
326pub fn config_path(home: &Path) -> PathBuf {
327 config_dir(home).join("config.toml")
328}
329
330fn legacy_config_path(home: &Path) -> PathBuf {
331 home.join(".lucy").join("config.toml")
332}
333
334fn migrate_legacy_config(home: &Path, destination: &Path) -> Result<(), ConfigError> {
335 let legacy = legacy_config_path(home);
336 let legacy_dir = legacy.parent().expect("legacy config has a parent");
337 ensure_not_symlink(legacy_dir)
338 .map_err(|_error| ConfigError::new("unable to secure legacy config.toml"))?;
339
340 let metadata = match fs::symlink_metadata(&legacy) {
341 Ok(metadata) => metadata,
342 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
343 Err(_error) => return Err(ConfigError::new("unable to inspect legacy config.toml")),
344 };
345 if metadata.file_type().is_symlink() || !metadata.is_file() {
346 return Err(ConfigError::new("unable to secure legacy config.toml"));
347 }
348 let bytes = fs::read(&legacy)
349 .map_err(|_error| ConfigError::new("unable to read legacy config.toml"))?;
350 ensure_private_file(&legacy)
351 .map_err(|_error| ConfigError::new("unable to secure legacy config.toml"))?;
352
353 let mut options = OpenOptions::new();
354 options.write(true).create_new(true);
355 #[cfg(unix)]
356 options.mode(0o600);
357 let mut destination_file = options
358 .open(destination)
359 .map_err(|_error| ConfigError::new("unable to migrate legacy config.toml"))?;
360 destination_file
361 .write_all(&bytes)
362 .and_then(|()| destination_file.flush())
363 .map_err(|_error| ConfigError::new("unable to migrate legacy config.toml"))?;
364 ensure_private_file(destination)
365 .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
366 fs::remove_file(legacy)
367 .map_err(|_error| ConfigError::new("unable to remove legacy config.toml"))?;
368 Ok(())
369}
370
371fn generated_config_contains_active_key() -> bool {
372 std::env::var(GENERATED_API_KEY_ENV)
373 .ok()
374 .filter(|secret| !secret.is_empty())
375 .is_some_and(|secret| GENERATED_CONFIG.contains(&secret))
376}
377
378pub(crate) fn ensure_not_symlink(path: &Path) -> io::Result<()> {
379 match fs::symlink_metadata(path) {
380 Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new(
381 io::ErrorKind::InvalidInput,
382 "symlinks are not allowed for protected paths",
383 )),
384 Ok(_) => Ok(()),
385 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
386 Err(error) => Err(error),
387 }
388}
389
390pub(crate) fn ensure_private_dir(path: &Path) -> io::Result<()> {
391 ensure_not_symlink(path)?;
392 fs::create_dir_all(path)?;
393 let metadata = fs::symlink_metadata(path)?;
394 if metadata.file_type().is_symlink() || !metadata.is_dir() {
395 return Err(io::Error::new(
396 io::ErrorKind::InvalidInput,
397 "protected path is not a directory",
398 ));
399 }
400 #[cfg(unix)]
401 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
402 Ok(())
403}
404
405pub(crate) fn ensure_private_file(path: &Path) -> io::Result<()> {
406 ensure_not_symlink(path)?;
407 #[cfg(unix)]
408 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
409 #[cfg(not(unix))]
410 let _ = path;
411 Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 #[cfg(unix)]
418 use std::os::unix::fs::{symlink, PermissionsExt};
419 use std::sync::atomic::{AtomicU64, Ordering};
420 use std::time::{SystemTime, UNIX_EPOCH};
421
422 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
423
424 fn temporary_home() -> PathBuf {
425 loop {
426 let stamp = SystemTime::now()
427 .duration_since(UNIX_EPOCH)
428 .expect("clock")
429 .as_nanos();
430 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
431 let path = std::env::temp_dir().join(format!(
432 "lucy-config-{stamp}-{}-{counter}",
433 std::process::id()
434 ));
435 match fs::create_dir(&path) {
436 Ok(()) => return path,
437 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
438 Err(error) => panic!("temp home: {error}"),
439 }
440 }
441 }
442
443 #[test]
444 fn bootstraps_config_without_overwriting_existing_bytes() {
445 let home = temporary_home();
446 let first = Config::load_or_create(&home).expect("create config");
447 assert_eq!(first.llm.model, "");
448 assert_eq!(first.llm.base_url, DEFAULT_BASE_URL);
449 assert_eq!(first.auth.provider, AuthProvider::Openrouter);
450 assert_eq!(
451 first.auth.api_key_env.as_deref(),
452 Some(GENERATED_API_KEY_ENV)
453 );
454
455 let path = config_path(&home);
456 let generated = fs::read(&path).expect("generated bytes");
457 #[cfg(unix)]
458 assert_eq!(
459 fs::metadata(&path)
460 .expect("config metadata")
461 .permissions()
462 .mode()
463 & 0o777,
464 0o600
465 );
466 #[cfg(unix)]
467 assert_eq!(
468 fs::metadata(config_dir(&home))
469 .expect("Lucy directory metadata")
470 .permissions()
471 .mode()
472 & 0o777,
473 0o700
474 );
475 let custom = b"system_prompt = \"custom\"\n[llm]\nmodel = \"local\"\n";
476 fs::write(&path, custom).expect("custom config");
477 let loaded = Config::load_or_create(&home).expect("load custom config");
478 assert_eq!(loaded.system_prompt, "custom");
479 assert_eq!(loaded.llm.model, "local");
480 assert_ne!(generated, custom);
481 assert_eq!(fs::read(path).expect("bytes after load"), custom);
482
483 fs::remove_dir_all(home).expect("remove temp home");
484 }
485
486 #[cfg(unix)]
487 #[test]
488 fn rejects_symlinked_config_files_and_directories() {
489 let home = temporary_home();
490 let lucy = config_dir(&home);
491 fs::create_dir_all(&lucy).expect("Lucy directory");
492 let target = home.join("config-target.toml");
493 fs::write(&target, "system_prompt = \"target\"\n").expect("target config");
494 let path = config_path(&home);
495 symlink(&target, &path).expect("config symlink");
496 assert!(Config::load_or_create(&home).is_err());
497 assert!(Config::load_from_path(&path).is_err());
498 fs::remove_file(path).expect("remove config symlink");
499 fs::remove_dir(lucy).expect("remove Lucy directory");
500 fs::remove_file(target).expect("remove target config");
501 fs::remove_dir_all(&home).expect("remove temp home");
502
503 let home = temporary_home();
504 let target = home.join("lucy-target");
505 fs::create_dir(&target).expect("target directory");
506 fs::create_dir_all(config_dir(&home).parent().expect("config parent"))
507 .expect("config parent");
508 symlink(&target, config_dir(&home)).expect("Lucy directory symlink");
509 assert!(Config::ensure_exists(&home).is_err());
510 fs::remove_file(config_dir(&home)).expect("remove Lucy directory symlink");
511 fs::remove_dir(target).expect("remove target directory");
512 fs::remove_dir_all(home).expect("remove temp home");
513 }
514
515 #[test]
516 fn migrates_a_legacy_config_once_without_overwriting_xdg_config() {
517 let home = temporary_home();
518 let legacy = legacy_config_path(&home);
519 fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy parent");
520 let legacy_bytes = b"system_prompt = \"legacy\"\n[llm]\nmodel = \"old-model\"\n";
521 fs::write(&legacy, legacy_bytes).expect("legacy config");
522
523 let config = Config::load_or_create(&home).expect("migrate legacy config");
524 assert_eq!(config.system_prompt, "legacy");
525 assert_eq!(
526 fs::read(config_path(&home)).expect("migrated bytes"),
527 legacy_bytes
528 );
529 assert!(!legacy.exists());
530
531 fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy parent");
532 fs::write(&legacy, b"system_prompt = \"stale\"\n").expect("stale legacy config");
533 let loaded = Config::load_or_create(&home).expect("retain XDG config");
534 assert_eq!(loaded.system_prompt, "legacy");
535 assert_eq!(
536 fs::read(config_path(&home)).expect("XDG bytes"),
537 legacy_bytes
538 );
539 assert_eq!(
540 fs::read(&legacy).expect("legacy bytes"),
541 b"system_prompt = \"stale\"\n"
542 );
543
544 fs::remove_dir_all(home).expect("remove temp home");
545 }
546
547 #[test]
548 fn config_dir_uses_absolute_xdg_home_and_defaults_otherwise() {
549 let home = temporary_home();
550 let xdg_home = home.join("custom-xdg");
551 assert_eq!(
552 config_dir_from_xdg_home(&home, Some(xdg_home.as_os_str())),
553 xdg_home.join("lucy")
554 );
555 assert_eq!(
556 config_dir_from_xdg_home(&home, None),
557 home.join(".config/lucy")
558 );
559 assert_eq!(
560 config_dir_from_xdg_home(&home, Some(std::ffi::OsStr::new("relative"))),
561 home.join(".config/lucy")
562 );
563 fs::remove_dir_all(home).expect("remove temp home");
564 }
565
566 #[test]
567 fn malformed_toml_error_does_not_include_source_details() {
568 let home = temporary_home();
569 let path = config_path(&home);
570 fs::create_dir_all(path.parent().expect("config parent")).expect("config parent");
571 fs::write(
572 &path,
573 "system_prompt = \"provider-secret\n[llm]\nmodel = [\n",
574 )
575 .expect("malformed config");
576
577 let error = Config::load_from_path(&path).expect_err("malformed TOML");
578 let message = error.to_string();
579 assert!(message.contains("invalid TOML"));
580 assert!(!message.contains("provider-secret"));
581 assert!(!message.contains("system_prompt"));
582 assert!(!message.contains(&path.display().to_string()));
583 fs::remove_dir_all(home).expect("remove temp home");
584 }
585
586 #[test]
587 fn omitted_api_key_environment_uses_openai_default() {
588 let config = Config {
589 system_prompt: "prompt".to_owned(),
590 auth: AuthConfig::default(),
591 llm: LlmConfig {
592 base_url: "http://localhost".to_owned(),
593 model: "model".to_owned(),
594 api_key_env: None,
595 effort: None,
596 },
597 };
598 assert_eq!(
599 config.resolved_llm().expect("settings").api_key_env,
600 DEFAULT_API_KEY_ENV
601 );
602 }
603
604 #[test]
605 fn auth_provider_rejects_mixed_credentials() {
606 let config = Config {
607 system_prompt: "prompt".to_owned(),
608 auth: AuthConfig {
609 provider: AuthProvider::CodexSubscription,
610 api_key_env: None,
611 },
612 llm: LlmConfig {
613 base_url: DEFAULT_BASE_URL.to_owned(),
614 model: "model".to_owned(),
615 api_key_env: Some("OPENROUTER_API_KEY".to_owned()),
616 effort: None,
617 },
618 };
619 let error = config.resolved_auth().expect_err("mixed auth");
620 assert_eq!(
621 error.to_string(),
622 "codex_subscription cannot be combined with an API-key environment"
623 );
624 }
625
626 #[test]
627 fn openrouter_rejects_the_codex_auth_sentinel_environment() {
628 let config = Config {
629 system_prompt: "prompt".to_owned(),
630 auth: AuthConfig {
631 provider: AuthProvider::Openrouter,
632 api_key_env: Some(CODEX_API_KEY_ENV_SENTINEL.to_owned()),
633 },
634 llm: LlmConfig::default(),
635 };
636 assert!(config.resolved_auth().is_err());
637 }
638
639 #[test]
640 fn codex_auth_resolves_without_an_api_key_environment() {
641 let config = Config {
642 system_prompt: "prompt".to_owned(),
643 auth: AuthConfig {
644 provider: AuthProvider::CodexSubscription,
645 api_key_env: None,
646 },
647 llm: LlmConfig {
648 base_url: DEFAULT_BASE_URL.to_owned(),
649 model: "model".to_owned(),
650 api_key_env: None,
651 effort: None,
652 },
653 };
654 assert_eq!(
655 config.resolved_auth().expect("codex auth").provider,
656 AuthProvider::CodexSubscription
657 );
658 }
659
660 #[test]
661 fn resolved_effort_passes_through_and_trims() {
662 let config = |effort: Option<&str>| Config {
663 system_prompt: "prompt".to_owned(),
664 auth: AuthConfig::default(),
665 llm: LlmConfig {
666 base_url: "http://localhost".to_owned(),
667 model: "model".to_owned(),
668 api_key_env: Some("LUCY_KEY".to_owned()),
669 effort: effort.map(str::to_owned),
670 },
671 };
672 assert_eq!(config(None).resolved_llm().expect("none").effort, None);
673 assert_eq!(
674 config(Some("high"))
675 .resolved_llm()
676 .expect("set")
677 .effort
678 .as_deref(),
679 Some("high")
680 );
681 assert_eq!(
682 config(Some(" medium "))
683 .resolved_llm()
684 .expect("trim")
685 .effort
686 .as_deref(),
687 Some("medium")
688 );
689 }
690}