Skip to main content

bctx_cloud_core/client/
sync.rs

1#[cfg(not(feature = "cloud-server"))]
2use anyhow::bail;
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5use vault::fact::MemoFact;
6
7#[derive(Debug, Serialize, Deserialize)]
8pub struct SyncPushPayload {
9    pub project_hash: String,
10    pub facts: Vec<MemoFact>,
11    pub client_version: String,
12}
13
14#[derive(Debug, Serialize, Deserialize)]
15pub struct SyncPullResponse {
16    pub facts: Vec<MemoFact>,
17    pub server_version: u64,
18}
19
20#[derive(Debug, Serialize, Deserialize)]
21pub struct SyncPushResponse {
22    pub accepted: usize,
23    pub merged: usize,
24    pub server_version: u64,
25}
26
27/// Push CrystallizedKnowledge facts to the cloud for the given project.
28#[cfg(feature = "cloud-server")]
29pub async fn push_vault(
30    endpoint: &str,
31    token: &str,
32    project_hash: &str,
33    facts: Vec<MemoFact>,
34) -> Result<SyncPushResponse> {
35    let payload = SyncPushPayload {
36        project_hash: project_hash.to_string(),
37        facts,
38        client_version: env!("CARGO_PKG_VERSION").to_string(),
39    };
40    let resp = reqwest::Client::builder()
41        .timeout(std::time::Duration::from_secs(15))
42        .connect_timeout(std::time::Duration::from_secs(8))
43        .build()?
44        .post(format!("{endpoint}/sync/vault"))
45        .bearer_auth(token)
46        .json(&payload)
47        .send()
48        .await?
49        .error_for_status()?
50        .json::<SyncPushResponse>()
51        .await?;
52    Ok(resp)
53}
54
55#[cfg(not(feature = "cloud-server"))]
56pub async fn push_vault(
57    _endpoint: &str,
58    _token: &str,
59    _project_hash: &str,
60    _facts: Vec<MemoFact>,
61) -> Result<SyncPushResponse> {
62    bail!("cloud-server feature not enabled")
63}
64
65/// Pull CrystallizedKnowledge facts from the cloud for the given project.
66#[cfg(feature = "cloud-server")]
67pub async fn pull_vault(
68    endpoint: &str,
69    token: &str,
70    project_hash: &str,
71) -> Result<SyncPullResponse> {
72    let resp = reqwest::Client::builder()
73        .timeout(std::time::Duration::from_secs(15))
74        .connect_timeout(std::time::Duration::from_secs(8))
75        .build()?
76        .get(format!("{endpoint}/sync/vault/{project_hash}"))
77        .bearer_auth(token)
78        .send()
79        .await?
80        .error_for_status()?
81        .json::<SyncPullResponse>()
82        .await?;
83    Ok(resp)
84}
85
86#[cfg(not(feature = "cloud-server"))]
87pub async fn pull_vault(
88    _endpoint: &str,
89    _token: &str,
90    _project_hash: &str,
91) -> Result<SyncPullResponse> {
92    bail!("cloud-server feature not enabled")
93}
94
95/// Sends one daily usage snapshot. Blocking with short timeouts. Called only from the
96/// detached `bctx sync --telemetry` process, never from a command a user is waiting on.
97#[cfg(feature = "cloud-server")]
98pub fn push_usage_snapshot(
99    endpoint: &str,
100    token: &str,
101    snapshot: &crate::usage::UsageSnapshot,
102) -> anyhow::Result<()> {
103    let rt = tokio::runtime::Builder::new_current_thread()
104        .enable_all()
105        .build()?;
106    rt.block_on(async {
107        let client = reqwest::Client::builder()
108            .timeout(std::time::Duration::from_secs(10))
109            .connect_timeout(std::time::Duration::from_secs(5))
110            .build()?;
111        let resp: serde_json::Value = client
112            .post(format!("{endpoint}/sync/usage"))
113            .bearer_auth(token)
114            .json(snapshot)
115            .send()
116            .await?
117            .error_for_status()?
118            .json()
119            .await?;
120        if resp["ok"] == serde_json::json!(true) {
121            Ok(())
122        } else {
123            anyhow::bail!("snapshot rejected: {}", resp["reason"])
124        }
125    })
126}
127
128#[cfg(not(feature = "cloud-server"))]
129pub fn push_usage_snapshot(
130    _endpoint: &str,
131    _token: &str,
132    _snapshot: &crate::usage::UsageSnapshot,
133) -> anyhow::Result<()> {
134    bail!("cloud-server feature not enabled")
135}
136
137#[cfg(all(test, not(feature = "cloud-server")))]
138mod stub_tests {
139    #[test]
140    fn without_the_cloud_client_a_snapshot_is_never_reported_as_sent() {
141        let snapshot = crate::usage::UsageSnapshot {
142            device_id: "11111111-1111-4111-8111-111111111111".into(),
143            client_version: "test".into(),
144            rows: vec![],
145        };
146        assert!(super::push_usage_snapshot("http://127.0.0.1:9", "t", &snapshot).is_err());
147    }
148}