1use serde_json::{Value, json};
4
5use super::model::{HostAssetError, MergedFile};
6use super::profiles::{CCD_COMPAT_PROFILE, LifecycleProfile};
7
8pub fn merge_claude_settings(settings: Value) -> Option<Value> {
19 merge_claude_settings_with_profile(settings, &CCD_COMPAT_PROFILE)
20}
21
22pub 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
111pub 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
121pub 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
171pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
179 merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
180}
181
182pub 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
230pub 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
238pub 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
283pub 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
293pub 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
332pub 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
376pub 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}