use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub server: ServerConfig,
pub index: IndexConfig,
pub embedding: EmbeddingConfig,
pub search: SearchConfig,
pub git: GitConfig,
pub context: ContextTuning,
pub feedback: FeedbackTuning,
pub dependencies: DependencyConfig,
pub hooks: HooksConfig,
pub beads: BeadsConfig,
pub archive: ArchiveConfig,
#[serde(default)]
pub sql: SqlConfig,
pub access: AccessConfig,
pub sources: SourcesConfig,
pub groups: Vec<GroupConfig>,
pub file_types: Vec<FileTypeRule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quipu_endpoint: Option<String>,
#[serde(default)]
pub quipu_push_chunks: bool,
#[serde(default)]
pub quipu_push_inferred: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ServerConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bind_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_path_prefix: Option<String>,
}
pub type GlobalConfig = Config;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct IndexConfig {
pub include: Vec<String>,
pub exclude: Vec<String>,
pub use_gitignore: bool,
pub chunk_size: usize,
pub chunk_overlap: usize,
pub multimodal: bool,
pub documents: bool,
pub entities: bool,
}
impl Default for IndexConfig {
fn default() -> Self {
Self {
include: vec![
"**/*.rs".into(),
"**/*.ts".into(),
"**/*.tsx".into(),
"**/*.js".into(),
"**/*.jsx".into(),
"**/*.py".into(),
"**/*.go".into(),
"**/*.java".into(),
"**/*.cpp".into(),
"**/*.cc".into(),
"**/*.hpp".into(),
"**/*.yml".into(),
"**/*.yaml".into(),
"**/*.j2".into(),
"**/*.tf".into(),
"**/*.sh".into(),
"**/*.md".into(),
],
exclude: vec![
"**/node_modules/**".into(),
"**/target/**".into(),
"**/dist/**".into(),
"**/.git/**".into(),
"**/build/**".into(),
"**/__pycache__/**".into(),
"**/CONTRIBUTING.md".into(),
"**/contributing.md".into(),
"**/searchindex*.js".into(),
"**/*.min.js".into(),
"**/*.min.css".into(),
"**/.scratch/**".into(),
"**/vendor/**".into(),
"**/.venv/**".into(),
"**/book/book/**".into(),
],
use_gitignore: true,
chunk_size: 50,
chunk_overlap: 10,
multimodal: false,
documents: false,
entities: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbeddingConfig {
pub backend: EmbeddingBackend,
pub model: String,
pub batch_size: usize,
pub dimensions: Option<usize>,
pub gpu: bool,
pub api: Option<ApiEmbeddingConfig>,
pub custom_model: Option<CustomModelConfig>,
pub context: ContextualEmbeddingConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum EmbeddingBackend {
Onnx,
OpenaiApi,
}
impl Default for EmbeddingBackend {
fn default() -> Self {
Self::Onnx
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiEmbeddingConfig {
pub url: String,
pub api_key: Option<String>,
}
impl ApiEmbeddingConfig {
pub fn resolve_api_key(&self) -> Option<String> {
self.api_key.as_ref().and_then(|key| {
if let Some(var_name) = key.strip_prefix("env:") {
std::env::var(var_name).ok()
} else if key.is_empty() {
None
} else {
Some(key.clone())
}
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomModelConfig {
pub model_path: String,
pub tokenizer_path: String,
pub max_seq_len: Option<usize>,
}
impl Default for EmbeddingConfig {
fn default() -> Self {
Self {
backend: EmbeddingBackend::default(),
model: "all-MiniLM-L6-v2".into(),
batch_size: 32,
dimensions: None,
gpu: false,
api: None,
custom_model: None,
context: ContextualEmbeddingConfig::default(),
}
}
}
#[path = "config_gpu.rs"]
mod gpu;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ContextualEmbeddingConfig {
pub context_lines: usize,
pub enabled_languages: Vec<String>,
}
impl Default for ContextualEmbeddingConfig {
fn default() -> Self {
Self {
context_lines: 5,
enabled_languages: vec!["markdown".into()],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchConfig {
pub default_limit: usize,
pub semantic_weight: f32,
pub recency_half_life_days: f32,
pub recency_weight: f32,
pub rrf_k: f32,
pub doc_demotion: f32,
#[serde(default)]
pub ppr_weight: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reranker: Option<crate::search::rerank::RerankerConfig>,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
default_limit: 10,
semantic_weight: 0.9,
recency_half_life_days: 30.0,
recency_weight: 0.3,
rrf_k: 60.0,
doc_demotion: 0.3,
ppr_weight: if cfg!(feature = "knowledge") {
0.3
} else {
0.0
},
reranker: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DependencyConfig {
pub enabled: bool,
pub resolve_imports: bool,
}
impl Default for DependencyConfig {
fn default() -> Self {
Self {
enabled: true,
resolve_imports: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GitConfig {
pub coupling_enabled: bool,
pub coupling_depth: usize,
pub coupling_threshold: u32,
pub coupling_freq_weight: f32,
pub coupling_recency_days: f32,
pub commits_enabled: bool,
pub commits_depth: usize,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
coupling_enabled: true,
coupling_depth: 5000,
coupling_threshold: 3,
coupling_freq_weight: 0.7,
coupling_recency_days: 30.0,
commits_enabled: true,
commits_depth: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ContextTuning {
pub bridge_boost_factor: f32,
pub max_bridged_files: usize,
pub max_bridged_chunks_per_file: usize,
pub coupling_threshold: f32,
pub knowledge_budget_pct: f32,
pub knowledge_max_hops: u32,
pub neighbor_budget_pct: f32,
pub budget_unit: crate::search::context::BudgetUnit,
}
impl Default for ContextTuning {
fn default() -> Self {
Self {
bridge_boost_factor: 0.3,
max_bridged_files: 2,
max_bridged_chunks_per_file: 1,
coupling_threshold: 0.1,
knowledge_budget_pct: 15.0,
knowledge_max_hops: 2,
neighbor_budget_pct: 10.0,
budget_unit: crate::search::context::BudgetUnit::Line,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FeedbackTuning {
pub boost_max: f32,
pub boost_weight: f32,
}
impl Default for FeedbackTuning {
fn default() -> Self {
Self {
boost_max: 0.3,
boost_weight: 0.2,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HooksConfig {
pub threshold: f32,
pub budget: usize,
pub content_mode: String,
pub min_prompt_length: usize,
pub gate_threshold: f32,
pub dedup_enabled: bool,
pub show_docs: bool,
#[serde(default = "default_format_mode")]
pub format_mode: String,
#[serde(default = "default_true")]
pub reducing_enabled: bool,
#[serde(default)]
pub keyword_repos: Vec<KeywordRepoRule>,
#[serde(default = "default_repo_affinity_boost")]
pub repo_affinity_boost: f32,
#[serde(default = "default_feedback_prompt_interval")]
pub feedback_prompt_interval: u64,
#[serde(default)]
pub skip_prefixes: Vec<String>,
#[serde(default = "default_true")]
pub bundle_auto_inject: bool,
#[serde(default = "default_bundle_inject_lines")]
pub bundle_inject_lines: usize,
#[serde(default = "default_bundle_max_inject")]
pub bundle_max_inject: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeywordRepoRule {
pub keywords: Vec<String>,
pub repos: Vec<String>,
}
fn default_format_mode() -> String {
"standard".into()
}
fn default_repo_affinity_boost() -> f32 {
2.0
}
fn default_feedback_prompt_interval() -> u64 {
5
}
fn default_true() -> bool {
true
}
fn default_bundle_inject_lines() -> usize {
30
}
fn default_bundle_max_inject() -> usize {
2
}
pub const VALID_FORMAT_MODES: &[&str] = &["standard", "minimal", "verbose", "xml"];
impl Default for HooksConfig {
fn default() -> Self {
Self {
threshold: 0.5,
budget: 300,
content_mode: "full".into(),
min_prompt_length: 20,
gate_threshold: 0.45,
dedup_enabled: true,
show_docs: true,
format_mode: "standard".into(),
reducing_enabled: true,
keyword_repos: vec![],
repo_affinity_boost: 2.0,
feedback_prompt_interval: 5,
skip_prefixes: vec![],
bundle_auto_inject: true,
bundle_inject_lines: 30,
bundle_max_inject: 2,
}
}
}
impl HooksConfig {
pub fn resolve_keyword_repos(&self, query: &str) -> Vec<String> {
if self.keyword_repos.is_empty() {
return vec![];
}
let query_lower = query.to_lowercase();
let mut repos: Vec<String> = Vec::new();
for rule in &self.keyword_repos {
if rule
.keywords
.iter()
.any(|kw| query_lower.contains(&kw.to_lowercase()))
{
for repo in &rule.repos {
if !repos.contains(repo) {
repos.push(repo.clone());
}
}
}
}
repos
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BeadsSource {
#[default]
Dolt,
Jsonl,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BeadsConfig {
pub enabled: bool,
pub source: BeadsSource,
pub host: String,
pub port: u16,
pub user: String,
pub databases: Vec<String>,
pub jsonl_paths: std::collections::BTreeMap<String, String>,
pub include_comments: bool,
pub include_closed: bool,
pub max_age_days: u32,
pub exclude_labels: Vec<String>,
}
impl Default for BeadsConfig {
fn default() -> Self {
Self {
enabled: false,
source: BeadsSource::default(),
host: "localhost".into(),
port: 3306,
user: "root".into(),
databases: vec![],
jsonl_paths: std::collections::BTreeMap::new(),
include_comments: true,
include_closed: false,
max_age_days: 90,
exclude_labels: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ArchiveConfig {
pub enabled: bool,
pub sources: Vec<ArchiveSource>,
pub webhook_secret: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveSource {
pub name: String,
pub path: String,
pub schema: String,
#[serde(default)]
pub name_field: String,
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
enabled: false,
sources: vec![],
webhook_secret: String::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SqlConfig {
pub enabled: bool,
pub sources: Vec<SqlSourceConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SqlSourceConfig {
pub name: String,
pub url_env: String,
pub query: String,
pub id_column: String,
#[serde(default)]
pub text_columns: Vec<String>,
#[serde(default)]
pub tag_columns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AccessConfig {
pub default_allow: bool,
pub roles: Vec<RoleConfig>,
}
impl Default for AccessConfig {
fn default() -> Self {
Self {
default_allow: true,
roles: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleConfig {
pub name: String,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub deny_paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SourcesConfig {
#[serde(default)]
pub remote_template: String,
pub default_url: String,
pub repos: std::collections::HashMap<String, String>,
#[serde(default)]
pub forge_overrides: std::collections::HashMap<String, String>,
}
impl Default for SourcesConfig {
fn default() -> Self {
Self {
remote_template: String::new(),
default_url: String::new(),
repos: std::collections::HashMap::new(),
forge_overrides: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupConfig {
pub name: String,
pub repos: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileTypeRule {
pub name: String,
pub patterns: Vec<String>,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path.display()))
}
pub fn resolve_group(&self, name: &str) -> Option<&[String]> {
self.groups
.iter()
.find(|g| g.name == name)
.map(|g| g.repos.as_slice())
}
pub fn group_filter(&self, name: &str) -> Option<String> {
self.resolve_group(name).and_then(|repos| {
if repos.is_empty() {
return None;
}
let escaped: Vec<String> = repos
.iter()
.map(|r| format!("'{}'", r.replace('\'', "''")))
.collect();
Some(format!("repo IN ({})", escaped.join(", ")))
})
}
pub fn save(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
std::fs::write(path, content)
.with_context(|| format!("Failed to write config file: {}", path.display()))
}
pub fn data_dir(repo_root: &Path) -> PathBuf {
repo_root.join(".bobbin")
}
pub fn config_path(repo_root: &Path) -> PathBuf {
Self::data_dir(repo_root).join("config.toml")
}
pub fn db_path(repo_root: &Path) -> PathBuf {
Self::data_dir(repo_root).join("index.db")
}
pub fn feedback_db_path(repo_root: &Path) -> PathBuf {
Self::data_dir(repo_root).join("feedback.db")
}
pub fn lance_path(repo_root: &Path) -> PathBuf {
Self::data_dir(repo_root).join("vectors")
}
pub fn model_cache_dir() -> Result<PathBuf> {
let project_dirs = directories::ProjectDirs::from("dev", "bobbin", "bobbin")
.context("Failed to determine user directories")?;
Ok(project_dirs.cache_dir().join("models"))
}
pub fn global_config_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("dev", "bobbin", "bobbin")
.map(|dirs| dirs.config_dir().to_path_buf())
}
pub fn global_config_path() -> Option<PathBuf> {
Self::global_config_dir().map(|dir| dir.join("config.toml"))
}
}
impl Config {
pub fn load_global() -> Config {
Self::global_config_path()
.and_then(|path| {
if path.exists() {
std::fs::read_to_string(&path).ok()
} else {
None
}
})
.and_then(|content| toml::from_str(&content).ok())
.unwrap_or_default()
}
pub fn save_global(&self) -> Result<()> {
let path = Self::global_config_path().context("Failed to determine global config path")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create global config directory: {}",
parent.display()
)
})?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize global config")?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write global config: {}", path.display()))
}
pub fn load_merged(repo_root: &Path) -> Result<Self> {
let global = Self::load_global();
let repo_config_path = Self::config_path(repo_root);
if repo_config_path.exists() {
let repo_toml = std::fs::read_to_string(&repo_config_path)
.with_context(|| format!("Failed to read {}", repo_config_path.display()))?;
let repo_table: toml::Value = toml::from_str(&repo_toml)
.with_context(|| format!("Failed to parse {}", repo_config_path.display()))?;
let global_table = toml::Value::try_from(&global)
.context("Failed to serialize global config for merge")?;
let merged = deep_merge_toml(global_table, repo_table);
merged
.try_into()
.context("Failed to deserialize merged config")
} else {
Ok(global)
}
}
}
fn deep_merge_toml(base: toml::Value, overlay: toml::Value) -> toml::Value {
match (base, overlay) {
(toml::Value::Table(mut base_map), toml::Value::Table(overlay_map)) => {
for (key, overlay_val) in overlay_map {
let merged = if let Some(base_val) = base_map.remove(&key) {
deep_merge_toml(base_val, overlay_val)
} else {
overlay_val
};
base_map.insert(key, merged);
}
toml::Value::Table(base_map)
}
(_base, overlay) => overlay,
}
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;