1use std::fs;
7use std::path::Path;
8
9use serde_json::{Map, Value};
10
11use crate::error::{DotAgentError, Result};
12
13const MERGEABLE_FILES: &[&str] = &[
15 "hooks.json",
16 "mcp.json",
17 "settings.json",
18 "settings.local.json",
19];
20
21const PROFILE_MARKER: &str = "_dot_agent_profile";
23
24pub fn is_mergeable_json(path: &Path) -> bool {
26 path.file_name()
27 .and_then(|n| n.to_str())
28 .is_some_and(|name| MERGEABLE_FILES.contains(&name))
29}
30
31#[derive(Debug, Clone, Default)]
33pub struct MergeRecord {
34 pub added_paths: Vec<String>,
36}
37
38#[derive(Debug)]
40pub struct MergeResult {
41 pub content: String,
43 pub record: MergeRecord,
45 pub changed: bool,
47}
48
49#[derive(Debug)]
51pub struct UnmergeResult {
52 pub content: String,
54 pub removed_paths: Vec<String>,
56 pub changed: bool,
58}
59
60pub fn merge_json(
62 target_content: Option<&str>,
63 source_content: &str,
64 profile_name: &str,
65) -> Result<MergeResult> {
66 let source: Value =
67 serde_json::from_str(source_content).map_err(|e| DotAgentError::JsonParseError {
68 message: e.to_string(),
69 })?;
70
71 let mut target: Value = match target_content {
72 Some(content) if !content.trim().is_empty() => {
73 serde_json::from_str(content).map_err(|e| DotAgentError::JsonParseError {
74 message: e.to_string(),
75 })?
76 }
77 _ => Value::Object(Map::new()),
78 };
79
80 let mut record = MergeRecord::default();
81 let changed = merge_value(
82 &mut target,
83 &source,
84 profile_name,
85 "",
86 &mut record.added_paths,
87 );
88
89 let content =
90 serde_json::to_string_pretty(&target).map_err(|e| DotAgentError::JsonParseError {
91 message: e.to_string(),
92 })?;
93
94 Ok(MergeResult {
95 content,
96 record,
97 changed,
98 })
99}
100
101pub fn unmerge_json(target_content: &str, profile_name: &str) -> Result<UnmergeResult> {
103 let mut target: Value =
104 serde_json::from_str(target_content).map_err(|e| DotAgentError::JsonParseError {
105 message: e.to_string(),
106 })?;
107
108 let mut removed_paths = Vec::new();
109 let changed = unmerge_value(&mut target, profile_name, "", &mut removed_paths);
110
111 cleanup_empty(&mut target);
113
114 let content =
115 serde_json::to_string_pretty(&target).map_err(|e| DotAgentError::JsonParseError {
116 message: e.to_string(),
117 })?;
118
119 Ok(UnmergeResult {
120 content,
121 removed_paths,
122 changed,
123 })
124}
125
126fn merge_value(
128 target: &mut Value,
129 source: &Value,
130 profile_name: &str,
131 path: &str,
132 added_paths: &mut Vec<String>,
133) -> bool {
134 let mut changed = false;
135
136 match (target, source) {
137 (Value::Object(target_map), Value::Object(source_map)) => {
138 for (key, source_val) in source_map {
139 if key == "$schema" {
141 continue;
142 }
143
144 let new_path = if path.is_empty() {
145 key.clone()
146 } else {
147 format!("{}.{}", path, key)
148 };
149
150 if let Some(target_val) = target_map.get_mut(key) {
151 if merge_value(target_val, source_val, profile_name, &new_path, added_paths) {
153 changed = true;
154 }
155 } else {
156 let marked_val = mark_value(source_val.clone(), profile_name);
158 target_map.insert(key.clone(), marked_val);
159 added_paths.push(new_path);
160 changed = true;
161 }
162 }
163 }
164 (Value::Array(target_arr), Value::Array(source_arr)) => {
165 for (i, source_item) in source_arr.iter().enumerate() {
167 if !array_contains_equivalent(target_arr, source_item) {
169 let mut marked_item = source_item.clone();
170 if let Value::Object(ref mut obj) = marked_item {
171 obj.insert(
172 PROFILE_MARKER.to_string(),
173 Value::String(profile_name.to_string()),
174 );
175 }
176 target_arr.push(marked_item);
177 added_paths.push(format!("{}[{}]", path, i));
178 changed = true;
179 }
180 }
181 }
182 _ => {
183 }
185 }
186
187 changed
188}
189
190fn array_contains_equivalent(arr: &[Value], item: &Value) -> bool {
192 arr.iter().any(|existing| values_equivalent(existing, item))
193}
194
195fn values_equivalent(a: &Value, b: &Value) -> bool {
197 match (a, b) {
198 (Value::Object(a_map), Value::Object(b_map)) => {
199 let a_keys: Vec<_> = a_map.keys().filter(|k| *k != PROFILE_MARKER).collect();
201 let b_keys: Vec<_> = b_map.keys().filter(|k| *k != PROFILE_MARKER).collect();
202
203 if a_keys.len() != b_keys.len() {
204 return false;
205 }
206
207 for key in &a_keys {
208 match (a_map.get(*key), b_map.get(*key)) {
209 (Some(av), Some(bv)) => {
210 if !values_equivalent(av, bv) {
211 return false;
212 }
213 }
214 _ => return false,
215 }
216 }
217 true
218 }
219 (Value::Array(a_arr), Value::Array(b_arr)) => {
220 if a_arr.len() != b_arr.len() {
221 return false;
222 }
223 a_arr
224 .iter()
225 .zip(b_arr.iter())
226 .all(|(a, b)| values_equivalent(a, b))
227 }
228 _ => a == b,
229 }
230}
231
232fn mark_value(mut value: Value, profile_name: &str) -> Value {
234 match &mut value {
235 Value::Object(obj) => {
236 obj.insert(
237 PROFILE_MARKER.to_string(),
238 Value::String(profile_name.to_string()),
239 );
240 }
241 Value::Array(arr) => {
242 for item in arr.iter_mut() {
244 if let Value::Object(obj) = item {
245 obj.insert(
246 PROFILE_MARKER.to_string(),
247 Value::String(profile_name.to_string()),
248 );
249 }
250 }
251 }
252 _ => {}
253 }
254 value
255}
256
257fn unmerge_value(
259 target: &mut Value,
260 profile_name: &str,
261 path: &str,
262 removed_paths: &mut Vec<String>,
263) -> bool {
264 let mut changed = false;
265
266 match target {
267 Value::Object(map) => {
268 if let Some(Value::String(marker)) = map.get(PROFILE_MARKER) {
270 if marker == profile_name {
271 return true;
274 }
275 }
276
277 let keys_to_remove: Vec<_> = map
279 .iter()
280 .filter_map(|(key, val)| {
281 if key == PROFILE_MARKER {
282 return None;
283 }
284 if let Value::Object(obj) = val {
285 if let Some(Value::String(marker)) = obj.get(PROFILE_MARKER) {
286 if marker == profile_name {
287 return Some(key.clone());
288 }
289 }
290 }
291 None
292 })
293 .collect();
294
295 for key in keys_to_remove {
296 let key_path = if path.is_empty() {
297 key.clone()
298 } else {
299 format!("{}.{}", path, key)
300 };
301 map.remove(&key);
302 removed_paths.push(key_path);
303 changed = true;
304 }
305
306 for (key, val) in map.iter_mut() {
308 if key == PROFILE_MARKER {
309 continue;
310 }
311 let key_path = if path.is_empty() {
312 key.clone()
313 } else {
314 format!("{}.{}", path, key)
315 };
316 if unmerge_value(val, profile_name, &key_path, removed_paths) {
317 changed = true;
318 }
319 }
320 }
321 Value::Array(arr) => {
322 let original_len = arr.len();
324 arr.retain(|item| {
325 if let Value::Object(obj) = item {
326 if let Some(Value::String(marker)) = obj.get(PROFILE_MARKER) {
327 if marker == profile_name {
328 return false;
329 }
330 }
331 }
332 true
333 });
334
335 if arr.len() != original_len {
336 removed_paths.push(format!("{}[*]", path));
337 changed = true;
338 }
339
340 for (i, item) in arr.iter_mut().enumerate() {
342 let item_path = format!("{}[{}]", path, i);
343 if unmerge_value(item, profile_name, &item_path, removed_paths) {
344 changed = true;
345 }
346 }
347 }
348 _ => {}
349 }
350
351 changed
352}
353
354fn cleanup_empty(value: &mut Value) {
356 match value {
357 Value::Object(map) => {
358 for val in map.values_mut() {
360 cleanup_empty(val);
361 }
362 map.retain(|_, v| !is_empty_container(v));
364 }
365 Value::Array(arr) => {
366 for item in arr.iter_mut() {
367 cleanup_empty(item);
368 }
369 arr.retain(|v| !is_empty_container(v));
370 }
371 _ => {}
372 }
373}
374
375fn is_empty_container(value: &Value) -> bool {
376 match value {
377 Value::Object(map) => map.is_empty(),
378 Value::Array(arr) => arr.is_empty(),
379 _ => false,
380 }
381}
382
383pub fn merge_json_file(
385 target_path: &Path,
386 source_path: &Path,
387 profile_name: &str,
388) -> Result<MergeResult> {
389 let source_content = fs::read_to_string(source_path)?;
390
391 let target_content = if target_path.exists() {
392 Some(fs::read_to_string(target_path)?)
393 } else {
394 None
395 };
396
397 merge_json(target_content.as_deref(), &source_content, profile_name)
398}
399
400pub fn unmerge_json_file(target_path: &Path, profile_name: &str) -> Result<Option<UnmergeResult>> {
402 if !target_path.exists() {
403 return Ok(None);
404 }
405
406 let content = fs::read_to_string(target_path)?;
407 let result = unmerge_json(&content, profile_name)?;
408
409 Ok(Some(result))
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_merge_hooks_json() {
418 let target = r#"{
419 "hooks": {
420 "PreToolUse": [
421 {"matcher": "existing", "hooks": []}
422 ]
423 }
424 }"#;
425
426 let source = r#"{
427 "hooks": {
428 "SessionStart": [
429 {"matcher": "startup", "hooks": [{"type": "command", "command": "echo hi"}]}
430 ]
431 }
432 }"#;
433
434 let result = merge_json(Some(target), source, "superpowers").unwrap();
435
436 let merged: Value = serde_json::from_str(&result.content).unwrap();
437
438 assert!(merged["hooks"]["PreToolUse"].is_array());
440
441 let session_start = &merged["hooks"]["SessionStart"][0];
443 assert_eq!(session_start["_dot_agent_profile"], "superpowers");
444 assert_eq!(session_start["matcher"], "startup");
445 }
446
447 #[test]
448 fn test_unmerge_hooks_json() {
449 let content = r#"{
450 "hooks": {
451 "PreToolUse": [
452 {"matcher": "existing", "hooks": []}
453 ],
454 "SessionStart": [
455 {"matcher": "startup", "_dot_agent_profile": "superpowers", "hooks": []}
456 ]
457 }
458 }"#;
459
460 let result = unmerge_json(content, "superpowers").unwrap();
461
462 let unmerged: Value = serde_json::from_str(&result.content).unwrap();
463
464 assert!(unmerged["hooks"]["PreToolUse"].is_array());
466
467 assert!(
469 unmerged["hooks"]["SessionStart"].is_null()
470 || unmerged["hooks"]["SessionStart"]
471 .as_array()
472 .unwrap()
473 .is_empty()
474 );
475 }
476
477 #[test]
478 fn test_merge_mcp_json() {
479 let target = r#"{
480 "mcpServers": {
481 "existing-server": {"command": "existing"}
482 }
483 }"#;
484
485 let source = r#"{
486 "mcpServers": {
487 "new-server": {"command": "new"}
488 }
489 }"#;
490
491 let result = merge_json(Some(target), source, "my-profile").unwrap();
492
493 let merged: Value = serde_json::from_str(&result.content).unwrap();
494
495 assert!(merged["mcpServers"]["existing-server"].is_object());
497 assert!(merged["mcpServers"]["new-server"].is_object());
498 assert_eq!(
499 merged["mcpServers"]["new-server"]["_dot_agent_profile"],
500 "my-profile"
501 );
502 }
503
504 #[test]
505 fn test_no_duplicate_merge() {
506 let target = r#"{
507 "hooks": {
508 "SessionStart": [
509 {"matcher": "startup", "_dot_agent_profile": "superpowers", "hooks": []}
510 ]
511 }
512 }"#;
513
514 let source = r#"{
515 "hooks": {
516 "SessionStart": [
517 {"matcher": "startup", "hooks": []}
518 ]
519 }
520 }"#;
521
522 let result = merge_json(Some(target), source, "superpowers").unwrap();
523
524 let merged: Value = serde_json::from_str(&result.content).unwrap();
525
526 let session_start = merged["hooks"]["SessionStart"].as_array().unwrap();
528 assert_eq!(session_start.len(), 1);
529 }
530}