Skip to main content

oneharness_core/domain/
sync.rs

1//! Pure logic for `oneharness sync`: building the JSON fragment each harness's
2//! project config file must contain, and the non-destructive deep merge that
3//! folds it into whatever the file already holds. All I/O (reading and writing
4//! the actual files) lives in `src/io/sync.rs`.
5
6use serde_json::{Map, Value};
7
8use crate::domain::config::FileConfig;
9use crate::domain::harness::HarnessSpec;
10
11/// What `sync` should do for one harness, computed purely from the unified
12/// config and the registry's [`crate::domain::harness::SyncSpec`].
13pub struct SyncPlan {
14    /// The JSON object to merge into the harness's config file; `None` when
15    /// nothing is configured for it (the harness is then reported `skipped`).
16    pub fragment: Option<Value>,
17    /// Top-level settings that exist but have no mapping for this harness
18    /// (e.g. a top-level `allowed_tools` while the harness has no allow-list
19    /// concept). Surfaced in the report and on stderr so a rule that did not
20    /// land is always visible — never silently dropped. Per-harness fields
21    /// can't end up here; those are rejected at config parse time.
22    pub unmapped: Vec<&'static str>,
23}
24
25/// Build the sync plan for one harness. Pure.
26///
27/// The fragment starts from the harness's raw `[harness.<id>.settings]` table
28/// (converted to JSON), then the unified rule lists and hooks are inserted at
29/// the registry's key paths — an explicit `allowed_tools`/`denied_tools`/
30/// `hooks` wins over the same key in the raw table.
31pub fn plan(cfg: &FileConfig, spec: &HarnessSpec) -> Result<SyncPlan, String> {
32    let Some(sync) = &spec.sync else {
33        // No config surface at all: anything aimed at this harness from the
34        // top level is unmapped (per-harness fields were parse-rejected).
35        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    // Complete the schema for any top-level key the fragment touches (e.g.
87    // Cursor requires both permissions arrays); untouched keys stay unseeded.
88    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        // Parse-time validation guarantees hooks only appear where a path
103        // exists, so this expect cannot fire on user input.
104        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
120/// Insert `value` at a key path, creating intermediate objects. An explicit
121/// unified field overwrites the same key from the raw settings table (the
122/// dedicated field is the more specific intent).
123fn 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
138/// Merge `fragment` into `existing`, non-destructively:
139///
140/// - objects merge per key — keys absent from the fragment are never touched;
141/// - arrays union — existing entries keep their order, fragment entries not
142///   already present are appended (so re-syncing is idempotent);
143/// - anything else (scalars, or a type mismatch) takes the fragment's value —
144///   for keys oneharness manages, the unified config is the source of truth.
145pub 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        // An explicit rule list overwrites the same key from the raw table.
242        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        // opencode has a config file but no list-shaped permission concept;
261        // codex has no config surface at all. Both must say so, loudly.
262        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        // Cursor's CLI rejects a permissions block missing either array
289        // (observed live: "permissions.deny Required"), so a partial write
290        // must be seeded into a schema-valid shape.
291        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        // And the seed never invents a permissions block out of nothing.
300        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}