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