Skip to main content

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}