use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use super::{Config, ConfigCacheSlot, default_shell_allowlist};
const CONFIG_PROFILE_ENV: &str = "LEAN_CTX_CONFIG_PROFILE";
pub(super) fn environment_config_profile() -> Option<String> {
std::env::var(CONFIG_PROFILE_ENV)
.ok()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
}
pub(super) fn parse_config_with_profile(
raw: &str,
explicit_profile: Option<&str>,
) -> Result<Config, String> {
let mut value: toml::Value = toml::from_str(raw).map_err(|error| error.to_string())?;
let configured_profile = value.get("config_profile").and_then(toml::Value::as_str);
let selected = explicit_profile
.map(str::trim)
.filter(|name| !name.is_empty())
.or(configured_profile);
if let Some(name) = selected {
let profiles = value
.get("profiles")
.and_then(toml::Value::as_table)
.ok_or_else(|| format!("config profile '{name}' selected but [profiles] is missing"))?;
let mut overlay = profiles
.get(name)
.and_then(toml::Value::as_table)
.cloned()
.ok_or_else(|| format!("config profile '{name}' is not defined"))?;
if overlay.remove("profiles").is_some() || overlay.remove("config_profile").is_some() {
return Err(format!(
"config profile '{name}' cannot override reserved profile keys"
));
}
merge_toml_tables(
value
.as_table_mut()
.expect("a TOML document always has a root table"),
overlay,
);
}
value.try_into().map_err(|error| error.to_string())
}
fn merge_toml_tables(base: &mut toml::Table, overlay: toml::Table) {
for (key, overlay_value) in overlay {
match (base.get_mut(&key), overlay_value) {
(Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
merge_toml_tables(base_table, overlay_table);
}
(_, replacement) => {
base.insert(key, replacement);
}
}
}
}
static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
#[must_use]
pub fn last_config_parse_error() -> Option<String> {
LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
}
fn record_parse_error(err: Option<String>) {
if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
*guard = err;
}
}
pub(crate) fn strip_sensitive_overrides(local: &mut Config) -> Vec<&'static str> {
let mut withheld: Vec<&'static str> = Vec::new();
if local.shell_allowlist != default_shell_allowlist() {
local.shell_allowlist = default_shell_allowlist();
withheld.push("shell_allowlist");
}
if !local.shell_allowlist_extra.is_empty() {
local.shell_allowlist_extra.clear();
withheld.push("shell_allowlist_extra");
}
if !local.allow_paths.is_empty() {
local.allow_paths.clear();
withheld.push("allow_paths");
}
if !local.extra_roots.is_empty() {
local.extra_roots.clear();
withheld.push("extra_roots");
}
if !local.allow_symlink_roots.is_empty() {
local.allow_symlink_roots.clear();
withheld.push("allow_symlink_roots");
}
if !local.custom_aliases.is_empty() {
local.custom_aliases.clear();
withheld.push("custom_aliases");
}
if !local.passthrough_urls.is_empty() {
local.passthrough_urls.clear();
withheld.push("passthrough_urls");
}
if local.proxy.anthropic_upstream.is_some()
|| local.proxy.openai_upstream.is_some()
|| local.proxy.chatgpt_upstream.is_some()
|| local.proxy.gemini_upstream.is_some()
{
local.proxy.anthropic_upstream = None;
local.proxy.openai_upstream = None;
local.proxy.chatgpt_upstream = None;
local.proxy.gemini_upstream = None;
withheld.push("proxy.*_upstream");
}
if local.rules_scope.is_some() {
local.rules_scope = None;
withheld.push("rules_scope");
}
if local.rules_injection.is_some() {
local.rules_injection = None;
withheld.push("rules_injection");
}
if local.permission_inheritance.is_some() {
local.permission_inheritance = None;
withheld.push("permission_inheritance");
}
if !local.disabled_tools.is_empty() {
local.disabled_tools.clear();
withheld.push("disabled_tools");
}
if local.tool_profile.is_some() {
local.tool_profile = None;
withheld.push("tool_profile");
}
if !local.tools_enabled.is_empty() {
local.tools_enabled.clear();
withheld.push("tools_enabled");
}
if !local.default_tool_categories.is_empty() {
local.default_tool_categories.clear();
withheld.push("default_tool_categories");
}
if !local.index.respect_gitignore {
local.index.respect_gitignore = true;
withheld.push("index.respect_gitignore");
}
withheld
}
#[must_use]
pub fn local_sensitive_overrides(local_toml: &str) -> Vec<&'static str> {
let selected = environment_config_profile();
match parse_config_with_profile(local_toml, selected.as_deref()) {
Ok(mut parsed) => strip_sensitive_overrides(&mut parsed),
Err(_) => Vec::new(),
}
}
impl Config {
pub fn path() -> Option<PathBuf> {
crate::core::paths::config_dir()
.ok()
.map(|d| d.join("config.toml"))
}
#[must_use]
pub fn missing_config_path() -> Option<PathBuf> {
match Self::path() {
Some(p) if !p.exists() => Some(p),
_ => None,
}
}
pub fn local_path(project_root: &str) -> PathBuf {
PathBuf::from(project_root).join(".lean-ctx.toml")
}
pub(crate) fn find_project_root() -> Option<String> {
static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
ROOT_CACHE
.get_or_init(Self::find_project_root_inner)
.clone()
}
fn find_project_root_inner() -> Option<String> {
if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
&& !env_root.is_empty()
{
return Some(env_root);
}
let cwd = std::env::current_dir().ok();
if let Some(root) =
crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
{
let root_path = std::path::Path::new(&root);
let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
let has_marker = crate::core::pathutil::has_project_marker(root_path);
if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
{
return Some(root);
}
}
if let Some(ref cwd) = cwd {
let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
let git_root = if may_probe_cwd {
std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
} else {
None
};
if let Some(root) = git_root {
return Some(root);
}
if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
return Some(cwd.to_string_lossy().to_string());
}
}
None
}
pub fn load() -> Self {
(*Self::load_arc()).clone()
}
pub fn load_arc() -> Arc<Self> {
static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
let Some(path) = Self::path() else {
return Arc::new(Self::default());
};
let project_root = Self::find_project_root();
let local_path = project_root.as_deref().map(Self::local_path);
let global_content = std::fs::read_to_string(&path).ok();
let local_content = local_path
.as_ref()
.filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
.and_then(|p| std::fs::read_to_string(p).ok());
let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
let selected_profile = environment_config_profile();
if let Ok(guard) = CACHE.lock()
&& let Some((ref cfg, ref cached_global, ref cached_local, ref cached_profile)) = *guard
&& *cached_global == global_hash
&& *cached_local == local_hash
&& *cached_profile == selected_profile
{
return Arc::clone(cfg);
}
let mut cfg: Config = if let Some(ref content) = global_content {
match parse_config_with_profile(content, selected_profile.as_deref()) {
Ok(c) => {
record_parse_error(None);
c
}
Err(e) => {
record_parse_error(Some(e.clone()));
tracing::warn!("config parse error in {}: {e}", path.display());
eprintln!(
"\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
path.display()
);
Self::default()
}
}
} else {
record_parse_error(None);
Self::default()
};
if let Some(ref local) = local_content {
let trusted = project_root.as_deref().is_some_and(|r| {
crate::core::workspace_trust::is_trusted_for(
std::path::Path::new(r),
local_hash.as_deref().unwrap_or_default(),
)
});
cfg.merge_local(local, trusted);
}
let cfg = Arc::new(cfg);
if let Ok(mut guard) = CACHE.lock() {
*guard = Some((Arc::clone(&cfg), global_hash, local_hash, selected_profile));
}
cfg
}
pub fn load_global() -> Self {
Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
}
pub(super) fn load_global_from(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).unwrap_or_default(),
_ => Self::default(),
}
}
}