jules-core 0.1.1

Core abstractions and traits for Jules-SDK
//! Activity module.

use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fmt;

/// An error that can occur when building an [`Activity`].
#[derive(Debug)]
pub struct ActivityBuildError(String);

impl fmt::Display for ActivityBuildError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Activity build error: {}", self.0)
    }
}

impl Error for ActivityBuildError {}

/// A single step within a generated [`Plan`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PlanStep {
    #[serde(skip_serializing_if = "Option::is_none")]
    id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    description: Option<String>,
}

impl PlanStep {
    /// Returns the step's id, if configured.
    #[must_use]
    pub fn id(&self) -> Option<&str> {
        self.id.as_deref()
    }

    /// Returns the step's title, if configured.
    #[must_use]
    pub fn title(&self) -> Option<&str> {
        self.title.as_deref()
    }

    /// Returns the step's description, if configured.
    #[must_use]
    pub fn description(&self) -> Option<&str> {
        self.description.as_deref()
    }
}

/// A plan generated by the Jules agent, consisting of an ordered list of steps.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Plan {
    #[serde(skip_serializing_if = "Option::is_none")]
    id: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    steps: Vec<PlanStep>,
}

impl Plan {
    /// Returns the plan's id, if configured.
    #[must_use]
    pub fn id(&self) -> Option<&str> {
        self.id.as_deref()
    }

    /// Returns the plan's steps.
    #[must_use]
    pub fn steps(&self) -> &[PlanStep] {
        &self.steps
    }
}

/// The `planGenerated` activity detail: emitted when the agent has produced a [`Plan`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PlanGenerated {
    #[serde(skip_serializing_if = "Option::is_none")]
    plan: Option<Plan>,
}

impl PlanGenerated {
    /// Returns the generated plan, if present.
    #[must_use]
    pub fn plan(&self) -> Option<&Plan> {
        self.plan.as_ref()
    }
}

/// Represents an activity within a session (e.g. a plan being generated, a message, or a
/// progress update).
///
/// Field shape matches the real `v1alpha` Jules API `Activity` resource (verified against the
/// live API's `GET /v1alpha/{session}/activities` response on 2026-08-08). Only the
/// `planGenerated` activity kind was observed live; other kinds (e.g. messages, progress
/// updates, completion events) are UNVERIFIED and preserved via `extra` rather than dropped, so
/// no activity data is silently lost for kinds this model doesn't explicitly know about yet.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Activity {
    #[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")]
    create_time: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    originator: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    plan_generated: Option<PlanGenerated>,
    /// Any activity fields not explicitly modeled above (e.g. other activity kinds), preserved
    /// verbatim rather than dropped.
    #[serde(flatten)]
    extra: serde_json::Map<String, serde_json::Value>,
}

impl Activity {
    /// Creates a new [`ActivityBuilder`] to construct an [`Activity`].
    #[must_use]
    pub fn builder() -> ActivityBuilder {
        ActivityBuilder::default()
    }

    /// Returns the id of the activity, if configured.
    #[must_use]
    pub fn id(&self) -> Option<&str> {
        self.id.as_deref()
    }

    /// Returns the name of the activity, if configured.
    #[must_use]
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    /// Returns the activity's creation timestamp (RFC 3339), if configured.
    #[must_use]
    pub fn create_time(&self) -> Option<&str> {
        self.create_time.as_deref()
    }

    /// Returns who/what originated the activity (e.g. `AGENT`, `USER`), if configured.
    #[must_use]
    pub fn originator(&self) -> Option<&str> {
        self.originator.as_deref()
    }

    /// Returns the `planGenerated` detail, if this activity is a plan-generation event.
    #[must_use]
    pub fn plan_generated(&self) -> Option<&PlanGenerated> {
        self.plan_generated.as_ref()
    }

    /// Returns any activity fields not explicitly modeled (other activity kinds), keyed by
    /// their raw JSON field name.
    #[must_use]
    pub fn extra(&self) -> &serde_json::Map<String, serde_json::Value> {
        &self.extra
    }
}

/// A builder for constructing an [`Activity`].
#[derive(Debug, Default)]
pub struct ActivityBuilder {
    id: Option<String>,
    name: Option<String>,
    create_time: Option<String>,
    originator: Option<String>,
    plan_generated: Option<PlanGenerated>,
}

impl ActivityBuilder {
    /// Sets the id for the activity.
    #[must_use]
    pub fn id(mut self, id: impl Into<String>) -> Self {
        self.id = Some(id.into());
        self
    }

    /// Sets the name for the activity.
    #[must_use]
    pub fn name(mut self, name: impl Into<String>) -> Self {
        self.name = Some(name.into());
        self
    }

    /// Sets the creation timestamp for the activity.
    #[must_use]
    pub fn create_time(mut self, create_time: impl Into<String>) -> Self {
        self.create_time = Some(create_time.into());
        self
    }

    /// Sets the originator for the activity.
    #[must_use]
    pub fn originator(mut self, originator: impl Into<String>) -> Self {
        self.originator = Some(originator.into());
        self
    }

    /// Builds the [`Activity`] from the provided configuration.
    ///
    /// # Errors
    ///
    /// Returns an [`ActivityBuildError`] if the activity cannot be built from the provided configuration.
    pub fn build(self) -> Result<Activity, ActivityBuildError> {
        Ok(Activity {
            id: self.id,
            name: self.name,
            create_time: self.create_time,
            originator: self.originator,
            plan_generated: self.plan_generated,
            extra: serde_json::Map::new(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_activity_builder_with_fields() {
        let activity = Activity::builder()
            .id("act-1")
            .name("My Activity")
            .build()
            .unwrap();
        assert_eq!(activity.id(), Some("act-1"));
        assert_eq!(activity.name(), Some("My Activity"));
    }

    #[test]
    fn test_activity_builder_without_fields() {
        let activity = Activity::builder().build().unwrap();
        assert_eq!(activity.id(), None);
        assert_eq!(activity.name(), None);
    }

    /// Deserializes a payload shaped like the real `v1alpha` API response, proving the
    /// `camelCase` wire format round-trips correctly into this `snake_case` model.
    #[test]
    fn test_activity_deserializes_real_api_shape() {
        let json = r#"{
            "name": "sessions/000/activities/001",
            "createTime": "2026-08-08T12:42:12Z",
            "originator": "AGENT",
            "planGenerated": {
                "plan": {
                    "id": "plan-1",
                    "steps": [
                        {"id": "step-1", "title": "Investigate", "description": "Look around"}
                    ]
                }
            },
            "id": "001"
        }"#;

        let activity: Activity = serde_json::from_str(json).unwrap();
        assert_eq!(activity.originator(), Some("AGENT"));
        let plan = activity
            .plan_generated()
            .and_then(PlanGenerated::plan)
            .unwrap();
        assert_eq!(plan.steps().len(), 1);
        assert_eq!(plan.steps()[0].title(), Some("Investigate"));
    }

    /// An activity kind not explicitly modeled must still round-trip via `extra`, proving
    /// unknown activity kinds aren't silently dropped.
    #[test]
    fn test_activity_preserves_unknown_kind_via_extra() {
        let json = r#"{
            "name": "sessions/000/activities/002",
            "createTime": "2026-08-08T12:42:12Z",
            "originator": "USER",
            "id": "002",
            "userMessaged": {
                "message": "hello"
            }
        }"#;

        let activity: Activity = serde_json::from_str(json).unwrap();
        assert!(activity.plan_generated().is_none());
        assert!(activity.extra().contains_key("userMessaged"));
        assert_eq!(
            activity.extra()["userMessaged"]["message"],
            serde_json::Value::String("hello".to_string())
        );
    }
}