Skip to main content

microclaw_engine/
config_persistence.rs

1use std::collections::BTreeSet;
2use std::path::Path;
3use std::str::FromStr;
4
5use microclaw_core::error::MicroClawError;
6use yaml_edit::{path::YamlPath, Document};
7
8use crate::config::Config;
9
10#[derive(Debug, Clone)]
11enum ChangeKind {
12    Set(serde_yaml::Value),
13    Delete,
14}
15
16#[derive(Debug, Clone)]
17struct YamlChange {
18    path: Vec<String>,
19    kind: ChangeKind,
20}
21
22pub fn save_config_delta_preserving_comments(
23    path: &Path,
24    before: &Config,
25    after: &Config,
26) -> Result<(), MicroClawError> {
27    if !path.exists() {
28        return after.save_yaml(&path.to_string_lossy());
29    }
30
31    let raw = std::fs::read_to_string(path).map_err(|e| {
32        MicroClawError::Config(format!(
33            "Failed to read config file {}: {e}",
34            path.display()
35        ))
36    })?;
37
38    let before_yaml = serde_yaml::to_value(before)
39        .map_err(|e| MicroClawError::Config(format!("Failed to serialize previous config: {e}")))?;
40    let after_yaml = serde_yaml::to_value(after)
41        .map_err(|e| MicroClawError::Config(format!("Failed to serialize updated config: {e}")))?;
42    let changes = collect_changes(&before_yaml, &after_yaml);
43    if changes.is_empty() {
44        return Ok(());
45    }
46
47    let doc = Document::from_str(&raw)
48        .map_err(|e| MicroClawError::Config(format!("Failed to parse YAML config: {e}")))?;
49
50    for change in changes
51        .iter()
52        .filter(|change| matches!(change.kind, ChangeKind::Delete))
53        .rev()
54    {
55        let _ = doc.remove_path(&to_yaml_path(&change.path));
56    }
57
58    for change in changes {
59        if let ChangeKind::Set(value) = &change.kind {
60            set_path_with_serde_value(&doc, &to_yaml_path(&change.path), value).map_err(|e| {
61                MicroClawError::Config(format!("Failed to convert YAML value: {e}"))
62            })?;
63        }
64    }
65
66    let mut rendered = doc.to_string();
67    rendered = restore_leading_comments(&raw, &rendered);
68
69    std::fs::write(path, rendered).map_err(|e| {
70        MicroClawError::Config(format!(
71            "Failed to write config file {}: {e}",
72            path.display()
73        ))
74    })?;
75    Ok(())
76}
77
78fn collect_changes(before: &serde_yaml::Value, after: &serde_yaml::Value) -> Vec<YamlChange> {
79    let mut out = Vec::new();
80    collect_changes_rec(&mut out, Vec::new(), Some(before), Some(after));
81    out.sort_by_key(|item| item.path.len());
82    out
83}
84
85fn collect_changes_rec(
86    out: &mut Vec<YamlChange>,
87    path: Vec<String>,
88    before: Option<&serde_yaml::Value>,
89    after: Option<&serde_yaml::Value>,
90) {
91    match (before, after) {
92        (
93            Some(serde_yaml::Value::Mapping(before_map)),
94            Some(serde_yaml::Value::Mapping(after_map)),
95        ) => {
96            let mut keys = BTreeSet::new();
97            for key in before_map.keys() {
98                keys.insert(yaml_key_to_string(key));
99            }
100            for key in after_map.keys() {
101                keys.insert(yaml_key_to_string(key));
102            }
103            for key in keys {
104                let mut next_path = path.clone();
105                next_path.push(key.clone());
106                let before_value = mapping_get(before_map, &key);
107                let after_value = mapping_get(after_map, &key);
108                collect_changes_rec(out, next_path, before_value, after_value);
109            }
110        }
111        (Some(before_value), Some(after_value)) => {
112            if before_value != after_value {
113                out.push(YamlChange {
114                    path,
115                    kind: ChangeKind::Set(after_value.clone()),
116                });
117            }
118        }
119        (None, Some(after_value)) => out.push(YamlChange {
120            path,
121            kind: ChangeKind::Set(after_value.clone()),
122        }),
123        (Some(_), None) => out.push(YamlChange {
124            path,
125            kind: ChangeKind::Delete,
126        }),
127        (None, None) => {}
128    }
129}
130
131fn mapping_get<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> {
132    map.iter()
133        .find_map(|(k, v)| (yaml_key_to_string(k) == key).then_some(v))
134}
135
136fn yaml_key_to_string(key: &serde_yaml::Value) -> String {
137    match key {
138        serde_yaml::Value::String(v) => v.clone(),
139        _ => serde_yaml::to_string(key)
140            .unwrap_or_default()
141            .trim()
142            .to_string(),
143    }
144}
145
146fn to_yaml_path(path: &[String]) -> String {
147    path.iter()
148        .map(|segment| escape_path_segment(segment))
149        .collect::<Vec<_>>()
150        .join(".")
151}
152
153fn escape_path_segment(segment: &str) -> String {
154    segment
155        .replace('\\', "\\\\")
156        .replace('.', "\\.")
157        .replace('[', "\\[")
158        .replace(']', "\\]")
159}
160
161fn set_path_with_serde_value(
162    doc: &Document,
163    path: &str,
164    value: &serde_yaml::Value,
165) -> Result<(), String> {
166    let normalized = normalize_yaml_snippet(value)?;
167    let parsed = Document::from_str(&normalized)
168        .map_err(|e| format!("failed to parse serialized yaml snippet: {e}"))?;
169
170    if let Some(mapping) = parsed.as_mapping() {
171        doc.set_path(path, &mapping);
172    } else if let Some(sequence) = parsed.as_sequence() {
173        doc.set_path(path, &sequence);
174    } else if let Some(scalar) = parsed.as_scalar() {
175        doc.set_path(path, &scalar);
176    } else {
177        return Err("unsupported yaml value node kind".to_string());
178    }
179    Ok(())
180}
181
182fn normalize_yaml_snippet(value: &serde_yaml::Value) -> Result<String, String> {
183    let raw = match value {
184        serde_yaml::Value::Tagged(tagged) => serde_yaml::to_string(&tagged.value),
185        _ => serde_yaml::to_string(value),
186    }
187    .map_err(|e| format!("failed to serialize yaml value: {e}"))?;
188
189    let lines = raw
190        .lines()
191        .filter(|line| !line.trim().is_empty())
192        .filter(|line| *line != "---" && *line != "...")
193        .collect::<Vec<_>>();
194    if lines.is_empty() {
195        return Ok("null".to_string());
196    }
197    let mut out = lines.join("\n");
198    out.push('\n');
199    Ok(out)
200}
201
202fn restore_leading_comments(original: &str, rendered: &str) -> String {
203    let leading = extract_leading_comments(original);
204    if leading.is_empty() {
205        return rendered.to_string();
206    }
207    if rendered.starts_with(&leading) {
208        return rendered.to_string();
209    }
210    let mut out = String::with_capacity(leading.len() + rendered.len());
211    out.push_str(&leading);
212    out.push_str(rendered.trim_start_matches('\n'));
213    if !out.ends_with('\n') {
214        out.push('\n');
215    }
216    out
217}
218
219fn extract_leading_comments(input: &str) -> String {
220    let mut lines = Vec::new();
221    for line in input.lines() {
222        let trimmed = line.trim();
223        if trimmed.starts_with('#') || trimmed.is_empty() {
224            lines.push(line);
225        } else {
226            break;
227        }
228    }
229    if lines.is_empty() {
230        return String::new();
231    }
232    let mut out = lines.join("\n");
233    out.push('\n');
234    out
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn parse_cfg(yaml: &str) -> Config {
242        let mut cfg: Config = serde_yaml::from_str(yaml).expect("parse config");
243        cfg.post_deserialize().expect("normalize config");
244        cfg
245    }
246
247    #[test]
248    fn preserves_comments_when_updating_scalar_values() {
249        let path = std::env::temp_dir().join(format!(
250            "microclaw-config-patch-{}.yaml",
251            uuid::Uuid::new_v4()
252        ));
253        let original = r#"# global comment
254# provider comment
255llm_provider: "anthropic"
256api_key: "test-key"
257# token budget comment
258memory_token_budget: 1500
259channels:
260  web:
261    # web enabled comment
262    enabled: true
263"#;
264        std::fs::write(&path, original).expect("write original");
265
266        let before = parse_cfg(original);
267        let mut after = before.clone();
268        after.llm_provider = "openai".to_string();
269        after.memory_token_budget = 2048;
270        if let Some(web) = after
271            .channels
272            .get_mut("web")
273            .and_then(|v| v.as_mapping_mut())
274        {
275            web.insert(
276                serde_yaml::Value::String("enabled".to_string()),
277                serde_yaml::Value::Bool(false),
278            );
279        }
280
281        save_config_delta_preserving_comments(&path, &before, &after).expect("patch config");
282        let updated = std::fs::read_to_string(&path).expect("read updated");
283        assert!(updated.contains("# global comment"));
284        assert!(updated.contains("# provider comment"));
285        assert!(updated.contains("# token budget comment"));
286        assert!(updated.contains("# web enabled comment"));
287        assert!(updated.contains("llm_provider: openai"));
288        assert!(updated.contains("memory_token_budget: 2048"));
289        assert!(updated.contains("enabled: false"));
290        let _ = std::fs::remove_file(path);
291    }
292}