microclaw-engine 0.6.0

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use std::collections::BTreeSet;
use std::path::Path;
use std::str::FromStr;

use microclaw_core::error::MicroClawError;
use yaml_edit::{path::YamlPath, Document};

use crate::config::Config;

#[derive(Debug, Clone)]
enum ChangeKind {
    Set(serde_yaml::Value),
    Delete,
}

#[derive(Debug, Clone)]
struct YamlChange {
    path: Vec<String>,
    kind: ChangeKind,
}

pub fn save_config_delta_preserving_comments(
    path: &Path,
    before: &Config,
    after: &Config,
) -> Result<(), MicroClawError> {
    if !path.exists() {
        return after.save_yaml(&path.to_string_lossy());
    }

    let raw = std::fs::read_to_string(path).map_err(|e| {
        MicroClawError::Config(format!(
            "Failed to read config file {}: {e}",
            path.display()
        ))
    })?;

    let before_yaml = serde_yaml::to_value(before)
        .map_err(|e| MicroClawError::Config(format!("Failed to serialize previous config: {e}")))?;
    let after_yaml = serde_yaml::to_value(after)
        .map_err(|e| MicroClawError::Config(format!("Failed to serialize updated config: {e}")))?;
    let changes = collect_changes(&before_yaml, &after_yaml);
    if changes.is_empty() {
        return Ok(());
    }

    let doc = Document::from_str(&raw)
        .map_err(|e| MicroClawError::Config(format!("Failed to parse YAML config: {e}")))?;

    for change in changes
        .iter()
        .filter(|change| matches!(change.kind, ChangeKind::Delete))
        .rev()
    {
        let _ = doc.remove_path(&to_yaml_path(&change.path));
    }

    for change in changes {
        if let ChangeKind::Set(value) = &change.kind {
            set_path_with_serde_value(&doc, &to_yaml_path(&change.path), value).map_err(|e| {
                MicroClawError::Config(format!("Failed to convert YAML value: {e}"))
            })?;
        }
    }

    let mut rendered = doc.to_string();
    rendered = restore_leading_comments(&raw, &rendered);

    std::fs::write(path, rendered).map_err(|e| {
        MicroClawError::Config(format!(
            "Failed to write config file {}: {e}",
            path.display()
        ))
    })?;
    Ok(())
}

fn collect_changes(before: &serde_yaml::Value, after: &serde_yaml::Value) -> Vec<YamlChange> {
    let mut out = Vec::new();
    collect_changes_rec(&mut out, Vec::new(), Some(before), Some(after));
    out.sort_by_key(|item| item.path.len());
    out
}

fn collect_changes_rec(
    out: &mut Vec<YamlChange>,
    path: Vec<String>,
    before: Option<&serde_yaml::Value>,
    after: Option<&serde_yaml::Value>,
) {
    match (before, after) {
        (
            Some(serde_yaml::Value::Mapping(before_map)),
            Some(serde_yaml::Value::Mapping(after_map)),
        ) => {
            let mut keys = BTreeSet::new();
            for key in before_map.keys() {
                keys.insert(yaml_key_to_string(key));
            }
            for key in after_map.keys() {
                keys.insert(yaml_key_to_string(key));
            }
            for key in keys {
                let mut next_path = path.clone();
                next_path.push(key.clone());
                let before_value = mapping_get(before_map, &key);
                let after_value = mapping_get(after_map, &key);
                collect_changes_rec(out, next_path, before_value, after_value);
            }
        }
        (Some(before_value), Some(after_value)) => {
            if before_value != after_value {
                out.push(YamlChange {
                    path,
                    kind: ChangeKind::Set(after_value.clone()),
                });
            }
        }
        (None, Some(after_value)) => out.push(YamlChange {
            path,
            kind: ChangeKind::Set(after_value.clone()),
        }),
        (Some(_), None) => out.push(YamlChange {
            path,
            kind: ChangeKind::Delete,
        }),
        (None, None) => {}
    }
}

fn mapping_get<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> {
    map.iter()
        .find_map(|(k, v)| (yaml_key_to_string(k) == key).then_some(v))
}

fn yaml_key_to_string(key: &serde_yaml::Value) -> String {
    match key {
        serde_yaml::Value::String(v) => v.clone(),
        _ => serde_yaml::to_string(key)
            .unwrap_or_default()
            .trim()
            .to_string(),
    }
}

fn to_yaml_path(path: &[String]) -> String {
    path.iter()
        .map(|segment| escape_path_segment(segment))
        .collect::<Vec<_>>()
        .join(".")
}

fn escape_path_segment(segment: &str) -> String {
    segment
        .replace('\\', "\\\\")
        .replace('.', "\\.")
        .replace('[', "\\[")
        .replace(']', "\\]")
}

fn set_path_with_serde_value(
    doc: &Document,
    path: &str,
    value: &serde_yaml::Value,
) -> Result<(), String> {
    let normalized = normalize_yaml_snippet(value)?;
    let parsed = Document::from_str(&normalized)
        .map_err(|e| format!("failed to parse serialized yaml snippet: {e}"))?;

    if let Some(mapping) = parsed.as_mapping() {
        doc.set_path(path, &mapping);
    } else if let Some(sequence) = parsed.as_sequence() {
        doc.set_path(path, &sequence);
    } else if let Some(scalar) = parsed.as_scalar() {
        doc.set_path(path, &scalar);
    } else {
        return Err("unsupported yaml value node kind".to_string());
    }
    Ok(())
}

fn normalize_yaml_snippet(value: &serde_yaml::Value) -> Result<String, String> {
    let raw = match value {
        serde_yaml::Value::Tagged(tagged) => serde_yaml::to_string(&tagged.value),
        _ => serde_yaml::to_string(value),
    }
    .map_err(|e| format!("failed to serialize yaml value: {e}"))?;

    let lines = raw
        .lines()
        .filter(|line| !line.trim().is_empty())
        .filter(|line| *line != "---" && *line != "...")
        .collect::<Vec<_>>();
    if lines.is_empty() {
        return Ok("null".to_string());
    }
    let mut out = lines.join("\n");
    out.push('\n');
    Ok(out)
}

fn restore_leading_comments(original: &str, rendered: &str) -> String {
    let leading = extract_leading_comments(original);
    if leading.is_empty() {
        return rendered.to_string();
    }
    if rendered.starts_with(&leading) {
        return rendered.to_string();
    }
    let mut out = String::with_capacity(leading.len() + rendered.len());
    out.push_str(&leading);
    out.push_str(rendered.trim_start_matches('\n'));
    if !out.ends_with('\n') {
        out.push('\n');
    }
    out
}

fn extract_leading_comments(input: &str) -> String {
    let mut lines = Vec::new();
    for line in input.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('#') || trimmed.is_empty() {
            lines.push(line);
        } else {
            break;
        }
    }
    if lines.is_empty() {
        return String::new();
    }
    let mut out = lines.join("\n");
    out.push('\n');
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse_cfg(yaml: &str) -> Config {
        let mut cfg: Config = serde_yaml::from_str(yaml).expect("parse config");
        cfg.post_deserialize().expect("normalize config");
        cfg
    }

    #[test]
    fn preserves_comments_when_updating_scalar_values() {
        let path = std::env::temp_dir().join(format!(
            "microclaw-config-patch-{}.yaml",
            uuid::Uuid::new_v4()
        ));
        let original = r#"# global comment
# provider comment
llm_provider: "anthropic"
api_key: "test-key"
# token budget comment
memory_token_budget: 1500
channels:
  web:
    # web enabled comment
    enabled: true
"#;
        std::fs::write(&path, original).expect("write original");

        let before = parse_cfg(original);
        let mut after = before.clone();
        after.llm_provider = "openai".to_string();
        after.memory_token_budget = 2048;
        if let Some(web) = after
            .channels
            .get_mut("web")
            .and_then(|v| v.as_mapping_mut())
        {
            web.insert(
                serde_yaml::Value::String("enabled".to_string()),
                serde_yaml::Value::Bool(false),
            );
        }

        save_config_delta_preserving_comments(&path, &before, &after).expect("patch config");
        let updated = std::fs::read_to_string(&path).expect("read updated");
        assert!(updated.contains("# global comment"));
        assert!(updated.contains("# provider comment"));
        assert!(updated.contains("# token budget comment"));
        assert!(updated.contains("# web enabled comment"));
        assert!(updated.contains("llm_provider: openai"));
        assert!(updated.contains("memory_token_budget: 2048"));
        assert!(updated.contains("enabled: false"));
        let _ = std::fs::remove_file(path);
    }
}