caldir-core 0.17.2

Core library for caldir calendar storage, sync, and provider protocol
Documentation
mod account;
mod error;
mod handler;
mod registry;
mod secrets;
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 secrets::{SecretError, SecretStore};
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)?;

        // Make call:
        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"
        );
    }
}