Skip to main content

lucy/
config.rs

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    /// Update only the user-selected fields while preserving other TOML content.
200    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
305/// Return Lucy's XDG configuration directory. An unset, empty, or relative
306/// `XDG_CONFIG_HOME` falls back to the standard `$HOME/.config` location.
307pub 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
320/// Lucy's legacy state directory. Sessions intentionally remain here while
321/// user-editable configuration moves to the XDG configuration directory.
322pub 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}