1use serde_json::{Map, Value};
7
8use crate::domain::config::FileConfig;
9use crate::domain::harness::HarnessSpec;
10
11pub struct SyncPlan {
14 pub fragment: Option<Value>,
17 pub unmapped: Vec<&'static str>,
23}
24
25pub fn plan(cfg: &FileConfig, spec: &HarnessSpec) -> Result<SyncPlan, String> {
32 let Some(sync) = &spec.sync else {
33 let mut unmapped = Vec::new();
36 if !cfg.allowed_tools_for(spec.id).is_empty() {
37 unmapped.push("allowed_tools");
38 }
39 if !cfg.denied_tools_for(spec.id).is_empty() {
40 unmapped.push("denied_tools");
41 }
42 return Ok(SyncPlan {
43 fragment: None,
44 unmapped,
45 });
46 };
47
48 let mut root = match cfg.settings_for(spec.id) {
49 Some(table) => {
50 let value = serde_json::to_value(table)
51 .map_err(|e| format!("settings table is not representable as JSON: {e}"))?;
52 match value {
53 Value::Object(map) => map,
54 _ => return Err("`settings` must be a table".to_string()),
55 }
56 }
57 None => Map::new(),
58 };
59 let mut unmapped = Vec::new();
60
61 let rules = [
62 (
63 "allowed_tools",
64 cfg.allowed_tools_for(spec.id),
65 sync.allow_path,
66 ),
67 (
68 "denied_tools",
69 cfg.denied_tools_for(spec.id),
70 sync.deny_path,
71 ),
72 ];
73 for (name, values, path) in rules {
74 if values.is_empty() {
75 continue;
76 }
77 match path {
78 Some(path) => {
79 let list = Value::Array(values.iter().cloned().map(Value::String).collect());
80 insert_at(&mut root, path, list);
81 }
82 None => unmapped.push(name),
83 }
84 }
85
86 if let Some(seed) = sync.schema_seed {
89 let seed: Value =
90 serde_json::from_str(seed).expect("registry schema_seed is valid JSON (test-pinned)");
91 if let Value::Object(seed_map) = seed {
92 for (key, seed_value) in seed_map {
93 if let Some(current) = root.get(&key) {
94 let completed = deep_merge(&seed_value, current);
95 root.insert(key, completed);
96 }
97 }
98 }
99 }
100
101 if let Some(hooks) = cfg.hooks_for(spec.id) {
102 let path = sync.hooks_path.expect("hooks validated against hooks_path");
105 let value = serde_json::to_value(hooks)
106 .map_err(|e| format!("hooks table is not representable as JSON: {e}"))?;
107 insert_at(&mut root, path, value);
108 }
109
110 Ok(SyncPlan {
111 fragment: if root.is_empty() {
112 None
113 } else {
114 Some(Value::Object(root))
115 },
116 unmapped,
117 })
118}
119
120fn insert_at(root: &mut Map<String, Value>, path: &[&str], value: Value) {
124 let (last, parents) = path.split_last().expect("key paths are non-empty");
125 let mut node = root;
126 for key in parents {
127 let entry = node
128 .entry(key.to_string())
129 .or_insert_with(|| Value::Object(Map::new()));
130 if !entry.is_object() {
131 *entry = Value::Object(Map::new());
132 }
133 node = entry.as_object_mut().expect("just ensured an object");
134 }
135 node.insert(last.to_string(), value);
136}
137
138pub fn deep_merge(existing: &Value, fragment: &Value) -> Value {
146 match (existing, fragment) {
147 (Value::Object(a), Value::Object(b)) => {
148 let mut merged = a.clone();
149 for (key, frag) in b {
150 let entry = match a.get(key) {
151 Some(prev) => deep_merge(prev, frag),
152 None => frag.clone(),
153 };
154 merged.insert(key.clone(), entry);
155 }
156 Value::Object(merged)
157 }
158 (Value::Array(a), Value::Array(b)) => {
159 let mut merged = a.clone();
160 for item in b {
161 if !merged.contains(item) {
162 merged.push(item.clone());
163 }
164 }
165 Value::Array(merged)
166 }
167 _ => fragment.clone(),
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::domain::{config, harness};
175 use serde_json::json;
176
177 fn cfg(text: &str) -> FileConfig {
178 config::parse(text).expect("config should parse")
179 }
180
181 fn plan_for(text: &str, id: &str) -> SyncPlan {
182 plan(&cfg(text), harness::by_id(id).unwrap()).expect("plan should build")
183 }
184
185 #[test]
186 fn nothing_configured_means_no_fragment() {
187 let p = plan_for("model = \"x\"", "claude-code");
188 assert!(p.fragment.is_none());
189 assert!(p.unmapped.is_empty());
190 }
191
192 #[test]
193 fn rules_and_hooks_land_at_the_registry_paths() {
194 let p = plan_for(
195 concat!(
196 "allowed_tools = [\"Bash(git log:*)\"]\n",
197 "denied_tools = [\"Bash(rm:*)\"]\n",
198 "[harness.claude-code.hooks]\n",
199 "PreToolUse = []\n",
200 ),
201 "claude-code",
202 );
203 assert_eq!(
204 p.fragment.unwrap(),
205 json!({
206 "permissions": {
207 "allow": ["Bash(git log:*)"],
208 "deny": ["Bash(rm:*)"],
209 },
210 "hooks": { "PreToolUse": [] },
211 })
212 );
213 assert!(p.unmapped.is_empty());
214 }
215
216 #[test]
217 fn crush_deny_maps_to_disabled_tools() {
218 let p = plan_for("denied_tools = [\"bash\"]", "crush");
219 assert_eq!(
220 p.fragment.unwrap(),
221 json!({ "options": { "disabled_tools": ["bash"] } })
222 );
223 }
224
225 #[test]
226 fn settings_table_passes_through_and_explicit_rules_win() {
227 let p = plan_for(
228 concat!(
229 "[harness.opencode.settings.permission]\n",
230 "edit = \"deny\"\n",
231 "[harness.opencode.settings.permission.bash]\n",
232 "\"git *\" = \"allow\"\n",
233 ),
234 "opencode",
235 );
236 assert_eq!(
237 p.fragment.unwrap(),
238 json!({ "permission": { "edit": "deny", "bash": { "git *": "allow" } } })
239 );
240
241 let p = plan_for(
243 concat!(
244 "[harness.claude-code]\n",
245 "allowed_tools = [\"Read\"]\n",
246 "[harness.claude-code.settings.permissions]\n",
247 "allow = [\"stale\"]\n",
248 "defaultMode = \"acceptEdits\"\n",
249 ),
250 "claude-code",
251 );
252 assert_eq!(
253 p.fragment.unwrap(),
254 json!({ "permissions": { "allow": ["Read"], "defaultMode": "acceptEdits" } })
255 );
256 }
257
258 #[test]
259 fn top_level_rules_without_a_mapping_are_reported_unmapped() {
260 let text = "allowed_tools = [\"x\"]\ndenied_tools = [\"y\"]";
263 let p = plan_for(text, "opencode");
264 assert!(p.fragment.is_none());
265 assert_eq!(p.unmapped, ["allowed_tools", "denied_tools"]);
266 let p = plan_for(text, "codex");
267 assert!(p.fragment.is_none());
268 assert_eq!(p.unmapped, ["allowed_tools", "denied_tools"]);
269 }
270
271 #[test]
272 fn every_registry_schema_seed_is_valid_json() {
273 for spec in harness::all() {
274 if let Some(seed) = spec.sync.as_ref().and_then(|s| s.schema_seed) {
275 let value: Value = serde_json::from_str(seed)
276 .unwrap_or_else(|e| panic!("{}: schema_seed is not JSON: {e}", spec.id));
277 assert!(
278 value.is_object(),
279 "{}: schema_seed must be an object",
280 spec.id
281 );
282 }
283 }
284 }
285
286 #[test]
287 fn cursor_allow_only_is_completed_with_an_empty_deny() {
288 let p = plan_for(
292 "[harness.cursor]\nallowed_tools = [\"Shell(touch)\"]",
293 "cursor",
294 );
295 assert_eq!(
296 p.fragment.unwrap(),
297 json!({ "permissions": { "allow": ["Shell(touch)"], "deny": [] } })
298 );
299 let p = plan_for(
301 "[harness.cursor.settings]\neditor = { vimMode = true }",
302 "cursor",
303 );
304 assert_eq!(
305 p.fragment.unwrap(),
306 json!({ "editor": { "vimMode": true } })
307 );
308 }
309
310 #[test]
311 fn deep_merge_preserves_unrelated_keys_and_unions_arrays() {
312 let existing = json!({
313 "permissions": { "allow": ["Read", "Bash(ls *)"], "defaultMode": "plan" },
314 "env": { "FOO": "bar" },
315 });
316 let fragment = json!({
317 "permissions": { "allow": ["Bash(ls *)", "Edit"], "deny": ["Bash(rm *)"] },
318 });
319 let merged = deep_merge(&existing, &fragment);
320 assert_eq!(
321 merged,
322 json!({
323 "permissions": {
324 "allow": ["Read", "Bash(ls *)", "Edit"],
325 "defaultMode": "plan",
326 "deny": ["Bash(rm *)"],
327 },
328 "env": { "FOO": "bar" },
329 })
330 );
331 }
332
333 #[test]
334 fn deep_merge_is_idempotent_and_scalars_take_the_fragment() {
335 let fragment = json!({ "a": { "b": [1, 2], "c": "new" } });
336 let once = deep_merge(&json!({ "a": { "c": "old" } }), &fragment);
337 assert_eq!(once, json!({ "a": { "b": [1, 2], "c": "new" } }));
338 let twice = deep_merge(&once, &fragment);
339 assert_eq!(twice, once, "re-syncing must change nothing");
340 }
341}