lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Merge behavior for host-owned configuration files.

use serde_json::{Value, json};

use super::model::{HostAssetError, MergedFile};
use super::profiles::{CCD_COMPAT_PROFILE, LifecycleProfile};

// ============================================================================
// Merge: .claude/settings.json
// ============================================================================

/// Additive merge of CCD-managed lifecycle hooks into a Claude
/// `settings.json` value, using the default [`CCD_COMPAT_PROFILE`].
///
/// See [`merge_claude_settings_with_profile`] for the algorithm. This
/// is the back-compat entry point preserved for callers that pre-date
/// the profile abstraction.
pub fn merge_claude_settings(settings: Value) -> Option<Value> {
    merge_claude_settings_with_profile(settings, &CCD_COMPAT_PROFILE)
}

/// Additive merge of `profile`'s managed lifecycle hooks into a Claude
/// `settings.json` value.
///
/// Algorithm:
/// 1. Ensure a top-level `hooks` object. Preserve every other top-level
///    key.
/// 2. For each managed event in `profile.claude_managed_events`,
///    ensure a matcher entry exists. Within its `hooks` array, drop
///    any entry whose `command` is recognized as a stale managed
///    entry for `profile` (current prefix or any of
///    `profile.claude_legacy_substrings`), then append the current
///    managed entry rendered through `profile.claude_command`.
/// 3. Non-managed entries within the same matcher are preserved in
///    original order.
/// 4. The retired `TaskCompleted` event is removed when its only
///    entries are managed by `profile`; user-owned `TaskCompleted`
///    hooks are preserved.
///
/// Returns `None` when the existing `hooks` shape is incompatible:
/// `hooks` is not an object, or a managed event's value is not an array,
/// or a matcher's inner `hooks` is not an array. Callers treat `None` as
/// malformed input.
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");
    }
}

/// Merge an existing serialized `.claude/settings.json` body into the
/// CCD-managed shape (default [`CCD_COMPAT_PROFILE`]). See
/// [`merge_claude_settings_text_with_profile`] for the contract.
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)
}

/// Merge an existing serialized `.claude/settings.json` body into
/// `profile`'s managed shape. Returns:
/// * `Ok(Some(merged))` on success;
/// * `Ok(None)` when the existing body is malformed and `force` was
///   not requested (caller should warn and skip);
/// * `Err(_)` for parse errors that `force` cannot recover from
///   without reset.
///
/// When `force` is true, parse errors and shape mismatches fall back
/// to a fresh merge against an empty object.
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,
    }))
}

// ============================================================================
// Merge: .codex/hooks.json
// ============================================================================

/// Additive merge of CCD-managed lifecycle hooks into a Codex
/// `hooks.json` value (default [`CCD_COMPAT_PROFILE`]). See
/// [`merge_codex_hooks_with_profile`] for the algorithm.
pub fn merge_codex_hooks(hooks_doc: Value) -> Option<Value> {
    merge_codex_hooks_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
}

/// Additive merge of `profile`'s managed lifecycle hooks into a Codex
/// `hooks.json` value. See [`merge_claude_settings_with_profile`] for
/// the algorithm; the only differences are the managed event table
/// and the per-entry `timeout`/`statusMessage` fields Codex carries.
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)
}

/// True when `hooks_doc` already contains the full CCD-managed Codex
/// lifecycle hook set (default [`CCD_COMPAT_PROFILE`]). Used by status
/// reporting to detect "Codex hooks installed externally" vs.
/// "needs apply".
pub fn codex_hooks_contain_managed_lifecycle(hooks_doc: &Value) -> bool {
    codex_hooks_contain_managed_lifecycle_with_profile(hooks_doc, &CCD_COMPAT_PROFILE)
}

/// True when `hooks_doc` already contains the full set of `profile`'s
/// managed Codex lifecycle hooks.
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)
        })
}

/// Merge an existing serialized `.codex/hooks.json` body using the
/// default [`CCD_COMPAT_PROFILE`]. See
/// [`merge_codex_hooks_text_with_profile`] for the contract.
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)
}

/// Merge an existing serialized `.codex/hooks.json` body using
/// `profile`. Returns `Ok(None)` when the existing body is invalid
/// JSON or has an incompatible top-level shape; `_force` is reserved
/// for symmetry with [`merge_claude_settings_text_with_profile`] and
/// currently behaves the same regardless of value (the previous CCD
/// implementation never branched on it).
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,
    }))
}

// ============================================================================
// Merge: .codex/config.toml
// ============================================================================

/// Merge `[features].hooks = true` into an existing Codex
/// `config.toml`, preserving every other key.
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,
    })
}

/// True when the parsed TOML enables `[features].hooks`.
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)
}