Skip to main content

talon_core/
config.rs

1//! Configuration model for standalone and federated Talon processes.
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6mod auth;
7mod chunker;
8mod defaults;
9mod endpoints;
10pub mod keychain;
11mod scope_filter;
12mod search;
13#[doc(hidden)]
14pub mod test_literals;
15use crate::indexer::build_include_globset;
16
17pub use auth::{CredentialEntry, CredentialsConfig, EndpointAuthConfig, ResolvedAuth};
18pub use chunker::ChunkerConfig;
19pub use endpoints::{
20    ChatAdapter, ChatAskConfig, ChatExpansionConfig, ChatSection, EmbeddingAdapter,
21    EmbeddingConfig, McpConfig, McpHooksConfig, RerankAdapter, RerankConfig, RerankScoreScale,
22};
23pub use scope_filter::ScopeFilter;
24pub use search::{InspectConfig, SearchConfig};
25
26/// Priority tier for scope-based ranking.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
28#[serde(rename_all = "lowercase")]
29pub enum ScopePriority {
30    /// Compiled knowledge scope.
31    Boosted,
32    /// Active-work scope.
33    Elevated,
34    /// Neutral (1.0x multiplier).
35    #[default]
36    Normal,
37    /// Low-priority scope.
38    Muted,
39    /// Explicit-opt-in scope.
40    Buried,
41}
42
43impl ScopePriority {
44    /// Returns the post-rerank score multiplier.
45    #[must_use]
46    pub const fn multiplier(self) -> f64 {
47        match self {
48            Self::Boosted => 1.2,
49            Self::Elevated => 1.1,
50            Self::Normal => 1.0,
51            Self::Muted => 0.85,
52            Self::Buried => 0.5,
53        }
54    }
55
56    /// Applies the multiplier only when it is allowed by the relevance gate.
57    ///
58    /// Positive priority boosts are gated so a weak match in a high-priority
59    /// scope cannot shout over a stronger match elsewhere. Negative weights
60    /// still apply below the floor because muted/buried scopes are provenance
61    /// signals, not relevance claims.
62    #[must_use]
63    pub fn apply_to_score(self, score: f64) -> f64 {
64        apply_scope_multiplier(score, self.multiplier())
65    }
66
67    /// Applies scope priority while honoring an explicit user-selected scope.
68    ///
69    /// `--scope NAME` is an additive request: default scopes remain in play,
70    /// but matches from the requested scope should not be muted below neutral.
71    #[must_use]
72    pub fn apply_to_score_with_explicit(self, score: f64, explicitly_requested: bool) -> f64 {
73        let multiplier = if explicitly_requested {
74            self.multiplier().max(Self::Normal.multiplier())
75        } else {
76            self.multiplier()
77        };
78        apply_scope_multiplier(score, multiplier)
79    }
80}
81
82fn apply_scope_multiplier(score: f64, multiplier: f64) -> f64 {
83    const POSITIVE_BOOST_RELEVANCE_FLOOR: f64 = 0.4;
84    if multiplier > 1.0 && score < POSITIVE_BOOST_RELEVANCE_FLOOR {
85        score
86    } else {
87        score * multiplier
88    }
89}
90
91/// Resolution result for a file-to-scope lookup.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct ScopeResolution {
94    /// Resolved priority tier.
95    pub priority: ScopePriority,
96    /// Whether this scope is in the default search set.
97    pub default: bool,
98}
99
100impl Default for ScopeResolution {
101    fn default() -> Self {
102        Self {
103            priority: ScopePriority::Normal,
104            default: true,
105        }
106    }
107}
108
109/// Glob patterns for a scope.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(untagged)]
112pub enum ScopeGlob {
113    /// Single glob string.
114    Single(String),
115    /// Array of glob strings.
116    Multiple(Vec<String>),
117}
118
119impl ScopeGlob {
120    /// Returns all glob patterns for this scope.
121    #[must_use]
122    pub fn patterns(&self) -> Vec<&str> {
123        match self {
124            Self::Single(g) => vec![g.as_str()],
125            Self::Multiple(g) => g.iter().map(String::as_str).collect(),
126        }
127    }
128}
129
130/// Scope name keyed map.
131///
132/// Uses `IndexMap` so iteration follows TOML declaration order — narrower or
133/// more sensitive scopes declared above broader ones win when their globs
134/// overlap (per spec §6.3).
135pub type ScopesConfig = indexmap::IndexMap<String, Scope>;
136
137/// A single scope definition.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct Scope {
141    /// Glob pattern(s) matching files in this scope.
142    pub glob: ScopeGlob,
143    /// Priority tier for ranking.
144    pub priority: ScopePriority,
145    /// Whether this scope is included in the default search set.
146    pub default: bool,
147    /// Whether `talon inspect` reports findings for files in this scope.
148    ///
149    /// Files in `inspect = false` scopes are still indexed and used for link
150    /// resolution (so a wikilink target in `daily/` still satisfies a wiki
151    /// note's link), but no findings are emitted with `from_path` in this
152    /// scope. Defaults to true.
153    #[serde(default = "default_true")]
154    pub inspect: bool,
155}
156
157const fn default_true() -> bool {
158    true
159}
160
161/// Full Talon runtime configuration.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct TalonConfig {
165    /// Host or standalone vault path.
166    pub vault_path: PathBuf,
167    /// `SQLite` index path.
168    pub db_path: PathBuf,
169    /// Path to the loaded config file (not serialized; injected at load time).
170    #[serde(skip)]
171    pub config_file_path: Option<PathBuf>,
172    /// Glob-style include patterns.
173    #[serde(default)]
174    pub include_patterns: Vec<String>,
175    /// Glob-style ignore patterns.
176    #[serde(default)]
177    pub ignore_patterns: Vec<String>,
178    /// Named API credentials referenced by capability blocks.
179    #[serde(default)]
180    pub credentials: CredentialsConfig,
181    /// Embedding endpoint configuration.
182    pub embedding: EmbeddingConfig,
183    /// Rerank endpoint configuration.
184    pub rerank: RerankConfig,
185    /// Chat endpoints for expansion and ask.
186    pub chat: ChatSection,
187    /// MCP runtime settings.
188    #[serde(default)]
189    pub mcp: McpConfig,
190    /// Named scopes for vault partitioning and ranking.
191    #[serde(default)]
192    pub scopes: ScopesConfig,
193    /// Search defaults and cache/client tunables.
194    #[serde(default)]
195    pub search: SearchConfig,
196    /// Lint settings (global ignore globs, etc.).
197    #[serde(default)]
198    pub inspect: InspectConfig,
199    /// Chunker settings from the `[indexer]` table.
200    #[serde(default, rename = "indexer")]
201    pub chunker: ChunkerConfig,
202}
203
204impl TalonConfig {
205    /// Returns the configured vault path.
206    #[must_use]
207    pub fn vault_path(&self) -> &Path {
208        &self.vault_path
209    }
210
211    /// Returns the configured database path.
212    #[must_use]
213    pub fn db_path(&self) -> &Path {
214        &self.db_path
215    }
216
217    /// Returns the resolved scope for a file path.
218    ///
219    /// Walks scopes in declaration order; first match wins.
220    /// Returns the default scope if no scope matches.
221    #[must_use]
222    pub fn resolve_scope(&self, path: &Path) -> ScopeResolution {
223        for scope in self.scopes.values() {
224            if matches_path_glob(path, &scope.glob) {
225                return ScopeResolution {
226                    priority: scope.priority,
227                    default: scope.default,
228                };
229            }
230        }
231        // Unmatched files fall into synthetic unscoped bucket: normal priority, default true
232        ScopeResolution::default()
233    }
234
235    /// Returns the name of the scope this path resolves to, or `None` for the
236    /// synthetic unscoped bucket.
237    #[must_use]
238    pub fn resolve_scope_name(&self, path: &Path) -> Option<&str> {
239        for (name, scope) in &self.scopes {
240            if matches_path_glob(path, &scope.glob) {
241                return Some(name.as_str());
242            }
243        }
244        None
245    }
246
247    /// Returns true when `path` should be excluded from `inspect` findings.
248    ///
249    /// Excludes paths that are either (1) in a scope with `inspect = false`, or
250    /// (2) matched by any glob in `[inspect].ignore`. The global ignore list takes
251    /// precedence — even paths in `inspect = true` scopes are excluded if they
252    /// match an ignore glob. Excluded paths remain in the index and continue
253    /// to satisfy link-target resolution.
254    #[must_use]
255    pub fn inspect_excluded(&self, path: &Path) -> bool {
256        let path_str = path.to_string_lossy();
257        let ignored = self
258            .inspect
259            .ignore
260            .iter()
261            .any(|glob| glob_matches_path(glob, path_str.as_ref()));
262        if ignored {
263            return true;
264        }
265        for scope in self.scopes.values() {
266            if matches_path_glob(path, &scope.glob) {
267                return !scope.inspect;
268            }
269        }
270        false
271    }
272
273    /// Returns the set of scope names that are in the default search set.
274    #[must_use]
275    pub fn default_scope_names(&self) -> Vec<&String> {
276        self.scopes
277            .iter()
278            .filter(|(_, s)| s.default)
279            .map(|(n, _)| n)
280            .collect()
281    }
282
283    /// Returns the scope with the given name, or an error.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`TalonError::InvalidScope`] if the scope name is not found.
288    pub fn get_scope(&self, name: &str) -> Result<&Scope, crate::error::TalonError> {
289        self.scopes
290            .get(name)
291            .ok_or_else(|| crate::error::TalonError::InvalidScope {
292                name: name.to_string(),
293            })
294    }
295}
296
297/// Checks whether a path matches any of the glob patterns in a scope.
298fn matches_path_glob(path: &Path, glob: &ScopeGlob) -> bool {
299    let path_str = path.to_string_lossy();
300    glob.patterns()
301        .iter()
302        .any(|pattern| glob_matches_path(pattern, path_str.as_ref()))
303}
304
305fn glob_matches_path(pattern: &str, path: &str) -> bool {
306    build_include_globset(&[pattern.to_string()]).is_ok_and(|set| set.is_match(path))
307}