jules_rs/
client.rs

1//! HTTP client for the Jules API.
2//!
3//! This module provides the main [`JulesClient`] struct for interacting with
4//! the Jules API endpoints.
5
6use crate::error::{JulesError, Result};
7use crate::models::*;
8use futures_util::{StreamExt, stream::Stream};
9use reqwest::{Client, Method, RequestBuilder};
10use serde::Deserialize;
11use std::pin::Pin;
12use url::Url;
13
14/// The main client for interacting with the Jules API.
15///
16/// `JulesClient` provides methods for all Jules API operations including
17/// managing sessions, activities, and sources.
18///
19/// # Example
20///
21/// ```rust,no_run
22/// use jules_rs::JulesClient;
23///
24/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
25/// let client = JulesClient::new("YOUR_OAUTH_TOKEN")?;
26///
27/// // List sessions
28/// let response = client.list_sessions(Some(10), None).await?;
29/// println!("Found {} sessions", response.sessions.len());
30/// # Ok(())
31/// # }
32/// ```
33pub struct JulesClient {
34    http: Client,
35    base_url: Url,
36    token: String,
37}
38
39impl JulesClient {
40    /// Creates a new Jules API client.
41    ///
42    /// # Arguments
43    ///
44    /// * `api_key` - An API key from [jules.google.com/settings](https://jules.google.com/settings).
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if the base URL cannot be parsed (should not happen
49    /// under normal circumstances).
50    ///
51    /// # Example
52    ///
53    /// ```rust,no_run
54    /// use jules_rs::JulesClient;
55    ///
56    /// let client = JulesClient::new("YOUR_API_KEY").unwrap();
57    /// ```
58    pub fn new(token: impl Into<String>) -> Result<Self> {
59        Ok(Self {
60            http: Client::new(),
61            base_url: Url::parse("https://jules.googleapis.com/v1alpha/")?,
62            token: token.into(),
63        })
64    }
65
66    fn request(&self, method: Method, path: &str) -> RequestBuilder {
67        let url = self.base_url.join(path).expect("Path joining failed");
68        self.http
69            .request(method, url)
70            .header("X-Goog-Api-Key", &self.token)
71            .header("Accept", "application/json")
72    }
73
74    async fn execute<T>(&self, builder: RequestBuilder) -> Result<T>
75    where
76        T: for<'de> Deserialize<'de>,
77    {
78        let response = builder.send().await?;
79        if !response.status().is_success() {
80            let status = response.status();
81            let message = response.text().await.unwrap_or_default();
82            return Err(JulesError::Api { status, message });
83        }
84        Ok(response.json().await?)
85    }
86
87    // --- Sessions API ---
88
89    /// Creates a new coding session.
90    ///
91    /// # Arguments
92    ///
93    /// * `session` - The session configuration including prompt and source context.
94    ///
95    /// # Returns
96    ///
97    /// The created session with server-generated fields populated (name, id, etc.).
98    ///
99    /// # Example
100    ///
101    /// ```rust,no_run
102    /// use jules_rs::{JulesClient, Session, SourceContext, GitHubRepoContext};
103    ///
104    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
105    /// let client = JulesClient::new("TOKEN")?;
106    /// let session = Session {
107    ///     prompt: "Fix the bug".to_string(),
108    ///     source_context: SourceContext {
109    ///         source: "sources/repo-id".to_string(),
110    ///         github_repo_context: Some(GitHubRepoContext {
111    ///             starting_branch: "main".to_string(),
112    ///         }),
113    ///     },
114    ///     // ... other fields set to None/default
115    /// #   name: None, id: None, title: None, require_plan_approval: None,
116    /// #   automation_mode: None, create_time: None, update_time: None,
117    /// #   state: None, url: None, outputs: None,
118    /// };
119    /// let created = client.create_session(&session).await?;
120    /// # Ok(())
121    /// # }
122    /// ```
123    pub async fn create_session(&self, session: &Session) -> Result<Session> {
124        let rb = self.request(Method::POST, "sessions").json(session);
125        self.execute(rb).await
126    }
127
128    /// Gets a session by its resource name.
129    ///
130    /// # Arguments
131    ///
132    /// * `name` - The full resource name (e.g., `sessions/abc123`).
133    pub async fn get_session(&self, name: &str) -> Result<Session> {
134        self.execute(self.request(Method::GET, name)).await
135    }
136
137    /// Deletes a session.
138    ///
139    /// # Arguments
140    ///
141    /// * `name` - The full resource name of the session to delete.
142    pub async fn delete_session(&self, name: &str) -> Result<()> {
143        let _: Empty = self.execute(self.request(Method::DELETE, name)).await?;
144        Ok(())
145    }
146
147    /// Lists sessions with pagination.
148    ///
149    /// # Arguments
150    ///
151    /// * `page_size` - Maximum number of sessions to return (1-100, default 30).
152    /// * `page_token` - Token from a previous response for pagination.
153    ///
154    /// # Returns
155    ///
156    /// A response containing sessions and optionally a token for the next page.
157    pub async fn list_sessions(
158        &self,
159        page_size: Option<i32>,
160        page_token: Option<String>,
161    ) -> Result<ListSessionsResponse> {
162        let mut rb = self.request(Method::GET, "sessions");
163        if let Some(ps) = page_size {
164            rb = rb.query(&[("pageSize", ps)]);
165        }
166        if let Some(pt) = page_token {
167            rb = rb.query(&[("pageToken", pt)]);
168        }
169        self.execute(rb).await
170    }
171
172    /// Returns an async stream over all sessions.
173    ///
174    /// This method automatically handles pagination, yielding sessions one at
175    /// a time until all sessions have been retrieved.
176    ///
177    /// # Example
178    ///
179    /// ```rust,no_run
180    /// use jules_rs::JulesClient;
181    /// use futures_util::StreamExt;
182    ///
183    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
184    /// let client = JulesClient::new("TOKEN")?;
185    /// let mut stream = client.stream_sessions();
186    ///
187    /// while let Some(result) = stream.next().await {
188    ///     let session = result?;
189    ///     println!("Session: {:?}", session.title);
190    /// }
191    /// # Ok(())
192    /// # }
193    /// ```
194    pub fn stream_sessions(&self) -> Pin<Box<dyn Stream<Item = Result<Session>> + '_>> {
195        Box::pin(
196            futures_util::stream::unfold(Some("".to_string()), move |state| async move {
197                let current_token = state?;
198                let token_opt = if current_token.is_empty() {
199                    None
200                } else {
201                    Some(current_token)
202                };
203
204                match self.list_sessions(Some(100), token_opt).await {
205                    Ok(resp) => {
206                        let next_token = resp.next_page_token.clone().unwrap_or_default();
207                        let next_state = if next_token.is_empty() {
208                            None
209                        } else {
210                            Some(next_token)
211                        };
212                        let items: Vec<Result<Session>> =
213                            resp.sessions.into_iter().map(Ok).collect();
214                        Some((futures_util::stream::iter(items), next_state))
215                    }
216                    Err(e) => {
217                        let items: Vec<Result<Session>> = vec![Err(e)];
218                        Some((futures_util::stream::iter(items), None))
219                    }
220                }
221            })
222            .flatten(),
223        )
224    }
225
226    /// Sends a message to an active session.
227    ///
228    /// Use this to provide additional context or respond to the agent's
229    /// questions during a session.
230    ///
231    /// # Arguments
232    ///
233    /// * `session_name` - The full resource name of the session.
234    /// * `prompt` - The message to send.
235    pub async fn send_message(&self, session_name: &str, prompt: &str) -> Result<()> {
236        let path = format!("{}:sendMessage", session_name);
237        let body = SendMessageRequest {
238            prompt: prompt.to_string(),
239        };
240        let _: Empty = self
241            .execute(self.request(Method::POST, &path).json(&body))
242            .await?;
243        Ok(())
244    }
245
246    /// Approves the current plan for a session.
247    ///
248    /// When a session is in the `AWAITING_PLAN_APPROVAL` state, call this
249    /// method to approve the plan and allow the agent to proceed.
250    ///
251    /// # Arguments
252    ///
253    /// * `session_name` - The full resource name of the session.
254    pub async fn approve_plan(&self, session_name: &str) -> Result<()> {
255        let path = format!("{}:approvePlan", session_name);
256        let body = ApprovePlanRequest {};
257        let _: Empty = self
258            .execute(self.request(Method::POST, &path).json(&body))
259            .await?;
260        Ok(())
261    }
262
263    // --- Activities API ---
264
265    /// Gets an activity by its resource name.
266    ///
267    /// # Arguments
268    ///
269    /// * `name` - The full resource name (e.g., `sessions/123/activities/456`).
270    pub async fn get_activity(&self, name: &str) -> Result<Activity> {
271        self.execute(self.request(Method::GET, name)).await
272    }
273
274    /// Lists activities for a session with pagination.
275    ///
276    /// # Arguments
277    ///
278    /// * `session_name` - The full resource name of the session.
279    /// * `page_size` - Maximum number of activities to return.
280    /// * `page_token` - Token from a previous response for pagination.
281    pub async fn list_activities(
282        &self,
283        session_name: &str,
284        page_size: Option<i32>,
285        page_token: Option<String>,
286    ) -> Result<ListActivitiesResponse> {
287        let path = format!("{}/activities", session_name);
288        let mut rb = self.request(Method::GET, &path);
289        if let Some(ps) = page_size {
290            rb = rb.query(&[("pageSize", ps)]);
291        }
292        if let Some(pt) = page_token {
293            rb = rb.query(&[("pageToken", pt)]);
294        }
295        self.execute(rb).await
296    }
297
298    // --- Sources API ---
299
300    /// Gets a source by its resource name.
301    ///
302    /// # Arguments
303    ///
304    /// * `name` - The full resource name (e.g., `sources/abc123`).
305    pub async fn get_source(&self, name: &str) -> Result<Source> {
306        self.execute(self.request(Method::GET, name)).await
307    }
308
309    /// Lists available sources (connected repositories) with pagination.
310    ///
311    /// # Arguments
312    ///
313    /// * `filter` - Optional filter expression.
314    /// * `page_size` - Maximum number of sources to return.
315    /// * `page_token` - Token from a previous response for pagination.
316    pub async fn list_sources(
317        &self,
318        filter: Option<String>,
319        page_size: Option<i32>,
320        page_token: Option<String>,
321    ) -> Result<ListSourcesResponse> {
322        let mut rb = self.request(Method::GET, "sources");
323        if let Some(f) = filter {
324            rb = rb.query(&[("filter", f)]);
325        }
326        if let Some(ps) = page_size {
327            rb = rb.query(&[("pageSize", ps)]);
328        }
329        if let Some(pt) = page_token {
330            rb = rb.query(&[("pageToken", pt)]);
331        }
332        self.execute(rb).await
333    }
334}