Skip to main content

caldir_core/
provider.rs

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        // Make call:
89        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}