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 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.";
14
15const 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."
16
17[llm]
18base_url = "https://openrouter.ai/api/v1"
19model = ""
20api_key_env = "OPENROUTER_API_KEY"
21# Optional reasoning effort sent as the OpenAI Chat Completions "reasoning_effort"
22# field, e.g. "low", "medium", "high". Omit or leave unset to send no effort.
23# Use a value your provider and model support; an unsupported value fails at runtime.
24# effort = "medium"
25"#;
26
27#[derive(Debug)]
28pub struct ConfigError(String);
29
30impl ConfigError {
31    fn new(message: impl Into<String>) -> Self {
32        Self(message.into())
33    }
34}
35
36impl std::fmt::Display for ConfigError {
37    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        formatter.write_str(&self.0)
39    }
40}
41
42impl std::error::Error for ConfigError {}
43
44impl From<io::Error> for ConfigError {
45    fn from(_error: io::Error) -> Self {
46        Self::new("configuration file error")
47    }
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
51pub struct Config {
52    #[serde(default = "default_system_prompt")]
53    pub system_prompt: String,
54    #[serde(default)]
55    pub llm: LlmConfig,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
59pub struct LlmConfig {
60    #[serde(default = "default_base_url")]
61    pub base_url: String,
62    #[serde(default)]
63    pub model: String,
64    #[serde(default)]
65    pub api_key_env: Option<String>,
66    #[serde(default)]
67    pub effort: Option<String>,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
71pub struct LlmSettings {
72    pub base_url: String,
73    pub model: String,
74    pub api_key_env: String,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub effort: Option<String>,
77}
78
79impl Default for Config {
80    fn default() -> Self {
81        Self {
82            system_prompt: DEFAULT_SYSTEM_PROMPT.to_owned(),
83            llm: LlmConfig::default(),
84        }
85    }
86}
87
88impl Default for LlmConfig {
89    fn default() -> Self {
90        Self {
91            base_url: DEFAULT_BASE_URL.to_owned(),
92            model: String::new(),
93            api_key_env: None,
94            effort: None,
95        }
96    }
97}
98
99fn default_system_prompt() -> String {
100    DEFAULT_SYSTEM_PROMPT.to_owned()
101}
102
103fn default_base_url() -> String {
104    DEFAULT_BASE_URL.to_owned()
105}
106
107impl Config {
108    pub fn load_or_create(home: &Path) -> Result<Self, ConfigError> {
109        Self::ensure_exists(home)?;
110        Self::load_from_path(&config_path(home))
111    }
112
113    pub fn ensure_exists(home: &Path) -> Result<(), ConfigError> {
114        let path = config_path(home);
115        ensure_private_dir(&config_dir(home))?;
116        ensure_not_symlink(&path)?;
117
118        if !path.exists() {
119            migrate_legacy_config(home, &path)?;
120        }
121        if path.exists() {
122            ensure_private_file(&path)?;
123            return Ok(());
124        }
125        if generated_config_contains_active_key() {
126            return Err(ConfigError::new("configuration bootstrap rejected"));
127        }
128
129        let mut options = OpenOptions::new();
130        options.write(true).create_new(true);
131        #[cfg(unix)]
132        options.mode(0o600);
133        match options.open(&path) {
134            Ok(mut file) => {
135                file.write_all(GENERATED_CONFIG.as_bytes())?;
136                file.flush()?;
137                ensure_private_file(&path)?;
138                Ok(())
139            }
140            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
141                ensure_private_file(&path)?;
142                Ok(())
143            }
144            Err(_error) => Err(ConfigError::new("unable to create config.toml")),
145        }
146    }
147
148    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
149        ensure_not_symlink(path)
150            .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
151        let bytes = fs::read_to_string(path)
152            .map_err(|_error| ConfigError::new("unable to read config.toml"))?;
153        ensure_private_file(path)
154            .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
155        toml::from_str(&bytes)
156            .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))
157    }
158
159    /// Update only the user-selected fields while preserving other TOML content.
160    pub fn save_selection(
161        home: &Path,
162        model: &str,
163        effort: Option<&str>,
164    ) -> Result<(), ConfigError> {
165        let path = config_path(home);
166        let source = fs::read_to_string(&path)
167            .map_err(|_| ConfigError::new("unable to read config.toml"))?;
168        let mut document: toml::Value = toml::from_str(&source)
169            .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))?;
170        let llm = document
171            .as_table_mut()
172            .and_then(|root| root.get_mut("llm"))
173            .and_then(toml::Value::as_table_mut)
174            .ok_or_else(|| ConfigError::new("config.toml is missing [llm]"))?;
175        llm.insert("model".to_owned(), toml::Value::String(model.to_owned()));
176        match effort.filter(|value| !value.trim().is_empty()) {
177            Some(value) => {
178                llm.insert(
179                    "effort".to_owned(),
180                    toml::Value::String(value.trim().to_owned()),
181                );
182            }
183            None => {
184                llm.remove("effort");
185            }
186        }
187        let rendered = toml::to_string_pretty(&document)
188            .map_err(|_| ConfigError::new("unable to write config.toml"))?;
189        fs::write(&path, rendered).map_err(|_| ConfigError::new("unable to write config.toml"))?;
190        ensure_private_file(&path).map_err(|_| ConfigError::new("unable to secure config.toml"))
191    }
192
193    pub fn resolved_llm(&self) -> Result<LlmSettings, ConfigError> {
194        let base_url = self.llm.base_url.trim().to_owned();
195        if base_url.is_empty() {
196            return Err(ConfigError::new("llm.base_url must not be empty"));
197        }
198
199        let api_key_env = self
200            .llm
201            .api_key_env
202            .as_deref()
203            .unwrap_or(DEFAULT_API_KEY_ENV)
204            .trim()
205            .to_owned();
206        if api_key_env.is_empty() {
207            return Err(ConfigError::new("llm.api_key_env must not be empty"));
208        }
209
210        let effort = self.llm.effort.as_deref().map(str::trim).map(str::to_owned);
211
212        Ok(LlmSettings {
213            base_url,
214            model: self.llm.model.trim().to_owned(),
215            api_key_env,
216            effort,
217        })
218    }
219}
220
221/// Return Lucy's XDG configuration directory. An unset, empty, or relative
222/// `XDG_CONFIG_HOME` falls back to the standard `$HOME/.config` location.
223pub fn config_dir(home: &Path) -> PathBuf {
224    config_dir_from_xdg_home(home, std::env::var_os("XDG_CONFIG_HOME").as_deref())
225}
226
227fn config_dir_from_xdg_home(home: &Path, xdg_config_home: Option<&std::ffi::OsStr>) -> PathBuf {
228    xdg_config_home
229        .filter(|path| !path.is_empty())
230        .map(PathBuf::from)
231        .filter(|path| path.is_absolute())
232        .unwrap_or_else(|| home.join(".config"))
233        .join("lucy")
234}
235
236/// Lucy's legacy state directory. Sessions intentionally remain here while
237/// user-editable configuration moves to the XDG configuration directory.
238pub fn lucy_dir(home: &Path) -> PathBuf {
239    home.join(".lucy")
240}
241
242pub fn config_path(home: &Path) -> PathBuf {
243    config_dir(home).join("config.toml")
244}
245
246fn legacy_config_path(home: &Path) -> PathBuf {
247    home.join(".lucy").join("config.toml")
248}
249
250fn migrate_legacy_config(home: &Path, destination: &Path) -> Result<(), ConfigError> {
251    let legacy = legacy_config_path(home);
252    let legacy_dir = legacy.parent().expect("legacy config has a parent");
253    ensure_not_symlink(legacy_dir)
254        .map_err(|_error| ConfigError::new("unable to secure legacy config.toml"))?;
255
256    let metadata = match fs::symlink_metadata(&legacy) {
257        Ok(metadata) => metadata,
258        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
259        Err(_error) => return Err(ConfigError::new("unable to inspect legacy config.toml")),
260    };
261    if metadata.file_type().is_symlink() || !metadata.is_file() {
262        return Err(ConfigError::new("unable to secure legacy config.toml"));
263    }
264    let bytes = fs::read(&legacy)
265        .map_err(|_error| ConfigError::new("unable to read legacy config.toml"))?;
266    ensure_private_file(&legacy)
267        .map_err(|_error| ConfigError::new("unable to secure legacy config.toml"))?;
268
269    let mut options = OpenOptions::new();
270    options.write(true).create_new(true);
271    #[cfg(unix)]
272    options.mode(0o600);
273    let mut destination_file = options
274        .open(destination)
275        .map_err(|_error| ConfigError::new("unable to migrate legacy config.toml"))?;
276    destination_file
277        .write_all(&bytes)
278        .and_then(|()| destination_file.flush())
279        .map_err(|_error| ConfigError::new("unable to migrate legacy config.toml"))?;
280    ensure_private_file(destination)
281        .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
282    fs::remove_file(legacy)
283        .map_err(|_error| ConfigError::new("unable to remove legacy config.toml"))?;
284    Ok(())
285}
286
287fn generated_config_contains_active_key() -> bool {
288    std::env::var(GENERATED_API_KEY_ENV)
289        .ok()
290        .filter(|secret| !secret.is_empty())
291        .is_some_and(|secret| GENERATED_CONFIG.contains(&secret))
292}
293
294pub(crate) fn ensure_not_symlink(path: &Path) -> io::Result<()> {
295    match fs::symlink_metadata(path) {
296        Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new(
297            io::ErrorKind::InvalidInput,
298            "symlinks are not allowed for protected paths",
299        )),
300        Ok(_) => Ok(()),
301        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
302        Err(error) => Err(error),
303    }
304}
305
306pub(crate) fn ensure_private_dir(path: &Path) -> io::Result<()> {
307    ensure_not_symlink(path)?;
308    fs::create_dir_all(path)?;
309    let metadata = fs::symlink_metadata(path)?;
310    if metadata.file_type().is_symlink() || !metadata.is_dir() {
311        return Err(io::Error::new(
312            io::ErrorKind::InvalidInput,
313            "protected path is not a directory",
314        ));
315    }
316    #[cfg(unix)]
317    fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
318    Ok(())
319}
320
321pub(crate) fn ensure_private_file(path: &Path) -> io::Result<()> {
322    ensure_not_symlink(path)?;
323    #[cfg(unix)]
324    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
325    #[cfg(not(unix))]
326    let _ = path;
327    Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    #[cfg(unix)]
334    use std::os::unix::fs::{symlink, PermissionsExt};
335    use std::sync::atomic::{AtomicU64, Ordering};
336    use std::time::{SystemTime, UNIX_EPOCH};
337
338    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
339
340    fn temporary_home() -> PathBuf {
341        loop {
342            let stamp = SystemTime::now()
343                .duration_since(UNIX_EPOCH)
344                .expect("clock")
345                .as_nanos();
346            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
347            let path = std::env::temp_dir().join(format!(
348                "lucy-config-{stamp}-{}-{counter}",
349                std::process::id()
350            ));
351            match fs::create_dir(&path) {
352                Ok(()) => return path,
353                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
354                Err(error) => panic!("temp home: {error}"),
355            }
356        }
357    }
358
359    #[test]
360    fn bootstraps_config_without_overwriting_existing_bytes() {
361        let home = temporary_home();
362        let first = Config::load_or_create(&home).expect("create config");
363        assert_eq!(first.llm.model, "");
364        assert_eq!(first.llm.base_url, DEFAULT_BASE_URL);
365        assert_eq!(
366            first.llm.api_key_env.as_deref(),
367            Some(GENERATED_API_KEY_ENV)
368        );
369
370        let path = config_path(&home);
371        let generated = fs::read(&path).expect("generated bytes");
372        #[cfg(unix)]
373        assert_eq!(
374            fs::metadata(&path)
375                .expect("config metadata")
376                .permissions()
377                .mode()
378                & 0o777,
379            0o600
380        );
381        #[cfg(unix)]
382        assert_eq!(
383            fs::metadata(config_dir(&home))
384                .expect("Lucy directory metadata")
385                .permissions()
386                .mode()
387                & 0o777,
388            0o700
389        );
390        let custom = b"system_prompt = \"custom\"\n[llm]\nmodel = \"local\"\n";
391        fs::write(&path, custom).expect("custom config");
392        let loaded = Config::load_or_create(&home).expect("load custom config");
393        assert_eq!(loaded.system_prompt, "custom");
394        assert_eq!(loaded.llm.model, "local");
395        assert_ne!(generated, custom);
396        assert_eq!(fs::read(path).expect("bytes after load"), custom);
397
398        fs::remove_dir_all(home).expect("remove temp home");
399    }
400
401    #[cfg(unix)]
402    #[test]
403    fn rejects_symlinked_config_files_and_directories() {
404        let home = temporary_home();
405        let lucy = config_dir(&home);
406        fs::create_dir_all(&lucy).expect("Lucy directory");
407        let target = home.join("config-target.toml");
408        fs::write(&target, "system_prompt = \"target\"\n").expect("target config");
409        let path = config_path(&home);
410        symlink(&target, &path).expect("config symlink");
411        assert!(Config::load_or_create(&home).is_err());
412        assert!(Config::load_from_path(&path).is_err());
413        fs::remove_file(path).expect("remove config symlink");
414        fs::remove_dir(lucy).expect("remove Lucy directory");
415        fs::remove_file(target).expect("remove target config");
416        fs::remove_dir_all(&home).expect("remove temp home");
417
418        let home = temporary_home();
419        let target = home.join("lucy-target");
420        fs::create_dir(&target).expect("target directory");
421        fs::create_dir_all(config_dir(&home).parent().expect("config parent"))
422            .expect("config parent");
423        symlink(&target, config_dir(&home)).expect("Lucy directory symlink");
424        assert!(Config::ensure_exists(&home).is_err());
425        fs::remove_file(config_dir(&home)).expect("remove Lucy directory symlink");
426        fs::remove_dir(target).expect("remove target directory");
427        fs::remove_dir_all(home).expect("remove temp home");
428    }
429
430    #[test]
431    fn migrates_a_legacy_config_once_without_overwriting_xdg_config() {
432        let home = temporary_home();
433        let legacy = legacy_config_path(&home);
434        fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy parent");
435        let legacy_bytes = b"system_prompt = \"legacy\"\n[llm]\nmodel = \"old-model\"\n";
436        fs::write(&legacy, legacy_bytes).expect("legacy config");
437
438        let config = Config::load_or_create(&home).expect("migrate legacy config");
439        assert_eq!(config.system_prompt, "legacy");
440        assert_eq!(
441            fs::read(config_path(&home)).expect("migrated bytes"),
442            legacy_bytes
443        );
444        assert!(!legacy.exists());
445
446        fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy parent");
447        fs::write(&legacy, b"system_prompt = \"stale\"\n").expect("stale legacy config");
448        let loaded = Config::load_or_create(&home).expect("retain XDG config");
449        assert_eq!(loaded.system_prompt, "legacy");
450        assert_eq!(
451            fs::read(config_path(&home)).expect("XDG bytes"),
452            legacy_bytes
453        );
454        assert_eq!(
455            fs::read(&legacy).expect("legacy bytes"),
456            b"system_prompt = \"stale\"\n"
457        );
458
459        fs::remove_dir_all(home).expect("remove temp home");
460    }
461
462    #[test]
463    fn config_dir_uses_absolute_xdg_home_and_defaults_otherwise() {
464        let home = temporary_home();
465        let xdg_home = home.join("custom-xdg");
466        assert_eq!(
467            config_dir_from_xdg_home(&home, Some(xdg_home.as_os_str())),
468            xdg_home.join("lucy")
469        );
470        assert_eq!(
471            config_dir_from_xdg_home(&home, None),
472            home.join(".config/lucy")
473        );
474        assert_eq!(
475            config_dir_from_xdg_home(&home, Some(std::ffi::OsStr::new("relative"))),
476            home.join(".config/lucy")
477        );
478        fs::remove_dir_all(home).expect("remove temp home");
479    }
480
481    #[test]
482    fn malformed_toml_error_does_not_include_source_details() {
483        let home = temporary_home();
484        let path = config_path(&home);
485        fs::create_dir_all(path.parent().expect("config parent")).expect("config parent");
486        fs::write(
487            &path,
488            "system_prompt = \"provider-secret\n[llm]\nmodel = [\n",
489        )
490        .expect("malformed config");
491
492        let error = Config::load_from_path(&path).expect_err("malformed TOML");
493        let message = error.to_string();
494        assert!(message.contains("invalid TOML"));
495        assert!(!message.contains("provider-secret"));
496        assert!(!message.contains("system_prompt"));
497        assert!(!message.contains(&path.display().to_string()));
498        fs::remove_dir_all(home).expect("remove temp home");
499    }
500
501    #[test]
502    fn omitted_api_key_environment_uses_openai_default() {
503        let config = Config {
504            system_prompt: "prompt".to_owned(),
505            llm: LlmConfig {
506                base_url: "http://localhost".to_owned(),
507                model: "model".to_owned(),
508                api_key_env: None,
509                effort: None,
510            },
511        };
512        assert_eq!(
513            config.resolved_llm().expect("settings").api_key_env,
514            DEFAULT_API_KEY_ENV
515        );
516    }
517
518    #[test]
519    fn resolved_effort_passes_through_and_trims() {
520        let config = |effort: Option<&str>| Config {
521            system_prompt: "prompt".to_owned(),
522            llm: LlmConfig {
523                base_url: "http://localhost".to_owned(),
524                model: "model".to_owned(),
525                api_key_env: Some("LUCY_KEY".to_owned()),
526                effort: effort.map(str::to_owned),
527            },
528        };
529        assert_eq!(config(None).resolved_llm().expect("none").effort, None);
530        assert_eq!(
531            config(Some("high"))
532                .resolved_llm()
533                .expect("set")
534                .effort
535                .as_deref(),
536            Some("high")
537        );
538        assert_eq!(
539            config(Some("  medium  "))
540                .resolved_llm()
541                .expect("trim")
542                .effort
543                .as_deref(),
544            Some("medium")
545        );
546    }
547}