Skip to main content

lifeloop/host_assets/
merge.rs

1//! Merge behavior for host-owned configuration files.
2
3use serde_json::{Value, json};
4
5use super::model::{HostAssetError, MergedFile};
6use super::profiles::{CCD_COMPAT_PROFILE, LifecycleProfile};
7
8// ============================================================================
9// Merge: .claude/settings.json
10// ============================================================================
11
12/// Additive merge of CCD-managed lifecycle hooks into a Claude
13/// `settings.json` value, using the default [`CCD_COMPAT_PROFILE`].
14///
15/// See [`merge_claude_settings_with_profile`] for the algorithm. This
16/// is the back-compat entry point preserved for callers that pre-date
17/// the profile abstraction.
18pub fn merge_claude_settings(settings: Value) -> Option<Value> {
19    merge_claude_settings_with_profile(settings, &CCD_COMPAT_PROFILE)
20}
21
22/// Additive merge of `profile`'s managed lifecycle hooks into a Claude
23/// `settings.json` value.
24///
25/// Algorithm:
26/// 1. Ensure a top-level `hooks` object. Preserve every other top-level
27///    key.
28/// 2. For each managed event in `profile.claude_managed_events`,
29///    ensure a matcher entry exists. Within its `hooks` array, drop
30///    any entry whose `command` is recognized as a stale managed
31///    entry for `profile` (current prefix or any of
32///    `profile.claude_legacy_substrings`), then append the current
33///    managed entry rendered through `profile.claude_command`.
34/// 3. Non-managed entries within the same matcher are preserved in
35///    original order.
36/// 4. The retired `TaskCompleted` event is removed when its only
37///    entries are managed by `profile`; user-owned `TaskCompleted`
38///    hooks are preserved.
39///
40/// Returns `None` when the existing `hooks` shape is incompatible:
41/// `hooks` is not an object, or a managed event's value is not an array,
42/// or a matcher's inner `hooks` is not an array. Callers treat `None` as
43/// malformed input.
44pub fn merge_claude_settings_with_profile(
45    mut settings: Value,
46    profile: &LifecycleProfile,
47) -> Option<Value> {
48    profile.validate().ok()?;
49    let root_obj = settings.as_object_mut()?;
50    let hooks_entry = root_obj
51        .entry("hooks")
52        .or_insert_with(|| Value::Object(Default::default()));
53    let hooks_obj = hooks_entry.as_object_mut()?;
54    scrub_retired_task_completed_event(hooks_obj, profile);
55
56    for (event, hook_arg, matcher) in profile.claude_managed_events {
57        let event_entry = hooks_obj
58            .entry(*event)
59            .or_insert_with(|| Value::Array(Vec::new()));
60        let event_array = event_entry.as_array_mut()?;
61
62        let matcher_idx = event_array.iter().position(|entry| {
63            entry
64                .get("matcher")
65                .and_then(Value::as_str)
66                .map(|s| s == *matcher)
67                .unwrap_or(false)
68        });
69        let matcher_entry = match matcher_idx {
70            Some(idx) => &mut event_array[idx],
71            None => event_array.push_mut(json!({ "matcher": matcher, "hooks": [] })),
72        };
73
74        let hooks_inside = matcher_entry
75            .get_mut("hooks")
76            .and_then(Value::as_array_mut)?;
77        hooks_inside.retain(|entry| !profile.claude_entry_is_managed_or_legacy(entry));
78        hooks_inside.push(json!({
79            "type": "command",
80            "command": profile.claude_command(hook_arg),
81        }));
82    }
83
84    Some(settings)
85}
86
87fn scrub_retired_task_completed_event(
88    hooks_obj: &mut serde_json::Map<String, Value>,
89    profile: &LifecycleProfile,
90) {
91    let Some(task_completed) = hooks_obj.get_mut("TaskCompleted") else {
92        return;
93    };
94    let Some(event_array) = task_completed.as_array_mut() else {
95        return;
96    };
97
98    event_array.retain_mut(|entry| {
99        let Some(hooks_inside) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
100            return true;
101        };
102        hooks_inside.retain(|hook| !profile.claude_entry_is_managed_or_legacy(hook));
103        !hooks_inside.is_empty()
104    });
105
106    if event_array.is_empty() {
107        hooks_obj.remove("TaskCompleted");
108    }
109}
110
111/// Merge an existing serialized `.claude/settings.json` body into the
112/// CCD-managed shape (default [`CCD_COMPAT_PROFILE`]). See
113/// [`merge_claude_settings_text_with_profile`] for the contract.
114pub fn merge_claude_settings_text(
115    existing: Option<&str>,
116    force: bool,
117) -> Result<Option<MergedFile>, HostAssetError> {
118    merge_claude_settings_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
119}
120
121/// Merge an existing serialized `.claude/settings.json` body into
122/// `profile`'s managed shape. Returns:
123/// * `Ok(Some(merged))` on success;
124/// * `Ok(None)` when the existing body is malformed and `force` was
125///   not requested (caller should warn and skip);
126/// * `Err(_)` for parse errors that `force` cannot recover from
127///   without reset.
128///
129/// When `force` is true, parse errors and shape mismatches fall back
130/// to a fresh merge against an empty object.
131pub fn merge_claude_settings_text_with_profile(
132    existing: Option<&str>,
133    force: bool,
134    profile: &LifecycleProfile,
135) -> Result<Option<MergedFile>, HostAssetError> {
136    let parsed = match existing {
137        None => Value::Object(Default::default()),
138        Some(body) => match serde_json::from_str::<Value>(body) {
139            Ok(v) => v,
140            Err(_) if force => Value::Object(Default::default()),
141            Err(_) => return Ok(None),
142        },
143    };
144
145    let root = if parsed.is_object() {
146        parsed
147    } else if force {
148        Value::Object(Default::default())
149    } else {
150        return Ok(None);
151    };
152
153    let merged = match merge_claude_settings_with_profile(root, profile) {
154        Some(v) => v,
155        None if force => {
156            merge_claude_settings_with_profile(Value::Object(Default::default()), profile)
157                .expect("empty object is always a valid base")
158        }
159        None => return Ok(None),
160    };
161    let rendered =
162        serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
163            reason: err.to_string(),
164        })?;
165    Ok(Some(MergedFile {
166        existing: existing.map(str::to_owned),
167        rendered,
168    }))
169}
170
171// ============================================================================
172// Merge: .codex/hooks.json
173// ============================================================================
174
175/// Additive merge of CCD-managed lifecycle hooks into a Codex
176/// `hooks.json` value (default [`CCD_COMPAT_PROFILE`]). See
177/// [`merge_codex_hooks_with_profile`] for the algorithm.
178pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
179    merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
180}
181
182/// Additive merge of `profile`'s managed lifecycle hooks into a Codex
183/// `hooks.json` value. See [`merge_claude_settings_with_profile`] for
184/// the algorithm; the only differences are the managed event table
185/// and the per-entry `timeout`/`statusMessage` fields Codex carries.
186pub fn merge_codex_hooks_with_profile(
187    mut hooks_doc: Value,
188    profile: &LifecycleProfile,
189) -> Option<Value> {
190    profile.validate().ok()?;
191    let hooks_entry = hooks_doc
192        .as_object_mut()?
193        .entry("hooks")
194        .or_insert_with(|| Value::Object(Default::default()));
195    let hooks_obj = hooks_entry.as_object_mut()?;
196
197    for (event, hook_arg, matcher, status_message) in profile.codex_managed_events {
198        let event_entry = hooks_obj
199            .entry(*event)
200            .or_insert_with(|| Value::Array(Vec::new()));
201        let event_array = event_entry.as_array_mut()?;
202
203        let matcher_idx = event_array.iter().position(|entry| {
204            entry
205                .get("matcher")
206                .and_then(Value::as_str)
207                .map(|value| value == *matcher)
208                .unwrap_or(false)
209        });
210        let matcher_entry = match matcher_idx {
211            Some(idx) => &mut event_array[idx],
212            None => event_array.push_mut(json!({ "matcher": matcher, "hooks": [] })),
213        };
214
215        let hooks_inside = matcher_entry
216            .get_mut("hooks")
217            .and_then(Value::as_array_mut)?;
218        hooks_inside.retain(|entry| !profile.codex_entry_is_managed(entry));
219        hooks_inside.push(json!({
220            "type": "command",
221            "command": profile.codex_command(hook_arg),
222            "timeout": 30,
223            "statusMessage": status_message,
224        }));
225    }
226
227    Some(hooks_doc)
228}
229
230/// True when `hooks_doc` already contains the full CCD-managed Codex
231/// lifecycle hook set (default [`CCD_COMPAT_PROFILE`]). Used by status
232/// reporting to detect "Codex hooks installed externally" vs.
233/// "needs apply".
234pub fn codex_hooks_contain_managed_lifecycle(hooks_doc: &Value) -> bool {
235    codex_hooks_contain_managed_lifecycle_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
236}
237
238/// True when `hooks_doc` already contains the full set of `profile`'s
239/// managed Codex lifecycle hooks.
240pub fn codex_hooks_contain_managed_lifecycle_with_profile(
241    hooks_doc: &Value,
242    profile: &LifecycleProfile,
243) -> bool {
244    if profile.validate().is_err() {
245        return false;
246    }
247    profile
248        .codex_managed_events
249        .iter()
250        .all(|(event, hook_arg, _, _)| {
251            codex_event_contains_managed_hook(hooks_doc, event, hook_arg, profile)
252        })
253}
254
255fn codex_event_contains_managed_hook(
256    hooks_doc: &Value,
257    event: &str,
258    hook_arg: &str,
259    profile: &LifecycleProfile,
260) -> bool {
261    let expected = profile.codex_command(hook_arg);
262    hooks_doc
263        .get("hooks")
264        .and_then(|hooks| hooks.get(event))
265        .and_then(Value::as_array)
266        .into_iter()
267        .flatten()
268        .filter_map(|matcher| matcher.get("hooks").and_then(Value::as_array))
269        .flatten()
270        .any(|hook| {
271            hook.get("command")
272                .and_then(Value::as_str)
273                .map(|command| {
274                    command == expected
275                        || (!profile.codex_command_prefix.is_empty()
276                            && command.starts_with(profile.codex_command_prefix)
277                            && command.contains(hook_arg))
278                })
279                .unwrap_or(false)
280        })
281}
282
283/// Merge an existing serialized `.codex/hooks.json` body using the
284/// default [`CCD_COMPAT_PROFILE`]. See
285/// [`merge_codex_hooks_text_with_profile`] for the contract.
286pub fn merge_codex_hooks_text(
287    existing: Option<&str>,
288    force: bool,
289) -> Result<Option<MergedFile>, HostAssetError> {
290    merge_codex_hooks_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
291}
292
293/// Merge an existing serialized `.codex/hooks.json` body using
294/// `profile`. Returns `Ok(None)` when the existing body is invalid
295/// JSON or has an incompatible top-level shape; `_force` is reserved
296/// for symmetry with [`merge_claude_settings_text_with_profile`] and
297/// currently behaves the same regardless of value (the previous CCD
298/// implementation never branched on it).
299pub fn merge_codex_hooks_text_with_profile(
300    existing: Option<&str>,
301    _force: bool,
302    profile: &LifecycleProfile,
303) -> Result<Option<MergedFile>, HostAssetError> {
304    let parsed = match existing {
305        None => Value::Object(Default::default()),
306        Some(body) => match serde_json::from_str::<Value>(body) {
307            Ok(value) => value,
308            Err(_) => return Ok(None),
309        },
310    };
311
312    let root = if parsed.is_object() {
313        parsed
314    } else {
315        return Ok(None);
316    };
317
318    let merged = match merge_codex_hooks_with_profile(root, profile) {
319        Some(value) => value,
320        None => return Ok(None),
321    };
322    let rendered =
323        serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
324            reason: err.to_string(),
325        })?;
326    Ok(Some(MergedFile {
327        existing: existing.map(str::to_owned),
328        rendered,
329    }))
330}
331
332// ============================================================================
333// Merge: .codex/config.toml
334// ============================================================================
335
336/// Merge `[features].hooks = true` into an existing Codex
337/// `config.toml`, preserving every other key.
338pub fn merge_codex_config_text(existing: Option<&str>) -> Result<MergedFile, HostAssetError> {
339    let mut root = match existing {
340        None => toml::Table::new(),
341        Some(raw) if raw.trim().is_empty() => toml::Table::new(),
342        Some(raw) => match raw.parse::<toml::Value>() {
343            Ok(value) => value
344                .as_table()
345                .cloned()
346                .ok_or_else(|| HostAssetError::Malformed {
347                    reason: "codex config.toml must be a TOML table".into(),
348                })?,
349            Err(err) => {
350                return Err(HostAssetError::Parse {
351                    reason: err.to_string(),
352                });
353            }
354        },
355    };
356
357    let features_entry = root
358        .entry("features".to_owned())
359        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
360    let features = features_entry
361        .as_table_mut()
362        .ok_or_else(|| HostAssetError::Malformed {
363            reason: "[features] must be a TOML table".into(),
364        })?;
365    features.insert("hooks".to_owned(), toml::Value::Boolean(true));
366
367    let rendered = toml::to_string_pretty(&root).map_err(|err| HostAssetError::Serialize {
368        reason: err.to_string(),
369    })?;
370    Ok(MergedFile {
371        existing: existing.map(str::to_owned),
372        rendered,
373    })
374}
375
376/// True when the parsed TOML enables `[features].hooks`.
377pub fn codex_hooks_feature_is_enabled(config: &toml::Value) -> bool {
378    config
379        .get("features")
380        .and_then(|features| features.get("hooks"))
381        .and_then(toml::Value::as_bool)
382        == Some(true)
383}