use super::{
McPaths,
settings::{SettingsScope, migrate_settings_value, remove_path, set_path, value_at_path},
};
use crate::persistence::{
CrossProcessFileLock, LOCK_WAIT_TIMEOUT, atomic_write,
atomic_write_no_clobber_with_permissions, in_process_file_lock, lock_mutex_until,
};
use anyhow::Context;
use std::{
fs,
io::{self, Read},
path::{Path, PathBuf},
time::Instant,
};
pub(super) const SETTINGS_SCHEMA_RELATIVE_REF: &str = "./state/settings.schema.json";
const SETTINGS_SCHEMA_FILE_NAME: &str = "settings.schema.json";
const PRE_V2_BACKUP_SUFFIX: &str = ".pre-v2.bak";
pub(super) const MAX_SETTINGS_FILE_BYTES: usize = 1024 * 1024;
fn create_pre_v2_settings_backup(path: &Path, original_bytes: &[u8]) -> anyhow::Result<()> {
let backup_path = pre_v2_backup_path(path)?;
let unix_mode = settings_source_mode(path)?;
atomic_write_no_clobber_with_permissions(&backup_path, original_bytes, unix_mode).with_context(
|| {
format!(
"failed to create pre-v2 settings backup {} for {}",
backup_path.display(),
path.display()
)
},
)
}
pub(super) fn write_migrated_file_if_snapshot_matches(
path: &Path,
original: &[u8],
migrated: &[u8],
) -> anyhow::Result<bool> {
ensure_settings_size(path, original.len())?;
ensure_settings_size(path, migrated.len())?;
if original == migrated {
return Ok(false);
}
ensure_source_snapshot(path, original)?;
create_pre_v2_settings_backup(path, original)?;
ensure_source_snapshot(path, original)?;
atomic_write(path, migrated)?;
Ok(true)
}
fn ensure_source_snapshot(path: &Path, original: &[u8]) -> anyhow::Result<()> {
match read_settings_bytes(path) {
Ok(Some(current)) if current == original => Ok(()),
Ok(Some(_)) => anyhow::bail!(
"settings source changed during migration; refusing to overwrite {}",
path.display()
),
Ok(None) => anyhow::bail!(
"settings source disappeared during migration; refusing to overwrite {}",
path.display()
),
Err(error) => Err(error).with_context(|| {
format!(
"failed to verify settings source snapshot for {}",
path.display()
)
}),
}
}
fn pre_v2_backup_path(path: &Path) -> anyhow::Result<PathBuf> {
let file_name = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("settings path has no file name: {}", path.display()))?;
let mut backup_name = file_name.to_os_string();
backup_name.push(PRE_V2_BACKUP_SUFFIX);
Ok(path.with_file_name(backup_name))
}
#[cfg(unix)]
fn settings_source_mode(path: &Path) -> anyhow::Result<Option<u32>> {
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(path)
.with_context(|| format!("failed to inspect settings source {}", path.display()))?;
Ok(Some(metadata.permissions().mode() & 0o777))
}
#[cfg(not(unix))]
fn settings_source_mode(_path: &Path) -> anyhow::Result<Option<u32>> {
Ok(None)
}
pub(super) fn migrate_existing_project_settings_file<T>(
paths: &McPaths,
global_raw: &serde_json::Value,
parse_valid_settings: &impl Fn(&serde_json::Value) -> anyhow::Result<T>,
) -> anyhow::Result<()> {
let path = &paths.project_settings_file;
if !path.exists() {
return Ok(());
}
validate_project_settings_target(paths)?;
let deadline = Instant::now() + LOCK_WAIT_TIMEOUT;
with_settings_file_lock(path, deadline, || {
let original = match read_settings_bytes(path)? {
Some(bytes) => bytes,
None => return Ok(()),
};
let raw: serde_json::Value = match serde_json::from_slice::<serde_json::Value>(&original) {
Ok(value) if value.is_object() => value,
Ok(_) | Err(_) => return Ok(()),
};
let migrated = migrate_settings_value(raw.clone())?;
let migration_changed = migrated != raw;
let mut canonical = migrated;
remove_path(&mut canonical, &["agent", "fast"]);
remove_path(&mut canonical, &["interface", "appearance"]);
let effective = merge_local_settings_values(global_raw.clone(), canonical.clone())?;
if parse_valid_settings(&effective).is_err() {
return Ok(());
}
if canonical != raw {
let bytes = serde_json::to_vec_pretty(&canonical)?;
if migration_changed {
write_migrated_file_if_snapshot_matches(path, &original, &bytes)?;
} else {
write_file_if_changed(path, &bytes)?;
}
}
Ok(())
})
}
pub(super) fn ensure_settings_schema_files<T>(
paths: &McPaths,
schema: &[u8],
parse_valid_settings: impl Fn(&serde_json::Value) -> anyhow::Result<T>,
) -> anyhow::Result<Option<(serde_json::Value, T)>> {
write_file_if_changed(&paths.state.join(SETTINGS_SCHEMA_FILE_NAME), schema)?;
let deadline = Instant::now() + LOCK_WAIT_TIMEOUT;
let global = with_settings_file_lock(&paths.settings_file, deadline, || {
let original = read_settings_bytes(&paths.settings_file)?;
let Some(original) = original else {
let mut canonical = serde_json::json!({"schema_version": 2});
let parsed = parse_valid_settings(&canonical)?;
insert_default_schema_ref_if_absent(&mut canonical);
write_file_if_changed(
&paths.settings_file,
serde_json::to_string_pretty(&canonical)?.as_bytes(),
)?;
return Ok(Some((canonical, parsed)));
};
let raw: serde_json::Value = match serde_json::from_slice::<serde_json::Value>(&original) {
Ok(value) if value.is_object() => value,
Ok(_) | Err(_) => return Ok(None),
};
let migrated = match migrate_settings_value(raw.clone()) {
Ok(value) => value,
Err(_) => return Ok(None),
};
let migration_changed = migrated != raw;
let mut canonical = migrated;
let parsed = match parse_valid_settings(&canonical) {
Ok(parsed) => parsed,
Err(_) => return Ok(None),
};
let mut changed = canonical != raw;
if insert_default_schema_ref_if_absent(&mut canonical) {
changed = true;
}
if changed {
let bytes = serde_json::to_vec_pretty(&canonical)?;
if migration_changed {
write_migrated_file_if_snapshot_matches(&paths.settings_file, &original, &bytes)?;
} else {
write_file_if_changed(&paths.settings_file, &bytes)?;
}
}
Ok(Some((canonical, parsed)))
})?;
if let Some((global_raw, parsed)) = global {
migrate_existing_project_settings_file(paths, &global_raw, &parse_valid_settings)?;
Ok(Some((global_raw, parsed)))
} else {
Ok(None)
}
}
pub(super) fn read_merged_settings_json(paths: &McPaths) -> anyhow::Result<serde_json::Value> {
let raw = read_settings_json_or_empty(&paths.settings_file)?;
merge_local_settings_json(paths, raw)
}
pub(super) fn merge_local_settings_json(
paths: &McPaths,
raw: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let local = paths
.local_settings_file
.as_ref()
.filter(|path| path.exists())
.or_else(|| {
paths
.project_settings_file
.exists()
.then_some(&paths.project_settings_file)
})
.map(|path| read_settings_json_or_empty(path))
.transpose()?;
merge_local_settings_values(raw, local.unwrap_or_else(|| serde_json::json!({})))
}
pub(super) fn merge_local_settings_values(
raw: serde_json::Value,
local: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let mut raw = migrate_settings_value(raw)?;
let global_fast = value_at_path(&raw, &["agent", "fast"]).cloned();
let global_appearance = value_at_path(&raw, &["interface", "appearance"]).cloned();
let mut local = migrate_settings_value(local)?;
remove_path(&mut local, &["agent", "fast"]);
remove_path(&mut local, &["interface", "appearance"]);
deep_merge_json(&mut raw, &local);
if let Some(global_fast) = global_fast {
set_path(&mut raw, &["agent", "fast"], global_fast)?;
} else {
remove_path(&mut raw, &["agent", "fast"]);
}
if let Some(global_appearance) = global_appearance {
set_path(&mut raw, &["interface", "appearance"], global_appearance)?;
} else {
remove_path(&mut raw, &["interface", "appearance"]);
}
Ok(raw)
}
pub(super) fn update_settings_json(
paths: &McPaths,
scope: SettingsScope,
mutate: impl FnOnce(&mut serde_json::Value) -> anyhow::Result<()>,
) -> anyhow::Result<bool> {
if scope == SettingsScope::Project && paths.settings_file != paths.project_settings_file {
let deadline = Instant::now() + LOCK_WAIT_TIMEOUT;
return with_settings_file_lock(&paths.settings_file, deadline, || {
update_settings_json_locked(paths, scope, mutate)
});
}
update_settings_json_locked(paths, scope, mutate)
}
fn update_settings_json_locked(
paths: &McPaths,
scope: SettingsScope,
mutate: impl FnOnce(&mut serde_json::Value) -> anyhow::Result<()>,
) -> anyhow::Result<bool> {
prepare_settings_scope_dir(paths, scope)?;
let target = settings_path_for_scope(paths, scope);
let deadline = Instant::now() + LOCK_WAIT_TIMEOUT;
with_settings_file_lock(&target, deadline, || {
let original = read_settings_bytes(&target)?;
let mut raw = match original.as_deref() {
Some(bytes) => {
serde_json::from_slice::<serde_json::Value>(bytes).with_context(|| {
format!("failed to parse settings JSON from {}", target.display())
})?
}
None => serde_json::json!({}),
};
if !raw.is_object() {
anyhow::bail!("settings must be a JSON object: {}", target.display());
}
let migrated = migrate_settings_value(raw.clone())?;
let migration_changed = migrated != raw;
raw = migrated;
mutate(&mut raw)?;
let bytes = serde_json::to_vec_pretty(&raw)?;
let should_write = match original.as_deref() {
Some(existing) => existing != bytes.as_slice(),
None => true,
};
if migration_changed
&& should_write
&& let Some(original) = original.as_deref()
{
write_migrated_file_if_snapshot_matches(&target, original, &bytes)
} else {
write_file_if_changed(&target, &bytes)
}
})
}
pub(super) fn settings_path_for_scope(paths: &McPaths, scope: SettingsScope) -> PathBuf {
match scope {
SettingsScope::Global => paths.settings_file.clone(),
SettingsScope::Project => paths.project_settings_file.clone(),
}
}
fn prepare_settings_scope_dir(paths: &McPaths, scope: SettingsScope) -> anyhow::Result<()> {
match scope {
SettingsScope::Global => fs::create_dir_all(&paths.root)?,
SettingsScope::Project => {
validate_project_settings_target(paths)?;
if let Some(parent) = paths.project_settings_file.parent() {
fs::create_dir_all(parent)?;
}
}
}
Ok(())
}
fn validate_project_settings_target(paths: &McPaths) -> anyhow::Result<()> {
let settings = &paths.project_settings_file;
let Some(project_dir) = settings.parent().and_then(Path::parent) else {
anyhow::bail!(
"project settings path has no project directory: {}",
settings.display()
);
};
let marker_dir = project_dir.join(".magi-code");
let canonical_project = project_dir.canonicalize().with_context(|| {
format!(
"failed to canonicalize project dir {}",
project_dir.display()
)
})?;
if marker_dir.exists() {
let canonical_marker = marker_dir.canonicalize().with_context(|| {
format!(
"failed to canonicalize project config dir {}",
marker_dir.display()
)
})?;
if !canonical_marker.starts_with(&canonical_project) {
anyhow::bail!("project config dir escapes cwd: {}", marker_dir.display());
}
}
if settings.exists() {
let canonical_settings = settings.canonicalize().with_context(|| {
format!(
"failed to canonicalize project settings file {}",
settings.display()
)
})?;
if !canonical_settings.starts_with(&canonical_project) {
anyhow::bail!("project settings file escapes cwd: {}", settings.display());
}
}
Ok(())
}
pub(super) fn write_file_if_changed(path: &Path, bytes: &[u8]) -> anyhow::Result<bool> {
ensure_settings_size(path, bytes.len())?;
match read_settings_bytes(path)? {
Some(existing) if existing == bytes => Ok(false),
Some(_) => {
atomic_write(path, bytes)?;
Ok(true)
}
None => {
atomic_write(path, bytes)?;
Ok(true)
}
}
}
pub(super) fn insert_default_schema_ref_if_absent(raw: &mut serde_json::Value) -> bool {
let Some(object) = raw.as_object_mut() else {
return false;
};
if object.contains_key("$schema") {
return false;
}
object.insert(
"$schema".to_string(),
serde_json::Value::String(SETTINGS_SCHEMA_RELATIVE_REF.to_string()),
);
true
}
pub(super) fn deep_merge_json(base: &mut serde_json::Value, override_val: &serde_json::Value) {
match (base, override_val) {
(serde_json::Value::Object(base_object), serde_json::Value::Object(override_object)) => {
for (key, value) in override_object {
match base_object.get_mut(key) {
Some(base_value) => deep_merge_json(base_value, value),
None => {
base_object.insert(key.clone(), value.clone());
}
}
}
}
(base_value, override_value) => *base_value = override_value.clone(),
}
}
pub(super) fn read_settings_json_or_empty(path: &Path) -> anyhow::Result<serde_json::Value> {
let text = read_settings_text_or_empty(path)?;
serde_json::from_str(&text)
.with_context(|| format!("failed to parse settings JSON from {}", path.display()))
}
pub(super) fn read_settings_text_or_empty(path: &Path) -> anyhow::Result<String> {
let Some(bytes) = read_settings_bytes(path)? else {
return Ok("{}".to_string());
};
String::from_utf8(bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
.with_context(|| format!("failed to read {}", path.display()))
}
fn read_settings_bytes(path: &Path) -> anyhow::Result<Option<Vec<u8>>> {
let file = match fs::File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
let mut bytes = Vec::new();
file.take((MAX_SETTINGS_FILE_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.with_context(|| format!("failed to read {}", path.display()))?;
ensure_settings_size(path, bytes.len())?;
Ok(Some(bytes))
}
fn ensure_settings_size(path: &Path, bytes: usize) -> anyhow::Result<()> {
if bytes > MAX_SETTINGS_FILE_BYTES {
anyhow::bail!(
"settings file exceeded {MAX_SETTINGS_FILE_BYTES} byte limit: {}",
path.display()
);
}
Ok(())
}
pub(super) fn settings_file_lock(
path: &Path,
) -> anyhow::Result<std::sync::Arc<std::sync::Mutex<()>>> {
in_process_file_lock(path, "settings")
}
pub(super) fn with_settings_file_lock<T>(
target: &Path,
deadline: Instant,
operation: impl FnOnce() -> anyhow::Result<T>,
) -> anyhow::Result<T> {
let settings_lock = settings_file_lock(target)?;
let lock_label = format!("settings lock for {}", target.display());
let _settings_guard = lock_mutex_until(&settings_lock, deadline, &lock_label)?;
let _file_guard = CrossProcessFileLock::acquire_until(target, deadline)?;
operation()
}