use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub struct SessionBuildError(String);
impl fmt::Display for SessionBuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Session build error: {}", self.0)
}
}
impl Error for SessionBuildError {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GithubRepoContext {
#[serde(skip_serializing_if = "Option::is_none")]
starting_branch: Option<String>,
}
impl GithubRepoContext {
#[must_use]
pub fn new(starting_branch: impl Into<String>) -> Self {
Self {
starting_branch: Some(starting_branch.into()),
}
}
#[must_use]
pub fn starting_branch(&self) -> Option<&str> {
self.starting_branch.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SourceContext {
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
github_repo_context: Option<GithubRepoContext>,
#[serde(skip_serializing_if = "Option::is_none")]
environment_variables_enabled: Option<bool>,
}
impl SourceContext {
#[must_use]
pub fn new(source: impl Into<String>) -> Self {
Self {
source: Some(source.into()),
..Self::default()
}
}
#[must_use]
pub fn with_github_repo_context(mut self, context: GithubRepoContext) -> Self {
self.github_repo_context = Some(context);
self
}
#[must_use]
pub fn with_environment_variables_enabled(mut self, enabled: bool) -> Self {
self.environment_variables_enabled = Some(enabled);
self
}
#[must_use]
pub fn source(&self) -> Option<&str> {
self.source.as_deref()
}
#[must_use]
pub fn github_repo_context(&self) -> Option<&GithubRepoContext> {
self.github_repo_context.as_ref()
}
#[must_use]
pub fn environment_variables_enabled(&self) -> Option<bool> {
self.environment_variables_enabled
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
create_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
update_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_context: Option<SourceContext>,
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
}
impl Session {
#[must_use]
pub fn builder() -> SessionBuilder {
SessionBuilder::default()
}
#[must_use]
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
#[must_use]
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
#[must_use]
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
#[must_use]
pub fn create_time(&self) -> Option<&str> {
self.create_time.as_deref()
}
#[must_use]
pub fn update_time(&self) -> Option<&str> {
self.update_time.as_deref()
}
#[must_use]
pub fn state(&self) -> Option<&str> {
self.state.as_deref()
}
#[must_use]
pub fn source_context(&self) -> Option<&SourceContext> {
self.source_context.as_ref()
}
#[must_use]
pub fn prompt(&self) -> Option<&str> {
self.prompt.as_deref()
}
#[must_use]
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
}
#[derive(Debug, Default)]
pub struct SessionBuilder {
id: Option<String>,
name: Option<String>,
title: Option<String>,
create_time: Option<String>,
update_time: Option<String>,
state: Option<String>,
source_context: Option<SourceContext>,
prompt: Option<String>,
url: Option<String>,
}
impl SessionBuilder {
#[must_use]
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn create_time(mut self, create_time: impl Into<String>) -> Self {
self.create_time = Some(create_time.into());
self
}
#[must_use]
pub fn update_time(mut self, update_time: impl Into<String>) -> Self {
self.update_time = Some(update_time.into());
self
}
#[must_use]
pub fn state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
#[must_use]
pub fn source_context(mut self, source_context: SourceContext) -> Self {
self.source_context = Some(source_context);
self
}
#[must_use]
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = Some(prompt.into());
self
}
#[must_use]
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
pub fn build(self) -> Result<Session, SessionBuildError> {
Ok(Session {
id: self.id,
name: self.name,
title: self.title,
create_time: self.create_time,
update_time: self.update_time,
state: self.state,
source_context: self.source_context,
prompt: self.prompt,
url: self.url,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_builder_with_name_and_id() {
let session = Session::builder()
.id("session-123")
.name("My Session")
.build()
.unwrap();
assert_eq!(session.id(), Some("session-123"));
assert_eq!(session.name(), Some("My Session"));
}
#[test]
fn test_session_builder_without_fields() {
let session = Session::builder().build().unwrap();
assert_eq!(session.id(), None);
assert_eq!(session.name(), None);
}
#[test]
fn test_session_builder_with_full_fields() {
let session = Session::builder()
.id("11413719004378428992")
.name("sessions/11413719004378428992")
.title("Example session")
.create_time("2026-08-08T12:42:12.441608052Z")
.update_time("2026-08-08T12:59:20.277897Z")
.state("AWAITING_USER_FEEDBACK")
.source_context(
SourceContext::new("sources/github/example-owner/example-repo")
.with_github_repo_context(GithubRepoContext::new("main"))
.with_environment_variables_enabled(true),
)
.prompt("Do the thing")
.url("https://jules.google.com/session/123")
.build()
.unwrap();
assert_eq!(session.title(), Some("Example session"));
assert_eq!(session.state(), Some("AWAITING_USER_FEEDBACK"));
assert_eq!(
session.source_context().and_then(SourceContext::source),
Some("sources/github/example-owner/example-repo")
);
assert_eq!(
session
.source_context()
.and_then(SourceContext::github_repo_context)
.and_then(GithubRepoContext::starting_branch),
Some("main")
);
}
#[test]
fn test_session_deserializes_real_api_shape() {
let json = r#"{
"name": "sessions/11413719004378428992",
"title": "Example session",
"createTime": "2026-08-08T12:42:12.441608052Z",
"updateTime": "2026-08-08T12:59:20.277897Z",
"state": "AWAITING_USER_FEEDBACK",
"sourceContext": {
"source": "sources/github/example-owner/example-repo",
"githubRepoContext": {
"startingBranch": "main"
},
"environmentVariablesEnabled": true
},
"prompt": "Do the thing",
"url": "https://jules.google.com/session/123",
"id": "11413719004378428992"
}"#;
let session: Session = serde_json::from_str(json).unwrap();
assert_eq!(session.name(), Some("sessions/11413719004378428992"));
assert_eq!(session.state(), Some("AWAITING_USER_FEEDBACK"));
assert_eq!(
session
.source_context()
.and_then(SourceContext::github_repo_context)
.and_then(GithubRepoContext::starting_branch),
Some("main")
);
}
}