harness_locate/types.rs
1//! Core type definitions for harness path resolution.
2
3use std::fmt;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8/// Supported AI coding harnesses.
9///
10/// This enum represents the different AI coding assistants whose
11/// configuration paths can be discovered.
12///
13/// # Extensibility
14///
15/// This enum is marked `#[non_exhaustive]` to allow adding new
16/// harness types in future versions without breaking changes.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum HarnessKind {
20 /// Claude Code (Anthropic's CLI)
21 ClaudeCode,
22 /// OpenCode
23 OpenCode,
24 /// Goose (Block's AI coding assistant)
25 Goose,
26 /// AMP Code (Sourcegraph's AI coding assistant)
27 AmpCode,
28 /// GitHub Copilot CLI (@github/copilot npm package)
29 CopilotCli,
30 /// Crush (Charmbracelet's AI coding assistant)
31 Crush,
32 /// Factory Droid (Factory's AI coding assistant)
33 Droid,
34}
35
36impl fmt::Display for HarnessKind {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 Self::ClaudeCode => write!(f, "Claude Code"),
40 Self::OpenCode => write!(f, "OpenCode"),
41 Self::Goose => write!(f, "Goose"),
42 Self::AmpCode => write!(f, "AMP Code"),
43 Self::CopilotCli => write!(f, "Copilot CLI"),
44 Self::Crush => write!(f, "Crush"),
45 Self::Droid => write!(f, "Droid"),
46 }
47 }
48}
49
50impl HarnessKind {
51 #[must_use]
52 pub const fn as_str(&self) -> &'static str {
53 match self {
54 Self::ClaudeCode => "Claude Code",
55 Self::OpenCode => "OpenCode",
56 Self::Goose => "Goose",
57 Self::AmpCode => "AMP Code",
58 Self::CopilotCli => "Copilot CLI",
59 Self::Crush => "Crush",
60 Self::Droid => "Droid",
61 }
62 }
63
64 /// All supported harness kinds.
65 ///
66 /// Useful for iterating over all harnesses to check installation status
67 /// or enumerate capabilities.
68 ///
69 /// # Examples
70 ///
71 /// ```
72 /// use harness_locate::types::HarnessKind;
73 ///
74 /// for kind in HarnessKind::ALL {
75 /// println!("{}", kind);
76 /// }
77 /// ```
78 pub const ALL: &'static [Self] = &[
79 Self::ClaudeCode,
80 Self::OpenCode,
81 Self::Goose,
82 Self::AmpCode,
83 Self::CopilotCli,
84 Self::Crush,
85 Self::Droid,
86 ];
87
88 /// Returns the known CLI binary names for this harness.
89 ///
90 /// These are the executable names that indicate the harness is installed
91 /// and available in PATH.
92 ///
93 /// # Examples
94 ///
95 /// ```
96 /// use harness_locate::HarnessKind;
97 ///
98 /// assert_eq!(HarnessKind::ClaudeCode.binary_names(), &["claude"]);
99 /// assert_eq!(HarnessKind::OpenCode.binary_names(), &["opencode"]);
100 /// assert_eq!(HarnessKind::Goose.binary_names(), &["goose"]);
101 /// ```
102 #[must_use]
103 pub fn binary_names(&self) -> &'static [&'static str] {
104 match self {
105 Self::ClaudeCode => &["claude"],
106 Self::OpenCode => &["opencode"],
107 Self::Goose => &["goose"],
108 Self::AmpCode => &["amp"],
109 Self::CopilotCli => &["copilot"],
110 Self::Crush => &["crush"],
111 Self::Droid => &["droid"],
112 }
113 }
114
115 /// Returns the expected directory name(s) for a resource kind.
116 ///
117 /// Different harnesses use different naming conventions:
118 /// - OpenCode uses singular names (`skill`, `command`)
119 /// - Other harnesses use plural names (`skills`, `commands`)
120 ///
121 /// Returns `None` if the harness doesn't support that resource type.
122 /// When multiple names are returned, index 0 is the canonical name.
123 ///
124 /// # Examples
125 ///
126 /// ```
127 /// use harness_locate::{HarnessKind, ResourceKind};
128 ///
129 /// // OpenCode uses singular
130 /// assert_eq!(
131 /// HarnessKind::OpenCode.directory_names(ResourceKind::Skills),
132 /// Some(&["skill"][..])
133 /// );
134 ///
135 /// // Claude Code uses plural
136 /// assert_eq!(
137 /// HarnessKind::ClaudeCode.directory_names(ResourceKind::Skills),
138 /// Some(&["skills"][..])
139 /// );
140 ///
141 /// // Goose doesn't support commands
142 /// assert_eq!(
143 /// HarnessKind::Goose.directory_names(ResourceKind::Commands),
144 /// None
145 /// );
146 /// ```
147 #[must_use]
148 pub const fn directory_names(self, resource: ResourceKind) -> Option<&'static [&'static str]> {
149 match (self, resource) {
150 // OpenCode - singular names
151 (Self::OpenCode, ResourceKind::Skills) => Some(&["skill"]),
152 (Self::OpenCode, ResourceKind::Commands) => Some(&["command"]),
153 (Self::OpenCode, ResourceKind::Agents) => Some(&["agent"]),
154 (Self::OpenCode, ResourceKind::Plugins) => Some(&["plugin"]),
155
156 // Claude Code - plural names
157 (Self::ClaudeCode, ResourceKind::Skills) => Some(&["skills"]),
158 (Self::ClaudeCode, ResourceKind::Commands) => Some(&["commands"]),
159 (Self::ClaudeCode, ResourceKind::Agents) => Some(&["agents"]),
160 (Self::ClaudeCode, ResourceKind::Plugins) => Some(&["plugins"]),
161
162 // Goose - limited support (skills only)
163 (Self::Goose, ResourceKind::Skills) => Some(&["skills"]),
164
165 // AmpCode - plural names, limited support
166 (Self::AmpCode, ResourceKind::Skills) => Some(&["skills"]),
167 (Self::AmpCode, ResourceKind::Commands) => Some(&["commands"]),
168
169 // Copilot CLI - plural names, skills and agents only
170 (Self::CopilotCli, ResourceKind::Skills) => Some(&["skills"]),
171 (Self::CopilotCli, ResourceKind::Agents) => Some(&["agents"]),
172
173 // Crush - skills only (like Goose)
174 (Self::Crush, ResourceKind::Skills) => Some(&["skills"]),
175
176 // Droid - plural names (like Claude Code)
177 (Self::Droid, ResourceKind::Skills) => Some(&["skills"]),
178 (Self::Droid, ResourceKind::Commands) => Some(&["commands"]),
179 (Self::Droid, ResourceKind::Agents) => Some(&["droids"]),
180
181 // Unsupported combinations
182 _ => None,
183 }
184 }
185}
186
187/// Scope for path resolution.
188///
189/// Determines whether to look up global (user-level) or
190/// project-local configuration paths.
191#[derive(Debug, Clone)]
192pub enum Scope {
193 /// User-level global configuration (e.g., `~/.config/...`)
194 Global,
195 /// Project-local configuration (e.g., `.claude/` in project root)
196 Project(PathBuf),
197 /// Custom path for profile-scoped resources (inherits harness directory structure)
198 Custom(PathBuf),
199}
200
201/// Installation status of a harness on the current system.
202///
203/// Represents the different states a harness can be in, from not installed
204/// to fully configured with both binary and configuration present.
205///
206/// # Extensibility
207///
208/// This enum is marked `#[non_exhaustive]` to allow adding new
209/// status variants in future versions without breaking changes.
210#[derive(Debug, Clone, PartialEq, Eq)]
211#[non_exhaustive]
212pub enum InstallationStatus {
213 /// Harness is not installed (no binary or config found).
214 NotInstalled,
215 /// Only configuration directory exists (no binary in PATH).
216 ConfigOnly {
217 /// Path to the configuration directory.
218 config_path: PathBuf,
219 },
220 /// Only the binary exists in PATH (no configuration found).
221 BinaryOnly {
222 /// Path to the binary executable.
223 binary_path: PathBuf,
224 },
225 /// Fully installed with both binary and configuration.
226 FullyInstalled {
227 /// Path to the binary executable.
228 binary_path: PathBuf,
229 /// Path to the configuration directory.
230 config_path: PathBuf,
231 },
232}
233
234impl InstallationStatus {
235 /// Returns `true` if the harness CLI can be invoked.
236 ///
237 /// A harness is runnable if its binary is available in PATH,
238 /// regardless of whether configuration exists.
239 ///
240 /// # Examples
241 ///
242 /// ```
243 /// use harness_locate::InstallationStatus;
244 /// use std::path::PathBuf;
245 ///
246 /// let status = InstallationStatus::BinaryOnly {
247 /// binary_path: PathBuf::from("/usr/bin/claude"),
248 /// };
249 /// assert!(status.is_runnable());
250 ///
251 /// let status = InstallationStatus::NotInstalled;
252 /// assert!(!status.is_runnable());
253 /// ```
254 #[must_use]
255 pub fn is_runnable(&self) -> bool {
256 matches!(self, Self::BinaryOnly { .. } | Self::FullyInstalled { .. })
257 }
258
259 /// Returns the binary path if available.
260 ///
261 /// # Examples
262 ///
263 /// ```
264 /// use harness_locate::InstallationStatus;
265 /// use std::path::{Path, PathBuf};
266 ///
267 /// let status = InstallationStatus::FullyInstalled {
268 /// binary_path: PathBuf::from("/usr/bin/claude"),
269 /// config_path: PathBuf::from("/home/user/.claude"),
270 /// };
271 /// assert_eq!(status.binary_path(), Some(Path::new("/usr/bin/claude")));
272 /// ```
273 #[must_use]
274 pub fn binary_path(&self) -> Option<&Path> {
275 match self {
276 Self::BinaryOnly { binary_path } | Self::FullyInstalled { binary_path, .. } => {
277 Some(binary_path)
278 }
279 _ => None,
280 }
281 }
282
283 /// Returns the config path if available.
284 ///
285 /// # Examples
286 ///
287 /// ```
288 /// use harness_locate::InstallationStatus;
289 /// use std::path::{Path, PathBuf};
290 ///
291 /// let status = InstallationStatus::ConfigOnly {
292 /// config_path: PathBuf::from("/home/user/.claude"),
293 /// };
294 /// assert_eq!(status.config_path(), Some(Path::new("/home/user/.claude")));
295 /// ```
296 #[must_use]
297 pub fn config_path(&self) -> Option<&Path> {
298 match self {
299 Self::ConfigOnly { config_path } | Self::FullyInstalled { config_path, .. } => {
300 Some(config_path)
301 }
302 _ => None,
303 }
304 }
305}
306
307/// Types of paths a harness may provide.
308///
309/// Each harness can have different configuration directories
310/// for different purposes.
311///
312/// # Extensibility
313///
314/// This enum is marked `#[non_exhaustive]` to allow adding new
315/// path types in future versions without breaking changes.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317#[non_exhaustive]
318pub enum PathType {
319 /// Main configuration directory
320 Config,
321 /// Skills/capabilities definitions
322 Skills,
323 /// Custom commands
324 Commands,
325 /// MCP (Model Context Protocol) configuration
326 Mcp,
327 /// Rules and constraints
328 Rules,
329}
330
331/// Categories of resources that harnesses manage in named directories.
332///
333/// Used with [`HarnessKind::directory_names`] to query expected
334/// directory naming conventions.
335///
336/// **Note:** Rules are not included because they are stored at the root
337/// level (config dir or project root), not in a named subdirectory.
338///
339/// # Extensibility
340///
341/// This enum is marked `#[non_exhaustive]` to allow adding new
342/// resource kinds in future versions without breaking changes.
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
344#[non_exhaustive]
345pub enum ResourceKind {
346 /// Skills/capabilities definitions
347 Skills,
348 /// Custom commands
349 Commands,
350 /// Agent definitions
351 Agents,
352 /// Plugin extensions
353 Plugins,
354}
355
356/// File formats used by harness configuration files.
357///
358/// Different harnesses use different formats for their configuration,
359/// commands, and other resources.
360///
361/// # Extensibility
362///
363/// This enum is marked `#[non_exhaustive]` to allow adding new
364/// formats in future versions without breaking changes.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
366#[non_exhaustive]
367pub enum FileFormat {
368 /// Standard JSON format.
369 Json,
370 /// JSON with comments (JSONC).
371 Jsonc,
372 /// YAML format.
373 Yaml,
374 /// Plain Markdown.
375 Markdown,
376 /// Markdown with YAML frontmatter.
377 MarkdownWithFrontmatter,
378}
379
380/// Directory layout structure for resource directories.
381///
382/// Harnesses organize their resources in different ways:
383/// - Flat: Files directly in the directory (e.g., `commands/foo.md`)
384/// - Nested: Subdirectory per resource (e.g., `skills/foo/SKILL.md`)
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386pub enum DirectoryStructure {
387 /// Files directly in the directory.
388 ///
389 /// Example: `commands/foo.md`, `commands/bar.md`
390 Flat {
391 /// Glob pattern for matching files (e.g., `"*.md"`).
392 file_pattern: String,
393 },
394 /// Subdirectory per resource with a fixed filename inside.
395 ///
396 /// Example: `skills/foo/SKILL.md`, `skills/bar/SKILL.md`
397 Nested {
398 /// Pattern for subdirectory names (e.g., `"*"`).
399 subdir_pattern: String,
400 /// Fixed filename or marker directory within each subdirectory.
401 ///
402 /// Can be a file (e.g., `"SKILL.md"`) or a marker directory
403 /// (e.g., `".claude-plugin"` for plugin detection).
404 file_name: String,
405 },
406}
407
408/// A directory-based resource location.
409///
410/// Represents a directory that contains multiple resource files,
411/// such as commands or skills directories.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct DirectoryResource {
414 /// Path to the directory.
415 pub path: PathBuf,
416 /// Whether the directory currently exists on the filesystem.
417 pub exists: bool,
418 /// How resources are organized within the directory.
419 pub structure: DirectoryStructure,
420 /// Format of files within the directory.
421 pub file_format: FileFormat,
422}
423
424/// A configuration file resource location.
425///
426/// Represents a single configuration file that may contain
427/// multiple configuration entries, accessed via a key path.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct ConfigResource {
430 /// Path to the configuration file.
431 pub file: PathBuf,
432 /// Whether the file currently exists on the filesystem.
433 pub file_exists: bool,
434 /// JSON pointer path to the relevant section (e.g., `"/mcpServers"`).
435 pub key_path: String,
436 /// Format of the configuration file.
437 pub format: FileFormat,
438 /// Optional JSON Schema URL for validation.
439 pub schema_url: Option<String>,
440}
441
442/// A value that may be a plain string or a reference to an environment variable.
443///
444/// This type handles the different syntax each harness uses for environment
445/// variable references:
446/// - Claude Code: `${VAR}`
447/// - OpenCode: `{env:VAR}`
448/// - Goose: Uses `env_keys` array, values resolved at runtime
449///
450/// # Serde Behavior
451///
452/// Uses `#[serde(untagged)]` for clean JSON representation:
453/// - Plain string: `"hello"` deserializes to `Plain("hello")`
454/// - Object with env key: `{"env": "VAR"}` deserializes to `EnvRef { env: "VAR" }`
455///
456/// # Examples
457///
458/// ```
459/// use harness_locate::types::{EnvValue, HarnessKind};
460///
461/// // Create an environment variable reference
462/// let api_key = EnvValue::env("MY_API_KEY");
463///
464/// // Convert to Claude Code format
465/// assert_eq!(api_key.to_native(HarnessKind::ClaudeCode), "${MY_API_KEY}");
466///
467/// // Convert to OpenCode format
468/// assert_eq!(api_key.to_native(HarnessKind::OpenCode), "{env:MY_API_KEY}");
469/// ```
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(untagged)]
472pub enum EnvValue {
473 /// A plain string value.
474 Plain(String),
475 /// A reference to an environment variable.
476 EnvRef {
477 /// The name of the environment variable.
478 env: String,
479 },
480}
481
482impl EnvValue {
483 /// Creates a plain string value.
484 ///
485 /// # Examples
486 ///
487 /// ```
488 /// use harness_locate::types::EnvValue;
489 ///
490 /// let value = EnvValue::plain("hello");
491 /// assert_eq!(value.resolve(), Some("hello".to_string()));
492 /// ```
493 #[must_use]
494 pub fn plain(s: impl Into<String>) -> Self {
495 Self::Plain(s.into())
496 }
497
498 /// Creates an environment variable reference.
499 ///
500 /// # Examples
501 ///
502 /// ```
503 /// use harness_locate::types::EnvValue;
504 ///
505 /// let value = EnvValue::env("MY_VAR");
506 /// // Resolution depends on whether MY_VAR is set in the environment
507 /// ```
508 #[must_use]
509 pub fn env(var: impl Into<String>) -> Self {
510 Self::EnvRef { env: var.into() }
511 }
512
513 /// Converts to the harness-specific native string format.
514 ///
515 /// # Arguments
516 ///
517 /// * `kind` - The target harness format
518 ///
519 /// # Returns
520 ///
521 /// - For `Plain`: Returns the string as-is
522 /// - For `EnvRef` with Claude Code: Returns `${VAR}`
523 /// - For `EnvRef` with OpenCode: Returns `{env:VAR}`
524 /// - For `EnvRef` with Goose: Resolves the env var immediately
525 ///
526 /// # Examples
527 ///
528 /// ```
529 /// use harness_locate::types::{EnvValue, HarnessKind};
530 ///
531 /// let value = EnvValue::env("API_KEY");
532 /// assert_eq!(value.to_native(HarnessKind::ClaudeCode), "${API_KEY}");
533 /// assert_eq!(value.to_native(HarnessKind::OpenCode), "{env:API_KEY}");
534 /// ```
535 #[must_use]
536 pub fn to_native(&self, kind: HarnessKind) -> String {
537 match self {
538 Self::Plain(s) => s.clone(),
539 Self::EnvRef { env } => match kind {
540 HarnessKind::ClaudeCode
541 | HarnessKind::AmpCode
542 | HarnessKind::CopilotCli
543 | HarnessKind::Droid => {
544 format!("${{{env}}}")
545 }
546 HarnessKind::OpenCode | HarnessKind::Crush => format!("{{env:{env}}}"),
547 HarnessKind::Goose => std::env::var(env).unwrap_or_default(),
548 },
549 }
550 }
551
552 /// Fallible version of [`to_native`](Self::to_native) that returns an error
553 /// when an environment variable reference cannot be resolved.
554 ///
555 /// For Goose harness, this validates that the referenced environment variable
556 /// is actually set, returning `Error::MissingEnvVar` if not.
557 ///
558 /// For other harnesses that use template syntax (Claude Code, OpenCode, AmpCode),
559 /// this behaves identically to `to_native` since the variable is not resolved
560 /// at conversion time.
561 ///
562 /// # Errors
563 ///
564 /// Returns [`crate::Error::MissingEnvVar`] if the harness is Goose and the
565 /// referenced environment variable is not set.
566 ///
567 /// # Examples
568 ///
569 /// ```
570 /// use harness_locate::types::{EnvValue, HarnessKind};
571 ///
572 /// // Template-based harnesses always succeed
573 /// let value = EnvValue::env("SOME_VAR");
574 /// assert!(value.try_to_native(HarnessKind::ClaudeCode).is_ok());
575 ///
576 /// // Goose requires the env var to be set
577 /// // SAFETY: Test environment only, no concurrent access
578 /// unsafe { std::env::set_var("TEST_VAR", "value"); }
579 /// let value = EnvValue::env("TEST_VAR");
580 /// assert_eq!(value.try_to_native(HarnessKind::Goose).unwrap(), "value");
581 /// ```
582 pub fn try_to_native(&self, kind: HarnessKind) -> crate::Result<String> {
583 match self {
584 Self::Plain(s) => Ok(s.clone()),
585 Self::EnvRef { env } => match kind {
586 HarnessKind::ClaudeCode
587 | HarnessKind::AmpCode
588 | HarnessKind::CopilotCli
589 | HarnessKind::Droid => Ok(format!("${{{env}}}")),
590 HarnessKind::OpenCode | HarnessKind::Crush => Ok(format!("{{env:{env}}}")),
591 HarnessKind::Goose => std::env::var(env)
592 .map_err(|_| crate::Error::MissingEnvVar { name: env.clone() }),
593 },
594 }
595 }
596
597 /// Parses a harness-specific native string format into an `EnvValue`.
598 ///
599 /// # Arguments
600 ///
601 /// * `s` - The string to parse
602 /// * `kind` - The source harness format
603 ///
604 /// # Returns
605 ///
606 /// - For Claude Code: Parses `${VAR}` pattern
607 /// - For OpenCode: Parses `{env:VAR}` pattern
608 /// - For Goose: Always returns `Plain` (Goose doesn't use inline syntax)
609 /// - If no pattern matches, returns `Plain`
610 ///
611 /// # Examples
612 ///
613 /// ```
614 /// use harness_locate::types::{EnvValue, HarnessKind};
615 ///
616 /// let value = EnvValue::from_native("${API_KEY}", HarnessKind::ClaudeCode);
617 /// assert_eq!(value, EnvValue::env("API_KEY"));
618 ///
619 /// let value = EnvValue::from_native("{env:API_KEY}", HarnessKind::OpenCode);
620 /// assert_eq!(value, EnvValue::env("API_KEY"));
621 ///
622 /// let value = EnvValue::from_native("plain text", HarnessKind::ClaudeCode);
623 /// assert_eq!(value, EnvValue::plain("plain text"));
624 /// ```
625 #[must_use]
626 pub fn from_native(s: &str, kind: HarnessKind) -> Self {
627 match kind {
628 HarnessKind::ClaudeCode
629 | HarnessKind::AmpCode
630 | HarnessKind::CopilotCli
631 | HarnessKind::Droid => {
632 if let Some(var) = s.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
633 Self::EnvRef {
634 env: var.to_string(),
635 }
636 } else {
637 Self::Plain(s.to_string())
638 }
639 }
640 HarnessKind::OpenCode | HarnessKind::Crush => {
641 if let Some(var) = s.strip_prefix("{env:").and_then(|s| s.strip_suffix('}')) {
642 Self::EnvRef {
643 env: var.to_string(),
644 }
645 } else {
646 Self::Plain(s.to_string())
647 }
648 }
649 HarnessKind::Goose => Self::Plain(s.to_string()),
650 }
651 }
652
653 /// Resolves the value, looking up environment variables if needed.
654 ///
655 /// # Returns
656 ///
657 /// - For `Plain`: Returns `Some(value)`
658 /// - For `EnvRef`: Returns `Some(value)` if the env var is set, `None` otherwise
659 ///
660 /// # Examples
661 ///
662 /// ```
663 /// use harness_locate::types::EnvValue;
664 ///
665 /// let plain = EnvValue::plain("hello");
666 /// assert_eq!(plain.resolve(), Some("hello".to_string()));
667 ///
668 /// // EnvRef resolution depends on whether the variable is set
669 /// let env_ref = EnvValue::env("UNLIKELY_TO_EXIST_12345");
670 /// assert_eq!(env_ref.resolve(), None);
671 /// ```
672 #[must_use]
673 pub fn resolve(&self) -> Option<String> {
674 match self {
675 Self::Plain(s) => Some(s.clone()),
676 Self::EnvRef { env } => std::env::var(env).ok(),
677 }
678 }
679
680 /// Returns `true` if this is a plain string value.
681 #[must_use]
682 pub fn is_plain(&self) -> bool {
683 matches!(self, Self::Plain(_))
684 }
685
686 /// Returns `true` if this is an environment variable reference.
687 #[must_use]
688 pub fn is_env_ref(&self) -> bool {
689 matches!(self, Self::EnvRef { .. })
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn plain_constructor() {
699 let value = EnvValue::plain("hello");
700 assert!(value.is_plain());
701 assert!(!value.is_env_ref());
702 assert_eq!(value, EnvValue::Plain("hello".to_string()));
703 }
704
705 #[test]
706 fn env_constructor() {
707 let value = EnvValue::env("MY_VAR");
708 assert!(value.is_env_ref());
709 assert!(!value.is_plain());
710 assert_eq!(
711 value,
712 EnvValue::EnvRef {
713 env: "MY_VAR".to_string()
714 }
715 );
716 }
717
718 #[test]
719 fn to_native_plain_returns_value_unchanged() {
720 let value = EnvValue::plain("hello world");
721 assert_eq!(value.to_native(HarnessKind::ClaudeCode), "hello world");
722 assert_eq!(value.to_native(HarnessKind::OpenCode), "hello world");
723 assert_eq!(value.to_native(HarnessKind::Goose), "hello world");
724 }
725
726 #[test]
727 fn to_native_claude_code_format() {
728 let value = EnvValue::env("API_KEY");
729 assert_eq!(value.to_native(HarnessKind::ClaudeCode), "${API_KEY}");
730 }
731
732 #[test]
733 fn to_native_opencode_format() {
734 let value = EnvValue::env("API_KEY");
735 assert_eq!(value.to_native(HarnessKind::OpenCode), "{env:API_KEY}");
736 }
737
738 #[test]
739 fn to_native_goose_resolves_env_var() {
740 // SAFETY: Test runs single-threaded; no concurrent access to this env var
741 unsafe { std::env::set_var("TEST_GOOSE_VAR", "resolved_value") };
742 let value = EnvValue::env("TEST_GOOSE_VAR");
743 assert_eq!(value.to_native(HarnessKind::Goose), "resolved_value");
744 unsafe { std::env::remove_var("TEST_GOOSE_VAR") };
745 }
746
747 #[test]
748 fn to_native_goose_returns_empty_for_unset_var() {
749 let value = EnvValue::env("UNLIKELY_VAR_NAME_12345");
750 assert_eq!(value.to_native(HarnessKind::Goose), "");
751 }
752
753 #[test]
754 fn try_to_native_plain_always_succeeds() {
755 let value = EnvValue::plain("hello world");
756 assert_eq!(
757 value.try_to_native(HarnessKind::ClaudeCode).unwrap(),
758 "hello world"
759 );
760 assert_eq!(
761 value.try_to_native(HarnessKind::OpenCode).unwrap(),
762 "hello world"
763 );
764 assert_eq!(
765 value.try_to_native(HarnessKind::Goose).unwrap(),
766 "hello world"
767 );
768 }
769
770 #[test]
771 fn try_to_native_template_harnesses_always_succeed() {
772 let value = EnvValue::env("NONEXISTENT_VAR_XYZ");
773 assert_eq!(
774 value.try_to_native(HarnessKind::ClaudeCode).unwrap(),
775 "${NONEXISTENT_VAR_XYZ}"
776 );
777 assert_eq!(
778 value.try_to_native(HarnessKind::OpenCode).unwrap(),
779 "{env:NONEXISTENT_VAR_XYZ}"
780 );
781 assert_eq!(
782 value.try_to_native(HarnessKind::AmpCode).unwrap(),
783 "${NONEXISTENT_VAR_XYZ}"
784 );
785 }
786
787 #[test]
788 fn try_to_native_goose_succeeds_when_var_set() {
789 unsafe { std::env::set_var("TEST_TRY_NATIVE_VAR", "success") };
790 let value = EnvValue::env("TEST_TRY_NATIVE_VAR");
791 assert_eq!(value.try_to_native(HarnessKind::Goose).unwrap(), "success");
792 unsafe { std::env::remove_var("TEST_TRY_NATIVE_VAR") };
793 }
794
795 #[test]
796 fn try_to_native_goose_fails_when_var_unset() {
797 let value = EnvValue::env("DEFINITELY_NOT_SET_VAR_ABC");
798 let result = value.try_to_native(HarnessKind::Goose);
799 assert!(result.is_err());
800 let err = result.unwrap_err();
801 assert!(
802 matches!(err, crate::Error::MissingEnvVar { name } if name == "DEFINITELY_NOT_SET_VAR_ABC")
803 );
804 }
805
806 #[test]
807 fn from_native_claude_code_parses_env_ref() {
808 let value = EnvValue::from_native("${MY_VAR}", HarnessKind::ClaudeCode);
809 assert_eq!(value, EnvValue::env("MY_VAR"));
810 }
811
812 #[test]
813 fn from_native_claude_code_plain_for_non_matching() {
814 let value = EnvValue::from_native("plain text", HarnessKind::ClaudeCode);
815 assert_eq!(value, EnvValue::plain("plain text"));
816 }
817
818 #[test]
819 fn from_native_opencode_parses_env_ref() {
820 let value = EnvValue::from_native("{env:MY_VAR}", HarnessKind::OpenCode);
821 assert_eq!(value, EnvValue::env("MY_VAR"));
822 }
823
824 #[test]
825 fn from_native_opencode_plain_for_non_matching() {
826 let value = EnvValue::from_native("plain text", HarnessKind::OpenCode);
827 assert_eq!(value, EnvValue::plain("plain text"));
828 }
829
830 #[test]
831 fn from_native_goose_always_plain() {
832 let value = EnvValue::from_native("${MY_VAR}", HarnessKind::Goose);
833 assert_eq!(value, EnvValue::plain("${MY_VAR}"));
834
835 let value = EnvValue::from_native("{env:MY_VAR}", HarnessKind::Goose);
836 assert_eq!(value, EnvValue::plain("{env:MY_VAR}"));
837 }
838
839 #[test]
840 fn resolve_plain_returns_value() {
841 let value = EnvValue::plain("hello");
842 assert_eq!(value.resolve(), Some("hello".to_string()));
843 }
844
845 #[test]
846 fn resolve_env_ref_returns_value_when_set() {
847 // SAFETY: Test runs single-threaded; no concurrent access to this env var
848 unsafe { std::env::set_var("TEST_RESOLVE_VAR", "test_value") };
849 let value = EnvValue::env("TEST_RESOLVE_VAR");
850 assert_eq!(value.resolve(), Some("test_value".to_string()));
851 unsafe { std::env::remove_var("TEST_RESOLVE_VAR") };
852 }
853
854 #[test]
855 fn resolve_env_ref_returns_none_when_unset() {
856 let value = EnvValue::env("UNLIKELY_VAR_NAME_67890");
857 assert_eq!(value.resolve(), None);
858 }
859
860 #[test]
861 fn serde_plain_string_roundtrip() {
862 let value = EnvValue::plain("hello");
863 let json = serde_json::to_string(&value).unwrap();
864 assert_eq!(json, r#""hello""#);
865 let parsed: EnvValue = serde_json::from_str(&json).unwrap();
866 assert_eq!(parsed, value);
867 }
868
869 #[test]
870 fn serde_env_ref_roundtrip() {
871 let value = EnvValue::env("MY_VAR");
872 let json = serde_json::to_string(&value).unwrap();
873 assert_eq!(json, r#"{"env":"MY_VAR"}"#);
874 let parsed: EnvValue = serde_json::from_str(&json).unwrap();
875 assert_eq!(parsed, value);
876 }
877
878 #[test]
879 fn serde_deserialize_plain_from_string() {
880 let parsed: EnvValue = serde_json::from_str(r#""plain text""#).unwrap();
881 assert_eq!(parsed, EnvValue::plain("plain text"));
882 }
883
884 #[test]
885 fn serde_deserialize_env_ref_from_object() {
886 let parsed: EnvValue = serde_json::from_str(r#"{"env":"API_KEY"}"#).unwrap();
887 assert_eq!(parsed, EnvValue::env("API_KEY"));
888 }
889
890 #[test]
891 fn binary_names_claude_code() {
892 assert_eq!(HarnessKind::ClaudeCode.binary_names(), &["claude"]);
893 }
894
895 #[test]
896 fn binary_names_opencode() {
897 assert_eq!(HarnessKind::OpenCode.binary_names(), &["opencode"]);
898 }
899
900 #[test]
901 fn binary_names_goose() {
902 assert_eq!(HarnessKind::Goose.binary_names(), &["goose"]);
903 }
904
905 #[test]
906 fn binary_names_returns_static_slice() {
907 for kind in HarnessKind::ALL {
908 assert_eq!(kind.binary_names().len(), 1);
909 }
910 }
911
912 #[test]
913 fn installation_status_is_runnable() {
914 assert!(!InstallationStatus::NotInstalled.is_runnable());
915 assert!(
916 !InstallationStatus::ConfigOnly {
917 config_path: PathBuf::from("/config"),
918 }
919 .is_runnable()
920 );
921 assert!(
922 InstallationStatus::BinaryOnly {
923 binary_path: PathBuf::from("/bin"),
924 }
925 .is_runnable()
926 );
927 assert!(
928 InstallationStatus::FullyInstalled {
929 binary_path: PathBuf::from("/bin"),
930 config_path: PathBuf::from("/config"),
931 }
932 .is_runnable()
933 );
934 }
935
936 #[test]
937 fn installation_status_accessors() {
938 let status = InstallationStatus::FullyInstalled {
939 binary_path: PathBuf::from("/bin/claude"),
940 config_path: PathBuf::from("/home/.claude"),
941 };
942 assert_eq!(status.binary_path(), Some(Path::new("/bin/claude")));
943 assert_eq!(status.config_path(), Some(Path::new("/home/.claude")));
944
945 let status = InstallationStatus::NotInstalled;
946 assert_eq!(status.binary_path(), None);
947 assert_eq!(status.config_path(), None);
948 }
949
950 #[test]
951 fn directory_names_opencode_singular() {
952 assert_eq!(
953 HarnessKind::OpenCode.directory_names(ResourceKind::Skills),
954 Some(&["skill"][..])
955 );
956 assert_eq!(
957 HarnessKind::OpenCode.directory_names(ResourceKind::Commands),
958 Some(&["command"][..])
959 );
960 assert_eq!(
961 HarnessKind::OpenCode.directory_names(ResourceKind::Agents),
962 Some(&["agent"][..])
963 );
964 assert_eq!(
965 HarnessKind::OpenCode.directory_names(ResourceKind::Plugins),
966 Some(&["plugin"][..])
967 );
968 }
969
970 #[test]
971 fn directory_names_claude_code_plural() {
972 assert_eq!(
973 HarnessKind::ClaudeCode.directory_names(ResourceKind::Skills),
974 Some(&["skills"][..])
975 );
976 assert_eq!(
977 HarnessKind::ClaudeCode.directory_names(ResourceKind::Commands),
978 Some(&["commands"][..])
979 );
980 }
981
982 #[test]
983 fn directory_names_unsupported_returns_none() {
984 assert_eq!(
985 HarnessKind::Goose.directory_names(ResourceKind::Commands),
986 None
987 );
988 assert_eq!(
989 HarnessKind::Goose.directory_names(ResourceKind::Plugins),
990 None
991 );
992 }
993
994 #[test]
995 fn directory_names_all_harnesses_support_skills() {
996 for kind in HarnessKind::ALL {
997 assert!(
998 kind.directory_names(ResourceKind::Skills).is_some(),
999 "{kind} should support skills"
1000 );
1001 }
1002 }
1003}