Skip to main content

exomonad_core/
ffi.rs

1use serde::{de::DeserializeOwned, Deserialize, Serialize};
2use std::fmt::Debug;
3
4/// Trait that all FFI boundary types must implement.
5///
6/// This ensures consistent serialization, validation, and error handling
7/// across the WASM boundary.
8pub trait FFIBoundary: Serialize + DeserializeOwned + Send + Sync + 'static {
9    /// Validate the data after deserialization.
10    ///
11    /// This allows for "Parse, don't validate" at the boundary layer, but
12    /// still provides a standard way to enforce domain invariants.
13    #[allow(clippy::result_large_err)]
14    fn validate(&self) -> Result<(), FFIError> {
15        Ok(())
16    }
17
18    /// Schema version for compatibility checking.
19    fn schema_version() -> u32 {
20        1
21    }
22}
23
24/// Standardized error code for programmatic handling across the FFI boundary.
25#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum ErrorCode {
28    /// Resource (file, issue, branch, etc.) does not exist.
29    NotFound,
30    /// Missing or invalid credentials (e.g., GitHub token).
31    NotAuthenticated,
32    /// Git command failed (e.g., merge conflict, dirty working directory).
33    GitError,
34    /// File system operation failed (e.g., permission denied).
35    IoError,
36    /// Network request failed (e.g., API unreachable).
37    NetworkError,
38    /// Invalid input parameters provided to the host function.
39    InvalidInput,
40    /// Unexpected internal error (bug in the host function or runtime).
41    #[default]
42    InternalError,
43    /// Operation timed out.
44    Timeout,
45    /// Resource already exists (e.g., worktree path).
46    AlreadyExists,
47}
48
49/// Rich context for debugging errors.
50#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
51pub struct ErrorContext {
52    /// The command that failed (e.g., "git worktree add ...").
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub command: Option<String>,
55
56    /// Process exit code, if applicable.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub exit_code: Option<i32>,
59
60    /// Standard error output from the command (truncated if necessary).
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub stderr: Option<String>,
63
64    /// Standard output from the command (truncated if necessary).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub stdout: Option<String>,
67
68    /// Relevant file path.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub file_path: Option<String>,
71
72    /// Working directory where the operation was attempted.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub working_dir: Option<String>,
75}
76
77/// Structured error returned to the WASM guest.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct FFIError {
80    /// Human-readable summary of the error.
81    pub message: String,
82
83    /// Programmatic error code.
84    pub code: ErrorCode,
85
86    /// Rich context for debugging.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub context: Option<ErrorContext>,
89
90    /// Actionable suggestion for recovery.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub suggestion: Option<String>,
93}
94
95impl std::fmt::Display for FFIError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(f, "[{:?}] {}", self.code, self.message)
98    }
99}
100
101impl std::error::Error for FFIError {}
102
103/// Standardized result envelope for all host functions.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105#[serde(tag = "kind", content = "payload")]
106pub enum FFIResult<T> {
107    Success(T),
108    Error(FFIError),
109}
110
111impl<T> FFIResult<T> {
112    /// Create a success result.
113    pub fn success(value: T) -> Self {
114        Self::Success(value)
115    }
116
117    /// Create an error result with full details.
118    pub fn error(
119        message: impl Into<String>,
120        code: ErrorCode,
121        context: Option<ErrorContext>,
122        suggestion: Option<String>,
123    ) -> Self {
124        Self::Error(FFIError {
125            message: message.into(),
126            code,
127            context,
128            suggestion,
129        })
130    }
131
132    /// Create a simple error result.
133    pub fn simple_error(message: impl Into<String>, code: ErrorCode) -> Self {
134        Self::Error(FFIError {
135            message: message.into(),
136            code,
137            context: None,
138            suggestion: None,
139        })
140    }
141}
142
143impl<T: FFIBoundary> FFIBoundary for FFIResult<T> {}
144impl FFIBoundary for FFIError {}
145impl FFIBoundary for String {}
146impl FFIBoundary for bool {}
147impl<T: FFIBoundary> FFIBoundary for Vec<T> {}
148impl<T: FFIBoundary> FFIBoundary for Option<T> {}
149
150// Primitive FFIBoundary impls to match Haskell-side instances and
151// allow simple types like () / Int / Word64 across the FFI boundary.
152impl FFIBoundary for () {}
153
154// Signed integers (Haskell Int typically maps to a machine-sized int;
155// we provide common widths explicitly).
156impl FFIBoundary for i32 {}
157impl FFIBoundary for i64 {}
158impl FFIBoundary for isize {}
159impl FFIBoundary for i8 {}
160impl FFIBoundary for i16 {}
161
162// Unsigned integers (Haskell Word64).
163impl FFIBoundary for u64 {}
164impl FFIBoundary for u32 {}
165impl FFIBoundary for usize {}
166impl FFIBoundary for u8 {}
167impl FFIBoundary for u16 {}