use super::serde_defaults;
#[allow(clippy::wildcard_imports)]
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecretDetectionConfig {
pub enabled: bool,
pub redact: bool,
pub custom_patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SetupConfig {
pub auto_inject_rules: Option<bool>,
pub auto_inject_skills: Option<bool>,
#[serde(default = "serde_defaults::default_true")]
pub auto_update_mcp: bool,
}
impl Default for SetupConfig {
fn default() -> Self {
Self {
auto_inject_rules: None,
auto_inject_skills: None,
auto_update_mcp: true,
}
}
}
impl SetupConfig {
pub fn should_inject_rules(&self) -> bool {
match self.auto_inject_rules {
Some(v) => v,
None => Self::rules_already_present(),
}
}
pub fn should_inject_skills(&self) -> bool {
match self.auto_inject_skills {
Some(v) => v,
None => Self::rules_already_present(),
}
}
pub fn should_update_mcp(&self) -> bool {
self.auto_update_mcp
}
fn rules_already_present() -> bool {
let Some(home) = dirs::home_dir() else {
return false;
};
if crate::rules_inject::any_rules_marker_present(&home) {
return true;
}
let legacy_paths = [
crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
crate::core::editor_registry::codebuddy_rules_dir(&home).join("lean-ctx.md"),
];
legacy_paths.iter().any(|p| {
std::fs::read_to_string(p)
.is_ok_and(|c| c.contains(crate::core::rules_canonical::START_MARK))
})
}
}
impl Default for SecretDetectionConfig {
fn default() -> Self {
Self {
enabled: true,
redact: true,
custom_patterns: Vec::new(),
exclude_patterns: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ArchiveConfig {
pub enabled: bool,
pub threshold_chars: usize,
pub max_age_hours: u64,
pub max_disk_mb: u64,
pub ephemeral: bool,
pub ephemeral_min_tokens: usize,
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
enabled: true,
threshold_chars: 800,
max_age_hours: 48,
max_disk_mb: 500,
ephemeral: true,
ephemeral_min_tokens: 2000,
}
}
}
impl ArchiveConfig {
pub fn ephemeral_effective(&self) -> bool {
if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
return !matches!(v.trim(), "0" | "false" | "off");
}
self.ephemeral && self.enabled
}
pub fn ephemeral_min_tokens_effective(&self) -> usize {
if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL_MIN_TOKENS")
&& let Ok(n) = v.trim().parse::<usize>()
{
return n;
}
self.ephemeral_min_tokens
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProvidersConfig {
pub enabled: bool,
pub github: ProviderEntryConfig,
pub gitlab: ProviderEntryConfig,
pub auto_index: bool,
pub cache_ttl_secs: u64,
#[serde(default)]
pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
}
impl Default for ProvidersConfig {
fn default() -> Self {
Self {
enabled: true,
github: ProviderEntryConfig::default(),
gitlab: ProviderEntryConfig::default(),
auto_index: true,
cache_ttl_secs: 120,
mcp_bridges: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpBridgeEntry {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub auth_env: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProviderEntryConfig {
pub enabled: bool,
pub token: Option<String>,
pub api_url: Option<String>,
pub project: Option<String>,
}
impl Default for ProviderEntryConfig {
fn default() -> Self {
Self {
enabled: true,
token: None,
api_url: None,
project: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AutonomyConfig {
pub enabled: bool,
pub auto_preload: bool,
pub auto_dedup: bool,
pub auto_related: bool,
pub auto_consolidate: bool,
pub silent_preload: bool,
pub dedup_threshold: usize,
pub consolidate_every_calls: u32,
pub consolidate_cooldown_secs: u64,
#[serde(default = "serde_defaults::default_true")]
pub cognition_loop_enabled: bool,
#[serde(default = "serde_defaults::default_cognition_loop_interval")]
pub cognition_loop_interval_secs: u64,
#[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
pub cognition_loop_max_steps: u8,
#[serde(default = "serde_defaults::default_cognition_synthesis_min_cluster")]
pub cognition_synthesis_min_cluster: usize,
}
impl Default for AutonomyConfig {
fn default() -> Self {
Self {
enabled: true,
auto_preload: true,
auto_dedup: true,
auto_related: true,
auto_consolidate: true,
silent_preload: true,
dedup_threshold: 8,
consolidate_every_calls: 25,
consolidate_cooldown_secs: 120,
cognition_loop_enabled: true,
cognition_loop_interval_secs: 3600,
cognition_loop_max_steps: 9,
cognition_synthesis_min_cluster: 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UpdatesConfig {
pub auto_update: bool,
pub check_interval_hours: u64,
pub notify_only: bool,
}
impl Default for UpdatesConfig {
fn default() -> Self {
Self {
auto_update: false,
check_interval_hours: 6,
notify_only: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ContextConfig {
pub budget_tokens: usize,
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
budget_tokens: 8000,
}
}
}
impl UpdatesConfig {
pub fn from_env() -> Self {
let mut cfg = Self::default();
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
}
if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS")
&& let Ok(h) = v.parse::<u64>()
{
cfg.check_interval_hours = h.clamp(1, 168);
}
if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
}
cfg
}
}
impl AutonomyConfig {
pub fn from_env() -> Self {
let mut cfg = Self::default();
if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
&& (v == "false" || v == "0")
{
cfg.enabled = false;
}
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
cfg.auto_preload = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
cfg.auto_dedup = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
cfg.auto_related = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
cfg.auto_consolidate = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
cfg.silent_preload = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD")
&& let Ok(n) = v.parse()
{
cfg.dedup_threshold = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS")
&& let Ok(n) = v.parse()
{
cfg.consolidate_every_calls = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS")
&& let Ok(n) = v.parse()
{
cfg.consolidate_cooldown_secs = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
cfg.cognition_loop_enabled = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
&& let Ok(n) = v.parse()
{
cfg.cognition_loop_interval_secs = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
&& let Ok(n) = v.parse()
{
cfg.cognition_loop_max_steps = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
&& let Ok(n) = v.parse()
{
cfg.cognition_synthesis_min_cluster = n;
}
cfg
}
pub fn load() -> Self {
let file_cfg = Config::load().autonomy;
let mut cfg = file_cfg;
if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
&& (v == "false" || v == "0")
{
cfg.enabled = false;
}
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
cfg.auto_preload = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
cfg.auto_dedup = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
cfg.auto_related = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
cfg.silent_preload = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD")
&& let Ok(n) = v.parse()
{
cfg.dedup_threshold = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
cfg.cognition_loop_enabled = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
&& let Ok(n) = v.parse()
{
cfg.cognition_loop_interval_secs = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
&& let Ok(n) = v.parse()
{
cfg.cognition_loop_max_steps = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
&& let Ok(n) = v.parse()
{
cfg.cognition_synthesis_min_cluster = n;
}
cfg
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct CloudConfig {
pub contribute_enabled: bool,
pub last_contribute: Option<String>,
pub last_sync: Option<String>,
pub last_gain_sync: Option<String>,
pub last_model_pull: Option<String>,
pub auto_sync: bool,
pub last_auto_sync: Option<String>,
pub auto_index: bool,
pub last_index_push: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GainConfig {
pub auto_publish: bool,
pub leaderboard: bool,
pub display_name: Option<String>,
pub auto_publish_interval_hours: u64,
pub last_auto_publish: Option<String>,
}
impl Default for GainConfig {
fn default() -> Self {
Self {
auto_publish: false,
leaderboard: true,
display_name: None,
auto_publish_interval_hours: 24,
last_auto_publish: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CostConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub models: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub prices: HashMap<String, PriceOverride>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PriceOverride {
pub input_per_m: Option<f64>,
pub output_per_m: Option<f64>,
pub cache_write_per_m: Option<f64>,
pub cache_read_per_m: Option<f64>,
}
impl CostConfig {
pub fn model_for_client(&self, client: &str) -> Option<String> {
self.models
.get(client)
.or(self.default_model.as_ref())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CodeHealthConfig {
pub cognitive_threshold: u32,
pub gate: String,
pub annotate_reads: bool,
pub naming: bool,
pub coupling: bool,
#[serde(default)]
pub inject_context: bool,
}
impl Default for CodeHealthConfig {
fn default() -> Self {
Self {
cognitive_threshold: 15,
gate: "warn".to_string(),
annotate_reads: true,
naming: true,
coupling: true,
inject_context: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct IndexConfig {
pub respect_gitignore: bool,
pub exclude: Vec<String>,
pub include: Vec<String>,
}
impl Default for IndexConfig {
fn default() -> Self {
Self {
respect_gitignore: true,
exclude: Vec::new(),
include: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GraphConfig {
pub traversal_edges: bool,
}
impl Default for GraphConfig {
fn default() -> Self {
Self {
traversal_edges: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SkillifyConfig {
pub enabled: bool,
pub scope: String,
pub min_confidence: f32,
pub min_recurrence: u32,
}
impl Default for SkillifyConfig {
fn default() -> Self {
Self {
enabled: true,
scope: "project".to_string(),
min_confidence: 0.7,
min_recurrence: 2,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SummariesConfig {
pub enabled: bool,
pub every_n_turns: u32,
pub max_kept: u32,
}
impl Default for SummariesConfig {
fn default() -> Self {
Self {
enabled: true,
every_n_turns: 25,
max_kept: 100,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AliasEntry {
pub command: String,
pub alias: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LoopDetectionConfig {
pub normal_threshold: u32,
pub reduced_threshold: u32,
pub blocked_threshold: u32,
pub window_secs: u64,
pub search_group_limit: u32,
pub tool_total_limits: HashMap<String, u32>,
}
impl Default for LoopDetectionConfig {
fn default() -> Self {
let mut tool_total_limits = HashMap::new();
tool_total_limits.insert("ctx_read".to_string(), 100);
tool_total_limits.insert("ctx_search".to_string(), 80);
tool_total_limits.insert("ctx_shell".to_string(), 50);
tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
Self {
normal_threshold: 2,
reduced_threshold: 4,
blocked_threshold: 0,
window_secs: 300,
search_group_limit: 10,
tool_total_limits,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct GatewayServerConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seats: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_bind_host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_retention_days: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pseudonymize_persons: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<McpServerEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct McpServerEntry {
pub id: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMcpServer {
pub id: String,
pub url: String,
pub auth_env: Option<String>,
}
impl GatewayServerConfig {
#[must_use]
pub fn resolve_mcp_servers(&self, allow_insecure_http: bool) -> Vec<ResolvedMcpServer> {
let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
let mut out = Vec::new();
for entry in &self.mcp_servers {
if !entry.enabled.unwrap_or(true) {
continue;
}
let id = entry.id.trim();
if !is_valid_mcp_server_id(id) {
tracing::warn!(
"[gateway_server.mcp_servers] invalid id '{id}' \
(lowercase alnum/-/_ only) — entry skipped"
);
continue;
}
if !seen.insert(id) {
tracing::warn!(
"[gateway_server.mcp_servers] duplicate id '{id}' — keeping first entry"
);
continue;
}
match validate_mcp_upstream_url(&entry.url, allow_insecure_http) {
Ok(url) => out.push(ResolvedMcpServer {
id: id.to_string(),
url,
auth_env: entry
.auth_env
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string),
}),
Err(e) => {
tracing::warn!(
"[gateway_server.mcp_servers] '{id}' has invalid url — skipped: {e}"
);
}
}
}
out
}
#[must_use]
pub fn resolved_admin_bind_host(&self) -> std::net::IpAddr {
let raw = std::env::var("LEAN_CTX_GATEWAY_ADMIN_BIND_HOST")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| self.admin_bind_host.clone());
match raw.as_deref().map(str::trim) {
Some(v) if !v.is_empty() => v.parse().unwrap_or_else(|_| {
tracing::warn!(
"gateway_server.admin_bind_host '{v}' is not a valid IP address — binding 127.0.0.1"
);
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
}),
_ => std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
}
}
}
fn is_valid_mcp_server_id(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
fn validate_mcp_upstream_url(url: &str, allow_insecure_http: bool) -> Result<String, String> {
let trimmed = url.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Err("empty url".into());
}
if crate::core::config::is_local_proxy_url(trimmed) {
return Ok(trimmed.to_string());
}
if trimmed.starts_with("http://") {
if allow_insecure_http {
return Ok(trimmed.to_string());
}
return Err(format!(
"MCP upstream must use HTTPS: {trimmed} (for a trusted local-network HTTP \
upstream opt in with `[proxy] allow_insecure_http_upstream = true`)"
));
}
if trimmed.starts_with("https://") {
return Ok(trimmed.to_string());
}
Err(format!(
"MCP upstream must start with http:// or https://: {trimmed}"
))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbeddingConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dimensions: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_download: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deterministic: Option<bool>,
}
#[cfg(test)]
mod gateway_server_tests {
use super::*;
#[test]
fn admin_bind_defaults_to_loopback_and_rejects_garbage() {
let cfg = GatewayServerConfig::default();
assert!(cfg.resolved_admin_bind_host().is_loopback());
let cfg = GatewayServerConfig {
admin_bind_host: Some("not-an-ip".into()),
..Default::default()
};
assert!(
cfg.resolved_admin_bind_host().is_loopback(),
"a typo must narrow exposure, never widen it"
);
let cfg = GatewayServerConfig {
admin_bind_host: Some("0.0.0.0".into()),
..Default::default()
};
assert!(
!cfg.resolved_admin_bind_host().is_loopback(),
"explicit opt-in widens the bind"
);
}
fn mcp_entry(id: &str, url: &str) -> McpServerEntry {
McpServerEntry {
id: id.into(),
url: url.into(),
auth_env: None,
enabled: None,
}
}
#[test]
fn mcp_registry_validates_ids_urls_and_duplicates() {
let cfg = GatewayServerConfig {
mcp_servers: vec![
mcp_entry("github", "https://mcp.example.com/mcp/"),
mcp_entry("GitHub", "https://mcp.example.com/mcp"),
mcp_entry("github", "https://other.example.com/mcp"),
mcp_entry("plain", "http://mcp.example.com/mcp"),
mcp_entry("local", "http://127.0.0.1:9200/mcp"),
McpServerEntry {
enabled: Some(false),
..mcp_entry("disabled", "https://mcp.example.com/mcp")
},
McpServerEntry {
auth_env: Some(" GITHUB_MCP_PAT ".into()),
..mcp_entry("authed", "https://api.githubcopilot.com/mcp")
},
],
..Default::default()
};
let resolved = cfg.resolve_mcp_servers(false);
let ids: Vec<&str> = resolved.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, ["github", "local", "authed"]);
assert_eq!(resolved[0].url, "https://mcp.example.com/mcp");
assert_eq!(resolved[2].auth_env.as_deref(), Some("GITHUB_MCP_PAT"));
let with_optin = cfg.resolve_mcp_servers(true);
assert!(with_optin.iter().any(|s| s.id == "plain"));
}
#[test]
fn mcp_upstream_url_rules_match_the_proxy_posture() {
assert!(validate_mcp_upstream_url("https://mcp.example.com/mcp", false).is_ok());
assert!(validate_mcp_upstream_url("http://localhost:9200/mcp", false).is_ok());
assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", false).is_err());
assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", true).is_ok());
assert!(validate_mcp_upstream_url("ftp://mcp.example.com", false).is_err());
assert!(validate_mcp_upstream_url(" ", false).is_err());
}
}