Skip to main content

talon_cli/
config.rs

1//! Config loading and initialization for the CLI process boundary.
2
3use eyre::{Result, WrapErr as _, bail};
4use fs_err as fs;
5use std::path::{Component, Path, PathBuf};
6use talon_core::{
7    ChatAdapter, ChatAskConfig, ChatExpansionConfig, ChatSection, ContainerPath, CredentialsConfig,
8    EmbeddingAdapter, EmbeddingConfig, EndpointAuthConfig, RerankAdapter, RerankConfig,
9    TalonConfig,
10};
11
12/// Default config filename.
13pub const CONFIG_FILE_NAME: &str = "config.toml";
14
15/// Default config directory.
16pub const CONFIG_DIR_NAME: &str = "talon";
17
18/// Default config path: `~/.config/talon/config.toml`.
19#[must_use]
20pub fn default_config_path() -> PathBuf {
21    let base = non_empty_env_os("XDG_CONFIG_HOME").map_or_else(
22        || {
23            dirs::home_dir()
24                .unwrap_or_else(|| PathBuf::from("."))
25                .join(".config")
26        },
27        PathBuf::from,
28    );
29    base.join(CONFIG_DIR_NAME).join(CONFIG_FILE_NAME)
30}
31
32/// Default `SQLite` index path.
33#[must_use]
34pub fn default_db_path() -> PathBuf {
35    default_db_path_for_workspace("default")
36}
37
38/// Default `SQLite` index path for a workspace.
39#[must_use]
40pub fn default_db_path_for_workspace(workspace: &str) -> PathBuf {
41    dirs::home_dir()
42        .unwrap_or_else(|| PathBuf::from("."))
43        .join(".talon")
44        .join(format!("{}.db", sanitize_workspace_name(workspace)))
45}
46
47/// Config template written by `talon init`.
48pub const CONFIG_TEMPLATE: &str = r#"# Talon configuration.
49# Location: ~/.config/talon/config.toml
50
51vault_path = "/Users/you/path/to/obsidian"
52# Convention: ~/.talon/{workspace}.db. Update this if you rename the vault.
53db_path = "~/.talon/obsidian.db"
54include_patterns = ["**/*.md"]
55ignore_patterns = [".obsidian/**", ".git/**", "templates/**", "*.canvas"]
56
57[indexer]
58chunk_tokens = 512
59chunk_overlap = 64
60chunk_min_tokens = 16
61
62[search]
63candidate_limit = 60
64limit = 10
65cache_size = 200
66rerank_cache_size = 2000
67rerank_batch_size = 4
68rerank_max_tokens = 128
69
70[embedding]
71base_url = "http://localhost:8000"
72adapter = "tei"
73model = "embed"
74document_model = "embed_chunked"
75context_tokens = 512
76
77[rerank]
78base_url = "http://localhost:8000"
79adapter = "minimal"
80model = "rerank"
81score_scale = "normalized"
82truncate = true
83
84[chat.expansion]
85base_url = "http://localhost:8000/v1"
86model = "bonsai"
87context_tokens = 16000
88max_output_tokens = 768
89
90[chat.ask]
91model = "qwen-smol"
92context_tokens = 65536
93max_output_tokens = 4096
94planning_reasoning_effort = "none"
95synthesis_reasoning_effort = "none"
96
97[mcp.hooks]
98recall_deadline_ms = 20000
99
100# ── Scopes ─────────────────────────────────────────────────────────────────
101# Named vault partitions with priority-based ranking.
102# See docs/CONFIG.md for full reference.
103# Uncomment and edit the Karpathy preset below.
104#
105# [scopes.wiki]
106# glob     = ["wiki/**", "concepts/**"]
107# priority = "boosted"
108# default  = true
109#
110# ... additional scopes ...
111"#;
112
113/// Loads a config file from the given path.
114///
115/// # Errors
116///
117/// Returns an error if the file cannot be read or parsed.
118pub fn load_config_file(path: &Path) -> Result<TalonConfig> {
119    let content = fs::read_to_string(path)
120        .wrap_err_with(|| format!("failed to read config file: {}", path.display()))?;
121
122    let mut config: TalonConfig = toml::from_str(&content)
123        .wrap_err_with(|| format!("failed to parse config file: {}", path.display()))?;
124    resolve_config_paths(&mut config, path)?;
125    if let Err(message) = config.chunker.validate() {
126        bail!("{message}");
127    }
128
129    Ok(config)
130}
131
132/// Loads config from the default path or an explicit path.
133///
134/// # Errors
135///
136/// Returns an error if the config file cannot be found or parsed.
137pub fn load_config(explicit_path: Option<&Path>) -> Result<TalonConfig> {
138    let path = explicit_path
139        .map(std::path::Path::to_path_buf)
140        .or_else(|| non_empty_env_path("TALON_CONFIG_FILE"))
141        .unwrap_or_else(default_config_path);
142
143    if !path.exists() {
144        bail!(
145            "config not found at {}, run `talon init` first",
146            path.display()
147        );
148    }
149
150    let mut config = load_config_file(&path)?;
151    config.config_file_path = Some(path);
152
153    // TALON_VAULT overrides vault_path so callers (e.g. Hermes plugin) can
154    // target a specific vault without modifying the config file.
155    if let Some(vault_override) = non_empty_env_path("TALON_VAULT") {
156        config.vault_path = absolutize_path(vault_override, &std::env::current_dir()?);
157    }
158
159    Ok(config)
160}
161
162fn non_empty_env_os(key: &str) -> Option<std::ffi::OsString> {
163    std::env::var_os(key).filter(|value| !value.is_empty())
164}
165
166fn non_empty_env_path(key: &str) -> Option<PathBuf> {
167    std::env::var(key).ok().and_then(|value| {
168        if value.trim().is_empty() {
169            None
170        } else {
171            Some(PathBuf::from(value))
172        }
173    })
174}
175
176/// Initializes the config file at the default path.
177///
178/// Creates the directory if it doesn't exist. Does not overwrite an existing file.
179///
180/// # Errors
181///
182/// Returns an error if the config directory cannot be created or the file cannot be written.
183pub fn init_config() -> Result<bool> {
184    let path = default_config_path();
185
186    if path.exists() {
187        return Ok(false);
188    }
189
190    if let Some(parent) = path.parent() {
191        fs::create_dir_all(parent)
192            .wrap_err_with(|| format!("failed to create config directory: {}", parent.display()))?;
193    }
194
195    fs::write(&path, CONFIG_TEMPLATE)
196        .wrap_err_with(|| format!("failed to write config file: {}", path.display()))?;
197
198    Ok(true)
199}
200
201/// Builds a default config from a vault path.
202#[must_use]
203pub fn default_config_for_vault(vault_path: PathBuf) -> TalonConfig {
204    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
205    let vault_path = absolutize_path(vault_path, &cwd);
206    let db_path = default_db_path_for_workspace(&workspace_name_for_vault(&vault_path));
207
208    TalonConfig {
209        vault_path,
210        db_path,
211        config_file_path: None,
212        include_patterns: vec!["**/*.md".to_string()],
213        ignore_patterns: vec![
214            ".obsidian/**".to_string(),
215            ".git/**".to_string(),
216            "templates/**".to_string(),
217            "*.canvas".to_string(),
218        ],
219        credentials: CredentialsConfig::default(),
220        embedding: EmbeddingConfig {
221            base_url: "http://localhost:8000".to_string(),
222            auth: EndpointAuthConfig::default(),
223            adapter: EmbeddingAdapter::Tei,
224            model: "embed".to_string(),
225            document_model: Some("embed_chunked".to_string()),
226            context_tokens: 512,
227        },
228        rerank: RerankConfig {
229            base_url: "http://localhost:8000".to_string(),
230            auth: EndpointAuthConfig::default(),
231            adapter: RerankAdapter::Minimal,
232            model: "rerank".to_string(),
233            score_scale: talon_core::RerankScoreScale::default(),
234            truncate: true,
235        },
236        chat: ChatSection {
237            expansion: ChatExpansionConfig {
238                base_url: "http://localhost:8000/v1".to_string(),
239                auth: EndpointAuthConfig::default(),
240                adapter: ChatAdapter::default(),
241                model: "bonsai".to_string(),
242                context_tokens: 16_000,
243                max_output_tokens: Some(768),
244            },
245            ask: ChatAskConfig::default(),
246        },
247        mcp: talon_core::McpConfig::default(),
248        scopes: default_karpathy_scopes(),
249        search: talon_core::SearchConfig::default(),
250        inspect: talon_core::InspectConfig::default(),
251        chunker: talon_core::ChunkerConfig::default(),
252    }
253}
254
255fn workspace_name_for_vault(vault_path: &Path) -> String {
256    vault_path
257        .file_name()
258        .and_then(|name| name.to_str())
259        .filter(|name| !name.trim().is_empty())
260        .unwrap_or("default")
261        .to_string()
262}
263
264fn sanitize_workspace_name(value: &str) -> String {
265    let mut out = String::with_capacity(value.len());
266    for ch in value.chars() {
267        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
268            out.push(ch.to_ascii_lowercase());
269        } else {
270            out.push('-');
271        }
272    }
273    let trimmed = out.trim_matches('-');
274    if trimmed.is_empty() {
275        "default".to_string()
276    } else {
277        trimmed.to_string()
278    }
279}
280
281fn resolve_config_paths(config: &mut TalonConfig, config_path: &Path) -> Result<()> {
282    let cwd = std::env::current_dir()?;
283    let config_path = absolutize_path(config_path.to_path_buf(), &cwd);
284    let config_dir = config_path.parent().unwrap_or(&cwd);
285
286    config.vault_path = absolutize_path(config.vault_path.clone(), config_dir);
287    config.db_path = absolutize_path(config.db_path.clone(), config_dir);
288    Ok(())
289}
290
291fn absolutize_path(path: PathBuf, base: &Path) -> PathBuf {
292    let path = expand_tilde(path);
293    if path.is_absolute() {
294        path
295    } else {
296        base.join(path)
297    }
298}
299
300fn expand_tilde(path: PathBuf) -> PathBuf {
301    let Some(home) = dirs::home_dir() else {
302        return path;
303    };
304    let mut components = path.components();
305    match components.next() {
306        Some(Component::Normal(component)) if component == "~" => home.join(components.as_path()),
307        _ => path,
308    }
309}
310
311mod karpathy;
312mod refresh;
313use karpathy::default_karpathy_scopes;
314pub use refresh::{
315    RefreshLockPolicy, refresh_index_if_needed, refresh_index_with_lock, sync_lock_path,
316};
317
318/// Converts the configured vault path to a [`ContainerPath`], or `None` when
319/// config is absent (e.g. when running without a config file).
320#[must_use]
321pub fn vault_container_path(config: Option<&TalonConfig>) -> Option<ContainerPath> {
322    config.and_then(|c| ContainerPath::parse(c.vault_path.to_string_lossy().as_ref()).ok())
323}
324
325#[cfg(test)]
326mod tests;