Skip to main content

embassy_github/
provider.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Provider trait + skeleton stub.
5//!
6//! Live HTTP/API implementation deferred. The stub here returns one
7//! canned [`Organization`] per Lookup so callers can wire Formations
8//! against the surface today.
9
10use async_trait::async_trait;
11use converge_pack::FactPayload;
12use embassy_pack::{CallContext, Observation, content_hash};
13use serde::{Deserialize, Serialize};
14
15use crate::error::GithubError;
16use crate::types::{OrgSlug, Organization};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "kind", rename_all = "snake_case")]
20pub enum GithubRequest {
21    Lookup { identifier: OrgSlug },
22}
23
24impl FactPayload for GithubRequest {
25    const FAMILY: &'static str = "embassy.github.request";
26    const VERSION: u16 = 1;
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct GithubResponse {
31    pub records: Vec<Observation<Organization>>,
32}
33
34#[async_trait]
35pub trait GithubProvider: Send + Sync {
36    fn name(&self) -> &str;
37
38    async fn fetch(
39        &self,
40        request: &GithubRequest,
41        ctx: &CallContext,
42    ) -> Result<GithubResponse, GithubError>;
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct StubGithubProvider;
47
48#[async_trait]
49impl GithubProvider for StubGithubProvider {
50    fn name(&self) -> &'static str {
51        "stub_github"
52    }
53
54    async fn fetch(
55        &self,
56        request: &GithubRequest,
57        _ctx: &CallContext,
58    ) -> Result<GithubResponse, GithubError> {
59        let hash_input = serde_json::to_string(request)
60            .map_err(|e| GithubError::InvalidRequest(format!("non-serializable request: {e}")))?;
61        let request_hash = content_hash(&hash_input);
62
63        let GithubRequest::Lookup { identifier } = request;
64        let entity = Organization {
65            login: identifier.clone(),
66            html_url: "Stub Organization".to_string(),
67        };
68
69        let obs = Observation {
70            observation_id: format!("obs:github:{request_hash}"),
71            request_hash,
72            vendor: "stub_github".to_string(),
73            model: "stub".to_string(),
74            latency_ms: 5,
75            cost_estimate: None,
76            tokens: None,
77            content: entity,
78            raw_response: None,
79        };
80
81        Ok(GithubResponse { records: vec![obs] })
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[tokio::test]
90    async fn stub_request_hash_matches_content_hash() {
91        // Intent: replay-from-audit contract — the recorded
92        // request_hash must equal content_hash(canonical-JSON
93        // request). Same load-bearing guarantee as every other port.
94        let provider = StubGithubProvider;
95        let req = GithubRequest::Lookup {
96            identifier: OrgSlug::parse("STUB-001").unwrap(),
97        };
98        let resp = provider.fetch(&req, &CallContext::default()).await.unwrap();
99        let expected = content_hash(&serde_json::to_string(&req).unwrap());
100        assert_eq!(resp.records[0].request_hash, expected);
101    }
102
103    #[tokio::test]
104    async fn stub_returns_one_observation() {
105        let provider = StubGithubProvider;
106        let req = GithubRequest::Lookup {
107            identifier: OrgSlug::parse("STUB-001").unwrap(),
108        };
109        let resp = provider.fetch(&req, &CallContext::default()).await.unwrap();
110        assert_eq!(resp.records.len(), 1);
111    }
112}