Skip to main content

lifeloop/host_assets/
status.rs

1//! Installed host asset status checks.
2
3use super::merge::{
4    codex_hooks_feature_is_enabled, merge_claude_settings_text_with_profile,
5    merge_codex_hooks_text_with_profile,
6};
7use super::model::{AssetStatus, RenderedAsset};
8use super::profiles::{CCD_COMPAT_PROFILE, LifecycleProfile};
9use super::{CLAUDE_TARGET_SETTINGS, CODEX_TARGET_CONFIG, CODEX_TARGET_HOOKS};
10
11// ============================================================================
12// Asset status
13// ============================================================================
14
15/// Status of an installed asset that uses byte-equal comparison (every
16/// asset *except* the merge-aware Claude settings, Codex hooks, and
17/// Codex config files).
18///
19/// `existing_content` and `existing_mode` describe the on-disk state:
20/// `None` means the file does not exist. The caller is responsible for
21/// reading them.
22pub fn byte_equal_asset_status(
23    asset: &RenderedAsset,
24    existing_content: Option<&str>,
25    existing_mode: Option<u32>,
26) -> AssetStatus {
27    let Some(content) = existing_content else {
28        return AssetStatus::Missing;
29    };
30    if content != asset.contents {
31        return AssetStatus::Drifted;
32    }
33    if let Some(expected) = asset.mode {
34        match existing_mode {
35            Some(actual) if actual == expected => {}
36            _ => return AssetStatus::Drifted,
37        }
38    }
39    AssetStatus::Present
40}
41
42/// Status of `.claude/settings.json` against the rendered template
43/// for the default [`CCD_COMPAT_PROFILE`]. `existing` is the file
44/// body (`None` means missing). The merge is re-run and compared
45/// byte-for-byte to detect drift.
46pub fn claude_settings_status(existing: Option<&str>) -> AssetStatus {
47    claude_settings_status_with_profile(existing, &CCD_COMPAT_PROFILE)
48}
49
50/// Status of `.claude/settings.json` against the rendered template
51/// for `profile`.
52pub fn claude_settings_status_with_profile(
53    existing: Option<&str>,
54    profile: &LifecycleProfile,
55) -> AssetStatus {
56    let Some(content) = existing else {
57        return AssetStatus::Missing;
58    };
59    match merge_claude_settings_text_with_profile(Some(content), false, profile) {
60        Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
61        Ok(_) | Err(_) => AssetStatus::Drifted,
62    }
63}
64
65/// Status of `.codex/hooks.json` against the rendered template for
66/// the default [`CCD_COMPAT_PROFILE`].
67pub fn codex_hooks_status(existing: Option<&str>) -> AssetStatus {
68    codex_hooks_status_with_profile(existing, &CCD_COMPAT_PROFILE)
69}
70
71/// Status of `.codex/hooks.json` against the rendered template for
72/// `profile`.
73pub fn codex_hooks_status_with_profile(
74    existing: Option<&str>,
75    profile: &LifecycleProfile,
76) -> AssetStatus {
77    let Some(content) = existing else {
78        return AssetStatus::Missing;
79    };
80    match merge_codex_hooks_text_with_profile(Some(content), false, profile) {
81        Ok(Some(merged)) if merged.rendered == content => AssetStatus::Present,
82        Ok(_) | Err(_) => AssetStatus::Drifted,
83    }
84}
85
86/// Status of `.codex/config.toml`. Returns `Present` only when the
87/// `[features].hooks = true` flag is set; the rest of the file
88/// is ignored because users are free to add their own config.
89pub fn codex_config_status(existing: Option<&str>) -> AssetStatus {
90    let Some(content) = existing else {
91        return AssetStatus::Missing;
92    };
93    match content.parse::<toml::Value>() {
94        Ok(parsed) if codex_hooks_feature_is_enabled(&parsed) => AssetStatus::Present,
95        Ok(_) | Err(_) => AssetStatus::Drifted,
96    }
97}
98
99/// Compute status for one rendered asset by dispatching to the
100/// appropriate per-path comparison. Falls back to byte-equal
101/// comparison for any path that doesn't match a merge-aware target.
102pub fn asset_status(
103    asset: &RenderedAsset,
104    existing_content: Option<&str>,
105    existing_mode: Option<u32>,
106) -> AssetStatus {
107    match asset.relative_path {
108        CLAUDE_TARGET_SETTINGS => claude_settings_status(existing_content),
109        CODEX_TARGET_CONFIG => codex_config_status(existing_content),
110        CODEX_TARGET_HOOKS => codex_hooks_status(existing_content),
111        _ => byte_equal_asset_status(asset, existing_content, existing_mode),
112    }
113}
114
115/// Combine per-asset statuses into a single bundle status. `Drifted`
116/// dominates `Missing` dominates `Present`; an empty input is
117/// `NotApplicable`.
118pub fn aggregate_status<I: IntoIterator<Item = AssetStatus>>(statuses: I) -> AssetStatus {
119    let mut saw_missing = false;
120    let mut saw_drift = false;
121    let mut saw_any = false;
122    for s in statuses {
123        saw_any = true;
124        match s {
125            AssetStatus::Drifted => saw_drift = true,
126            AssetStatus::Missing => saw_missing = true,
127            AssetStatus::InvalidMode => return AssetStatus::InvalidMode,
128            AssetStatus::Present | AssetStatus::NotApplicable => {}
129        }
130    }
131    if !saw_any {
132        return AssetStatus::NotApplicable;
133    }
134    if saw_drift {
135        AssetStatus::Drifted
136    } else if saw_missing {
137        AssetStatus::Missing
138    } else {
139        AssetStatus::Present
140    }
141}