Skip to main content

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}