1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum IntegrationScopeKind {
12 Repo,
13 User,
14}
15
16#[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
28pub 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#[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
65pub fn relative_heddle_invocation() -> &'static str {
67 "heddle"
68}
69
70pub fn path_mode_from_absolute_flag(absolute: bool) -> PathModeKind {
72 if absolute {
73 PathModeKind::Absolute
74 } else {
75 PathModeKind::Relative
76 }
77}
78
79pub 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
94pub 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
113pub const SUPPORTED_HARNESSES: &[&str] = &["codex", "claude-code", "opencode"];
115
116#[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
128pub 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
143pub 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#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum IntegrationHarnessScopeError {
161 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
173pub 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
186pub 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#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum HarnessSelectionPlan {
200 None,
202 Auto,
204 Explicit(Vec<String>),
206}
207
208pub 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
221pub 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
227pub fn codex_config_has_relay(contents: &str) -> bool {
229 contents.contains("integration stamp codex") || contents.contains("integration relay codex")
230}
231
232pub fn is_timeline_capability_path(path: &str) -> bool {
234 path.ends_with("heddle.timeline.json")
235}
236
237pub 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
246pub fn empty_integrations_message() -> &'static str {
248 "No Heddle-managed harness integrations."
249}
250
251pub fn installed_message(harnesses: &[String]) -> String {
253 format!(
254 "Installed Heddle harness integrations for: {}",
255 harnesses.join(", ")
256 )
257}
258
259pub fn uninstalled_message(harnesses: &[String]) -> String {
261 format!(
262 "Uninstalled Heddle harness integrations for: {}",
263 harnesses.join(", ")
264 )
265}
266
267pub fn upgraded_message(harnesses: &[String]) -> String {
269 format!(
270 "Upgraded Heddle harness integrations for: {}",
271 harnesses.join(", ")
272 )
273}
274
275pub fn list_status_line(harness: &str, scope: &str, status: &str, method: &str) -> String {
277 format!("{harness} [{scope}] {status} ({method})")
278}
279
280pub 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
292pub fn missing_status_token() -> &'static str {
294 "missing"
295}
296
297pub fn drifted_status_token() -> &'static str {
299 "drifted"
300}
301
302pub 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}