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(&lucy_dir(home))?;
116        ensure_not_symlink(&path)?;
117
118        if !path.exists() && generated_config_contains_active_key() {
119            return Err(ConfigError::new("configuration bootstrap rejected"));
120        }
121
122        let mut options = OpenOptions::new();
123        options.write(true).create_new(true);
124        #[cfg(unix)]
125        options.mode(0o600);
126        match options.open(&path) {
127            Ok(mut file) => {
128                file.write_all(GENERATED_CONFIG.as_bytes())?;
129                file.flush()?;
130                ensure_private_file(&path)?;
131                Ok(())
132            }
133            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
134                ensure_private_file(&path)?;
135                Ok(())
136            }
137            Err(_error) => Err(ConfigError::new("unable to create config.toml")),
138        }
139    }
140
141    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
142        ensure_not_symlink(path)
143            .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
144        let bytes = fs::read_to_string(path)
145            .map_err(|_error| ConfigError::new("unable to read config.toml"))?;
146        ensure_private_file(path)
147            .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
148        toml::from_str(&bytes)
149            .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))
150    }
151
152    /// Update only the user-selected fields while preserving other TOML content.
153    pub fn save_selection(
154        home: &Path,
155        model: &str,
156        effort: Option<&str>,
157    ) -> Result<(), ConfigError> {
158        let path = config_path(home);
159        let source = fs::read_to_string(&path)
160            .map_err(|_| ConfigError::new("unable to read config.toml"))?;
161        let mut document: toml::Value = toml::from_str(&source)
162            .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))?;
163        let llm = document
164            .as_table_mut()
165            .and_then(|root| root.get_mut("llm"))
166            .and_then(toml::Value::as_table_mut)
167            .ok_or_else(|| ConfigError::new("config.toml is missing [llm]"))?;
168        llm.insert("model".to_owned(), toml::Value::String(model.to_owned()));
169        match effort.filter(|value| !value.trim().is_empty()) {
170            Some(value) => {
171                llm.insert(
172                    "effort".to_owned(),
173                    toml::Value::String(value.trim().to_owned()),
174                );
175            }
176            None => {
177                llm.remove("effort");
178            }
179        }
180        let rendered = toml::to_string_pretty(&document)
181            .map_err(|_| ConfigError::new("unable to write config.toml"))?;
182        fs::write(&path, rendered).map_err(|_| ConfigError::new("unable to write config.toml"))?;
183        ensure_private_file(&path).map_err(|_| ConfigError::new("unable to secure config.toml"))
184    }
185
186    pub fn resolved_llm(&self) -> Result<LlmSettings, ConfigError> {
187        let base_url = self.llm.base_url.trim().to_owned();
188        if base_url.is_empty() {
189            return Err(ConfigError::new("llm.base_url must not be empty"));
190        }
191
192        let api_key_env = self
193            .llm
194            .api_key_env
195            .as_deref()
196            .unwrap_or(DEFAULT_API_KEY_ENV)
197            .trim()
198            .to_owned();
199        if api_key_env.is_empty() {
200            return Err(ConfigError::new("llm.api_key_env must not be empty"));
201        }
202
203        let effort = self.llm.effort.as_deref().map(str::trim).map(str::to_owned);
204
205        Ok(LlmSettings {
206            base_url,
207            model: self.llm.model.trim().to_owned(),
208            api_key_env,
209            effort,
210        })
211    }
212}
213
214pub fn config_path(home: &Path) -> PathBuf {
215    home.join(".lucy").join("config.toml")
216}
217
218pub fn lucy_dir(home: &Path) -> PathBuf {
219    home.join(".lucy")
220}
221
222fn generated_config_contains_active_key() -> bool {
223    std::env::var(GENERATED_API_KEY_ENV)
224        .ok()
225        .filter(|secret| !secret.is_empty())
226        .is_some_and(|secret| GENERATED_CONFIG.contains(&secret))
227}
228
229pub(crate) fn ensure_not_symlink(path: &Path) -> io::Result<()> {
230    match fs::symlink_metadata(path) {
231        Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new(
232            io::ErrorKind::InvalidInput,
233            "symlinks are not allowed for protected paths",
234        )),
235        Ok(_) => Ok(()),
236        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
237        Err(error) => Err(error),
238    }
239}
240
241pub(crate) fn ensure_private_dir(path: &Path) -> io::Result<()> {
242    ensure_not_symlink(path)?;
243    fs::create_dir_all(path)?;
244    let metadata = fs::symlink_metadata(path)?;
245    if metadata.file_type().is_symlink() || !metadata.is_dir() {
246        return Err(io::Error::new(
247            io::ErrorKind::InvalidInput,
248            "protected path is not a directory",
249        ));
250    }
251    #[cfg(unix)]
252    fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
253    Ok(())
254}
255
256pub(crate) fn ensure_private_file(path: &Path) -> io::Result<()> {
257    ensure_not_symlink(path)?;
258    #[cfg(unix)]
259    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
260    #[cfg(not(unix))]
261    let _ = path;
262    Ok(())
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    #[cfg(unix)]
269    use std::os::unix::fs::{symlink, PermissionsExt};
270    use std::sync::atomic::{AtomicU64, Ordering};
271    use std::time::{SystemTime, UNIX_EPOCH};
272
273    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
274
275    fn temporary_home() -> PathBuf {
276        loop {
277            let stamp = SystemTime::now()
278                .duration_since(UNIX_EPOCH)
279                .expect("clock")
280                .as_nanos();
281            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
282            let path = std::env::temp_dir().join(format!(
283                "lucy-config-{stamp}-{}-{counter}",
284                std::process::id()
285            ));
286            match fs::create_dir(&path) {
287                Ok(()) => return path,
288                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
289                Err(error) => panic!("temp home: {error}"),
290            }
291        }
292    }
293
294    #[test]
295    fn bootstraps_config_without_overwriting_existing_bytes() {
296        let home = temporary_home();
297        let first = Config::load_or_create(&home).expect("create config");
298        assert_eq!(first.llm.model, "");
299        assert_eq!(first.llm.base_url, DEFAULT_BASE_URL);
300        assert_eq!(
301            first.llm.api_key_env.as_deref(),
302            Some(GENERATED_API_KEY_ENV)
303        );
304
305        let path = config_path(&home);
306        let generated = fs::read(&path).expect("generated bytes");
307        #[cfg(unix)]
308        assert_eq!(
309            fs::metadata(&path)
310                .expect("config metadata")
311                .permissions()
312                .mode()
313                & 0o777,
314            0o600
315        );
316        #[cfg(unix)]
317        assert_eq!(
318            fs::metadata(lucy_dir(&home))
319                .expect("Lucy directory metadata")
320                .permissions()
321                .mode()
322                & 0o777,
323            0o700
324        );
325        let custom = b"system_prompt = \"custom\"\n[llm]\nmodel = \"local\"\n";
326        fs::write(&path, custom).expect("custom config");
327        let loaded = Config::load_or_create(&home).expect("load custom config");
328        assert_eq!(loaded.system_prompt, "custom");
329        assert_eq!(loaded.llm.model, "local");
330        assert_ne!(generated, custom);
331        assert_eq!(fs::read(path).expect("bytes after load"), custom);
332
333        fs::remove_dir_all(home).expect("remove temp home");
334    }
335
336    #[cfg(unix)]
337    #[test]
338    fn rejects_symlinked_config_files_and_directories() {
339        let home = temporary_home();
340        let lucy = home.join(".lucy");
341        fs::create_dir(&lucy).expect("Lucy directory");
342        let target = home.join("config-target.toml");
343        fs::write(&target, "system_prompt = \"target\"\n").expect("target config");
344        let path = config_path(&home);
345        symlink(&target, &path).expect("config symlink");
346        assert!(Config::load_or_create(&home).is_err());
347        assert!(Config::load_from_path(&path).is_err());
348        fs::remove_file(path).expect("remove config symlink");
349        fs::remove_dir(lucy).expect("remove Lucy directory");
350        fs::remove_file(target).expect("remove target config");
351        fs::remove_dir(&home).expect("remove temp home");
352
353        let home = temporary_home();
354        let target = home.join("lucy-target");
355        fs::create_dir(&target).expect("target directory");
356        symlink(&target, home.join(".lucy")).expect("Lucy directory symlink");
357        assert!(Config::ensure_exists(&home).is_err());
358        fs::remove_file(home.join(".lucy")).expect("remove Lucy directory symlink");
359        fs::remove_dir(target).expect("remove target directory");
360        fs::remove_dir(home).expect("remove temp home");
361    }
362
363    #[test]
364    fn malformed_toml_error_does_not_include_source_details() {
365        let home = temporary_home();
366        let path = config_path(&home);
367        fs::create_dir_all(path.parent().expect("config parent")).expect("config parent");
368        fs::write(
369            &path,
370            "system_prompt = \"provider-secret\n[llm]\nmodel = [\n",
371        )
372        .expect("malformed config");
373
374        let error = Config::load_from_path(&path).expect_err("malformed TOML");
375        let message = error.to_string();
376        assert!(message.contains("invalid TOML"));
377        assert!(!message.contains("provider-secret"));
378        assert!(!message.contains("system_prompt"));
379        assert!(!message.contains(&path.display().to_string()));
380        fs::remove_dir_all(home).expect("remove temp home");
381    }
382
383    #[test]
384    fn omitted_api_key_environment_uses_openai_default() {
385        let config = Config {
386            system_prompt: "prompt".to_owned(),
387            llm: LlmConfig {
388                base_url: "http://localhost".to_owned(),
389                model: "model".to_owned(),
390                api_key_env: None,
391                effort: None,
392            },
393        };
394        assert_eq!(
395            config.resolved_llm().expect("settings").api_key_env,
396            DEFAULT_API_KEY_ENV
397        );
398    }
399
400    #[test]
401    fn resolved_effort_passes_through_and_trims() {
402        let config = |effort: Option<&str>| Config {
403            system_prompt: "prompt".to_owned(),
404            llm: LlmConfig {
405                base_url: "http://localhost".to_owned(),
406                model: "model".to_owned(),
407                api_key_env: Some("LUCY_KEY".to_owned()),
408                effort: effort.map(str::to_owned),
409            },
410        };
411        assert_eq!(config(None).resolved_llm().expect("none").effort, None);
412        assert_eq!(
413            config(Some("high"))
414                .resolved_llm()
415                .expect("set")
416                .effort
417                .as_deref(),
418            Some("high")
419        );
420        assert_eq!(
421            config(Some("  medium  "))
422                .resolved_llm()
423                .expect("trim")
424                .effort
425                .as_deref(),
426            Some("medium")
427        );
428    }
429}