opencrabs 0.3.82

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/adolfousier/opencrabs/releases
//! Fold a legacy section name into its canonical one before deserializing.
//!
//! `Config` reaches the A2A settings through `#[serde(alias = "gateway")]`, so
//! a file may spell that section either way. serde treats an alias as another
//! spelling of the same field, not as a second field, so a file carrying BOTH
//! `[gateway]` and `[a2a]` fails with `duplicate field \`a2a\`` — reported
//! against line 1, nowhere near either section (#1116).
//!
//! The load path then treated that like a syntax error and fell back to the
//! last-known-good snapshot, so the instance ran on a stale copy and every
//! later edit appeared to do nothing.
//!
//! Two sections naming one feature is the underlying problem. Until the legacy
//! spelling can be retired outright, this folds them together before serde
//! sees the document: the two tables merge into the canonical key and the file
//! parses as though it had been written with one section all along. Nobody has
//! to choose which to delete, and nothing is silently dropped.

/// Legacy spellings and the canonical key each folds into.
const ALIASES: &[(&str, &str)] = &[("gateway", "a2a")];

/// Merge every known legacy section into its canonical one, in place.
///
/// Returns the names that were folded, so the caller can say what happened
/// rather than changing the document silently.
///
/// Canonical wins on a per-key conflict: a value written under the current
/// name is the more deliberate of the two, and the legacy section is by
/// definition the older edit.
pub(crate) fn fold_legacy_sections(doc: &mut toml::Value) -> Vec<&'static str> {
    let mut folded = Vec::new();
    let Some(table) = doc.as_table_mut() else {
        return folded;
    };
    for (legacy, canonical) in ALIASES {
        let Some(legacy_val) = table.remove(*legacy) else {
            continue;
        };
        folded.push(*legacy);
        match table.get_mut(*canonical) {
            // Both present: merge, canonical wins per key.
            Some(canon_val) => merge_into(canon_val, legacy_val),
            // Only the legacy spelling: rename it.
            None => {
                table.insert((*canonical).to_string(), legacy_val);
            }
        }
    }
    folded
}

/// Deep-merge `from` into `into`, keeping whatever `into` already defines.
fn merge_into(into: &mut toml::Value, from: toml::Value) {
    let (Some(into_t), toml::Value::Table(from_t)) = (into.as_table_mut(), from) else {
        // Not both tables: the canonical value stands. A scalar under one
        // spelling and a table under the other is a malformed file, and
        // guessing which the user meant would be worse than keeping the
        // canonical one.
        return;
    };
    for (k, v) in from_t {
        match into_t.get_mut(&k) {
            Some(existing) => merge_into(existing, v),
            None => {
                into_t.insert(k, v);
            }
        }
    }
}

/// Rewrite a config file so the legacy section carries its current name.
///
/// The read-time fold above keeps both spellings working, but it leaves the
/// file untouched, so a config written years ago keeps its old section name
/// forever and the two names persist in the wild. This converges them: after
/// one run nobody has the legacy spelling, and the alias becomes dead weight
/// that can eventually be deleted.
///
/// Uses `toml_edit` rather than a `toml::Value` round-trip because the latter
/// discards comments, and these files are almost entirely comments — rewriting
/// one would cost the user every note they had written in it.
///
/// Returns the sections that were renamed, empty if the file already used the
/// current names. Only writes when something actually changed.
pub(crate) fn migrate_file(path: &std::path::Path) -> std::io::Result<Vec<&'static str>> {
    let contents = std::fs::read_to_string(path)?;
    let mut doc = match contents.parse::<toml_edit::DocumentMut>() {
        Ok(doc) => doc,
        // A file we cannot parse is not ours to rewrite. The loader reports
        // the parse error; silently mangling it here would be worse.
        Err(_) => return Ok(Vec::new()),
    };

    let mut renamed = Vec::new();
    for (legacy, canonical) in ALIASES {
        let Some(legacy_item) = doc.as_table_mut().remove(legacy) else {
            continue;
        };
        renamed.push(*legacy);
        match doc.as_table_mut().get_mut(canonical) {
            // Both present: fold the legacy keys in, canonical winning, so
            // nothing the user wrote under either name is lost.
            Some(existing) => {
                if let (Some(into), Some(from)) = (existing.as_table_mut(), legacy_item.as_table())
                {
                    for (k, v) in from.iter() {
                        if !into.contains_key(k) {
                            into.insert(k, v.clone());
                        }
                    }
                }
            }
            // The ordinary case: a straight rename, contents and comments kept.
            None => {
                doc.as_table_mut().insert(canonical, legacy_item);
            }
        }
    }

    if !renamed.is_empty() {
        std::fs::write(path, doc.to_string())?;
    }
    Ok(renamed)
}