use serde_json::{Value, json};
use super::model::{HostAssetError, MergedFile};
use super::profiles::{CCD_COMPAT_PROFILE, LifecycleProfile};
pub fn merge_claude_settings(settings: Value) -> Option<Value> {
merge_claude_settings_with_profile(settings, &CCD_COMPAT_PROFILE)
}
pub fn merge_claude_settings_with_profile(
mut settings: Value,
profile: &LifecycleProfile,
) -> Option<Value> {
profile.validate().ok()?;
let root_obj = settings.as_object_mut()?;
let hooks_entry = root_obj
.entry("hooks")
.or_insert_with(|| Value::Object(Default::default()));
let hooks_obj = hooks_entry.as_object_mut()?;
scrub_retired_task_completed_event(hooks_obj, profile);
for (event, hook_arg, matcher) in profile.claude_managed_events {
let event_entry = hooks_obj
.entry(*event)
.or_insert_with(|| Value::Array(Vec::new()));
let event_array = event_entry.as_array_mut()?;
let matcher_idx = event_array.iter().position(|entry| {
entry
.get("matcher")
.and_then(Value::as_str)
.map(|s| s == *matcher)
.unwrap_or(false)
});
let matcher_entry = match matcher_idx {
Some(idx) => &mut event_array[idx],
None => event_array.push_mut(json!({ "matcher": matcher, "hooks": [] })),
};
let hooks_inside = matcher_entry
.get_mut("hooks")
.and_then(Value::as_array_mut)?;
hooks_inside.retain(|entry| !profile.claude_entry_is_managed_or_legacy(entry));
hooks_inside.push(json!({
"type": "command",
"command": profile.claude_command(hook_arg),
}));
}
Some(settings)
}
fn scrub_retired_task_completed_event(
hooks_obj: &mut serde_json::Map<String, Value>,
profile: &LifecycleProfile,
) {
let Some(task_completed) = hooks_obj.get_mut("TaskCompleted") else {
return;
};
let Some(event_array) = task_completed.as_array_mut() else {
return;
};
event_array.retain_mut(|entry| {
let Some(hooks_inside) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
return true;
};
hooks_inside.retain(|hook| !profile.claude_entry_is_managed_or_legacy(hook));
!hooks_inside.is_empty()
});
if event_array.is_empty() {
hooks_obj.remove("TaskCompleted");
}
}
pub fn merge_claude_settings_text(
existing: Option<&str>,
force: bool,
) -> Result<Option<MergedFile>, HostAssetError> {
merge_claude_settings_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
}
pub fn merge_claude_settings_text_with_profile(
existing: Option<&str>,
force: bool,
profile: &LifecycleProfile,
) -> Result<Option<MergedFile>, HostAssetError> {
let parsed = match existing {
None => Value::Object(Default::default()),
Some(body) => match serde_json::from_str::<Value>(body) {
Ok(v) => v,
Err(_) if force => Value::Object(Default::default()),
Err(_) => return Ok(None),
},
};
let root = if parsed.is_object() {
parsed
} else if force {
Value::Object(Default::default())
} else {
return Ok(None);
};
let merged = match merge_claude_settings_with_profile(root, profile) {
Some(v) => v,
None if force => {
merge_claude_settings_with_profile(Value::Object(Default::default()), profile)
.expect("empty object is always a valid base")
}
None => return Ok(None),
};
let rendered =
serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
reason: err.to_string(),
})?;
Ok(Some(MergedFile {
existing: existing.map(str::to_owned),
rendered,
}))
}
pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
}
pub fn merge_codex_hooks_with_profile(
mut hooks_doc: Value,
profile: &LifecycleProfile,
) -> Option<Value> {
profile.validate().ok()?;
let hooks_entry = hooks_doc
.as_object_mut()?
.entry("hooks")
.or_insert_with(|| Value::Object(Default::default()));
let hooks_obj = hooks_entry.as_object_mut()?;
for (event, hook_arg, matcher, status_message) in profile.codex_managed_events {
let event_entry = hooks_obj
.entry(*event)
.or_insert_with(|| Value::Array(Vec::new()));
let event_array = event_entry.as_array_mut()?;
let matcher_idx = event_array.iter().position(|entry| {
entry
.get("matcher")
.and_then(Value::as_str)
.map(|value| value == *matcher)
.unwrap_or(false)
});
let matcher_entry = match matcher_idx {
Some(idx) => &mut event_array[idx],
None => event_array.push_mut(json!({ "matcher": matcher, "hooks": [] })),
};
let hooks_inside = matcher_entry
.get_mut("hooks")
.and_then(Value::as_array_mut)?;
hooks_inside.retain(|entry| !profile.codex_entry_is_managed(entry));
hooks_inside.push(json!({
"type": "command",
"command": profile.codex_command(hook_arg),
"timeout": 30,
"statusMessage": status_message,
}));
}
Some(hooks_doc)
}
pub fn codex_hooks_contain_managed_lifecycle(hooks_doc: &Value) -> bool {
codex_hooks_contain_managed_lifecycle_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
}
pub fn codex_hooks_contain_managed_lifecycle_with_profile(
hooks_doc: &Value,
profile: &LifecycleProfile,
) -> bool {
if profile.validate().is_err() {
return false;
}
profile
.codex_managed_events
.iter()
.all(|(event, hook_arg, _, _)| {
codex_event_contains_managed_hook(hooks_doc, event, hook_arg, profile)
})
}
fn codex_event_contains_managed_hook(
hooks_doc: &Value,
event: &str,
hook_arg: &str,
profile: &LifecycleProfile,
) -> bool {
let expected = profile.codex_command(hook_arg);
hooks_doc
.get("hooks")
.and_then(|hooks| hooks.get(event))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|matcher| matcher.get("hooks").and_then(Value::as_array))
.flatten()
.any(|hook| {
hook.get("command")
.and_then(Value::as_str)
.map(|command| {
command == expected
|| (!profile.codex_command_prefix.is_empty()
&& command.starts_with(profile.codex_command_prefix)
&& command.contains(hook_arg))
})
.unwrap_or(false)
})
}
pub fn merge_codex_hooks_text(
existing: Option<&str>,
force: bool,
) -> Result<Option<MergedFile>, HostAssetError> {
merge_codex_hooks_text_with_profile(existing, force, &CCD_COMPAT_PROFILE)
}
pub fn merge_codex_hooks_text_with_profile(
existing: Option<&str>,
_force: bool,
profile: &LifecycleProfile,
) -> Result<Option<MergedFile>, HostAssetError> {
let parsed = match existing {
None => Value::Object(Default::default()),
Some(body) => match serde_json::from_str::<Value>(body) {
Ok(value) => value,
Err(_) => return Ok(None),
},
};
let root = if parsed.is_object() {
parsed
} else {
return Ok(None);
};
let merged = match merge_codex_hooks_with_profile(root, profile) {
Some(value) => value,
None => return Ok(None),
};
let rendered =
serde_json::to_string_pretty(&merged).map_err(|err| HostAssetError::Serialize {
reason: err.to_string(),
})?;
Ok(Some(MergedFile {
existing: existing.map(str::to_owned),
rendered,
}))
}
pub fn merge_codex_config_text(existing: Option<&str>) -> Result<MergedFile, HostAssetError> {
let mut root = match existing {
None => toml::Table::new(),
Some(raw) if raw.trim().is_empty() => toml::Table::new(),
Some(raw) => match raw.parse::<toml::Value>() {
Ok(value) => value
.as_table()
.cloned()
.ok_or_else(|| HostAssetError::Malformed {
reason: "codex config.toml must be a TOML table".into(),
})?,
Err(err) => {
return Err(HostAssetError::Parse {
reason: err.to_string(),
});
}
},
};
let features_entry = root
.entry("features".to_owned())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let features = features_entry
.as_table_mut()
.ok_or_else(|| HostAssetError::Malformed {
reason: "[features] must be a TOML table".into(),
})?;
features.insert("hooks".to_owned(), toml::Value::Boolean(true));
let rendered = toml::to_string_pretty(&root).map_err(|err| HostAssetError::Serialize {
reason: err.to_string(),
})?;
Ok(MergedFile {
existing: existing.map(str::to_owned),
rendered,
})
}
pub fn codex_hooks_feature_is_enabled(config: &toml::Value) -> bool {
config
.get("features")
.and_then(|features| features.get("hooks"))
.and_then(toml::Value::as_bool)
== Some(true)
}