microclaw_engine/
config_persistence.rs1use 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}