magi-code 0.80.2

Repository-aware CLI coding agent for terminal work
Documentation
use super::{
    McPaths,
    settings::{
        SettingsScope, remove_path, set_path, validate_settings_schema_version, value_at_path,
    },
};
use crate::persistence::{
    CrossProcessFileLock, LOCK_WAIT_TIMEOUT, atomic_write, 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";
pub(super) const MAX_SETTINGS_FILE_BYTES: usize = 1024 * 1024;

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;
    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 parsed = match parse_valid_settings(&raw) {
            Ok(parsed) => parsed,
            Err(_) => return Ok(None),
        };
        Ok(Some((raw, parsed)))
    })
}

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| {
            if path == &paths.project_settings_file {
                validate_project_settings_target(paths)?;
            }
            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(
    mut raw: serde_json::Value,
    mut local: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
    validate_settings_schema_version(&raw)?;
    validate_settings_schema_version(&local)?;
    let global_fast = value_at_path(&raw, &["agent", "fast"]).cloned();
    let global_appearance = value_at_path(&raw, &["interface", "appearance"]).cloned();
    remove_path(&mut local, &["agent", "fast"]);
    remove_path(&mut local, &["interface", "appearance"]);
    remove_path(&mut local, &["capabilities", "mcp_approvals"]);
    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> {
    // Serialize scoped updates with global updates so effective-state validation cannot race
    // another cooperating writer changing the other layer.
    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());
        }
        validate_settings_schema_version(&raw)?;
        mutate(&mut raw)?;
        raw.as_object_mut()
            .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?
            .entry("schema_version")
            .or_insert(serde_json::json!(2));
        let bytes = serde_json::to_vec_pretty(&raw)?;
        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 crate::persistence::open_regular_file(path, false) {
        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()
}