Skip to main content

dot_agent_core/
json_merge.rs

1//! JSON merge functionality for dot-agent install/remove operations.
2//!
3//! Handles merging of JSON configuration files (hooks.json, mcp.json, settings.json)
4//! during profile installation, and removal of merged entries during uninstallation.
5
6use std::fs;
7use std::path::Path;
8
9use serde_json::{Map, Value};
10
11use crate::error::{DotAgentError, Result};
12
13/// Files that support JSON merging
14const MERGEABLE_FILES: &[&str] = &[
15    "hooks.json",
16    "mcp.json",
17    "settings.json",
18    "settings.local.json",
19];
20
21/// Marker key to identify which profile added an entry
22const PROFILE_MARKER: &str = "_dot_agent_profile";
23
24/// Check if a file path is a mergeable JSON file
25pub 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/// Record of what was merged into a JSON file
32#[derive(Debug, Clone, Default)]
33pub struct MergeRecord {
34    /// JSON paths that were added (e.g., "hooks.SessionStart[0]", "mcpServers.my-server")
35    pub added_paths: Vec<String>,
36}
37
38/// Result of a merge operation
39#[derive(Debug)]
40pub struct MergeResult {
41    /// The merged JSON content
42    pub content: String,
43    /// Record of what was merged (for metadata tracking)
44    pub record: MergeRecord,
45    /// Whether any actual changes were made
46    pub changed: bool,
47}
48
49/// Result of an unmerge operation
50#[derive(Debug)]
51pub struct UnmergeResult {
52    /// The JSON content after removing merged entries
53    pub content: String,
54    /// Paths that were removed
55    pub removed_paths: Vec<String>,
56    /// Whether any actual changes were made
57    pub changed: bool,
58}
59
60/// Merge source JSON into target JSON, marking entries with profile name
61pub 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
101/// Remove entries that were added by a specific profile
102pub 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    // Clean up empty objects and arrays
112    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
126/// Recursively merge source into target, marking arrays entries with profile marker
127fn 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                // Skip schema fields
140                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                    // Key exists, recurse
152                    if merge_value(target_val, source_val, profile_name, &new_path, added_paths) {
153                        changed = true;
154                    }
155                } else {
156                    // Key doesn't exist, add it (with marker if it's an object)
157                    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 arrays, append new items with profile marker
166            for (i, source_item) in source_arr.iter().enumerate() {
167                // Check if this exact item (minus marker) already exists
168                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            // For other types, don't overwrite existing values
184        }
185    }
186
187    changed
188}
189
190/// Check if array contains an equivalent item (ignoring profile marker)
191fn array_contains_equivalent(arr: &[Value], item: &Value) -> bool {
192    arr.iter().any(|existing| values_equivalent(existing, item))
193}
194
195/// Check if two values are equivalent (ignoring profile marker)
196fn values_equivalent(a: &Value, b: &Value) -> bool {
197    match (a, b) {
198        (Value::Object(a_map), Value::Object(b_map)) => {
199            // Compare all keys except profile marker
200            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
232/// Add profile marker to a value
233fn 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            // Mark each object in the array
243            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
257/// Recursively remove entries marked with the given profile
258fn 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            // Check if this object itself is marked by the profile
269            if let Some(Value::String(marker)) = map.get(PROFILE_MARKER) {
270                if marker == profile_name {
271                    // This entire object was added by the profile
272                    // The parent should handle removal
273                    return true;
274                }
275            }
276
277            // Collect keys to remove
278            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            // Recurse into remaining values
307            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            // Remove array items marked with the profile
323            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            // Recurse into remaining items
341            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
354/// Remove empty objects and arrays recursively
355fn cleanup_empty(value: &mut Value) {
356    match value {
357        Value::Object(map) => {
358            // First recurse
359            for val in map.values_mut() {
360                cleanup_empty(val);
361            }
362            // Then remove empty children
363            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
383/// Merge a JSON file from source profile into target
384pub 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
400/// Remove profile's merged entries from a JSON file
401pub 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        // Check that existing PreToolUse is preserved
439        assert!(merged["hooks"]["PreToolUse"].is_array());
440
441        // Check that SessionStart was added with marker
442        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        // Check that PreToolUse is preserved
465        assert!(unmerged["hooks"]["PreToolUse"].is_array());
466
467        // Check that SessionStart array is now empty (or removed)
468        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        // Check both servers exist
496        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        // Should not duplicate
527        let session_start = merged["hooks"]["SessionStart"].as_array().unwrap();
528        assert_eq!(session_start.len(), 1);
529    }
530}