context_harness/config.rs
1//! Configuration parsing and validation.
2//!
3//! Context Harness is configured via a TOML file (default: `config/ctx.toml`).
4//! The config defines database paths, chunking parameters, embedding provider
5//! settings, retrieval tuning, server bind address, and connector configurations.
6//!
7//! # Example Configuration
8//!
9//! ```toml
10//! [db]
11//! path = ".ctx/data/ctx.sqlite"
12//!
13//! [chunking]
14//! max_tokens = 700
15//! overlap_tokens = 80
16//!
17//! [embedding]
18//! provider = "openai" # "disabled" | "openai"
19//! model = "text-embedding-3-small"
20//! dims = 1536
21//!
22//! [vector_index]
23//! backend = "auto"
24//! path = "auto"
25//! metric = "cosine"
26//! index = "hnsw"
27//! fallback = "sqlite"
28//!
29//! [retrieval]
30//! final_limit = 12
31//! hybrid_alpha = 0.6 # 0.0 = keyword only, 1.0 = semantic only
32//!
33//! [server]
34//! bind = "127.0.0.1:7331"
35//!
36//! [connectors.filesystem.docs]
37//! root = "./docs"
38//! include_globs = ["**/*.md", "**/*.txt"]
39//!
40//! [connectors.git.platform]
41//! url = "https://github.com/acme/platform.git"
42//! branch = "main"
43//! ```
44//!
45//! # Connectors
46//!
47//! All connector types are **named** — you can configure multiple instances of each:
48//! - **Filesystem** (`[connectors.filesystem.<name>]`) — scan a local directory
49//! - **Git** (`[connectors.git.<name>]`) — clone/pull a Git repository
50//! - **S3** (`[connectors.s3.<name>]`) — list and download from an S3 bucket
51//! - **Script** (`[connectors.script.<name>]`) — custom Lua-scripted data sources
52//!
53//! # Validation
54//!
55//! [`load_config`] performs the following validations:
56//! - `chunking.max_tokens > 0`
57//! - `retrieval.final_limit >= 1`
58//! - `retrieval.hybrid_alpha ∈ [0.0, 1.0]`
59//! - When embedding provider is `openai` or `ollama`: `model` and `dims` must be set
60//! - Embedding provider must be one of: `"disabled"`, `"openai"`, `"ollama"`, `"local"`
61
62use anyhow::{Context, Result};
63use serde::{Deserialize, Serialize};
64use std::collections::HashMap;
65use std::path::{Path, PathBuf};
66
67use crate::ctx_dirs::{self, ConfigSourceKind};
68
69/// Top-level configuration structure.
70///
71/// Deserialized from the TOML config file. All sections are required
72/// except `connectors`, which defaults to an empty set.
73#[derive(Debug, Deserialize, Clone)]
74pub struct Config {
75 /// Database connection settings.
76 pub db: DbConfig,
77 /// Text chunking parameters.
78 pub chunking: ChunkingConfig,
79 /// Search and retrieval tuning.
80 pub retrieval: RetrievalConfig,
81 /// Embedding provider settings (defaults to disabled).
82 #[serde(default)]
83 pub embedding: EmbeddingConfig,
84 /// Optional vector-index acceleration settings (defaults to auto with SQLite fallback).
85 #[serde(default)]
86 #[allow(dead_code)]
87 pub vector_index: VectorIndexConfig,
88 /// HTTP server bind address.
89 #[allow(dead_code)]
90 pub server: ServerConfig,
91 /// Connector configurations (all optional).
92 #[serde(default)]
93 pub connectors: ConnectorsConfig,
94 /// Tool script configurations (all optional).
95 #[serde(default)]
96 pub tools: ToolsConfig,
97 /// Reusable MCP profile configurations (all optional).
98 ///
99 /// The historical `[agents.*]` table remains accepted as an alias.
100 #[serde(default, alias = "agents")]
101 pub profiles: ProfilesConfig,
102 /// Model aliases for standalone executable-agent resources.
103 #[serde(default)]
104 pub models: std::collections::BTreeMap<String, crate::agent_resource::ModelDefinition>,
105 /// Named stdio MCP servers available to executable agents.
106 #[serde(default)]
107 pub mcp_servers: std::collections::BTreeMap<String, McpServerConfig>,
108 /// Extension registry configurations (all optional).
109 #[serde(default)]
110 pub registries: HashMap<String, RegistryConfig>,
111}
112
113impl Config {
114 /// Create a minimal config suitable for commands that don't need
115 /// database or connector settings (e.g., `ctx connector test`).
116 pub fn minimal() -> Self {
117 Self {
118 db: DbConfig {
119 path: ctx_dirs::workspace_db_path(),
120 },
121 chunking: ChunkingConfig {
122 max_tokens: 700,
123 overlap_tokens: 0,
124 },
125 retrieval: RetrievalConfig {
126 hybrid_alpha: default_hybrid_alpha(),
127 candidate_k_keyword: default_candidate_k(),
128 candidate_k_vector: default_candidate_k(),
129 final_limit: default_final_limit(),
130 group_by: default_group_by(),
131 doc_agg: default_doc_agg(),
132 max_chunks_per_doc: default_max_chunks_per_doc(),
133 },
134 embedding: EmbeddingConfig::default(),
135 vector_index: VectorIndexConfig::default(),
136 server: ServerConfig {
137 bind: "127.0.0.1:7331".to_string(),
138 },
139 connectors: ConnectorsConfig::default(),
140 tools: ToolsConfig::default(),
141 profiles: ProfilesConfig::default(),
142 models: std::collections::BTreeMap::new(),
143 mcp_servers: std::collections::BTreeMap::new(),
144 registries: HashMap::new(),
145 }
146 }
147}
148
149/// A stdio MCP process. Execution inherits the environment and uses the run's
150/// workspace root as its working directory; launching requires process permission.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct McpServerConfig {
154 pub command: String,
155 #[serde(default)]
156 pub args: Vec<String>,
157 #[serde(default = "default_mcp_timeout_seconds")]
158 pub timeout_seconds: u64,
159}
160
161fn default_mcp_timeout_seconds() -> u64 {
162 30
163}
164
165impl McpServerConfig {
166 /// Validate both the namespace and bounded process settings without spawning.
167 pub fn validate(&self, name: &str) -> Result<()> {
168 if name.is_empty()
169 || name.len() > 64
170 || !name
171 .bytes()
172 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
173 {
174 anyhow::bail!(
175 "MCP server name must contain 1..64 ASCII letters, digits, underscores, or hyphens"
176 );
177 }
178 if self.command.trim().is_empty()
179 || self.command.len() > 4096
180 || self.command.contains('\0')
181 {
182 anyhow::bail!("MCP server '{name}' command must contain 1..4096 bytes and no NUL");
183 }
184 if self.args.len() > 128
185 || self.args.iter().any(|arg| arg.contains('\0'))
186 || self.args.iter().map(String::len).sum::<usize>() > 64 * 1024
187 {
188 anyhow::bail!("MCP server '{name}' arguments must contain at most 128 entries and 65536 bytes, with no NUL");
189 }
190 if !(1..=60).contains(&self.timeout_seconds) {
191 anyhow::bail!("MCP server '{name}' timeout_seconds must be in 1..=60");
192 }
193 Ok(())
194 }
195}
196
197#[derive(Debug, Clone)]
198pub struct ResolvedConfig {
199 pub config: Config,
200 pub path: Option<PathBuf>,
201 #[allow(dead_code)]
202 pub source: ConfigSourceKind,
203}
204
205/// Database configuration.
206///
207/// Specifies the path to the SQLite database file. The file and its
208/// parent directories are created automatically on first use.
209#[derive(Debug, Deserialize, Clone)]
210pub struct DbConfig {
211 /// Path to the SQLite database file (e.g. `".ctx/data/ctx.sqlite"`).
212 pub path: PathBuf,
213}
214
215/// Text chunking parameters.
216///
217/// Controls how document bodies are split into chunks for indexing
218/// and embedding. See [`crate::chunk`] for the chunking algorithm.
219#[derive(Debug, Deserialize, Clone)]
220pub struct ChunkingConfig {
221 /// Maximum tokens per chunk. Chunks are split on paragraph boundaries
222 /// to stay within this limit. Converted to characters via `max_tokens × 4`.
223 pub max_tokens: usize,
224 /// Number of overlapping tokens between adjacent chunks (reserved for future use).
225 #[serde(default = "default_overlap")]
226 #[allow(dead_code)]
227 pub overlap_tokens: usize,
228}
229
230fn default_overlap() -> usize {
231 0
232}
233
234/// Search and retrieval tuning parameters.
235///
236/// These settings control how keyword and semantic search results are
237/// merged in hybrid mode, and the overall result limits.
238///
239/// # Hybrid Scoring
240///
241/// The `hybrid_alpha` weight determines the blend between keyword (BM25)
242/// and semantic (cosine similarity) scores:
243///
244/// ```text
245/// hybrid_score = (1 - α) × keyword_score + α × semantic_score
246/// ```
247///
248/// - `α = 0.0` → pure keyword search
249/// - `α = 1.0` → pure semantic search
250/// - `α = 0.6` (default) → 60% semantic, 40% keyword
251///
252/// See `docs/HYBRID_SCORING.md` for the full specification.
253#[derive(Debug, Deserialize, Clone)]
254pub struct RetrievalConfig {
255 /// Weight for semantic vs. keyword scores in hybrid mode.
256 /// Range: `[0.0, 1.0]`. Default: `0.6`.
257 #[serde(default = "default_hybrid_alpha")]
258 pub hybrid_alpha: f64,
259 /// Number of keyword candidates to fetch before merging. Default: `80`.
260 #[serde(default = "default_candidate_k")]
261 pub candidate_k_keyword: i64,
262 /// Number of vector candidates to fetch before merging. Default: `80`.
263 #[serde(default = "default_candidate_k")]
264 pub candidate_k_vector: i64,
265 /// Maximum number of results to return after merging and ranking. Default: `12`.
266 #[serde(default = "default_final_limit")]
267 pub final_limit: i64,
268 /// Grouping strategy for results. Default: `"document"`.
269 #[serde(default = "default_group_by")]
270 #[allow(dead_code)]
271 pub group_by: String,
272 /// Aggregation method for document-level scores. Default: `"max"`.
273 #[serde(default = "default_doc_agg")]
274 #[allow(dead_code)]
275 pub doc_agg: String,
276 /// Maximum chunks per document in results. Default: `3`.
277 #[serde(default = "default_max_chunks_per_doc")]
278 #[allow(dead_code)]
279 pub max_chunks_per_doc: usize,
280}
281
282fn default_hybrid_alpha() -> f64 {
283 0.6
284}
285fn default_candidate_k() -> i64 {
286 80
287}
288fn default_final_limit() -> i64 {
289 12
290}
291fn default_group_by() -> String {
292 "document".to_string()
293}
294fn default_doc_agg() -> String {
295 "max".to_string()
296}
297fn default_max_chunks_per_doc() -> usize {
298 3
299}
300
301/// Embedding provider configuration.
302///
303/// Controls which embedding provider is used and its parameters.
304/// When `provider = "disabled"`, no embeddings are generated and
305/// semantic/hybrid search modes will return errors.
306///
307/// # Providers
308///
309/// | Provider | Description |
310/// |----------|-------------|
311/// | `"disabled"` | No embeddings (default) |
312/// | `"openai"` | OpenAI API (`text-embedding-3-small`, etc.) |
313/// | `"ollama"` | Local Ollama instance (`nomic-embed-text`, etc.) |
314/// | `"local"` | Built-in models via fastembed (primary) or tract (musl/Intel Mac) (`all-minilm-l6-v2`, etc.) |
315///
316/// When using `"openai"`, the `OPENAI_API_KEY` environment variable must be set.
317/// When using `"ollama"`, an Ollama instance must be running (default: `http://localhost:11434`).
318/// When using `"local"`, the model is downloaded on first use and cached in `~/.cache/huggingface/`.
319#[derive(Debug, Deserialize, Clone)]
320pub struct EmbeddingConfig {
321 /// Provider name: `"disabled"`, `"openai"`, `"ollama"`, or `"local"`. Default: `"disabled"`.
322 #[serde(default = "default_provider")]
323 pub provider: String,
324 /// Embedding model name (e.g. `"text-embedding-3-small"`, `"nomic-embed-text"`,
325 /// `"all-minilm-l6-v2"`). Required for `openai` and `ollama`; optional for `local`
326 /// (defaults to `"all-minilm-l6-v2"`).
327 #[serde(default)]
328 pub model: Option<String>,
329 /// Embedding vector dimensionality (e.g. `1536` for `text-embedding-3-small`).
330 /// Required for `openai` and `ollama`; auto-detected for `local`.
331 #[serde(default)]
332 pub dims: Option<usize>,
333 /// Number of texts to embed per batch. Default: `64`.
334 #[serde(default = "default_batch_size")]
335 pub batch_size: usize,
336 /// Maximum retry attempts for transient API errors. Default: `5`.
337 #[serde(default = "default_max_retries")]
338 pub max_retries: u32,
339 /// HTTP timeout per request in seconds. Default: `30`.
340 #[serde(default = "default_timeout_secs")]
341 pub timeout_secs: u64,
342 /// Base URL for Ollama API. Default: `"http://localhost:11434"`.
343 #[serde(default)]
344 pub url: Option<String>,
345}
346
347impl Default for EmbeddingConfig {
348 fn default() -> Self {
349 Self {
350 provider: "disabled".to_string(),
351 model: None,
352 dims: None,
353 batch_size: 64,
354 max_retries: 5,
355 timeout_secs: 30,
356 url: None,
357 }
358 }
359}
360
361fn default_provider() -> String {
362 "disabled".to_string()
363}
364fn default_batch_size() -> usize {
365 64
366}
367fn default_max_retries() -> u32 {
368 5
369}
370fn default_timeout_secs() -> u64 {
371 30
372}
373
374/// Optional vector-index acceleration configuration.
375///
376/// The default is automatic: use the built-in vector accelerator when the
377/// binary supports one and fall back to the exact SQLite vector scan otherwise.
378#[derive(Debug, Deserialize, Clone)]
379pub struct VectorIndexConfig {
380 /// Backend name. Default: `"auto"`.
381 #[serde(default = "default_vector_backend")]
382 #[allow(dead_code)]
383 pub backend: String,
384 /// Filesystem path for external vector-index state. Default: `"auto"`.
385 #[serde(default = "default_vector_path")]
386 #[allow(dead_code)]
387 pub path: PathBuf,
388 /// Distance metric. Default: `"cosine"`.
389 #[serde(default = "default_vector_metric")]
390 #[allow(dead_code)]
391 pub metric: String,
392 /// Index kind. Default: `"hnsw"`.
393 #[serde(default = "default_vector_index")]
394 #[allow(dead_code)]
395 pub index: String,
396 /// Fallback strategy when acceleration is unavailable. Default: `"sqlite"`.
397 #[serde(default = "default_vector_fallback")]
398 #[allow(dead_code)]
399 pub fallback: String,
400}
401
402impl Default for VectorIndexConfig {
403 fn default() -> Self {
404 Self {
405 backend: default_vector_backend(),
406 path: default_vector_path(),
407 metric: default_vector_metric(),
408 index: default_vector_index(),
409 fallback: default_vector_fallback(),
410 }
411 }
412}
413
414fn default_vector_backend() -> String {
415 "auto".to_string()
416}
417
418fn default_vector_path() -> PathBuf {
419 PathBuf::from("auto")
420}
421
422fn default_vector_metric() -> String {
423 "cosine".to_string()
424}
425
426fn default_vector_index() -> String {
427 "hnsw".to_string()
428}
429
430fn default_vector_fallback() -> String {
431 "sqlite".to_string()
432}
433
434/// HTTP server configuration.
435#[derive(Debug, Deserialize, Clone)]
436pub struct ServerConfig {
437 /// Socket address to bind to (e.g. `"127.0.0.1:7331"`).
438 pub bind: String,
439}
440
441/// Container for all connector configurations.
442///
443/// All connector types use named instances — you can configure multiple
444/// of each type. For example:
445///
446/// ```toml
447/// [connectors.git.platform]
448/// url = "https://github.com/acme/platform.git"
449///
450/// [connectors.git.auth-service]
451/// url = "https://github.com/acme/auth-service.git"
452/// ```
453///
454/// Use `ctx sync git` to sync all git connectors, or `ctx sync git:platform`
455/// for a specific one. `ctx sync all` syncs everything.
456#[derive(Debug, Deserialize, Clone, Default)]
457pub struct ConnectorsConfig {
458 /// Named filesystem connectors: walk local directories.
459 #[serde(default)]
460 pub filesystem: HashMap<String, FilesystemConnectorConfig>,
461 /// Named Git connectors: clone and scan Git repositories.
462 #[serde(default)]
463 pub git: HashMap<String, GitConnectorConfig>,
464 /// Named S3 connectors: list and download from S3 buckets.
465 #[serde(default)]
466 pub s3: HashMap<String, S3ConnectorConfig>,
467 /// Named Lua script connectors.
468 /// Each key is a connector name, each value contains the script path
469 /// and arbitrary config keys passed to the Lua `connector.scan()` function.
470 /// See `docs/LUA_CONNECTORS.md` for the full specification.
471 #[serde(default)]
472 pub script: HashMap<String, ScriptConnectorConfig>,
473}
474
475/// Lua script connector configuration.
476///
477/// Points to a `.lua` file implementing the connector interface. All fields
478/// except `path` and `timeout` are passed as a config table to the script's
479/// `connector.scan(config)` function.
480///
481/// Values containing `${VAR_NAME}` are expanded from the process environment.
482///
483/// # Example
484///
485/// ```toml
486/// [connectors.script.jira]
487/// path = "connectors/jira.lua"
488/// timeout = 600
489/// url = "https://mycompany.atlassian.net"
490/// api_token = "${JIRA_API_TOKEN}"
491/// project_key = "ENG"
492/// ```
493#[derive(Debug, Deserialize, Clone)]
494pub struct ScriptConnectorConfig {
495 /// Path to the `.lua` connector script.
496 pub path: PathBuf,
497 /// Maximum execution time in seconds. Default: `300`.
498 #[serde(default = "default_script_timeout")]
499 pub timeout: u64,
500 /// All other config keys — passed to the Lua `connector.scan()` function.
501 #[serde(flatten)]
502 pub extra: toml::Table,
503}
504
505fn default_script_timeout() -> u64 {
506 300
507}
508
509/// Container for all tool script configurations.
510///
511/// Tool scripts are Lua files that define MCP tools profiles can discover
512/// and call via the HTTP server. See `docs/LUA_TOOLS.md` for the full
513/// specification.
514#[derive(Debug, Deserialize, Clone, Default)]
515pub struct ToolsConfig {
516 /// Named Lua tool scripts.
517 /// Each key is the tool name, each value contains the script path
518 /// and arbitrary config keys accessible via `context.config` in the script.
519 #[serde(default)]
520 pub script: HashMap<String, ScriptToolConfig>,
521}
522
523/// Lua tool script configuration.
524///
525/// Points to a `.lua` file implementing the tool interface. All fields
526/// except `path` and `timeout` are passed as `context.config` to the
527/// script's `tool.execute(params, context)` function.
528///
529/// Values containing `${VAR_NAME}` are expanded from the process environment.
530///
531/// # Example
532///
533/// ```toml
534/// [tools.script.create_jira_ticket]
535/// path = "tools/create-jira-ticket.lua"
536/// timeout = 30
537/// url = "https://mycompany.atlassian.net"
538/// api_token = "${JIRA_API_TOKEN}"
539/// ```
540#[derive(Debug, Deserialize, Clone)]
541pub struct ScriptToolConfig {
542 /// Path to the `.lua` tool script.
543 pub path: PathBuf,
544 /// Maximum execution time in seconds. Default: `30`.
545 #[serde(default = "default_tool_timeout")]
546 pub timeout: u64,
547 /// All other config keys — accessible via `context.config` in the script.
548 #[serde(flatten)]
549 pub extra: toml::Table,
550}
551
552fn default_tool_timeout() -> u64 {
553 30
554}
555
556/// Container for all reusable profile configurations.
557///
558/// Profiles are named personas that combine a system prompt, scoped tools,
559/// and optional dynamic context injection. They can be defined inline
560/// in TOML or via Lua scripts.
561///
562/// # Example
563///
564/// ```toml
565/// [profiles.inline.code-reviewer]
566/// description = "Reviews code against project conventions"
567/// tools = ["search", "get"]
568/// system_prompt = "You are a senior code reviewer..."
569///
570/// [profiles.script.incident-responder]
571/// path = "profiles/incident-responder.lua"
572/// timeout = 30
573/// ```
574#[derive(Debug, Deserialize, Clone, Default)]
575pub struct ProfilesConfig {
576 /// Inline TOML profiles with static system prompts.
577 /// Each key is the profile name, each value contains the prompt and tool list.
578 #[serde(default)]
579 pub inline: HashMap<String, InlineProfileConfig>,
580 /// Lua script profiles with dynamic prompt resolution.
581 /// Each key is the profile name, each value contains the script path
582 /// and arbitrary config keys passed to `profile.resolve()`.
583 #[serde(default)]
584 pub script: HashMap<String, ScriptProfileConfig>,
585}
586
587/// Inline TOML profile configuration.
588///
589/// Defines a profile with a static system prompt and fixed tool list.
590///
591/// # Example
592///
593/// ```toml
594/// [profiles.inline.architect]
595/// description = "Answers architecture questions"
596/// tools = ["search", "get", "sources"]
597/// system_prompt = """
598/// You are a software architect. Search for ADRs and design
599/// docs to ground your recommendations.
600/// """
601/// ```
602#[derive(Debug, Deserialize, Clone)]
603pub struct InlineProfileConfig {
604 /// One-line description for profile discovery.
605 pub description: String,
606 /// List of tool names this profile suggests.
607 pub tools: Vec<String>,
608 /// The system prompt text.
609 pub system_prompt: String,
610}
611
612/// Lua script profile configuration.
613///
614/// Points to a `.lua` file implementing the profile interface. All fields
615/// except `path` and `timeout` are passed as config to the script's
616/// `profile.resolve(args, config, context)` function.
617///
618/// Values containing `${VAR_NAME}` are expanded from the process environment.
619///
620/// # Example
621///
622/// ```toml
623/// [profiles.script.incident-responder]
624/// path = "profiles/incident-responder.lua"
625/// timeout = 30
626/// search_limit = 5
627/// priority_sources = ["runbooks"]
628/// ```
629#[derive(Debug, Deserialize, Clone)]
630pub struct ScriptProfileConfig {
631 /// Path to the `.lua` profile script.
632 pub path: PathBuf,
633 /// Maximum execution time in seconds. Default: `30`.
634 #[serde(default = "default_profile_timeout")]
635 pub timeout: u64,
636 /// All other config keys — passed to the Lua `profile.resolve()` function.
637 #[serde(flatten)]
638 pub extra: toml::Table,
639}
640
641fn default_profile_timeout() -> u64 {
642 30
643}
644
645#[deprecated(note = "use ProfilesConfig")]
646#[allow(dead_code)]
647pub type AgentsConfig = ProfilesConfig;
648#[deprecated(note = "use InlineProfileConfig")]
649#[allow(dead_code)]
650pub type InlineAgentConfig = InlineProfileConfig;
651#[deprecated(note = "use ScriptProfileConfig")]
652#[allow(dead_code)]
653pub type ScriptAgentConfig = ScriptProfileConfig;
654
655/// Extension registry configuration.
656///
657/// Points to a local directory (optionally backed by a Git repository)
658/// containing Lua connector, tool, and profile scripts described by a
659/// `registry.toml` manifest.
660///
661/// # Example
662///
663/// ```toml
664/// [registries.community]
665/// url = "https://github.com/parallax-labs/ctx-registry.git"
666/// branch = "main"
667/// path = "~/.local/share/ctx/registries/community"
668/// readonly = true
669/// auto_update = true
670/// ```
671#[derive(Debug, Deserialize, Clone)]
672pub struct RegistryConfig {
673 /// Git repository URL to clone from. `None` means local-only (no git).
674 pub url: Option<String>,
675 /// Git branch or tag to track. Default: `"main"`.
676 pub branch: Option<String>,
677 /// Local filesystem path where the registry is (or will be) stored.
678 pub path: PathBuf,
679 /// If `true`, extensions on this path cannot be edited in place;
680 /// overrides are copied to a writable registry.
681 #[serde(default)]
682 pub readonly: bool,
683 /// If `true`, `ctx registry update` (and optionally `ctx sync`) will
684 /// `git pull` this registry automatically.
685 #[serde(default)]
686 #[allow(dead_code)]
687 pub auto_update: bool,
688}
689
690/// Filesystem connector configuration.
691///
692/// Scans a local directory tree, applying glob include/exclude filters.
693/// See [`crate::connector_fs`] for the scanning implementation.
694///
695/// # Example
696///
697/// ```toml
698/// [connectors.filesystem.docs]
699/// root = "./docs"
700/// include_globs = ["**/*.md", "**/*.txt"]
701/// exclude_globs = ["**/drafts/**"]
702/// follow_symlinks = false
703/// max_extract_bytes = 50_000_000
704/// ```
705#[derive(Debug, Deserialize, Clone)]
706pub struct FilesystemConnectorConfig {
707 /// Root directory to scan.
708 pub root: PathBuf,
709 /// Glob patterns for files to include. Default: `["**/*.md", "**/*.txt"]`.
710 #[serde(default = "default_include_globs")]
711 pub include_globs: Vec<String>,
712 /// Glob patterns for files to exclude. Default: `[]`.
713 #[serde(default)]
714 pub exclude_globs: Vec<String>,
715 /// Whether to follow symbolic links. Default: `false`.
716 #[serde(default)]
717 pub follow_symlinks: bool,
718 /// Files larger than this (bytes) are not extracted; they are skipped and counted in extraction skipped. Default: 50_000_000.
719 #[serde(default = "default_max_extract_bytes")]
720 pub max_extract_bytes: u64,
721}
722
723/// Git connector configuration.
724///
725/// Clones (or pulls) a Git repository and scans files within a configurable
726/// subdirectory. Extracts per-file metadata from `git log`.
727/// See [`crate::connector_git`] for the full implementation.
728///
729/// # Example
730///
731/// ```toml
732/// [connectors.git.platform]
733/// url = "https://github.com/acme/platform.git"
734/// branch = "main"
735/// root = "docs/"
736/// include_globs = ["**/*.md"]
737/// shallow = true
738/// ```
739#[derive(Debug, Deserialize, Clone)]
740pub struct GitConnectorConfig {
741 /// Git repository URL (`https://`, `git@`, or local path).
742 pub url: String,
743 /// Branch to clone/pull. Default: `"main"`.
744 #[serde(default = "default_git_branch")]
745 pub branch: String,
746 /// Subdirectory within the repo to scan. Default: `"."` (entire repo).
747 #[serde(default = "default_git_root")]
748 pub root: String,
749 /// Glob patterns for files to include. Default: `["**/*.md", "**/*.txt"]`.
750 #[serde(default = "default_include_globs")]
751 pub include_globs: Vec<String>,
752 /// Glob patterns for files to exclude. Default: `[]`.
753 #[serde(default)]
754 pub exclude_globs: Vec<String>,
755 /// Use shallow clone (`--depth 1`) to save disk space. Default: `true`.
756 #[serde(default = "default_true")]
757 pub shallow: bool,
758 /// Directory to cache cloned repos. Default: `.ctx/cache/git/<url-hash>/`
759 /// when using the workspace DB, otherwise `<db-dir>/.git-cache/<url-hash>/`.
760 #[serde(default)]
761 pub cache_dir: Option<PathBuf>,
762}
763
764/// Amazon S3 connector configuration.
765///
766/// Lists and downloads objects from an S3 bucket using the REST API with
767/// AWS Signature V4. Supports custom endpoints for S3-compatible services.
768/// See [`crate::connector_s3`] for the full implementation.
769///
770/// # Environment Variables
771///
772/// - `AWS_ACCESS_KEY_ID` — required
773/// - `AWS_SECRET_ACCESS_KEY` — required
774/// - `AWS_SESSION_TOKEN` — optional (for temporary credentials)
775///
776/// # Example
777///
778/// ```toml
779/// [connectors.s3.runbooks]
780/// bucket = "acme-docs"
781/// prefix = "engineering/runbooks/"
782/// region = "us-east-1"
783/// include_globs = ["**/*.md"]
784/// # endpoint_url = "http://localhost:9000" # for MinIO
785/// ```
786#[derive(Debug, Deserialize, Clone)]
787pub struct S3ConnectorConfig {
788 /// S3 bucket name.
789 pub bucket: String,
790 /// Key prefix to filter objects. Default: `""` (entire bucket).
791 #[serde(default)]
792 pub prefix: String,
793 /// AWS region. Default: `"us-east-1"`.
794 #[serde(default = "default_s3_region")]
795 pub region: String,
796 /// Glob patterns for object keys to include. Default: `["**/*.md", "**/*.txt"]`.
797 #[serde(default = "default_include_globs")]
798 pub include_globs: Vec<String>,
799 /// Glob patterns for object keys to exclude. Default: `[]`.
800 #[serde(default)]
801 pub exclude_globs: Vec<String>,
802 /// Custom endpoint URL for S3-compatible services (MinIO, LocalStack).
803 #[serde(default)]
804 pub endpoint_url: Option<String>,
805}
806
807fn default_git_branch() -> String {
808 "main".to_string()
809}
810
811fn default_git_root() -> String {
812 ".".to_string()
813}
814
815fn default_true() -> bool {
816 true
817}
818
819fn default_s3_region() -> String {
820 "us-east-1".to_string()
821}
822
823fn default_include_globs() -> Vec<String> {
824 vec!["**/*.md".to_string(), "**/*.txt".to_string()]
825}
826
827fn default_max_extract_bytes() -> u64 {
828 50_000_000
829}
830
831impl EmbeddingConfig {
832 /// Returns `true` if an embedding provider is configured (not `"disabled"`).
833 pub fn is_enabled(&self) -> bool {
834 self.provider != "disabled"
835 }
836}
837
838/// Load and validate a configuration file from disk.
839///
840/// # Arguments
841///
842/// * `path` — Path to a TOML configuration file.
843///
844/// # Errors
845///
846/// Returns an error if:
847/// - The file cannot be read or parsed
848/// - `chunking.max_tokens` is zero
849/// - `retrieval.final_limit` is less than 1
850/// - `retrieval.hybrid_alpha` is outside `[0.0, 1.0]`
851/// - Embedding provider is enabled but `model` or `dims` is missing/zero
852/// - Unknown embedding provider name
853#[allow(dead_code)]
854pub fn load_config(path: &Path) -> Result<Config> {
855 load_config_file(path)
856}
857
858pub fn load_config_for_cli(explicit_path: Option<PathBuf>) -> Result<ResolvedConfig> {
859 let paths = ctx_dirs::config_paths(explicit_path);
860 let source = paths.resolve();
861
862 match source.kind {
863 ConfigSourceKind::Explicit | ConfigSourceKind::Env | ConfigSourceKind::Global => {
864 let path = source
865 .path
866 .clone()
867 .expect("path-backed config source must include path");
868 let config = load_config_file(&path)?;
869 Ok(ResolvedConfig {
870 config,
871 path: Some(path),
872 source: source.kind,
873 })
874 }
875 ConfigSourceKind::Workspace | ConfigSourceKind::LegacyWorkspace => {
876 let workspace_path = source
877 .path
878 .clone()
879 .expect("path-backed config source must include path");
880 let workspace_value = load_config_value(&workspace_path)?;
881 let merged_value = if paths.global.exists() {
882 let mut global_value = load_config_value(&paths.global)?;
883 merge_toml(&mut global_value, workspace_value);
884 global_value
885 } else {
886 workspace_value
887 };
888 let config = config_from_value(merged_value)?;
889 Ok(ResolvedConfig {
890 config,
891 path: Some(workspace_path),
892 source: source.kind,
893 })
894 }
895 ConfigSourceKind::BuiltIn => Ok(ResolvedConfig {
896 config: Config::minimal(),
897 path: None,
898 source: ConfigSourceKind::BuiltIn,
899 }),
900 }
901}
902
903/// Resolve a workspace's effective config from its root directory (SPEC-0014 R4).
904///
905/// Resolves `<root>/.ctx/config.toml` (or the legacy `<root>/config/ctx.toml`)
906/// and merges global config defaults underneath it — mirroring the `Workspace`
907/// branch of [`load_config_for_cli`] but anchored at an explicit `root` instead
908/// of the process working directory. Relative store paths in the resolved
909/// config are re-rooted under `root` so each workspace addresses its own store.
910pub fn load_config_for_root(root: &Path) -> Result<ResolvedConfig> {
911 let workspace_path = root.join(".ctx").join("config.toml");
912 let legacy_path = root.join("config").join("ctx.toml");
913
914 let (chosen, kind) = if workspace_path.exists() {
915 (Some(workspace_path), ConfigSourceKind::Workspace)
916 } else if legacy_path.exists() {
917 (Some(legacy_path), ConfigSourceKind::LegacyWorkspace)
918 } else {
919 (None, ConfigSourceKind::BuiltIn)
920 };
921
922 let global = ctx_dirs::config_dir().join("config.toml");
923
924 let mut config = match &chosen {
925 Some(path) => {
926 let workspace_value = load_config_value(path)?;
927 let merged = if global.exists() {
928 let mut global_value = load_config_value(&global)?;
929 merge_toml(&mut global_value, workspace_value);
930 global_value
931 } else {
932 workspace_value
933 };
934 config_from_value(merged)?
935 }
936 None if global.exists() => load_config_file(&global)?,
937 None => Config::minimal(),
938 };
939
940 reroot_workspace_paths(&mut config, root);
941 Ok(ResolvedConfig {
942 config,
943 path: chosen,
944 source: kind,
945 })
946}
947
948/// Load a pinned config file as the sole source, with no global merge (SPEC-0014 R5).
949///
950/// Preserves explicit-config behavior (`config = …` in the workspace registry),
951/// but still re-roots relative store paths under `root` so the workspace
952/// addresses its own store in multi-workspace mode.
953pub fn load_config_pinned(config_path: &Path, root: &Path) -> Result<ResolvedConfig> {
954 let mut config = load_config_file(config_path)?;
955 reroot_workspace_paths(&mut config, root);
956 Ok(ResolvedConfig {
957 config,
958 path: Some(config_path.to_path_buf()),
959 source: ConfigSourceKind::Explicit,
960 })
961}
962
963/// Re-root relative store paths under an explicit workspace root so each
964/// workspace addresses its own SQLite store and vector sidecar regardless of
965/// the server's working directory (SPEC-0014 R55–R58).
966fn reroot_workspace_paths(config: &mut Config, root: &Path) {
967 if config.db.path.is_relative() {
968 config.db.path = root.join(&config.db.path);
969 }
970 // The vector sidecar path is derived from db.path when set to "auto" (see
971 // vector_index::resolve_vector_index_path), so only an explicit relative
972 // override needs re-rooting here.
973 if config.vector_index.path.as_os_str() != "auto" && config.vector_index.path.is_relative() {
974 config.vector_index.path = root.join(&config.vector_index.path);
975 }
976 // Filesystem connector roots feed `sources` health checks; re-root relative ones.
977 for fs in config.connectors.filesystem.values_mut() {
978 if fs.root.is_relative() {
979 fs.root = root.join(&fs.root);
980 }
981 }
982}
983
984pub fn ensure_workspace_config_for_init(explicit_path: Option<&Path>) -> Result<Option<PathBuf>> {
985 let paths = ctx_dirs::config_paths(explicit_path.map(Path::to_path_buf));
986 if paths.has_explicit_source() || paths.has_workspace_source() {
987 return Ok(paths.resolve().path);
988 }
989
990 let ctx_dir = ctx_dirs::workspace_dir();
991 std::fs::create_dir_all(ctx_dirs::workspace_data_dir())?;
992 std::fs::create_dir_all(ctx_dirs::workspace_cache_dir())?;
993 std::fs::write(ctx_dir.join(".gitignore"), "data/\ncache/\n")?;
994
995 let config_path = ctx_dirs::workspace_config_path();
996 if !config_path.exists() {
997 std::fs::write(&config_path, default_workspace_config_toml())?;
998 }
999 Ok(Some(config_path))
1000}
1001
1002fn load_config_file(path: &Path) -> Result<Config> {
1003 let content = std::fs::read_to_string(path)
1004 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
1005
1006 let config: Config = toml::from_str(&content).with_context(|| "Failed to parse config file")?;
1007 validate_config(config)
1008}
1009
1010fn load_config_value(path: &Path) -> Result<toml::Value> {
1011 let content = std::fs::read_to_string(path)
1012 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
1013 toml::from_str(&content).with_context(|| "Failed to parse config file")
1014}
1015
1016fn config_from_value(value: toml::Value) -> Result<Config> {
1017 let config: Config = value
1018 .try_into()
1019 .with_context(|| "Failed to parse config file")?;
1020 validate_config(config)
1021}
1022
1023fn validate_config(config: Config) -> Result<Config> {
1024 for (name, server) in &config.mcp_servers {
1025 server.validate(name)?;
1026 }
1027
1028 // Validate chunking
1029 if config.chunking.max_tokens == 0 {
1030 anyhow::bail!("chunking.max_tokens must be > 0");
1031 }
1032
1033 // Validate retrieval
1034 if config.retrieval.final_limit < 1 {
1035 anyhow::bail!("retrieval.final_limit must be >= 1");
1036 }
1037
1038 if !(0.0..=1.0).contains(&config.retrieval.hybrid_alpha) {
1039 anyhow::bail!("retrieval.hybrid_alpha must be in [0.0, 1.0]");
1040 }
1041
1042 // Validate embedding
1043 match config.embedding.provider.as_str() {
1044 "disabled" => {}
1045 "openai" | "ollama" => {
1046 if config.embedding.dims.is_none() || config.embedding.dims == Some(0) {
1047 anyhow::bail!(
1048 "embedding.dims must be > 0 when provider is '{}'",
1049 config.embedding.provider
1050 );
1051 }
1052 if config.embedding.model.is_none() {
1053 anyhow::bail!(
1054 "embedding.model must be specified when provider is '{}'",
1055 config.embedding.provider
1056 );
1057 }
1058 }
1059 "local" => {
1060 // model and dims are optional for local — defaults applied at runtime
1061 }
1062 other => anyhow::bail!(
1063 "Unknown embedding provider: '{}'. Must be disabled, openai, ollama, or local.",
1064 other
1065 ),
1066 }
1067
1068 match config.vector_index.backend.as_str() {
1069 "auto" | "zvec" | "sqlite" | "disabled" => {}
1070 other => anyhow::bail!(
1071 "Unknown vector_index.backend: '{}'. Must be auto, zvec, sqlite, or disabled.",
1072 other
1073 ),
1074 }
1075
1076 match config.vector_index.metric.as_str() {
1077 "cosine" => {}
1078 other => anyhow::bail!("Unknown vector_index.metric: '{}'. Must be cosine.", other),
1079 }
1080
1081 match config.vector_index.index.as_str() {
1082 "hnsw" | "flat" => {}
1083 other => anyhow::bail!(
1084 "Unknown vector_index.index: '{}'. Must be hnsw or flat.",
1085 other
1086 ),
1087 }
1088
1089 match config.vector_index.fallback.as_str() {
1090 "sqlite" | "disabled" => {}
1091 other => anyhow::bail!(
1092 "Unknown vector_index.fallback: '{}'. Must be sqlite or disabled.",
1093 other
1094 ),
1095 }
1096
1097 Ok(config)
1098}
1099
1100fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
1101 match (base, overlay) {
1102 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1103 for (key, overlay_value) in overlay_table {
1104 match base_table.get_mut(&key) {
1105 Some(base_value) => merge_toml(base_value, overlay_value),
1106 None => {
1107 base_table.insert(key, overlay_value);
1108 }
1109 }
1110 }
1111 }
1112 (base_value, overlay_value) => {
1113 *base_value = overlay_value;
1114 }
1115 }
1116}
1117
1118pub fn default_workspace_config_toml() -> &'static str {
1119 r#"[db]
1120path = ".ctx/data/ctx.sqlite"
1121
1122[chunking]
1123max_tokens = 700
1124overlap_tokens = 0
1125
1126[retrieval]
1127final_limit = 12
1128
1129[server]
1130bind = "127.0.0.1:7331"
1131"#
1132}