Skip to main content

ctx/
config.rs

1//! Project configuration loaded from `.ctx/config.toml`.
2//!
3//! An optional, committed TOML file that sets per-project defaults so teams
4//! don't have to pass the same flags/env vars on every invocation. Currently it
5//! configures the embedding backend; more sections can be added over time.
6//!
7//! ```toml
8//! [embedding]
9//! provider = "ollama"            # local | openai | ollama
10//! model = "qwen3-embedding:8b"   # provider-specific (Ollama/OpenAI model)
11//! # host = "http://localhost:11434"  # Ollama only
12//! ```
13//!
14//! Precedence for the resolved settings is always **CLI flag > environment
15//! variable > this file > built-in default**, so the config never overrides an
16//! explicit request.
17
18use std::path::Path;
19
20use serde::Deserialize;
21
22use crate::embeddings::Provider;
23use crate::index::CTX_DIR;
24
25/// Config file name inside `.ctx/`.
26pub const CONFIG_FILE: &str = "config.toml";
27
28/// Top-level `.ctx/config.toml` contents. Unknown keys are ignored so older
29/// binaries tolerate newer config files.
30#[derive(Debug, Clone, Default, Deserialize)]
31#[serde(default)]
32pub struct CtxConfig {
33    /// Embedding backend defaults.
34    pub embedding: EmbeddingConfig,
35}
36
37/// `[embedding]` section: default provider and provider-specific settings.
38#[derive(Debug, Clone, Default, Deserialize)]
39#[serde(default)]
40pub struct EmbeddingConfig {
41    /// Default provider when `--provider`/`--openai` are not given.
42    pub provider: Option<Provider>,
43    /// Model name (Ollama/OpenAI). For Ollama this overrides the built-in
44    /// default but is itself overridden by `OLLAMA_EMBED_MODEL`.
45    pub model: Option<String>,
46    /// Ollama host URL; overridden by `OLLAMA_HOST`.
47    pub host: Option<String>,
48}
49
50impl CtxConfig {
51    /// Load `<root>/.ctx/config.toml`. A missing file yields defaults; a malformed
52    /// file yields defaults with a warning (never fatal — config is optional).
53    pub fn load(root: &Path) -> Self {
54        Self::load_file(&root.join(CTX_DIR).join(CONFIG_FILE))
55    }
56
57    /// Load from an explicit path (used by tests and [`CtxConfig::load`]).
58    pub fn load_file(path: &Path) -> Self {
59        let text = match std::fs::read_to_string(path) {
60            Ok(text) => text,
61            Err(_) => return Self::default(), // absent/unreadable → defaults
62        };
63        match toml::from_str(&text) {
64            Ok(config) => config,
65            Err(e) => {
66                eprintln!("Warning: ignoring malformed {} ({e})", path.display());
67                Self::default()
68            }
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use std::io::Write;
77
78    fn write_temp(contents: &str) -> tempfile::NamedTempFile {
79        let mut f = tempfile::NamedTempFile::new().unwrap();
80        f.write_all(contents.as_bytes()).unwrap();
81        f
82    }
83
84    #[test]
85    fn missing_file_is_default() {
86        let cfg = CtxConfig::load_file(Path::new("/nonexistent/.ctx/config.toml"));
87        assert!(cfg.embedding.provider.is_none());
88        assert!(cfg.embedding.model.is_none());
89    }
90
91    #[test]
92    fn parses_embedding_section() {
93        let f = write_temp(
94            r#"
95[embedding]
96provider = "ollama"
97model = "qwen3-embedding:8b"
98"#,
99        );
100        let cfg = CtxConfig::load_file(f.path());
101        assert_eq!(cfg.embedding.provider, Some(Provider::Ollama));
102        assert_eq!(cfg.embedding.model.as_deref(), Some("qwen3-embedding:8b"));
103        assert!(cfg.embedding.host.is_none());
104    }
105
106    #[test]
107    fn unknown_keys_ignored() {
108        let f = write_temp(
109            r#"
110[embedding]
111provider = "openai"
112
113[future_section]
114whatever = true
115"#,
116        );
117        let cfg = CtxConfig::load_file(f.path());
118        assert_eq!(cfg.embedding.provider, Some(Provider::Openai));
119    }
120
121    #[test]
122    fn malformed_file_is_default() {
123        let f = write_temp("this is not valid toml : : :");
124        let cfg = CtxConfig::load_file(f.path());
125        assert!(cfg.embedding.provider.is_none());
126    }
127
128    #[test]
129    fn config_provides_resolution_default() {
130        // Flag wins over config; config wins over built-in default.
131        assert_eq!(
132            Provider::resolve(Some(Provider::Local), false, Some(Provider::Ollama)),
133            Provider::Local
134        );
135        assert_eq!(
136            Provider::resolve(None, false, Some(Provider::Ollama)),
137            Provider::Ollama
138        );
139        assert_eq!(Provider::resolve(None, false, None), Provider::Local);
140        // Deprecated --openai still beats config.
141        assert_eq!(
142            Provider::resolve(None, true, Some(Provider::Ollama)),
143            Provider::Openai
144        );
145    }
146}