Skip to main content

jules_core/session/
mod.rs

1//! Session module.
2
3use serde::{Deserialize, Serialize};
4use std::error::Error;
5use std::fmt;
6
7/// An error that can occur when building a [`Session`].
8#[derive(Debug)]
9pub struct SessionBuildError(String);
10
11impl fmt::Display for SessionBuildError {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        write!(f, "Session build error: {}", self.0)
14    }
15}
16
17impl Error for SessionBuildError {}
18
19/// GitHub-specific context for a [`SourceContext`].
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct GithubRepoContext {
23    #[serde(skip_serializing_if = "Option::is_none")]
24    starting_branch: Option<String>,
25}
26
27impl GithubRepoContext {
28    /// Creates a new `GithubRepoContext` with the given starting branch.
29    #[must_use]
30    pub fn new(starting_branch: impl Into<String>) -> Self {
31        Self {
32            starting_branch: Some(starting_branch.into()),
33        }
34    }
35
36    /// Returns the starting branch, if configured.
37    #[must_use]
38    pub fn starting_branch(&self) -> Option<&str> {
39        self.starting_branch.as_deref()
40    }
41}
42
43/// The source a [`Session`] operates on, as returned/accepted by the Jules API.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
45#[serde(rename_all = "camelCase")]
46pub struct SourceContext {
47    #[serde(skip_serializing_if = "Option::is_none")]
48    source: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    github_repo_context: Option<GithubRepoContext>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    environment_variables_enabled: Option<bool>,
53}
54
55impl SourceContext {
56    /// Creates a new `SourceContext` pointing at the given source (e.g. `sources/github/owner/repo`).
57    #[must_use]
58    pub fn new(source: impl Into<String>) -> Self {
59        Self {
60            source: Some(source.into()),
61            ..Self::default()
62        }
63    }
64
65    /// Sets the GitHub repo context (e.g. starting branch).
66    #[must_use]
67    pub fn with_github_repo_context(mut self, context: GithubRepoContext) -> Self {
68        self.github_repo_context = Some(context);
69        self
70    }
71
72    /// Sets whether environment variables are enabled for this source context.
73    #[must_use]
74    pub fn with_environment_variables_enabled(mut self, enabled: bool) -> Self {
75        self.environment_variables_enabled = Some(enabled);
76        self
77    }
78
79    /// Returns the source identifier, if configured.
80    #[must_use]
81    pub fn source(&self) -> Option<&str> {
82        self.source.as_deref()
83    }
84
85    /// Returns the GitHub repo context, if configured.
86    #[must_use]
87    pub fn github_repo_context(&self) -> Option<&GithubRepoContext> {
88        self.github_repo_context.as_ref()
89    }
90
91    /// Returns whether environment variables are enabled, if configured.
92    #[must_use]
93    pub fn environment_variables_enabled(&self) -> Option<bool> {
94        self.environment_variables_enabled
95    }
96}
97
98/// A session represents an active context for interactions with the Jules API.
99///
100/// Field shape matches the real `v1alpha` Jules API `Session` resource (verified against the
101/// live API's `GET /v1alpha/sessions` and `GET /v1alpha/{name}` responses on 2026-08-08).
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct Session {
105    #[serde(skip_serializing_if = "Option::is_none")]
106    id: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    name: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    title: Option<String>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    create_time: Option<String>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    update_time: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    state: Option<String>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    source_context: Option<SourceContext>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    prompt: Option<String>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    url: Option<String>,
123}
124
125impl Session {
126    /// Creates a new [`SessionBuilder`] to construct a [`Session`].
127    #[must_use]
128    pub fn builder() -> SessionBuilder {
129        SessionBuilder::default()
130    }
131
132    /// Returns the name of the session, if configured (e.g. `sessions/1234567890`).
133    #[must_use]
134    pub fn name(&self) -> Option<&str> {
135        self.name.as_deref()
136    }
137
138    /// Returns the id of the session, if configured.
139    #[must_use]
140    pub fn id(&self) -> Option<&str> {
141        self.id.as_deref()
142    }
143
144    /// Returns the human-readable title of the session, if configured.
145    #[must_use]
146    pub fn title(&self) -> Option<&str> {
147        self.title.as_deref()
148    }
149
150    /// Returns the session's creation timestamp (RFC 3339), if configured.
151    #[must_use]
152    pub fn create_time(&self) -> Option<&str> {
153        self.create_time.as_deref()
154    }
155
156    /// Returns the session's last-update timestamp (RFC 3339), if configured.
157    #[must_use]
158    pub fn update_time(&self) -> Option<&str> {
159        self.update_time.as_deref()
160    }
161
162    /// Returns the session's state (e.g. `AWAITING_USER_FEEDBACK`), if configured.
163    #[must_use]
164    pub fn state(&self) -> Option<&str> {
165        self.state.as_deref()
166    }
167
168    /// Returns the session's source context, if configured.
169    #[must_use]
170    pub fn source_context(&self) -> Option<&SourceContext> {
171        self.source_context.as_ref()
172    }
173
174    /// Returns the prompt the session was started with, if configured.
175    #[must_use]
176    pub fn prompt(&self) -> Option<&str> {
177        self.prompt.as_deref()
178    }
179
180    /// Returns the session's URL, if configured.
181    #[must_use]
182    pub fn url(&self) -> Option<&str> {
183        self.url.as_deref()
184    }
185}
186
187/// A builder for constructing a [`Session`].
188#[derive(Debug, Default)]
189pub struct SessionBuilder {
190    id: Option<String>,
191    name: Option<String>,
192    title: Option<String>,
193    create_time: Option<String>,
194    update_time: Option<String>,
195    state: Option<String>,
196    source_context: Option<SourceContext>,
197    prompt: Option<String>,
198    url: Option<String>,
199}
200
201impl SessionBuilder {
202    /// Sets the id for the session.
203    #[must_use]
204    pub fn id(mut self, id: impl Into<String>) -> Self {
205        self.id = Some(id.into());
206        self
207    }
208
209    /// Sets the name for the session.
210    #[must_use]
211    pub fn name(mut self, name: impl Into<String>) -> Self {
212        self.name = Some(name.into());
213        self
214    }
215
216    /// Sets the title for the session.
217    #[must_use]
218    pub fn title(mut self, title: impl Into<String>) -> Self {
219        self.title = Some(title.into());
220        self
221    }
222
223    /// Sets the creation timestamp for the session.
224    #[must_use]
225    pub fn create_time(mut self, create_time: impl Into<String>) -> Self {
226        self.create_time = Some(create_time.into());
227        self
228    }
229
230    /// Sets the last-update timestamp for the session.
231    #[must_use]
232    pub fn update_time(mut self, update_time: impl Into<String>) -> Self {
233        self.update_time = Some(update_time.into());
234        self
235    }
236
237    /// Sets the state for the session.
238    #[must_use]
239    pub fn state(mut self, state: impl Into<String>) -> Self {
240        self.state = Some(state.into());
241        self
242    }
243
244    /// Sets the source context for the session.
245    #[must_use]
246    pub fn source_context(mut self, source_context: SourceContext) -> Self {
247        self.source_context = Some(source_context);
248        self
249    }
250
251    /// Sets the prompt for the session.
252    #[must_use]
253    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
254        self.prompt = Some(prompt.into());
255        self
256    }
257
258    /// Sets the URL for the session.
259    #[must_use]
260    pub fn url(mut self, url: impl Into<String>) -> Self {
261        self.url = Some(url.into());
262        self
263    }
264
265    /// Builds the [`Session`] from the provided configuration.
266    ///
267    /// # Errors
268    ///
269    /// Returns a [`SessionBuildError`] if the session cannot be built from the provided configuration.
270    pub fn build(self) -> Result<Session, SessionBuildError> {
271        Ok(Session {
272            id: self.id,
273            name: self.name,
274            title: self.title,
275            create_time: self.create_time,
276            update_time: self.update_time,
277            state: self.state,
278            source_context: self.source_context,
279            prompt: self.prompt,
280            url: self.url,
281        })
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_session_builder_with_name_and_id() {
291        let session = Session::builder()
292            .id("session-123")
293            .name("My Session")
294            .build()
295            .unwrap();
296
297        assert_eq!(session.id(), Some("session-123"));
298        assert_eq!(session.name(), Some("My Session"));
299    }
300
301    #[test]
302    fn test_session_builder_without_fields() {
303        let session = Session::builder().build().unwrap();
304
305        assert_eq!(session.id(), None);
306        assert_eq!(session.name(), None);
307    }
308
309    #[test]
310    fn test_session_builder_with_full_fields() {
311        let session = Session::builder()
312            .id("11413719004378428992")
313            .name("sessions/11413719004378428992")
314            .title("Example session")
315            .create_time("2026-08-08T12:42:12.441608052Z")
316            .update_time("2026-08-08T12:59:20.277897Z")
317            .state("AWAITING_USER_FEEDBACK")
318            .source_context(
319                SourceContext::new("sources/github/example-owner/example-repo")
320                    .with_github_repo_context(GithubRepoContext::new("main"))
321                    .with_environment_variables_enabled(true),
322            )
323            .prompt("Do the thing")
324            .url("https://jules.google.com/session/123")
325            .build()
326            .unwrap();
327
328        assert_eq!(session.title(), Some("Example session"));
329        assert_eq!(session.state(), Some("AWAITING_USER_FEEDBACK"));
330        assert_eq!(
331            session.source_context().and_then(SourceContext::source),
332            Some("sources/github/example-owner/example-repo")
333        );
334        assert_eq!(
335            session
336                .source_context()
337                .and_then(SourceContext::github_repo_context)
338                .and_then(GithubRepoContext::starting_branch),
339            Some("main")
340        );
341    }
342
343    /// Deserializes a payload shaped like the real `v1alpha` API response, proving the
344    /// `camelCase` wire format round-trips correctly into this `snake_case` model.
345    #[test]
346    fn test_session_deserializes_real_api_shape() {
347        let json = r#"{
348            "name": "sessions/11413719004378428992",
349            "title": "Example session",
350            "createTime": "2026-08-08T12:42:12.441608052Z",
351            "updateTime": "2026-08-08T12:59:20.277897Z",
352            "state": "AWAITING_USER_FEEDBACK",
353            "sourceContext": {
354                "source": "sources/github/example-owner/example-repo",
355                "githubRepoContext": {
356                    "startingBranch": "main"
357                },
358                "environmentVariablesEnabled": true
359            },
360            "prompt": "Do the thing",
361            "url": "https://jules.google.com/session/123",
362            "id": "11413719004378428992"
363        }"#;
364
365        let session: Session = serde_json::from_str(json).unwrap();
366        assert_eq!(session.name(), Some("sessions/11413719004378428992"));
367        assert_eq!(session.state(), Some("AWAITING_USER_FEEDBACK"));
368        assert_eq!(
369            session
370                .source_context()
371                .and_then(SourceContext::github_repo_context)
372                .and_then(GithubRepoContext::starting_branch),
373            Some("main")
374        );
375    }
376}