lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Installed host asset status checks.

use super::merge::{
    codex_hooks_feature_is_enabled, merge_claude_settings_text_with_profile,
    merge_codex_hooks_text_with_profile,
};
use super::model::{AssetStatus, RenderedAsset};
use super::profiles::{CCD_COMPAT_PROFILE, LifecycleProfile};
use super::{CLAUDE_TARGET_SETTINGS, CODEX_TARGET_CONFIG, CODEX_TARGET_HOOKS};

// ============================================================================
// Asset status
// ============================================================================

/// Status of an installed asset that uses byte-equal comparison (every
/// asset *except* the merge-aware Claude settings, Codex hooks, and
/// Codex config files).
///
/// `existing_content` and `existing_mode` describe the on-disk state:
/// `None` means the file does not exist. The caller is responsible for
/// reading them.
pub fn byte_equal_asset_status(
    asset: &RenderedAsset,
    existing_content: Option<&str>,
    existing_mode: Option<u32>,
) -> AssetStatus {
    let Some(content) = existing_content else {
        return AssetStatus::Missing;
    };
    if content != asset.contents {
        return AssetStatus::Drifted;
    }
    if let Some(expected) = asset.mode {
        match existing_mode {
            Some(actual) if actual == expected => {}
            _ => return AssetStatus::Drifted,
        }
    }
    AssetStatus::Present
}

/// Status of `.claude/settings.json` against the rendered template
/// for the default [`CCD_COMPAT_PROFILE`]. `existing` is the file
/// body (`None` means missing). The merge is re-run and compared
/// byte-for-byte to detect drift.
pub fn claude_settings_status(existing: Option<&str>) -> AssetStatus {
    claude_settings_status_with_profile(existing, &CCD_COMPAT_PROFILE)
}

/// Status of `.claude/settings.json` against the rendered template
/// for `profile`.
pub fn claude_settings_status_with_profile(
    existing: Option<&str>,
    profile: &LifecycleProfile,
) -> AssetStatus {
    let Some(content) = existing else {
        return AssetStatus::Missing;
    };
    match merge_claude_settings_text_with_profile(Some(content), false, profile) {
        Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
        Ok(_) | Err(_) => AssetStatus::Drifted,
    }
}

/// Status of `.codex/hooks.json` against the rendered template for
/// the default [`CCD_COMPAT_PROFILE`].
pub fn codex_hooks_status(existing: Option<&str>) -> AssetStatus {
    codex_hooks_status_with_profile(existing, &CCD_COMPAT_PROFILE)
}

/// Status of `.codex/hooks.json` against the rendered template for
/// `profile`.
pub fn codex_hooks_status_with_profile(
    existing: Option<&str>,
    profile: &LifecycleProfile,
) -> AssetStatus {
    let Some(content) = existing else {
        return AssetStatus::Missing;
    };
    match merge_codex_hooks_text_with_profile(Some(content), false, profile) {
        Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
        Ok(_) | Err(_) => AssetStatus::Drifted,
    }
}

/// Status of `.codex/config.toml`. Returns `Present` only when the
/// `[features].hooks = true` flag is set; the rest of the file
/// is ignored because users are free to add their own config.
pub fn codex_config_status(existing: Option<&str>) -> AssetStatus {
    let Some(content) = existing else {
        return AssetStatus::Missing;
    };
    match content.parse::<toml::Value>() {
        Ok(parsed) if codex_hooks_feature_is_enabled(&parsed) => AssetStatus::Present,
        Ok(_) | Err(_) => AssetStatus::Drifted,
    }
}

/// Compute status for one rendered asset by dispatching to the
/// appropriate per-path comparison. Falls back to byte-equal
/// comparison for any path that doesn't match a merge-aware target.
pub fn asset_status(
    asset: &RenderedAsset,
    existing_content: Option<&str>,
    existing_mode: Option<u32>,
) -> AssetStatus {
    match asset.relative_path {
        CLAUDE_TARGET_SETTINGS => claude_settings_status(existing_content),
        CODEX_TARGET_CONFIG => codex_config_status(existing_content),
        CODEX_TARGET_HOOKS => codex_hooks_status(existing_content),
        _ => byte_equal_asset_status(asset, existing_content, existing_mode),
    }
}

/// Combine per-asset statuses into a single bundle status. `Drifted`
/// dominates `Missing` dominates `Present`; an empty input is
/// `NotApplicable`.
pub fn aggregate_status<I: IntoIterator<Item = AssetStatus>>(statuses: I) -> AssetStatus {
    let mut saw_missing = false;
    let mut saw_drift = false;
    let mut saw_any = false;
    for s in statuses {
        saw_any = true;
        match s {
            AssetStatus::Drifted => saw_drift = true,
            AssetStatus::Missing => saw_missing = true,
            AssetStatus::InvalidMode => return AssetStatus::InvalidMode,
            AssetStatus::Present | AssetStatus::NotApplicable => {}
        }
    }
    if !saw_any {
        return AssetStatus::NotApplicable;
    }
    if saw_drift {
        AssetStatus::Drifted
    } else if saw_missing {
        AssetStatus::Missing
    } else {
        AssetStatus::Present
    }
}