1mod account;
2mod error;
3mod handler;
4mod registry;
5mod secrets;
6mod slug;
7mod storage;
8pub(crate) mod transport;
9
10#[cfg(test)]
11pub(crate) mod mock_provider;
12
13use crate::rpc;
14use account::ProviderAccount;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::time::Duration;
18use transport::{ProviderTransport, SubprocessTransport};
19
20pub use error::ProviderError;
21pub use handler::{Error, Handler, Result, process_request, run_provider};
22pub use registry::ProviderRegistry;
23pub use secrets::{SecretError, SecretStore};
24pub use slug::{ProviderSlug, provider_slug_from_filename};
25pub use storage::{ProviderStorage, StorageError};
26pub use transport::ProviderTransportError;
27
28#[derive(Debug, Clone)]
29pub struct Provider {
30 slug: ProviderSlug,
31 transport: Arc<dyn ProviderTransport>,
32 timeout: Option<Duration>,
33}
34
35impl Provider {
36 pub(crate) fn from_binary_path(
37 binary_path: PathBuf,
38 ) -> std::result::Result<Self, ProviderError> {
39 if !is_executable(&binary_path) {
40 return Err(ProviderError::NotExecutable(binary_path));
41 }
42
43 let slug = binary_path
44 .file_name()
45 .and_then(|filename| filename.to_str())
46 .and_then(provider_slug_from_filename)
47 .ok_or_else(|| ProviderError::InvalidProviderFilename(binary_path.clone()))?;
48
49 let transport = SubprocessTransport::new(binary_path);
50
51 Ok(Provider {
52 slug,
53 transport: Arc::new(transport),
54 timeout: None,
55 })
56 }
57
58 pub fn slug(&self) -> &ProviderSlug {
59 &self.slug
60 }
61
62 pub(crate) fn set_timeout(&mut self, timeout: Duration) {
63 self.timeout = Some(timeout);
64 }
65
66 pub fn provider_account(&self, identifier: String) -> ProviderAccount {
67 ProviderAccount::new(self.clone(), identifier)
68 }
69
70 pub async fn connect(
71 &self,
72 options: serde_json::Map<String, serde_json::Value>,
73 data: serde_json::Map<String, serde_json::Value>,
74 ) -> std::result::Result<rpc::ConnectResponse, ProviderError> {
75 let result = self.call(rpc::Connect { options, data }).await?;
76
77 Ok(result)
78 }
79
80 pub(crate) async fn call<C: rpc::Rpc>(
81 &self,
82 call: C,
83 ) -> std::result::Result<C::Response, ProviderError> {
84 let request_value = call.to_json().map_err(ProviderError::Serialize)?;
85 let request_json =
86 serde_json::to_string(&request_value).map_err(ProviderError::Serialize)?;
87
88 let timeout = self.timeout.unwrap_or(C::TIMEOUT);
90 let response_json = self
91 .transport
92 .exchange(&request_json, timeout)
93 .await
94 .map_err(|err| ProviderError::TransportFor(self.slug.clone(), err))?;
95
96 let response: rpc::Response<rpc::Wire<C::Response>> =
97 serde_json::from_str(&response_json).map_err(ProviderError::Deserialize)?;
98
99 match response {
100 rpc::Response::Success { data } => Ok(rpc::WireValue::from_wire(data)),
101 rpc::Response::Error { error } => Err(ProviderError::Provider(error)),
102 }
103 }
104
105 #[cfg(test)]
106 pub(crate) fn with_transport(
107 slug: ProviderSlug,
108 transport: Arc<dyn ProviderTransport>,
109 ) -> Self {
110 Provider {
111 slug,
112 transport,
113 timeout: None,
114 }
115 }
116
117 #[cfg(test)]
118 pub(crate) fn transport(&self) -> &dyn ProviderTransport {
119 &*self.transport
120 }
121}
122
123#[cfg(unix)]
124fn is_executable(path: &Path) -> bool {
125 use std::os::unix::fs::PermissionsExt;
126
127 path.is_file()
128 && path
129 .metadata()
130 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
131 .unwrap_or(false)
132}
133
134#[cfg(windows)]
135fn is_executable(path: &Path) -> bool {
136 path.is_file()
137 && path
138 .extension()
139 .and_then(|extension| extension.to_str())
140 .map(|extension| format!(".{extension}"))
141 .is_some_and(|extension| extension.eq_ignore_ascii_case(std::env::consts::EXE_SUFFIX))
142}
143
144#[cfg(not(any(unix, windows)))]
145fn is_executable(path: &Path) -> bool {
146 path.is_file()
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::rpc::Rpc;
153 use crate::test_utils::test_binary;
154 use serde::{Deserialize, Serialize};
155 use std::time::Duration;
156 use transport::ProviderTransportError;
157 use transport::mock_transport::MockTransport;
158
159 #[derive(Serialize)]
160 struct EchoCommand {
161 value: String,
162 }
163
164 #[derive(Serialize, Deserialize, Debug, PartialEq)]
165 struct EchoResponse {
166 value: String,
167 }
168
169 impl Rpc for EchoCommand {
170 type Response = EchoResponse;
171 const METHOD: rpc::Method = rpc::Method::ListEvents;
172 const TIMEOUT: Duration = Duration::from_secs(7);
173 }
174
175 impl rpc::JsonWireValue for EchoResponse {}
176
177 fn provider_with_transport(transport: Arc<dyn ProviderTransport>) -> Provider {
178 Provider::with_transport(ProviderSlug::from("test"), transport)
179 }
180
181 #[test]
182 fn from_binary_path_succeeds_for_valid_provider_binary() {
183 let (_tmp, bin) = test_binary("caldir-provider-hooli");
184
185 let provider = Provider::from_binary_path(bin.clone()).unwrap();
186
187 assert_eq!(provider.slug.as_str(), "hooli");
188 }
189
190 #[test]
191 fn from_binary_path_errors_when_file_does_not_exist() {
192 let tmp = tempfile::TempDir::new().unwrap();
193 let bin = tmp.path().join("caldir-provider-nonexistant");
194
195 let result = Provider::from_binary_path(bin.clone());
196
197 assert!(matches!(result, Err(ProviderError::NotExecutable(p)) if p == bin));
198 }
199
200 #[cfg(unix)]
201 #[test]
202 fn from_binary_path_errors_when_file_not_executable() {
203 let tmp = tempfile::TempDir::new().unwrap();
204 let bin = tmp.path().join("caldir-provider-hooli");
205 std::fs::write(&bin, b"").unwrap();
206
207 let result = Provider::from_binary_path(bin.clone());
208
209 assert!(matches!(result, Err(ProviderError::NotExecutable(p)) if p == bin));
210 }
211
212 #[test]
213 fn from_binary_path_errors_when_filename_lacks_prefix() {
214 let (_tmp, bin) = test_binary("hooli");
215
216 let result = Provider::from_binary_path(bin.clone());
217
218 assert!(matches!(result, Err(ProviderError::InvalidProviderFilename(p)) if p == bin));
219 }
220
221 #[test]
222 fn from_binary_path_errors_when_slug_is_empty() {
223 let (_tmp, bin) = test_binary("caldir-provider");
224
225 let result = Provider::from_binary_path(bin.clone());
226
227 assert!(matches!(result, Err(ProviderError::InvalidProviderFilename(p)) if p == bin));
228 }
229
230 #[tokio::test]
231 async fn call_sends_typed_request_and_returns_typed_response() {
232 let mock = Arc::new(MockTransport::with_response(
233 r#"{"status":"success","data":{"value":"echoed"}}"#,
234 ));
235 let provider = provider_with_transport(mock.clone());
236
237 let response = provider
238 .call(EchoCommand {
239 value: "hello".into(),
240 })
241 .await
242 .unwrap();
243
244 assert_eq!(
245 response,
246 EchoResponse {
247 value: "echoed".into()
248 }
249 );
250
251 let captured: serde_json::Value =
252 serde_json::from_str(&mock.captured_request().unwrap()).unwrap();
253 assert_eq!(captured["command"], "list_events");
254 assert_eq!(captured["params"]["value"], "hello");
255 }
256
257 #[tokio::test]
258 async fn call_uses_per_command_timeout() {
259 let mock = Arc::new(MockTransport::with_response(
260 r#"{"status":"success","data":{"value":"x"}}"#,
261 ));
262 let provider = provider_with_transport(mock.clone());
263
264 provider
265 .call(EchoCommand { value: "x".into() })
266 .await
267 .unwrap();
268
269 assert_eq!(mock.captured_timeout(), Some(Duration::from_secs(7)));
270 }
271
272 #[tokio::test]
273 async fn call_prefers_configured_timeout_over_per_command_timeout() {
274 let mock = Arc::new(MockTransport::with_response(
275 r#"{"status":"success","data":{"value":"x"}}"#,
276 ));
277 let mut provider = provider_with_transport(mock.clone());
278 provider.set_timeout(Duration::from_secs(30));
279
280 provider
281 .call(EchoCommand { value: "x".into() })
282 .await
283 .unwrap();
284
285 assert_eq!(mock.captured_timeout(), Some(Duration::from_secs(30)));
286 }
287
288 #[tokio::test]
289 async fn call_returns_provider_error_on_error_response() {
290 for message in [
291 "oh no",
292 " Déjà vu?! [code: 42]\n 原因: a:b; a:b.\r\n\t ",
293 "Error handling request: outer: inner",
294 "",
295 ] {
296 let response = serde_json::json!({"status": "error", "error": message}).to_string();
297 let mock = Arc::new(MockTransport::with_response(response));
298 let provider = provider_with_transport(mock);
299
300 let err = provider
301 .call(EchoCommand { value: "x".into() })
302 .await
303 .unwrap_err();
304
305 assert_eq!(err.to_string(), message);
306 assert!(matches!(err, ProviderError::Provider(msg) if msg == message));
307 }
308 }
309
310 #[tokio::test]
311 async fn call_returns_deserialize_error_on_garbage_response() {
312 let mock = Arc::new(MockTransport::with_response("not json at all"));
313 let provider = provider_with_transport(mock);
314
315 let err = provider
316 .call(EchoCommand { value: "x".into() })
317 .await
318 .unwrap_err();
319
320 assert_eq!(err.to_string(), "failed to deserialize provider response");
321 let source = std::error::Error::source(&err).unwrap();
322 assert!(source.is::<serde_json::Error>());
323 let expected = format!("failed to deserialize provider response: {source}");
324 assert!(matches!(err, ProviderError::Deserialize(_)));
325 assert_eq!(format!("{:#}", anyhow::Error::new(err)), expected);
326 }
327
328 #[tokio::test]
329 async fn call_identifies_provider_in_transport_error() {
330 let mock = Arc::new(MockTransport::with_error(ProviderTransportError::Timeout(
331 Duration::from_secs(1),
332 )));
333 let provider = provider_with_transport(mock);
334
335 let err = provider
336 .call(EchoCommand { value: "x".into() })
337 .await
338 .unwrap_err();
339
340 assert!(matches!(
341 &err,
342 ProviderError::TransportFor(provider, ProviderTransportError::Timeout(_))
343 if provider.as_str() == "test"
344 ));
345 assert_eq!(
346 format!("{:#}", anyhow::Error::new(err)),
347 "provider test: Provider timed out after 1s"
348 );
349 }
350}