mod account;
mod error;
mod handler;
mod registry;
mod slug;
mod storage;
pub(crate) mod transport;
#[cfg(test)]
pub(crate) mod mock_provider;
use crate::rpc;
use account::ProviderAccount;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use transport::{ProviderTransport, SubprocessTransport};
pub use error::ProviderError;
pub use handler::{Error, Handler, Result, process_request, run_provider};
pub use registry::ProviderRegistry;
pub use slug::{ProviderSlug, provider_slug_from_filename};
pub use storage::{ProviderStorage, StorageError};
pub use transport::ProviderTransportError;
#[derive(Debug, Clone)]
pub struct Provider {
slug: ProviderSlug,
transport: Arc<dyn ProviderTransport>,
timeout: Option<Duration>,
}
impl Provider {
pub(crate) fn from_binary_path(
binary_path: PathBuf,
) -> std::result::Result<Self, ProviderError> {
if !is_executable(&binary_path) {
return Err(ProviderError::NotExecutable(binary_path));
}
let slug = binary_path
.file_name()
.and_then(|filename| filename.to_str())
.and_then(provider_slug_from_filename)
.ok_or_else(|| ProviderError::InvalidProviderFilename(binary_path.clone()))?;
let transport = SubprocessTransport::new(binary_path);
Ok(Provider {
slug,
transport: Arc::new(transport),
timeout: None,
})
}
pub fn slug(&self) -> &ProviderSlug {
&self.slug
}
pub(crate) fn set_timeout(&mut self, timeout: Duration) {
self.timeout = Some(timeout);
}
pub fn provider_account(&self, identifier: String) -> ProviderAccount {
ProviderAccount::new(self.clone(), identifier)
}
pub async fn connect(
&self,
options: serde_json::Map<String, serde_json::Value>,
data: serde_json::Map<String, serde_json::Value>,
) -> std::result::Result<rpc::ConnectResponse, ProviderError> {
let result = self.call(rpc::Connect { options, data }).await?;
Ok(result)
}
pub(crate) async fn call<C: rpc::Rpc>(
&self,
call: C,
) -> std::result::Result<C::Response, ProviderError> {
let request_value = call.to_json().map_err(ProviderError::Serialize)?;
let request_json =
serde_json::to_string(&request_value).map_err(ProviderError::Serialize)?;
let timeout = self.timeout.unwrap_or(C::TIMEOUT);
let response_json = self
.transport
.exchange(&request_json, timeout)
.await
.map_err(|err| ProviderError::TransportFor(self.slug.clone(), err))?;
let response: rpc::Response<rpc::Wire<C::Response>> =
serde_json::from_str(&response_json).map_err(ProviderError::Deserialize)?;
match response {
rpc::Response::Success { data } => Ok(rpc::WireValue::from_wire(data)),
rpc::Response::Error { error } => Err(ProviderError::Provider(error)),
}
}
#[cfg(test)]
pub(crate) fn with_transport(
slug: ProviderSlug,
transport: Arc<dyn ProviderTransport>,
) -> Self {
Provider {
slug,
transport,
timeout: None,
}
}
#[cfg(test)]
pub(crate) fn transport(&self) -> &dyn ProviderTransport {
&*self.transport
}
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.is_file()
&& path
.metadata()
.map(|metadata| metadata.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(windows)]
fn is_executable(path: &Path) -> bool {
path.is_file()
&& path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| format!(".{extension}"))
.is_some_and(|extension| extension.eq_ignore_ascii_case(std::env::consts::EXE_SUFFIX))
}
#[cfg(not(any(unix, windows)))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rpc::Rpc;
use crate::test_utils::test_binary;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use transport::ProviderTransportError;
use transport::mock_transport::MockTransport;
#[derive(Serialize)]
struct EchoCommand {
value: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct EchoResponse {
value: String,
}
impl Rpc for EchoCommand {
type Response = EchoResponse;
const METHOD: rpc::Method = rpc::Method::ListEvents;
const TIMEOUT: Duration = Duration::from_secs(7);
}
impl rpc::JsonWireValue for EchoResponse {}
fn provider_with_transport(transport: Arc<dyn ProviderTransport>) -> Provider {
Provider::with_transport(ProviderSlug::from("test"), transport)
}
#[test]
fn from_binary_path_succeeds_for_valid_provider_binary() {
let (_tmp, bin) = test_binary("caldir-provider-hooli");
let provider = Provider::from_binary_path(bin.clone()).unwrap();
assert_eq!(provider.slug.as_str(), "hooli");
}
#[test]
fn from_binary_path_errors_when_file_does_not_exist() {
let tmp = tempfile::TempDir::new().unwrap();
let bin = tmp.path().join("caldir-provider-nonexistant");
let result = Provider::from_binary_path(bin.clone());
assert!(matches!(result, Err(ProviderError::NotExecutable(p)) if p == bin));
}
#[cfg(unix)]
#[test]
fn from_binary_path_errors_when_file_not_executable() {
let tmp = tempfile::TempDir::new().unwrap();
let bin = tmp.path().join("caldir-provider-hooli");
std::fs::write(&bin, b"").unwrap();
let result = Provider::from_binary_path(bin.clone());
assert!(matches!(result, Err(ProviderError::NotExecutable(p)) if p == bin));
}
#[test]
fn from_binary_path_errors_when_filename_lacks_prefix() {
let (_tmp, bin) = test_binary("hooli");
let result = Provider::from_binary_path(bin.clone());
assert!(matches!(result, Err(ProviderError::InvalidProviderFilename(p)) if p == bin));
}
#[test]
fn from_binary_path_errors_when_slug_is_empty() {
let (_tmp, bin) = test_binary("caldir-provider");
let result = Provider::from_binary_path(bin.clone());
assert!(matches!(result, Err(ProviderError::InvalidProviderFilename(p)) if p == bin));
}
#[tokio::test]
async fn call_sends_typed_request_and_returns_typed_response() {
let mock = Arc::new(MockTransport::with_response(
r#"{"status":"success","data":{"value":"echoed"}}"#,
));
let provider = provider_with_transport(mock.clone());
let response = provider
.call(EchoCommand {
value: "hello".into(),
})
.await
.unwrap();
assert_eq!(
response,
EchoResponse {
value: "echoed".into()
}
);
let captured: serde_json::Value =
serde_json::from_str(&mock.captured_request().unwrap()).unwrap();
assert_eq!(captured["command"], "list_events");
assert_eq!(captured["params"]["value"], "hello");
}
#[tokio::test]
async fn call_uses_per_command_timeout() {
let mock = Arc::new(MockTransport::with_response(
r#"{"status":"success","data":{"value":"x"}}"#,
));
let provider = provider_with_transport(mock.clone());
provider
.call(EchoCommand { value: "x".into() })
.await
.unwrap();
assert_eq!(mock.captured_timeout(), Some(Duration::from_secs(7)));
}
#[tokio::test]
async fn call_prefers_configured_timeout_over_per_command_timeout() {
let mock = Arc::new(MockTransport::with_response(
r#"{"status":"success","data":{"value":"x"}}"#,
));
let mut provider = provider_with_transport(mock.clone());
provider.set_timeout(Duration::from_secs(30));
provider
.call(EchoCommand { value: "x".into() })
.await
.unwrap();
assert_eq!(mock.captured_timeout(), Some(Duration::from_secs(30)));
}
#[tokio::test]
async fn call_returns_provider_error_on_error_response() {
for message in [
"oh no",
" Déjà vu?! [code: 42]\n 原因: a:b; a:b.\r\n\t ",
"Error handling request: outer: inner",
"",
] {
let response = serde_json::json!({"status": "error", "error": message}).to_string();
let mock = Arc::new(MockTransport::with_response(response));
let provider = provider_with_transport(mock);
let err = provider
.call(EchoCommand { value: "x".into() })
.await
.unwrap_err();
assert_eq!(err.to_string(), message);
assert!(matches!(err, ProviderError::Provider(msg) if msg == message));
}
}
#[tokio::test]
async fn call_returns_deserialize_error_on_garbage_response() {
let mock = Arc::new(MockTransport::with_response("not json at all"));
let provider = provider_with_transport(mock);
let err = provider
.call(EchoCommand { value: "x".into() })
.await
.unwrap_err();
assert_eq!(err.to_string(), "failed to deserialize provider response");
let source = std::error::Error::source(&err).unwrap();
assert!(source.is::<serde_json::Error>());
let expected = format!("failed to deserialize provider response: {source}");
assert!(matches!(err, ProviderError::Deserialize(_)));
assert_eq!(format!("{:#}", anyhow::Error::new(err)), expected);
}
#[tokio::test]
async fn call_identifies_provider_in_transport_error() {
let mock = Arc::new(MockTransport::with_error(ProviderTransportError::Timeout(
Duration::from_secs(1),
)));
let provider = provider_with_transport(mock);
let err = provider
.call(EchoCommand { value: "x".into() })
.await
.unwrap_err();
assert!(matches!(
&err,
ProviderError::TransportFor(provider, ProviderTransportError::Timeout(_))
if provider.as_str() == "test"
));
assert_eq!(
format!("{:#}", anyhow::Error::new(err)),
"provider test: Provider timed out after 1s"
);
}
}