Skip to main content

tracevault_cli/
api_client.rs

1use serde::{Deserialize, Serialize};
2use std::error::Error;
3use std::path::Path;
4
5pub struct ApiClient {
6    base_url: String,
7    api_key: Option<String>,
8    client: reqwest::Client,
9}
10
11#[derive(Serialize)]
12pub struct PushTraceRequest {
13    pub repo_name: String,
14    pub commit_sha: String,
15    pub branch: Option<String>,
16    pub author: String,
17    pub model: Option<String>,
18    pub tool: Option<String>,
19    pub session_id: Option<String>,
20    pub total_tokens: Option<i64>,
21    pub input_tokens: Option<i64>,
22    pub output_tokens: Option<i64>,
23    pub estimated_cost_usd: Option<f64>,
24    pub api_calls: Option<i32>,
25    pub session_data: Option<serde_json::Value>,
26    pub transcript: Option<serde_json::Value>,
27    pub diff_data: Option<serde_json::Value>,
28    pub model_usage: Option<serde_json::Value>,
29    pub duration_ms: Option<i64>,
30    pub started_at: Option<String>,
31    pub ended_at: Option<String>,
32    pub user_messages: Option<i32>,
33    pub assistant_messages: Option<i32>,
34    pub tool_calls: Option<serde_json::Value>,
35    pub total_tool_calls: Option<i32>,
36    pub cache_read_tokens: Option<i64>,
37    pub cache_write_tokens: Option<i64>,
38    pub compactions: Option<i32>,
39    pub compaction_tokens_saved: Option<i64>,
40}
41
42#[derive(Deserialize)]
43pub struct PushTraceResponse {
44    pub commit_id: uuid::Uuid,
45}
46
47#[derive(Serialize)]
48pub struct RegisterRepoRequest {
49    pub repo_name: String,
50    pub github_url: Option<String>,
51}
52
53#[derive(Deserialize)]
54pub struct RegisterRepoResponse {
55    pub repo_id: uuid::Uuid,
56}
57
58#[derive(Deserialize)]
59pub struct DeviceAuthResponse {
60    pub token: String,
61}
62
63#[derive(Deserialize)]
64pub struct DeviceStatusResponse {
65    pub status: String,
66    pub token: Option<String>,
67    pub email: Option<String>,
68}
69
70#[derive(Debug, Serialize)]
71pub struct CheckPoliciesRequest {
72    pub sessions: Vec<SessionCheckData>,
73}
74
75#[derive(Debug, Serialize)]
76pub struct SessionCheckData {
77    pub session_id: String,
78    pub tool_calls: Option<serde_json::Value>,
79    pub files_modified: Option<Vec<String>>,
80    pub total_tool_calls: Option<i32>,
81}
82
83#[derive(Debug, Deserialize)]
84pub struct CheckPoliciesResponse {
85    pub passed: bool,
86    pub results: Vec<CheckResultItem>,
87    pub blocked: bool,
88}
89
90#[derive(Debug, Deserialize)]
91pub struct CheckResultItem {
92    pub rule_name: String,
93    pub result: String,
94    pub action: String,
95    pub severity: String,
96    pub details: String,
97}
98
99#[derive(Debug, Deserialize)]
100pub struct RepoListItem {
101    pub id: uuid::Uuid,
102    pub name: String,
103}
104
105#[derive(Debug, Serialize)]
106pub struct CiVerifyRequest {
107    pub commits: Vec<String>,
108}
109
110#[derive(Debug, Deserialize)]
111pub struct CiVerifyResponse {
112    pub status: String,
113    pub total_commits: usize,
114    pub registered_commits: usize,
115    pub sealed_commits: usize,
116    pub policy_passed_commits: usize,
117    pub results: Vec<CommitVerifyResult>,
118}
119
120#[derive(Debug, Deserialize)]
121pub struct CommitVerifyResult {
122    pub commit_sha: String,
123    pub status: String,
124    pub registered: bool,
125    pub sealed: bool,
126    pub signature_valid: bool,
127    pub chain_valid: bool,
128    pub policy_results: Vec<CiPolicyResult>,
129}
130
131#[derive(Debug, Deserialize)]
132pub struct CiPolicyResult {
133    pub rule_name: String,
134    pub result: String,
135    pub action: String,
136    pub severity: String,
137    pub details: String,
138}
139
140impl ApiClient {
141    pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
142        Self {
143            base_url: base_url.trim_end_matches('/').to_string(),
144            api_key: api_key.map(String::from),
145            client: reqwest::Client::new(),
146        }
147    }
148
149    pub async fn push_trace(
150        &self,
151        org_slug: &str,
152        req: PushTraceRequest,
153    ) -> Result<PushTraceResponse, Box<dyn Error>> {
154        let mut builder = self
155            .client
156            .post(format!("{}/api/v1/orgs/{}/traces", self.base_url, org_slug));
157
158        if let Some(key) = &self.api_key {
159            builder = builder.header("Authorization", format!("Bearer {key}"));
160        }
161
162        let resp = builder.json(&req).send().await?;
163
164        if !resp.status().is_success() {
165            let status = resp.status();
166            let body = resp.text().await.unwrap_or_default();
167            return Err(format!("Server returned {status}: {body}").into());
168        }
169
170        Ok(resp.json().await?)
171    }
172
173    pub async fn register_repo(
174        &self,
175        org_slug: &str,
176        req: RegisterRepoRequest,
177    ) -> Result<RegisterRepoResponse, Box<dyn Error>> {
178        let mut builder = self
179            .client
180            .post(format!("{}/api/v1/orgs/{}/repos", self.base_url, org_slug));
181
182        if let Some(key) = &self.api_key {
183            builder = builder.header("Authorization", format!("Bearer {key}"));
184        }
185
186        let resp = builder.json(&req).send().await?;
187
188        if !resp.status().is_success() {
189            let status = resp.status();
190            let body = resp.text().await.unwrap_or_default();
191            return Err(format!("Server returned {status}: {body}").into());
192        }
193
194        Ok(resp.json().await?)
195    }
196
197    pub async fn device_start(&self) -> Result<DeviceAuthResponse, Box<dyn Error>> {
198        let resp = self
199            .client
200            .post(format!("{}/api/v1/auth/device", self.base_url))
201            .send()
202            .await?;
203
204        if !resp.status().is_success() {
205            let status = resp.status();
206            let body = resp.text().await.unwrap_or_default();
207            return Err(format!("Server returned {status}: {body}").into());
208        }
209
210        Ok(resp.json().await?)
211    }
212
213    pub async fn device_status(&self, token: &str) -> Result<DeviceStatusResponse, Box<dyn Error>> {
214        let resp = self
215            .client
216            .get(format!(
217                "{}/api/v1/auth/device/{token}/status",
218                self.base_url
219            ))
220            .send()
221            .await?;
222
223        if !resp.status().is_success() {
224            let status = resp.status();
225            let body = resp.text().await.unwrap_or_default();
226            return Err(format!("Server returned {status}: {body}").into());
227        }
228
229        Ok(resp.json().await?)
230    }
231
232    pub async fn logout(&self) -> Result<(), Box<dyn Error>> {
233        let mut builder = self
234            .client
235            .post(format!("{}/api/v1/auth/logout", self.base_url));
236        if let Some(key) = &self.api_key {
237            builder = builder.header("Authorization", format!("Bearer {key}"));
238        }
239        let resp = builder.send().await?;
240        if !resp.status().is_success() {
241            let status = resp.status();
242            let body = resp.text().await.unwrap_or_default();
243            return Err(format!("Server returned {status}: {body}").into());
244        }
245        Ok(())
246    }
247
248    pub async fn list_repos(&self, org_slug: &str) -> Result<Vec<RepoListItem>, Box<dyn Error>> {
249        let mut builder = self
250            .client
251            .get(format!("{}/api/v1/orgs/{}/repos", self.base_url, org_slug));
252        if let Some(key) = &self.api_key {
253            builder = builder.header("Authorization", format!("Bearer {key}"));
254        }
255
256        let resp = builder.send().await?;
257
258        if !resp.status().is_success() {
259            let status = resp.status();
260            let body = resp.text().await.unwrap_or_default();
261            return Err(format!("Failed to list repos ({status}): {body}").into());
262        }
263
264        let repos: Vec<RepoListItem> = resp.json().await?;
265        Ok(repos)
266    }
267
268    pub async fn verify_commits(
269        &self,
270        org_slug: &str,
271        repo_id: &uuid::Uuid,
272        req: CiVerifyRequest,
273    ) -> Result<CiVerifyResponse, Box<dyn Error>> {
274        let mut builder = self.client.post(format!(
275            "{}/api/v1/orgs/{}/repos/{}/ci/verify",
276            self.base_url, org_slug, repo_id
277        ));
278        if let Some(key) = &self.api_key {
279            builder = builder.header("Authorization", format!("Bearer {key}"));
280        }
281
282        let resp = builder.json(&req).send().await?;
283
284        if !resp.status().is_success() {
285            let status = resp.status();
286            let body = resp.text().await.unwrap_or_default();
287            return Err(format!("CI verify failed ({status}): {body}").into());
288        }
289
290        Ok(resp.json().await?)
291    }
292
293    pub async fn push_commit(
294        &self,
295        org_slug: &str,
296        repo_id: &str,
297        req: &tracevault_core::streaming::CommitPushRequest,
298    ) -> Result<tracevault_core::streaming::CommitPushResponse, Box<dyn Error>> {
299        let mut builder = self.client.post(format!(
300            "{}/api/v1/orgs/{}/repos/{}/commits",
301            self.base_url, org_slug, repo_id
302        ));
303        if let Some(key) = &self.api_key {
304            builder = builder.header("Authorization", format!("Bearer {key}"));
305        }
306        let resp = builder.json(req).send().await?;
307        if !resp.status().is_success() {
308            let status = resp.status();
309            let body = resp.text().await.unwrap_or_default();
310            return Err(format!("Commit push failed ({status}): {body}").into());
311        }
312        Ok(resp.json().await?)
313    }
314
315    pub async fn stream_event(
316        &self,
317        org_slug: &str,
318        repo_id: &str,
319        req: &tracevault_core::streaming::StreamEventRequest,
320    ) -> Result<tracevault_core::streaming::StreamEventResponse, Box<dyn Error>> {
321        let mut builder = self.client.post(format!(
322            "{}/api/v1/orgs/{}/repos/{}/stream",
323            self.base_url, org_slug, repo_id
324        ));
325        if let Some(key) = &self.api_key {
326            builder = builder.header("Authorization", format!("Bearer {key}"));
327        }
328        let resp = builder.json(req).send().await?;
329        if !resp.status().is_success() {
330            let status = resp.status();
331            let body = resp.text().await.unwrap_or_default();
332            return Err(format!("Stream failed ({status}): {body}").into());
333        }
334        Ok(resp.json().await?)
335    }
336
337    pub async fn check_policies(
338        &self,
339        org_slug: &str,
340        repo_id: &uuid::Uuid,
341        req: CheckPoliciesRequest,
342    ) -> Result<CheckPoliciesResponse, Box<dyn Error>> {
343        let mut builder = self.client.post(format!(
344            "{}/api/v1/orgs/{}/repos/{}/policies/check",
345            self.base_url, org_slug, repo_id
346        ));
347        if let Some(key) = &self.api_key {
348            builder = builder.header("Authorization", format!("Bearer {key}"));
349        }
350
351        let resp = builder.json(&req).send().await?;
352
353        if !resp.status().is_success() {
354            let status = resp.status();
355            let body = resp.text().await.unwrap_or_default();
356            return Err(format!("Policy check failed ({status}): {body}").into());
357        }
358
359        let result: CheckPoliciesResponse = resp.json().await?;
360        Ok(result)
361    }
362}
363
364/// Resolve server URL and auth token from multiple sources.
365/// Priority: env var > credentials file > project config.toml
366/// Returns (server_url, auth_token).
367pub fn resolve_credentials(project_root: &Path) -> (Option<String>, Option<String>) {
368    use crate::credentials::Credentials;
369
370    // 1. Env var API key
371    let env_key = std::env::var("TRACEVAULT_API_KEY").ok();
372
373    // 2. Credentials file
374    let creds = Credentials::load();
375
376    // 3. Project config
377    let config_path = crate::config::TracevaultConfig::config_path(project_root);
378    let config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
379
380    let config_server_url = config_content
381        .lines()
382        .find(|l| l.starts_with("server_url"))
383        .and_then(|l| l.split('=').nth(1))
384        .map(|s| s.trim().trim_matches('"').to_string());
385
386    let config_api_key = config_content
387        .lines()
388        .find(|l| l.starts_with("api_key"))
389        .and_then(|l| l.split('=').nth(1))
390        .map(|s| s.trim().trim_matches('"').to_string());
391
392    // Resolve server URL: env > creds > config
393    let server_url = std::env::var("TRACEVAULT_SERVER_URL")
394        .ok()
395        .or_else(|| creds.as_ref().map(|c| c.server_url.clone()))
396        .or(config_server_url);
397
398    // Resolve token: env api key > creds token > config api key
399    let token = env_key
400        .or_else(|| creds.map(|c| c.token))
401        .or(config_api_key);
402
403    (server_url, token)
404}