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";
static CONFIG_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
thread_local! {
static IGNORE_ENV_PROFILE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(super) fn without_env_profile<T>(f: impl FnOnce() -> T) -> T {
let previous = IGNORE_ENV_PROFILE.with(|c| c.replace(true));
let out = f();
IGNORE_ENV_PROFILE.with(|c| c.set(previous));
out
}
pub(super) fn environment_config_profile() -> Option<String> {
if IGNORE_ENV_PROFILE.with(std::cell::Cell::get) {
return None;
}
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> {
resolve_config_table(raw, explicit_profile)?
.try_into()
.map_err(|error: toml::de::Error| error.to_string())
}
pub(super) fn resolve_config_table(
raw: &str,
explicit_profile: Option<&str>,
) -> Result<toml::Value, String> {
let migrated = Config::migrate_v3_compression_document(raw)?;
let raw = migrated.as_deref().unwrap_or(raw);
let mut value: toml::Value = toml::from_str(raw).map_err(|error| error.to_string())?;
super::renamed::apply(
value
.as_table_mut()
.expect("a TOML document always has a root table"),
);
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"
));
}
super::renamed::apply(&mut overlay);
merge_toml_tables(
value
.as_table_mut()
.expect("a TOML document always has a root table"),
overlay,
);
}
Ok(value)
}
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;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigLoadFailure {
Unreadable,
Invalid,
}
impl ConfigLoadFailure {
#[must_use]
pub fn code(self) -> &'static str {
match self {
Self::Unreadable => "config.global_unreadable",
Self::Invalid => "config.global_invalid",
}
}
}
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.write_allow_paths.is_empty() {
local.write_allow_paths.clear();
withheld.push("write_allow_paths");
}
if !local.read_only_roots.is_empty() {
local.read_only_roots.clear();
withheld.push("read_only_roots");
}
if local.read_redirect == super::ReadRedirect::Off {
local.read_redirect = super::ReadRedirect::default();
withheld.push("read_redirect");
}
if !local.allow_symlink_roots.is_empty() {
local.allow_symlink_roots.clear();
withheld.push("allow_symlink_roots");
}
if !local.write_allow_paths.is_empty() {
local.write_allow_paths.clear();
withheld.push("write_allow_paths");
}
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.redirect_exclude.is_empty() {
local.redirect_exclude.clear();
withheld.push("redirect_exclude");
}
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.semantic_mode == super::SemanticMode::Eager {
local.semantic_mode = super::SemanticMode::default();
withheld.push("semantic_mode");
}
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");
}
if !local.shell_allowlist_subcommand_scoping {
local.shell_allowlist_subcommand_scoping = true;
withheld.push("shell_allowlist_subcommand_scoping");
}
withheld
}
fn note_gateway_store_ceiling(cfg: &Config) {
crate::core::context_admission::receipt_store::capacity::note_configured_ceiling(
cfg.gateway_store_configured_mb(),
);
}
#[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(),
}
}
pub fn global_string_list(key: &str) -> Vec<String> {
let Some(path) = Config::path() else {
return Vec::new();
};
let Ok(raw) = std::fs::read_to_string(&path) else {
return Vec::new();
};
let Ok(table) = raw.parse::<toml::Table>() else {
return Vec::new();
};
table
.get(key)
.and_then(toml::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
impl Config {
pub fn path() -> Option<PathBuf> {
crate::core::paths::config_dir_read_only()
.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 fn find_project_root() -> Option<String> {
static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
if let Ok(project) =
crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
{
return project.map(|root| root.to_string_lossy().into_owned());
}
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 generation() -> u64 {
CONFIG_GENERATION.load(std::sync::atomic::Ordering::Acquire)
}
pub fn load_arc() -> Arc<Self> {
static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
Self::load_arc_with(Self::find_project_root().as_deref(), &CACHE)
}
pub(super) fn load_arc_with(
project_root: Option<&str>,
cache: &Mutex<ConfigCacheSlot>,
) -> Arc<Self> {
let Some(path) = Self::path() else {
return Arc::new(Self::default());
};
let local_path = project_root.map(Self::local_path);
let (global_content, unreadable) = match std::fs::read_to_string(&path) {
Ok(content) => (Some(content), None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, None),
Err(error) => (None, Some(error.kind())),
};
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)
.or_else(|| unreadable.map(|kind| format!("unreadable:{kind:?}")));
let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
let local_key = local_hash
.as_deref()
.map(|hash| format!("{}\0{hash}", project_root.unwrap_or_default()));
let selected_profile = environment_config_profile();
let trusted = local_content.is_some()
&& project_root.is_some_and(|r| {
crate::core::workspace_trust::is_trusted_for(
std::path::Path::new(r),
local_hash.as_deref().unwrap_or_default(),
)
});
let managed = crate::core::config::managed::current();
let managed_key = crate::core::config::managed::cache_key();
if let Ok(guard) = cache.lock()
&& let Some((
ref cfg,
ref cached_global,
ref cached_local,
ref cached_profile,
cached_trusted,
ref cached_managed,
)) = *guard
&& *cached_global == global_hash
&& *cached_local == local_key
&& *cached_profile == selected_profile
&& cached_trusted == trusted
&& *cached_managed == managed_key
{
note_gateway_store_ceiling(cfg);
return Arc::clone(cfg);
}
let mut failure = unreadable.map(|_| ConfigLoadFailure::Unreadable);
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) => {
failure = Some(ConfigLoadFailure::Invalid);
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 if let Some(kind) = unreadable {
let message = format!("cannot read config: {kind}");
record_parse_error(Some(message.clone()));
tracing::warn!("{message} ({})", path.display());
eprintln!(
"\x1b[33m[lean-ctx] WARNING: {message} ({})\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 {
cfg.merge_local(local, trusted);
}
if failure.is_none() {
cfg.migrate_v4_config_on_disk();
}
if let Some(managed) = &managed {
managed.apply_to_hive(&mut cfg.hive);
}
cfg.load_failure = failure;
let cfg = Arc::new(cfg);
if let Ok(mut guard) = cache.lock() {
*guard = Some((
Arc::clone(&cfg),
global_hash,
local_key,
selected_profile,
trusted,
managed_key,
));
}
CONFIG_GENERATION.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
note_gateway_store_ceiling(&cfg);
cfg
}
pub fn load_for_project_root(project_root: &str) -> Self {
let selected_profile = environment_config_profile();
let mut cfg = Self::path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|c| parse_config_with_profile(&c, selected_profile.as_deref()).ok())
.unwrap_or_default();
let local_path = Self::local_path(project_root);
if crate::core::pathutil::may_probe_path(local_path.as_path())
&& let Ok(local) = std::fs::read_to_string(&local_path)
{
let trusted = crate::core::workspace_trust::is_trusted_for(
std::path::Path::new(project_root),
&crate::core::hasher::hash_str(&local),
);
cfg.merge_local(&local, trusted);
}
cfg
}
pub(crate) fn migrate_v4_config_on_disk(&mut self) {
let path = Self::path();
let writable = path
.as_deref()
.is_some_and(allows_automatic_config_migration);
let raw = path
.as_deref()
.filter(|_| writable)
.and_then(|path| std::fs::read_to_string(path).ok());
let reset_pending = crate::core::telemetry_consent::legacy_opt_out_reset_pending();
let reset = raw
.as_deref()
.filter(|_| reset_pending)
.and_then(reset_legacy_telemetry_opt_out);
let reenables =
reset.is_some() && self.telemetry.preference == super::TelemetryPreference::DefaultOn;
let preserve_opt_out = if reenables {
self.telemetry.preference == super::TelemetryPreference::ExplicitlyDisabled
} else {
self.telemetry.explicitly_disabled()
};
let persisted = self.persist_v4_migration(
path.as_deref(),
writable,
raw.as_deref(),
reset.as_deref(),
reset_pending,
preserve_opt_out,
);
if reenables && persisted {
self.telemetry.enabled = true;
}
let preserve_opt_out = self.telemetry.explicitly_disabled();
if self.cloud.contribute_enabled {
self.cloud.contribute_enabled = false;
if !preserve_opt_out {
self.telemetry.enabled = true;
self.telemetry.preference = super::TelemetryPreference::ExplicitlyEnabled;
}
}
}
fn persist_v4_migration(
&self,
path: Option<&Path>,
writable: bool,
raw: Option<&str>,
reset: Option<&str>,
reset_pending: bool,
preserve_opt_out: bool,
) -> bool {
let Some(path) = path else {
return false;
};
let Some(raw) = raw else {
if !writable {
tracing::debug!(
"config migration remains in memory: source or directory is read-only"
);
}
if reset_pending && !path.exists() {
crate::core::telemetry_consent::mark_legacy_opt_out_reset();
}
return false;
};
let source = reset.unwrap_or(raw);
let migrated = match Self::migrate_v4_config_document(
source,
environment_config_profile().as_deref(),
preserve_opt_out,
) {
Ok(Some(updated)) => Some(updated),
Ok(None) => (source != raw).then(|| source.to_string()),
Err(error) => {
tracing::warn!("config migration failed: {error}");
return false;
}
};
let persisted = match migrated {
Some(updated) => {
match crate::config_io::write_atomic_config_migration_checked(
path,
&updated,
Some(raw.as_bytes()),
) {
Ok(()) => true,
Err(error) => {
warn_config_migration_paused_once(path, raw, &updated, &error);
false
}
}
}
None => true,
};
if persisted {
clear_config_migration_pause_marker(path);
}
if persisted && reset_pending {
crate::core::telemetry_consent::mark_legacy_opt_out_reset();
}
persisted
}
pub(super) fn migrate_v4_config_document(
raw: &str,
selected_profile: Option<&str>,
preserve_opt_out: bool,
) -> Result<Option<String>, String> {
let document = raw
.parse::<toml_edit::DocumentMut>()
.map_err(|error| error.to_string())?;
let legacy_telemetry = document
.get("cloud")
.and_then(|table| table.get("contribute_enabled"))
.and_then(toml_edit::Item::as_bool)
== Some(true);
let persisted_opt_out = document
.get("telemetry")
.and_then(|table| table.get("enabled"))
.and_then(toml_edit::Item::as_bool)
== Some(false);
let mut updated = if legacy_telemetry {
migrate_legacy_contribute_document(raw, preserve_opt_out || persisted_opt_out)
.ok_or_else(|| "invalid legacy telemetry configuration".to_string())?
} else {
raw.to_string()
};
if let Some(next) = Self::validated_v3_compression_document(&updated, selected_profile)? {
updated = next;
}
if let Some(next) = super::retired::migrate_retired_document(&updated)? {
updated = next;
}
parse_config_with_profile(&updated, selected_profile)?;
Ok((updated != raw).then_some(updated))
}
pub(crate) fn validated_v3_compression_document(
raw: &str,
selected_profile: Option<&str>,
) -> Result<Option<String>, String> {
let migrated = Self::migrate_v3_compression_document(raw)?;
if let Some(ref updated) = migrated {
parse_config_with_profile(updated, selected_profile)?;
}
Ok(migrated)
}
pub(crate) fn migrate_v3_compression_document(raw: &str) -> Result<Option<String>, String> {
fn migrate_table(table: &mut toml_edit::Table) -> Result<bool, String> {
let has_legacy = ["terse_agent", "output_density", "ultra_compact"]
.iter()
.any(|key| table.get(key).is_some());
if !has_legacy {
return Ok(false);
}
let terse = match table.get("terse_agent") {
Some(item) => Some(
item.as_str()
.ok_or_else(|| "terse_agent must be a string".to_string())?,
),
None => None,
};
if terse.is_some_and(|value| !matches!(value, "off" | "lite" | "full" | "ultra")) {
return Err("invalid terse_agent".to_string());
}
let density = match table.get("output_density") {
Some(item) => Some(
item.as_str()
.ok_or_else(|| "output_density must be a string".to_string())?,
),
None => None,
};
if density.is_some_and(|value| !matches!(value, "normal" | "terse" | "ultra")) {
return Err("invalid output_density".to_string());
}
let ultra = match table.get("ultra_compact") {
Some(item) => Some(
item.as_bool()
.ok_or_else(|| "ultra_compact must be a boolean".to_string())?,
),
None => None,
};
let level = if let Some(item) = table.get("compression_level") {
let value = item
.as_str()
.ok_or_else(|| "compression_level must be a string".to_string())?;
if !matches!(value, "off" | "lite" | "standard" | "max" | "raw") {
return Err(format!("invalid compression_level: {value}"));
}
value
} else if ultra == Some(true) || terse == Some("ultra") || density == Some("ultra") {
"max"
} else if terse == Some("full") {
"standard"
} else if terse == Some("lite") || density == Some("terse") {
"lite"
} else {
"off"
};
table["compression_level"] = toml_edit::value(level);
for key in ["terse_agent", "output_density", "ultra_compact"] {
table.remove(key);
}
Ok(true)
}
let mut document = raw
.parse::<toml_edit::DocumentMut>()
.map_err(|error| error.to_string())?;
let mut changed = migrate_table(document.as_table_mut())?;
if let Some(profiles) = document
.get_mut("profiles")
.and_then(toml_edit::Item::as_table_mut)
{
for (_, profile) in profiles.iter_mut() {
if let Some(table) = profile.as_table_mut() {
changed |= migrate_table(table)?;
}
}
}
Ok(changed.then(|| document.to_string()))
}
pub fn load_global() -> Self {
Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
}
pub fn try_load_global() -> Result<Self, super::error::LeanCtxError> {
let path = Self::path().ok_or_else(|| {
super::error::LeanCtxError::Config("cannot determine home directory".into())
})?;
Self::try_load_global_from(&path)
}
pub(super) fn try_load_global_from(path: &Path) -> Result<Self, super::error::LeanCtxError> {
match std::fs::read_to_string(path) {
Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).map_err(|error| {
super::error::LeanCtxError::Config(
format!("refusing invalid global config.toml ({error})").into(),
)
}),
Ok(_) => Ok(Self::default()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(error.into()),
}
}
pub(super) fn load_global_from(path: &Path) -> Self {
Self::try_load_global_from(path).unwrap_or_default()
}
}
const CONFIG_MIGRATION_PAUSE_MARKER: &str = "config_migration_paused";
pub(super) fn config_migration_pause_marker(path: &Path) -> Option<std::path::PathBuf> {
let target =
crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
let key = blake3::hash(target.to_string_lossy().as_bytes()).to_hex();
crate::core::paths::state_dir()
.ok()
.map(|dir| dir.join(format!("{CONFIG_MIGRATION_PAUSE_MARKER}.{}", &key[..16])))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigMigrationPause {
pub label: &'static str,
pub config: std::path::PathBuf,
pub backup: std::path::PathBuf,
pub receipt: std::path::PathBuf,
pub reason: String,
pub action: String,
pub original: Option<std::path::PathBuf>,
}
pub(super) fn describe_config_migration_pause(path: &Path) -> ConfigMigrationPause {
use crate::config_io::{ConfigMigrationJournalRefusal as Refusal, ConfigMigrationJournalState};
let config =
crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
let (backup, receipt) =
crate::config_io::config_migration_journal_files(path).unwrap_or_else(|| {
(
config.with_file_name("config.toml.v4-migration.bak"),
config.with_file_name("config.toml.v4-migration.json"),
)
});
let next = crate::config_io::config_migration_next_receipt_file(path)
.unwrap_or_else(|| config.with_file_name("config.toml.v4-migration.next.json"));
let dir = config
.parent()
.map_or_else(|| ".".to_string(), |dir| dir.display().to_string());
let (b, r, n, c) = (
backup.display(),
receipt.display(),
next.display(),
config.display(),
);
let fresh = "; the migration starts a fresh journal on the next command.";
let state = crate::config_io::config_migration_journal_state(path);
let (label, reason, action) = if let ConfigMigrationJournalState::Refused(refusal) = state {
let (reason, action) = match refusal {
Refusal::Symlink => (
"a migration journal file is a symbolic link, which lean-ctx does not follow."
.to_string(),
format!(
"Replace {b}, {r} and {n} with regular files or move them out of {dir}{fresh}"
),
),
Refusal::Unreadable => (
"a migration journal file cannot be read.".to_string(),
format!(
"Make {b} and {r} readable for lean-ctx; the migration retries on the next command."
),
),
Refusal::ReceiptUnverifiable => (
format!(
"the journal receipt {r} cannot be verified, so neither the migration nor \
`lean-ctx migrate config --rollback` can use it."
),
format!(
"Check {b} (the config before the recorded migration) and {r}, then move \
both out of {dir}{fresh}"
),
),
Refusal::BackupMismatch => (
format!(
"the journal backup {b} does not match its receipt {r}, so neither the \
migration nor `lean-ctx migrate config --rollback` can use it."
),
format!("Check {b} and {r}, then move both out of {dir}{fresh}"),
),
Refusal::OrphanBackup => (
format!(
"{b} has no receipt and matches neither the config nor an archived journal."
),
format!("Check {b}, then move it out of {dir}{fresh}"),
),
Refusal::OrphanReceipt => (
format!("{r} has no backup and the config is not the source it records."),
format!("Check {r}, then move it out of {dir}{fresh}"),
),
Refusal::NextReceiptUnverifiable => (
format!(
"the unfinished journal update {n} cannot be verified and the config \
matches neither recorded state."
),
format!(
"Check {n}, then move it out of {dir}; the migration retries on the next \
command."
),
),
};
(refusal.label(), reason, action)
} else {
let mut action = format!(
"Make {dir} and {c} writable for lean-ctx; the migration retries on the next command."
);
if state.rollback_available() {
action.push_str(&format!(
" `lean-ctx migrate config --rollback` restores the config recorded in {b}."
));
}
(
"write-refused",
"lean-ctx could not write the migrated config or its migration journal.".to_string(),
action,
)
};
ConfigMigrationPause {
label,
original: crate::config_io::original_config_migration_backup(path),
config,
backup,
receipt,
reason,
action,
}
}
impl ConfigMigrationPause {
pub fn message(&self) -> String {
let original = self
.original
.as_deref()
.map(crate::config_io::original_config_sentence)
.unwrap_or_default();
format!(
"config migration paused for {} ({}): {} {}{original} Reported once until the config \
or the journal changes.",
self.config.display(),
self.label,
self.reason,
self.action
)
}
}
pub fn config_migration_pause_status(path: &Path) -> Option<ConfigMigrationPause> {
config_migration_pause_marker(path)
.is_some_and(|marker| marker.is_file())
.then(|| describe_config_migration_pause(path))
}
pub(super) fn config_migration_pause_fingerprint(
path: &Path,
raw: &str,
updated: &str,
label: &str,
) -> String {
let mut hasher = blake3::Hasher::new();
let target =
crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
let target = target.to_string_lossy();
for part in [
label.as_bytes(),
target.as_bytes(),
raw.as_bytes(),
updated.as_bytes(),
] {
hasher.update(&(part.len() as u64).to_le_bytes());
hasher.update(part);
}
let journal = crate::config_io::config_migration_journal_files(path)
.map(|(backup, receipt)| vec![backup, receipt])
.unwrap_or_default();
let next = crate::config_io::config_migration_next_receipt_file(path);
for file in journal.into_iter().chain(next) {
let bytes = std::fs::read(file).unwrap_or_default();
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(&bytes);
}
hasher.finalize().to_hex().to_string()
}
pub(super) fn warn_config_migration_paused_once(
path: &Path,
raw: &str,
updated: &str,
error: &str,
) -> bool {
let pause = describe_config_migration_pause(path);
let fingerprint = config_migration_pause_fingerprint(path, raw, updated, pause.label);
let marker = config_migration_pause_marker(path);
let reported = marker
.as_deref()
.and_then(|marker| std::fs::read_to_string(marker).ok())
.is_some_and(|seen| seen.lines().next() == Some(fingerprint.as_str()));
tracing::debug!("config migration paused ({}): {error}", pause.label);
if reported {
return false;
}
tracing::warn!("{}", pause.message());
if let Some(marker) = marker {
if let Some(parent) = marker.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(marker, format!("{fingerprint}\n{}\n", pause.label));
}
true
}
fn clear_config_migration_pause_marker(path: &Path) {
if let Some(marker) = config_migration_pause_marker(path)
&& marker.exists()
{
let _ = std::fs::remove_file(marker);
}
}
fn allows_automatic_config_migration(path: &Path) -> bool {
path.parent().is_some_and(|parent| {
[path, parent].iter().all(|entry| {
std::fs::metadata(entry).is_ok_and(|metadata| !metadata.permissions().readonly())
})
})
}
pub(super) fn reset_legacy_telemetry_opt_out(raw: &str) -> Option<String> {
let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
let telemetry = document.get("telemetry")?;
let disabled = telemetry.get("enabled").and_then(toml_edit::Item::as_bool) == Some(false);
let implicit = matches!(
telemetry.get("preference").map(toml_edit::Item::as_str),
None | Some(Some("default_on"))
);
if !(disabled && implicit) {
return None;
}
document["telemetry"]["enabled"] = toml_edit::value(true);
Some(document.to_string())
}
fn migrate_legacy_contribute_document(raw: &str, preserve_opt_out: bool) -> Option<String> {
let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
document["cloud"]["contribute_enabled"] = toml_edit::value(false);
if !preserve_opt_out {
document["telemetry"]["enabled"] = toml_edit::value(true);
document["telemetry"]["preference"] = toml_edit::value("explicitly_enabled");
}
Some(document.to_string())
}
#[cfg(test)]
mod telemetry_migration_tests {
use super::*;
#[test]
#[cfg(unix)]
fn automatic_migration_respects_readonly_file_and_directory() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().expect("migration directory");
let path = directory.path().join("config.toml");
std::fs::write(&path, "ultra_compact = true\n").expect("legacy config");
assert!(allows_automatic_config_migration(&path));
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
let readonly_file = allows_automatic_config_migration(&path);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let readonly_directory = allows_automatic_config_migration(&path);
std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(!readonly_file);
assert!(!readonly_directory);
assert!(!allows_automatic_config_migration(
&directory.path().join("missing.toml")
));
}
#[test]
fn legacy_opt_in_becomes_explicit_and_send_eligible() {
let migrated = migrate_legacy_contribute_document(
"# keep me\n[cloud]\ncontribute_enabled = true\n",
false,
)
.expect("valid TOML");
assert!(migrated.contains("# keep me"));
let cfg: Config = toml::from_str(&migrated).expect("migrated config");
assert!(!cfg.cloud.contribute_enabled);
assert!(cfg.telemetry.enabled);
assert_eq!(
cfg.telemetry.preference,
super::super::TelemetryPreference::ExplicitlyEnabled
);
assert!(cfg.telemetry.send_eligible(None, None));
}
#[test]
fn legacy_contribute_never_overrides_explicit_opt_out() {
let migrated = migrate_legacy_contribute_document(
"[cloud]\ncontribute_enabled = true\n[telemetry]\nenabled = false\n",
true,
)
.expect("valid TOML");
let cfg: Config = toml::from_str(&migrated).expect("migrated config");
assert!(!cfg.cloud.contribute_enabled);
assert!(cfg.telemetry.explicitly_disabled());
}
}