heddle-client 0.10.3

Heddle hosted-backend client: auth, support, presence, the gRPC client wrappers, and the global credential store.
Documentation
use grpc::heddle::api::v1alpha1::{
    AnnotationScope, CompareResponse, ContextAnnotationKind, GetBlameRequest, GetBlameResponse,
    GetCompareRequest, GetContextHistoryRequest, GetContextHistoryResponse, ListContextRequest,
    ListContextResponse, ListContextSuggestionsRequest, ListContextSuggestionsResponse,
    ReviseContextRequest, ReviseContextResponse, SetContextRequest, SetContextResponse,
    SupersedeContextRequest, SupersedeContextResponse,
};
use tonic::Request;
use wire::ProtocolError;

use super::{HostedGrpcClient, helpers::status_to_protocol_error, operation_id::ClientOperationId};

const SET_CONTEXT: &str = "heddle.api.v1alpha1.RepositoryService/SetContext";
const REVISE_CONTEXT: &str = "heddle.api.v1alpha1.RepositoryService/ReviseContext";
const SUPERSEDE_CONTEXT: &str = "heddle.api.v1alpha1.RepositoryService/SupersedeContext";

impl HostedGrpcClient {
    pub async fn get_compare(
        &mut self,
        repo_path: &str,
        from: &str,
        to: &str,
        include_semantic: bool,
    ) -> Result<CompareResponse, ProtocolError> {
        let mut request = Request::new(GetCompareRequest {
            repo_path: super::helpers::repository_ref(repo_path),
            from: from.to_string(),
            to: to.to_string(),
            include_semantic,
        });
        self.apply_signed_auth(
            &mut request,
            "/heddle.api.v1alpha1.RepositoryService/GetCompare",
        )?;
        self.content
            .get_compare(request)
            .await
            .map_err(status_to_protocol_error)
            .map(|response| response.into_inner())
    }

    pub async fn get_blame(
        &mut self,
        repo_path: &str,
        r#ref: Option<&str>,
        path: &str,
    ) -> Result<GetBlameResponse, ProtocolError> {
        let mut request = Request::new(GetBlameRequest {
            repo_path: super::helpers::repository_ref(repo_path),
            r#ref: r#ref.unwrap_or_default().to_string(),
            path: path.to_string(),
        });
        self.apply_signed_auth(
            &mut request,
            "/heddle.api.v1alpha1.RepositoryService/GetBlame",
        )?;
        self.content
            .get_blame(request)
            .await
            .map_err(status_to_protocol_error)
            .map(|response| response.into_inner())
    }

    pub async fn list_context(
        &mut self,
        repo_path: &str,
        r#ref: Option<&str>,
        prefix: Option<&str>,
        tag_filter: Option<&str>,
    ) -> Result<ListContextResponse, ProtocolError> {
        let mut request = Request::new(ListContextRequest {
            repo_path: super::helpers::repository_ref(repo_path),
            r#ref: r#ref.unwrap_or_default().to_string(),
            prefix: prefix.map(str::to_string),
            tag_filter: tag_filter.map(str::to_string),
        });
        self.apply_signed_auth(
            &mut request,
            "/heddle.api.v1alpha1.RepositoryService/ListContext",
        )?;
        self.content
            .list_context(request)
            .await
            .map_err(status_to_protocol_error)
            .map(|response| response.into_inner())
    }

    #[allow(clippy::too_many_arguments)]
    pub async fn set_context(
        &mut self,
        repo_path: &str,
        path: &str,
        target_state_id: Option<&str>,
        scope: AnnotationScope,
        kind: ContextAnnotationKind,
        tags: Vec<String>,
        content: &str,
        agent_provider: Option<&str>,
        agent_model: Option<&str>,
        client_operation_id: String,
    ) -> Result<SetContextResponse, ProtocolError> {
        let operation_id =
            ClientOperationId::for_required_method(SET_CONTEXT, client_operation_id)?;
        let mut request = Request::new(SetContextRequest {
            repo_path: super::helpers::repository_ref(repo_path),
            path: path.to_string(),
            scope: Some(scope),
            tags,
            content: content.to_string(),
            agent_provider: agent_provider.unwrap_or_default().to_string(),
            agent_model: agent_model.unwrap_or_default().to_string(),
            target_state_id: target_state_id
                .and_then(|s| objects::object::StateId::parse(s).ok())
                .and_then(super::helpers::proto_state_id),
            kind: kind as i32,
            client_operation_id: operation_id.to_wire(),
        });
        self.apply_signed_auth(
            &mut request,
            "/heddle.api.v1alpha1.RepositoryService/SetContext",
        )?;
        self.content
            .set_context(request)
            .await
            .map_err(status_to_protocol_error)
            .map(|response| response.into_inner())
    }

    pub async fn get_context_history(
        &mut self,
        repo_path: &str,
        r#ref: Option<&str>,
        annotation_id: &str,
    ) -> Result<GetContextHistoryResponse, ProtocolError> {
        let mut request = Request::new(GetContextHistoryRequest {
            repo_path: super::helpers::repository_ref(repo_path),
            r#ref: r#ref.unwrap_or_default().to_string(),
            annotation_id: annotation_id.to_string(),
        });
        self.apply_signed_auth(
            &mut request,
            "/heddle.api.v1alpha1.RepositoryService/GetContextHistory",
        )?;
        self.content
            .get_context_history(request)
            .await
            .map_err(status_to_protocol_error)
            .map(|response| response.into_inner())
    }

    pub async fn list_context_suggestions(
        &mut self,
        repo_path: &str,
        r#ref: Option<&str>,
        limit: u32,
    ) -> Result<ListContextSuggestionsResponse, ProtocolError> {
        let mut request = Request::new(ListContextSuggestionsRequest {
            repo_path: super::helpers::repository_ref(repo_path),
            r#ref: r#ref.unwrap_or_default().to_string(),
            limit,
        });
        self.apply_signed_auth(
            &mut request,
            "/heddle.api.v1alpha1.RepositoryService/ListContextSuggestions",
        )?;
        self.content
            .list_context_suggestions(request)
            .await
            .map_err(status_to_protocol_error)
            .map(|response| response.into_inner())
    }

    #[allow(clippy::too_many_arguments)]
    pub async fn revise_context(
        &mut self,
        repo_path: &str,
        annotation_id: &str,
        content: &str,
        tags: Vec<String>,
        agent_provider: Option<&str>,
        agent_model: Option<&str>,
        kind: ContextAnnotationKind,
        client_operation_id: String,
    ) -> Result<ReviseContextResponse, ProtocolError> {
        let operation_id =
            ClientOperationId::for_required_method(REVISE_CONTEXT, client_operation_id)?;
        let mut request = Request::new(revise_context_request(
            repo_path,
            annotation_id,
            content,
            tags,
            agent_provider,
            agent_model,
            kind,
            &operation_id,
        ));
        self.apply_signed_auth(
            &mut request,
            "/heddle.api.v1alpha1.RepositoryService/ReviseContext",
        )?;
        self.content
            .revise_context(request)
            .await
            .map_err(status_to_protocol_error)
            .map(|response| response.into_inner())
    }

    #[allow(clippy::too_many_arguments)]
    pub async fn supersede_context(
        &mut self,
        repo_path: &str,
        annotation_id: &str,
        path: Option<&str>,
        target_state_id: Option<&str>,
        scope: AnnotationScope,
        tags: Vec<String>,
        content: &str,
        agent_provider: Option<&str>,
        agent_model: Option<&str>,
        kind: ContextAnnotationKind,
        client_operation_id: String,
    ) -> Result<SupersedeContextResponse, ProtocolError> {
        let operation_id =
            ClientOperationId::for_required_method(SUPERSEDE_CONTEXT, client_operation_id)?;
        let mut request = Request::new(supersede_context_request(
            repo_path,
            annotation_id,
            path,
            target_state_id,
            scope,
            tags,
            content,
            agent_provider,
            agent_model,
            kind,
            &operation_id,
        ));
        self.apply_signed_auth(
            &mut request,
            "/heddle.api.v1alpha1.RepositoryService/SupersedeContext",
        )?;
        self.content
            .supersede_context(request)
            .await
            .map_err(status_to_protocol_error)
            .map(|response| response.into_inner())
    }
}

#[allow(clippy::too_many_arguments)]
fn revise_context_request(
    repo_path: &str,
    annotation_id: &str,
    content: &str,
    tags: Vec<String>,
    agent_provider: Option<&str>,
    agent_model: Option<&str>,
    kind: ContextAnnotationKind,
    operation_id: &ClientOperationId,
) -> ReviseContextRequest {
    ReviseContextRequest {
        repo_path: super::helpers::repository_ref(repo_path),
        annotation_id: annotation_id.to_string(),
        content: content.to_string(),
        tags,
        agent_provider: agent_provider.unwrap_or_default().to_string(),
        agent_model: agent_model.unwrap_or_default().to_string(),
        kind: kind as i32,
        client_operation_id: operation_id.to_wire(),
    }
}

#[allow(clippy::too_many_arguments)]
fn supersede_context_request(
    repo_path: &str,
    annotation_id: &str,
    path: Option<&str>,
    target_state_id: Option<&str>,
    scope: AnnotationScope,
    tags: Vec<String>,
    content: &str,
    agent_provider: Option<&str>,
    agent_model: Option<&str>,
    kind: ContextAnnotationKind,
    operation_id: &ClientOperationId,
) -> SupersedeContextRequest {
    SupersedeContextRequest {
        repo_path: super::helpers::repository_ref(repo_path),
        annotation_id: annotation_id.to_string(),
        path: path.unwrap_or_default().to_string(),
        scope: Some(scope),
        tags,
        content: content.to_string(),
        agent_provider: agent_provider.unwrap_or_default().to_string(),
        agent_model: agent_model.unwrap_or_default().to_string(),
        target_state_id: target_state_id
            .and_then(|s| objects::object::StateId::parse(s).ok())
            .and_then(super::helpers::proto_state_id),
        kind: kind as i32,
        client_operation_id: operation_id.to_wire(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn context_revision_retry_request_reuses_the_callers_operation_id() {
        let operation_id =
            ClientOperationId::for_required_method(REVISE_CONTEXT, "caller-op-1").unwrap();
        let build = || {
            revise_context_request(
                "acme/widgets",
                "annotation-1",
                "updated context",
                vec!["reviewed".to_string()],
                None,
                None,
                ContextAnnotationKind::Rationale,
                &operation_id,
            )
        };
        let first = build();
        let retry = build();
        assert!(!first.client_operation_id.is_empty());
        assert_eq!(retry.client_operation_id, first.client_operation_id);
    }

    #[test]
    fn context_supersession_retry_request_reuses_the_callers_operation_id() {
        let operation_id =
            ClientOperationId::for_required_method(SUPERSEDE_CONTEXT, "caller-op-2").unwrap();
        let build = || {
            supersede_context_request(
                "acme/widgets",
                "annotation-1",
                Some("src/lib.rs"),
                None,
                AnnotationScope::default(),
                vec!["replacement".to_string()],
                "replacement context",
                None,
                None,
                ContextAnnotationKind::Rationale,
                &operation_id,
            )
        };
        let first = build();
        let retry = build();
        assert!(!first.client_operation_id.is_empty());
        assert_eq!(retry.client_operation_id, first.client_operation_id);
    }

    #[test]
    fn context_write_rejects_an_empty_caller_operation_id_before_transport() {
        for method in [SET_CONTEXT, REVISE_CONTEXT, SUPERSEDE_CONTEXT] {
            let error = ClientOperationId::for_required_method(method, "")
                .expect_err("required context writes must fail before transport");
            assert!(error.to_string().contains("non-empty client operation ID"));
        }
    }

    #[test]
    fn actual_context_rpc_retry_boundary_requires_reusing_the_caller_id() {
        #[allow(dead_code)]
        async fn compile_caller_retry(client: &mut HostedGrpcClient, client_operation_id: String) {
            let _ = client
                .revise_context(
                    "acme/widgets",
                    "annotation-1",
                    "updated context",
                    Vec::new(),
                    None,
                    None,
                    ContextAnnotationKind::Rationale,
                    client_operation_id.clone(),
                )
                .await;
            let _ = client
                .revise_context(
                    "acme/widgets",
                    "annotation-1",
                    "updated context",
                    Vec::new(),
                    None,
                    None,
                    ContextAnnotationKind::Rationale,
                    client_operation_id,
                )
                .await;
        }

        let _ = compile_caller_retry;
    }
}