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}