Skip to main content

verbs/
integration_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure harness integration planning (no FS / env / current_exe I/O).
3//!
4//! Owns scope/path-mode parsing, harness name normalization, scope rules,
5//! command path-mode classification, and status message assembly from
6//! primitive facts. Manifest I/O, install writers, and RecoveryAdvice stay
7//! CLI-owned.
8
9/// Install / manifest scope.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum IntegrationScopeKind {
12    Repo,
13    User,
14}
15
16/// Invalid `--scope` values.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum IntegrationScopeError {
19    Invalid { value: String },
20}
21
22impl IntegrationScopeError {
23    pub fn kind(&self) -> &'static str {
24        "integration_scope_invalid"
25    }
26}
27
28/// Parse `repo` / `user` scope tokens.
29pub fn parse_scope(s: &str) -> Result<IntegrationScopeKind, IntegrationScopeError> {
30    match s {
31        "repo" => Ok(IntegrationScopeKind::Repo),
32        "user" => Ok(IntegrationScopeKind::User),
33        other => Err(IntegrationScopeError::Invalid {
34            value: other.to_string(),
35        }),
36    }
37}
38
39impl IntegrationScopeKind {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Repo => "repo",
43            Self::User => "user",
44        }
45    }
46}
47
48/// Whether installed hooks invoke `heddle` via PATH or an absolute path.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum PathModeKind {
51    #[default]
52    Relative,
53    Absolute,
54}
55
56impl PathModeKind {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::Relative => "relative",
60            Self::Absolute => "absolute",
61        }
62    }
63}
64
65/// PATH-relative heddle invocation token.
66pub fn relative_heddle_invocation() -> &'static str {
67    "heddle"
68}
69
70/// Map the CLI `--absolute-path` flag to a path mode.
71pub fn path_mode_from_absolute_flag(absolute: bool) -> PathModeKind {
72    if absolute {
73        PathModeKind::Absolute
74    } else {
75        PathModeKind::Relative
76    }
77}
78
79/// A command line is PATH-relative iff its first whitespace-delimited token
80/// (optional shell single-quotes stripped) is exactly `heddle`.
81pub fn classify_command_path_mode(cmd: &str) -> PathModeKind {
82    let first = cmd
83        .split_whitespace()
84        .next()
85        .unwrap_or("")
86        .trim_matches('\'');
87    if first == relative_heddle_invocation() {
88        PathModeKind::Relative
89    } else {
90        PathModeKind::Absolute
91    }
92}
93
94/// Probe OpenCode plugin script text for relative vs absolute Bun spawn.
95pub fn classify_opencode_plugin_path_mode(contents: &str) -> Option<PathModeKind> {
96    let relative = contents.contains("Bun.spawnSync([\"heddle\"")
97        || contents.contains("Bun.spawnSync(['heddle'")
98        || contents.contains("Bun.spawn([\"heddle\"")
99        || contents.contains("Bun.spawn(['heddle'");
100    let absolute = contents.contains("Bun.spawnSync([\"/")
101        || contents.contains("Bun.spawnSync(['/")
102        || contents.contains("Bun.spawn([\"/")
103        || contents.contains("Bun.spawn(['/");
104    if relative {
105        Some(PathModeKind::Relative)
106    } else if absolute {
107        Some(PathModeKind::Absolute)
108    } else {
109        None
110    }
111}
112
113/// Canonical harness names accepted by install/uninstall/upgrade.
114pub const SUPPORTED_HARNESSES: &[&str] = &["codex", "claude-code", "opencode"];
115
116/// Unsupported harness name.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum IntegrationHarnessError {
119    Unsupported { harness: String },
120}
121
122impl IntegrationHarnessError {
123    pub fn kind(&self) -> &'static str {
124        "integration_harness_unsupported"
125    }
126}
127
128/// Normalize a single harness token (`claude` → `claude-code`).
129pub fn normalize_harness_name(name: &str) -> Result<&'static str, IntegrationHarnessError> {
130    match name.trim() {
131        "" => Err(IntegrationHarnessError::Unsupported {
132            harness: name.to_string(),
133        }),
134        "claude" | "claude-code" => Ok("claude-code"),
135        "codex" => Ok("codex"),
136        "opencode" => Ok("opencode"),
137        other => Err(IntegrationHarnessError::Unsupported {
138            harness: other.to_string(),
139        }),
140    }
141}
142
143/// Normalize a list of harness tokens, de-duplicating in sorted order.
144pub fn normalize_harness_names(
145    harnesses: impl IntoIterator<Item = impl AsRef<str>>,
146) -> Result<Vec<String>, IntegrationHarnessError> {
147    let mut seen = std::collections::BTreeSet::new();
148    for harness in harnesses {
149        let raw = harness.as_ref();
150        if raw.trim().is_empty() {
151            continue;
152        }
153        seen.insert(normalize_harness_name(raw)?.to_string());
154    }
155    Ok(seen.into_iter().collect())
156}
157
158/// Harness rejected the chosen install scope.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum IntegrationHarnessScopeError {
161    /// Codex currently requires user scope.
162    CodexRequiresUser,
163}
164
165impl IntegrationHarnessScopeError {
166    pub fn kind(&self) -> &'static str {
167        match self {
168            Self::CodexRequiresUser => "integration_codex_scope_invalid",
169        }
170    }
171}
172
173/// Scope rule shared by preflight and install paths.
174pub fn validate_harness_scope(
175    harness: &str,
176    scope: IntegrationScopeKind,
177) -> Result<(), IntegrationHarnessScopeError> {
178    match harness {
179        "codex" if scope != IntegrationScopeKind::User => {
180            Err(IntegrationHarnessScopeError::CodexRequiresUser)
181        }
182        _ => Ok(()),
183    }
184}
185
186/// Validate every harness against a parsed scope.
187pub fn validate_install_plan(
188    harnesses: &[String],
189    scope: IntegrationScopeKind,
190) -> Result<(), IntegrationHarnessScopeError> {
191    for harness in harnesses {
192        validate_harness_scope(harness, scope)?;
193    }
194    Ok(())
195}
196
197/// Pure selection parse before auto-detection / FS.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum HarnessSelectionPlan {
200    /// Explicit empty install (`none`).
201    None,
202    /// Detect from environment / tree (`auto`).
203    Auto,
204    /// Explicit harness list.
205    Explicit(Vec<String>),
206}
207
208/// Parse `--install-harnesses` selection without PATH/directory probes.
209pub fn plan_harness_selection(
210    selection: &str,
211) -> Result<HarnessSelectionPlan, IntegrationHarnessError> {
212    match selection {
213        "none" => Ok(HarnessSelectionPlan::None),
214        "auto" => Ok(HarnessSelectionPlan::Auto),
215        value => Ok(HarnessSelectionPlan::Explicit(normalize_harness_names(
216            value.split(',').map(|item| item.to_string()),
217        )?)),
218    }
219}
220
221/// Whether a claude settings body still contains a Heddle hook marker.
222pub fn claude_settings_has_relay(contents: &str) -> bool {
223    contents.contains("heddle integration relay claude-code")
224        || contents.contains("integration stamp claude-code")
225}
226
227/// Whether a codex config body still contains a Heddle hook marker.
228pub fn codex_config_has_relay(contents: &str) -> bool {
229    contents.contains("integration stamp codex") || contents.contains("integration relay codex")
230}
231
232/// Timeline capability path filter for opencode installs.
233pub fn is_timeline_capability_path(path: &str) -> bool {
234    path.ends_with("heddle.timeline.json")
235}
236
237/// Capability list for an installed integration from pure facts.
238pub fn integration_capabilities(harness: &str, has_timeline_paths: bool) -> Vec<String> {
239    if harness == "opencode" && has_timeline_paths {
240        vec!["timeline".to_string()]
241    } else {
242        Vec::new()
243    }
244}
245
246/// Human list/doctor empty state.
247pub fn empty_integrations_message() -> &'static str {
248    "No Heddle-managed harness integrations."
249}
250
251/// Install success message.
252pub fn installed_message(harnesses: &[String]) -> String {
253    format!(
254        "Installed Heddle harness integrations for: {}",
255        harnesses.join(", ")
256    )
257}
258
259/// Uninstall success message.
260pub fn uninstalled_message(harnesses: &[String]) -> String {
261    format!(
262        "Uninstalled Heddle harness integrations for: {}",
263        harnesses.join(", ")
264    )
265}
266
267/// Upgrade success message.
268pub fn upgraded_message(harnesses: &[String]) -> String {
269    format!(
270        "Upgraded Heddle harness integrations for: {}",
271        harnesses.join(", ")
272    )
273}
274
275/// One list-mode status line body.
276pub fn list_status_line(harness: &str, scope: &str, status: &str, method: &str) -> String {
277    format!("{harness} [{scope}] {status} ({method})")
278}
279
280/// One doctor-mode status line body.
281pub fn doctor_status_line(
282    harness: &str,
283    scope: &str,
284    path_mode: &str,
285    healthy: bool,
286    status: &str,
287) -> String {
288    let health = if healthy { "healthy" } else { status };
289    format!("{harness} [{scope}] (path: {path_mode}): {health}")
290}
291
292/// Health status token when a managed path is missing.
293pub fn missing_status_token() -> &'static str {
294    "missing"
295}
296
297/// Health status token when config drifted from Heddle markers.
298pub fn drifted_status_token() -> &'static str {
299    "drifted"
300}
301
302/// Healthy status token.
303pub fn healthy_status_token() -> &'static str {
304    "healthy"
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn scope_parse_and_labels() {
313        assert_eq!(parse_scope("repo").unwrap(), IntegrationScopeKind::Repo);
314        assert_eq!(parse_scope("user").unwrap(), IntegrationScopeKind::User);
315        assert!(matches!(
316            parse_scope("workspace"),
317            Err(IntegrationScopeError::Invalid { value }) if value == "workspace"
318        ));
319        assert_eq!(IntegrationScopeKind::Repo.as_str(), "repo");
320        assert_eq!(IntegrationScopeKind::User.as_str(), "user");
321    }
322
323    #[test]
324    fn path_mode_classification() {
325        assert_eq!(path_mode_from_absolute_flag(false), PathModeKind::Relative);
326        assert_eq!(path_mode_from_absolute_flag(true), PathModeKind::Absolute);
327        assert_eq!(relative_heddle_invocation(), "heddle");
328        assert_eq!(
329            classify_command_path_mode(
330                "heddle --repo /some/path integration relay claude-code Stop"
331            ),
332            PathModeKind::Relative
333        );
334        assert_eq!(
335            classify_command_path_mode(
336                "/Users/dev/.cargo/bin/heddle --repo /repo integration relay claude-code Stop"
337            ),
338            PathModeKind::Absolute
339        );
340        assert_eq!(
341            classify_command_path_mode(
342                "'/Users/dev/.cargo/bin/heddle' --repo /repo integration relay claude-code Stop"
343            ),
344            PathModeKind::Absolute
345        );
346        assert_eq!(
347            classify_opencode_plugin_path_mode("Bun.spawnSync([\"heddle\", '--repo']"),
348            Some(PathModeKind::Relative)
349        );
350        assert_eq!(
351            classify_opencode_plugin_path_mode("Bun.spawnSync([\"/usr/bin/heddle\", '--repo']"),
352            Some(PathModeKind::Absolute)
353        );
354        assert_eq!(classify_opencode_plugin_path_mode("no spawn here"), None);
355    }
356
357    #[test]
358    fn harness_normalize_and_scope_rules() {
359        assert_eq!(normalize_harness_name("claude").unwrap(), "claude-code");
360        assert_eq!(normalize_harness_name("codex").unwrap(), "codex");
361        assert!(matches!(
362            normalize_harness_name("windsurf"),
363            Err(IntegrationHarnessError::Unsupported { harness }) if harness == "windsurf"
364        ));
365        let names = normalize_harness_names(["claude", "codex", "claude-code"]).unwrap();
366        assert_eq!(names, vec!["claude-code".to_string(), "codex".to_string()]);
367
368        assert!(validate_harness_scope("codex", IntegrationScopeKind::User).is_ok());
369        assert_eq!(
370            validate_harness_scope("codex", IntegrationScopeKind::Repo),
371            Err(IntegrationHarnessScopeError::CodexRequiresUser)
372        );
373        assert!(validate_harness_scope("claude-code", IntegrationScopeKind::Repo).is_ok());
374        assert!(validate_install_plan(&["codex".into()], IntegrationScopeKind::Repo).is_err());
375    }
376
377    #[test]
378    fn selection_and_messages() {
379        assert_eq!(
380            plan_harness_selection("none").unwrap(),
381            HarnessSelectionPlan::None
382        );
383        assert_eq!(
384            plan_harness_selection("auto").unwrap(),
385            HarnessSelectionPlan::Auto
386        );
387        assert_eq!(
388            plan_harness_selection("codex,claude").unwrap(),
389            HarnessSelectionPlan::Explicit(vec!["claude-code".into(), "codex".into()])
390        );
391        assert_eq!(
392            installed_message(&["codex".into()]),
393            "Installed Heddle harness integrations for: codex"
394        );
395        assert!(uninstalled_message(&["a".into()]).contains("Uninstalled"));
396        assert!(upgraded_message(&["a".into()]).contains("Upgraded"));
397        assert_eq!(
398            empty_integrations_message(),
399            "No Heddle-managed harness integrations."
400        );
401        assert_eq!(
402            list_status_line("codex", "user", "healthy", "notify"),
403            "codex [user] healthy (notify)"
404        );
405        assert_eq!(
406            doctor_status_line("codex", "user", "relative", true, "healthy"),
407            "codex [user] (path: relative): healthy"
408        );
409        assert_eq!(
410            doctor_status_line("codex", "user", "relative", false, "missing"),
411            "codex [user] (path: relative): missing"
412        );
413    }
414
415    #[test]
416    fn health_and_capability_helpers() {
417        assert!(claude_settings_has_relay(
418            "heddle integration relay claude-code SessionStart"
419        ));
420        assert!(claude_settings_has_relay("integration stamp claude-code"));
421        assert!(!claude_settings_has_relay("{}"));
422        assert!(codex_config_has_relay("integration relay codex notify"));
423        assert!(codex_config_has_relay("integration stamp codex"));
424        assert!(is_timeline_capability_path(
425            "/repo/.opencode/plugins/heddle.timeline.json"
426        ));
427        assert!(!is_timeline_capability_path("heddle.js"));
428        assert_eq!(
429            integration_capabilities("opencode", true),
430            vec!["timeline".to_string()]
431        );
432        assert!(integration_capabilities("opencode", false).is_empty());
433        assert!(integration_capabilities("codex", true).is_empty());
434    }
435}