caldir-core 0.8.1

Shared types for caldir ecosystem (Event, protocol types)
Documentation
//! Provider subprocess protocol.
//!
//! This module handles communication with external provider binaries
//! (e.g., `caldir-provider-google`) using JSON over stdin/stdout.
//!
//! The protocol is designed to be language-agnostic: any executable
//! that speaks the JSON protocol can be a provider.
//!
//! Providers manage their own credentials and tokens. Core just passes
//! provider-specific parameters from the calendar config.

use crate::error::{CalDirError, CalDirResult};
use crate::remote::protocol::{
    Command, Connect, ConnectResponse, ProviderCommand, Request, Response,
};
use crate::remote::provider_account::ProviderAccount;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command as TokioCommand;
use tokio::time::timeout;

const PROVIDER_TIMEOUT: Duration = Duration::from_secs(15);
/// No timeout for auth commands since they involve user interaction.
const AUTH_TIMEOUT: Duration = Duration::from_secs(300);

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Provider(String);

impl fmt::Display for Provider {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl Provider {
    pub fn from_name(name: &str) -> Self {
        Provider(name.to_string())
    }

    pub fn name(&self) -> &str {
        &self.0
    }

    /// Returns directories from `CALDIR_PROVIDER_PATH` followed by `PATH`.
    fn provider_search_dirs() -> impl Iterator<Item = std::path::PathBuf> {
        let provider_path = std::env::var_os("CALDIR_PROVIDER_PATH");
        let system_path = std::env::var_os("PATH");
        provider_path
            .into_iter()
            .flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
            .chain(
                system_path
                    .into_iter()
                    .flat_map(|p| std::env::split_paths(&p).collect::<Vec<_>>()),
            )
    }

    /// Discover all installed provider binaries by scanning `CALDIR_PROVIDER_PATH` then `PATH`
    /// for `caldir-provider-*`.
    pub fn discover_installed() -> Vec<String> {
        let mut providers = std::collections::BTreeSet::new();
        let prefix = "caldir-provider-";

        for dir in Self::provider_search_dirs() {
            let Ok(entries) = std::fs::read_dir(&dir) else {
                continue;
            };
            for entry in entries.flatten() {
                let name = entry.file_name();
                let name = name.to_string_lossy();
                if let Some(provider_name) = name.strip_prefix(prefix) {
                    providers.insert(provider_name.to_string());
                }
            }
        }

        providers.into_iter().collect()
    }

    fn binary_path(&self) -> CalDirResult<std::path::PathBuf> {
        let binary_name = format!("caldir-provider-{}", self.0);

        // Check CALDIR_PROVIDER_PATH directories first (direct file lookup).
        if let Some(provider_path) = std::env::var_os("CALDIR_PROVIDER_PATH") {
            for dir in std::env::split_paths(&provider_path) {
                let candidate = dir.join(&binary_name);
                if candidate.is_file() {
                    return Ok(candidate);
                }
            }
        }

        // Fall back to which (searches PATH).
        let binary_path = which::which(&binary_name).map_err(|_| {
            CalDirError::ProviderNotInstalled(format!(
                "Provider '{}' not found. Install it with:\n  cargo install {}",
                self.0, binary_name
            ))
        })?;
        Ok(binary_path)
    }

    /// Advance the connect flow by one step.
    ///
    /// Returns `ConnectResponse::NeedsInput` if the provider needs more data,
    /// or `ConnectResponse::Done` with the account identifier when complete.
    pub async fn connect(
        &self,
        options: serde_json::Map<String, serde_json::Value>,
        data: serde_json::Map<String, serde_json::Value>,
    ) -> CalDirResult<ConnectResponse> {
        self.call_no_timeout(Connect { options, data }).await
    }

    /// Wrap a `ConnectResponse::Done` into a `ProviderAccount`.
    pub fn provider_account(&self, identifier: String) -> ProviderAccount {
        ProviderAccount::new(self.clone(), identifier)
    }

    /// Call a typed provider command and return the result.
    ///
    /// The response type is inferred from the command's associated type,
    /// ensuring compile-time type safety.
    pub async fn call<C: ProviderCommand>(&self, cmd: C) -> CalDirResult<C::Response> {
        timeout(PROVIDER_TIMEOUT, self.call_raw(C::command(), cmd))
            .await
            .map_err(|_| CalDirError::ProviderTimeout(PROVIDER_TIMEOUT.as_secs()))?
    }

    /// Call a typed provider command without timeout (for auth commands that involve user interaction).
    pub async fn call_no_timeout<C: ProviderCommand>(&self, cmd: C) -> CalDirResult<C::Response> {
        timeout(AUTH_TIMEOUT, self.call_raw(C::command(), cmd))
            .await
            .map_err(|_| CalDirError::ProviderTimeout(AUTH_TIMEOUT.as_secs()))?
    }

    /// Low-level call that sends a command with params and deserializes the response.
    async fn call_raw<P: Serialize, R: serde::de::DeserializeOwned>(
        &self,
        command: Command,
        params: P,
    ) -> CalDirResult<R> {
        let params =
            serde_json::to_value(params).map_err(|e| CalDirError::Serialization(e.to_string()))?;
        let request = Request { command, params };
        let request_json = serde_json::to_string(&request)
            .map_err(|e| CalDirError::Serialization(e.to_string()))?;

        let binary_path = self.binary_path()?;

        let mut child = TokioCommand::new(&binary_path)
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::inherit())
            .spawn()
            .map_err(|e| {
                CalDirError::Provider(format!("Failed to spawn {}: {}", binary_path.display(), e))
            })?;

        // Write request to stdin (unwrap safe: we piped stdin above)
        let mut stdin = child.stdin.take().unwrap();
        stdin
            .write_all(format!("{request_json}\n").as_bytes())
            .await?;
        drop(stdin);

        // Wait for process and collect output
        let output = child.wait_with_output().await?;

        if !output.status.success() {
            return Err(CalDirError::Provider(format!(
                "Provider exited with status: {}",
                output.status.code().unwrap_or(-1)
            )));
        }

        let response_str = String::from_utf8_lossy(&output.stdout);
        if response_str.is_empty() {
            return Err(CalDirError::Provider(
                "Provider returned no response".into(),
            ));
        }

        let response: Response<R> = serde_json::from_str(&response_str)
            .map_err(|e| CalDirError::Provider(format!("Failed to parse response: {}", e)))?;

        match response {
            Response::Success { data } => Ok(data),
            Response::Error { error } => Err(CalDirError::Provider(error)),
        }
    }
}