Skip to main content

lifeloop/host_assets/
model.rs

1//! Host adapter, asset descriptor, status, and error model.
2
3// ============================================================================
4// Adapters and modes
5// ============================================================================
6
7/// Host adapters Lifeloop ships asset rendering and merge support for.
8///
9/// The on-the-wire identifier (`as_str()`) is the same string an
10/// `AdapterManifest::adapter_id` would carry for the same harness; issue
11/// #6 lands the full manifest registry that consumes these ids.
12#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
13pub enum HostAdapter {
14    Claude,
15    Codex,
16    Hermes,
17    OpenClaw,
18}
19
20impl HostAdapter {
21    pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex, Self::Hermes, Self::OpenClaw];
22
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::Claude => "claude",
26            Self::Codex => "codex",
27            Self::Hermes => "hermes",
28            Self::OpenClaw => "openclaw",
29        }
30    }
31
32    /// Recognizes both canonical ids and the historical CCD aliases (e.g.
33    /// `claude-code`). Returns `None` for unknown names.
34    pub fn from_id(name: &str) -> Option<Self> {
35        match name {
36            "claude" | "claude-code" => Some(Self::Claude),
37            "codex" => Some(Self::Codex),
38            "hermes" => Some(Self::Hermes),
39            "openclaw" => Some(Self::OpenClaw),
40            _ => None,
41        }
42    }
43
44    /// Default integration mode when the host has no explicit declaration.
45    pub fn default_mode(self) -> IntegrationMode {
46        match self {
47            Self::Claude => IntegrationMode::NativeHook,
48            Self::Codex => IntegrationMode::ManualSkill,
49            Self::Hermes | Self::OpenClaw => IntegrationMode::ReferenceAdapter,
50        }
51    }
52}
53
54/// Integration modes supported by host asset rendering.
55///
56/// Mirrors the subset of [`crate::IntegrationMode`] that maps to actual
57/// installable assets. `TelemetryOnly` does not produce assets and is
58/// therefore not represented here; callers that have a full
59/// `IntegrationMode` can convert with [`Self::from_lifecycle_mode`].
60#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
61pub enum IntegrationMode {
62    ManualSkill,
63    LauncherWrapper,
64    NativeHook,
65    ReferenceAdapter,
66}
67
68impl IntegrationMode {
69    pub const ALL: &'static [Self] = &[
70        Self::ManualSkill,
71        Self::LauncherWrapper,
72        Self::NativeHook,
73        Self::ReferenceAdapter,
74    ];
75
76    pub fn as_str(self) -> &'static str {
77        match self {
78            Self::ManualSkill => "manual_skill",
79            Self::LauncherWrapper => "launcher_wrapper",
80            Self::NativeHook => "native_hook",
81            Self::ReferenceAdapter => "reference_adapter",
82        }
83    }
84
85    pub fn from_id(value: &str) -> Option<Self> {
86        match value {
87            "manual_skill" => Some(Self::ManualSkill),
88            "launcher_wrapper" => Some(Self::LauncherWrapper),
89            "native_hook" => Some(Self::NativeHook),
90            "reference_adapter" => Some(Self::ReferenceAdapter),
91            _ => None,
92        }
93    }
94
95    /// Convert from the broader [`crate::IntegrationMode`]. Returns `None`
96    /// for `TelemetryOnly`, which has no asset surface.
97    pub fn from_lifecycle_mode(mode: crate::IntegrationMode) -> Option<Self> {
98        match mode {
99            crate::IntegrationMode::ManualSkill => Some(Self::ManualSkill),
100            crate::IntegrationMode::LauncherWrapper => Some(Self::LauncherWrapper),
101            crate::IntegrationMode::NativeHook => Some(Self::NativeHook),
102            crate::IntegrationMode::ReferenceAdapter => Some(Self::ReferenceAdapter),
103            crate::IntegrationMode::TelemetryOnly => None,
104        }
105    }
106}
107
108/// Whether `host` supports the requested install mode. Gates `apply` so a
109/// caller can refuse before mutating files.
110pub fn supports_mode(host: HostAdapter, mode: IntegrationMode) -> bool {
111    match host {
112        HostAdapter::Claude => mode == IntegrationMode::NativeHook,
113        HostAdapter::Codex => matches!(
114            mode,
115            IntegrationMode::ManualSkill
116                | IntegrationMode::LauncherWrapper
117                | IntegrationMode::NativeHook
118        ),
119        HostAdapter::Hermes | HostAdapter::OpenClaw => mode == IntegrationMode::ReferenceAdapter,
120    }
121}
122
123/// Modes the adapter accepts, in declaration order, for diagnostic
124/// messaging. Order is informational.
125pub fn supported_modes(host: HostAdapter) -> &'static [IntegrationMode] {
126    match host {
127        HostAdapter::Claude => &[IntegrationMode::NativeHook],
128        HostAdapter::Codex => &[
129            IntegrationMode::ManualSkill,
130            IntegrationMode::LauncherWrapper,
131            IntegrationMode::NativeHook,
132        ],
133        HostAdapter::Hermes | HostAdapter::OpenClaw => &[IntegrationMode::ReferenceAdapter],
134    }
135}
136
137// ============================================================================
138// Asset descriptor and status
139// ============================================================================
140
141/// One file Lifeloop renders for a host adapter. `relative_path` is
142/// relative to the repository root the caller is rendering into. `mode`
143/// is a Unix permission bitmask when present; callers on non-Unix
144/// targets may ignore it, but cross-checking is still meaningful for
145/// status reporting.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct RenderedAsset {
148    pub relative_path: &'static str,
149    pub contents: String,
150    pub mode: Option<u32>,
151}
152
153/// Status of an installed asset relative to the rendered template.
154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
155pub enum AssetStatus {
156    /// The on-disk asset matches the rendered shape.
157    Present,
158    /// The asset path does not exist.
159    Missing,
160    /// The asset exists but its content (or permission mode) differs from
161    /// what Lifeloop would render. For merge-aware assets this means the
162    /// merge result no longer matches the file.
163    Drifted,
164    /// The (host, mode) pair is not a supported install combination.
165    InvalidMode,
166    /// No asset is expected for this (host, mode) pair.
167    NotApplicable,
168}
169
170/// Action taken for one asset during apply.
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub enum FileAction {
173    /// The asset was created.
174    Installed,
175    /// The asset existed and was rewritten with new content.
176    Updated,
177    /// The asset matched the rendered template; no write was needed.
178    AlreadyPresent,
179}
180
181/// Combine `current` with `next` so a multi-file apply reports the
182/// strongest action seen. `Updated` dominates `Installed` dominates
183/// `AlreadyPresent`.
184pub fn combine_actions(current: FileAction, next: FileAction) -> FileAction {
185    match (current, next) {
186        (FileAction::Updated, _) | (_, FileAction::Updated) => FileAction::Updated,
187        (FileAction::Installed, _) | (_, FileAction::Installed) => FileAction::Installed,
188        _ => FileAction::AlreadyPresent,
189    }
190}
191
192/// Outcome of merging managed entries into an existing settings/hooks
193/// file. `existing` is the prior file content (`None` if the file did
194/// not exist); `rendered` is the merged content the caller should
195/// write.
196#[derive(Clone, Debug, PartialEq, Eq)]
197pub struct MergedFile {
198    pub existing: Option<String>,
199    pub rendered: String,
200}
201
202/// Errors returned by merge or render entry points. Callers convert as
203/// needed; the variants are pinned so downstream tests can match.
204#[derive(Debug)]
205pub enum HostAssetError {
206    /// The (host, mode) pair is not a supported install combination.
207    UnsupportedMode {
208        host: HostAdapter,
209        mode: IntegrationMode,
210    },
211    /// The existing file's top-level shape conflicts with the merge
212    /// invariants (e.g. `hooks` is an array instead of an object).
213    Malformed { reason: String },
214    /// The existing file is invalid JSON or TOML.
215    Parse { reason: String },
216    /// Re-serialization failed (should be unreachable for well-formed
217    /// inputs; surfaced rather than panicked).
218    Serialize { reason: String },
219}
220
221impl std::fmt::Display for HostAssetError {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match self {
224            Self::UnsupportedMode { host, mode } => write!(
225                f,
226                "unsupported mode `{}` for {} (supported: {})",
227                mode.as_str(),
228                host.as_str(),
229                supported_modes(*host)
230                    .iter()
231                    .map(|m| m.as_str())
232                    .collect::<Vec<_>>()
233                    .join(", ")
234            ),
235            Self::Malformed { reason } => write!(f, "malformed managed file: {reason}"),
236            Self::Parse { reason } => write!(f, "parse error: {reason}"),
237            Self::Serialize { reason } => write!(f, "serialize error: {reason}"),
238        }
239    }
240}
241
242impl std::error::Error for HostAssetError {}
243
244pub const CLAUDE_SOURCE_SETTINGS: &str = ".ccd-hosts/claude/settings.json";
245pub const CLAUDE_TARGET_SETTINGS: &str = ".claude/settings.json";
246
247pub const CODEX_SOURCE_README: &str = ".ccd-hosts/codex/README.md";
248pub const CODEX_SOURCE_LAUNCHER: &str = ".ccd-hosts/codex/launcher.sh";
249pub const CODEX_SOURCE_CONFIG: &str = ".ccd-hosts/codex/config.toml";
250pub const CODEX_SOURCE_HOOKS: &str = ".ccd-hosts/codex/hooks.json";
251pub const CODEX_TARGET_LAUNCHER: &str = ".codex/ccd-launch.sh";
252pub const CODEX_TARGET_CONFIG: &str = ".codex/config.toml";
253pub const CODEX_TARGET_HOOKS: &str = ".codex/hooks.json";
254
255pub const OPENCLAW_SOURCE_ADAPTER: &str = ".ccd-hosts/openclaw/adapter.json";
256pub const OPENCLAW_TARGET_ADAPTER: &str = ".openclaw/ccd.json";
257
258pub const HERMES_SOURCE_ADAPTER: &str = ".ccd-hosts/hermes/adapter.json";
259pub const HERMES_TARGET_ADAPTER: &str = ".hermes/ccd.json";