Skip to main content

jules_core/
source.rs

1//! Source module.
2
3use serde::{Deserialize, Serialize};
4use std::error::Error;
5use std::fmt;
6
7/// An error that can occur when building a [`Source`].
8#[derive(Debug)]
9pub struct SourceBuildError(String);
10
11impl fmt::Display for SourceBuildError {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        write!(f, "Source build error: {}", self.0)
14    }
15}
16
17impl Error for SourceBuildError {}
18
19/// A branch reference within a GitHub repository.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct Branch {
23    #[serde(skip_serializing_if = "Option::is_none")]
24    display_name: Option<String>,
25}
26
27impl Branch {
28    /// Creates a new `Branch` with the given display name.
29    #[must_use]
30    pub fn new(display_name: impl Into<String>) -> Self {
31        Self {
32            display_name: Some(display_name.into()),
33        }
34    }
35
36    /// Returns the branch's display name, if configured.
37    #[must_use]
38    pub fn display_name(&self) -> Option<&str> {
39        self.display_name.as_deref()
40    }
41}
42
43/// GitHub repository details for a [`Source`].
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
45#[serde(rename_all = "camelCase")]
46pub struct GithubRepo {
47    #[serde(skip_serializing_if = "Option::is_none")]
48    owner: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    repo: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    is_private: Option<bool>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    default_branch: Option<Branch>,
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    branches: Vec<Branch>,
57}
58
59impl GithubRepo {
60    /// Creates a new `GithubRepo` for the given owner/repo.
61    #[must_use]
62    pub fn new(owner: impl Into<String>, repo: impl Into<String>) -> Self {
63        Self {
64            owner: Some(owner.into()),
65            repo: Some(repo.into()),
66            ..Self::default()
67        }
68    }
69
70    /// Sets whether the repository is private.
71    #[must_use]
72    pub fn with_is_private(mut self, is_private: bool) -> Self {
73        self.is_private = Some(is_private);
74        self
75    }
76
77    /// Sets the repository's default branch.
78    #[must_use]
79    pub fn with_default_branch(mut self, default_branch: Branch) -> Self {
80        self.default_branch = Some(default_branch);
81        self
82    }
83
84    /// Sets the repository's known branches.
85    #[must_use]
86    pub fn with_branches(mut self, branches: Vec<Branch>) -> Self {
87        self.branches = branches;
88        self
89    }
90
91    /// Returns the repository owner, if configured.
92    #[must_use]
93    pub fn owner(&self) -> Option<&str> {
94        self.owner.as_deref()
95    }
96
97    /// Returns the repository name, if configured.
98    #[must_use]
99    pub fn repo(&self) -> Option<&str> {
100        self.repo.as_deref()
101    }
102
103    /// Returns whether the repository is private, if known.
104    #[must_use]
105    pub fn is_private(&self) -> Option<bool> {
106        self.is_private
107    }
108
109    /// Returns the repository's default branch, if known.
110    #[must_use]
111    pub fn default_branch(&self) -> Option<&Branch> {
112        self.default_branch.as_ref()
113    }
114
115    /// Returns the repository's known branches.
116    #[must_use]
117    pub fn branches(&self) -> &[Branch] {
118        &self.branches
119    }
120}
121
122/// Represents a connected source (e.g. a GitHub repository) the Jules API can operate on.
123///
124/// Field shape matches the real `v1alpha` Jules API `Source` resource (verified against the
125/// live API's `GET /v1alpha/sources` response on 2026-08-08).
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct Source {
129    #[serde(skip_serializing_if = "Option::is_none")]
130    id: Option<String>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    name: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    github_repo: Option<GithubRepo>,
135}
136
137impl Source {
138    /// Creates a new [`SourceBuilder`] to construct a [`Source`].
139    #[must_use]
140    pub fn builder() -> SourceBuilder {
141        SourceBuilder::default()
142    }
143
144    /// Returns the id of the source, if configured.
145    #[must_use]
146    pub fn id(&self) -> Option<&str> {
147        self.id.as_deref()
148    }
149
150    /// Returns the name of the source, if configured (e.g. `sources/github/owner/repo`).
151    #[must_use]
152    pub fn name(&self) -> Option<&str> {
153        self.name.as_deref()
154    }
155
156    /// Returns the GitHub repository details, if this source is a GitHub repo.
157    #[must_use]
158    pub fn github_repo(&self) -> Option<&GithubRepo> {
159        self.github_repo.as_ref()
160    }
161}
162
163/// A builder for constructing a [`Source`].
164#[derive(Debug, Default)]
165pub struct SourceBuilder {
166    id: Option<String>,
167    name: Option<String>,
168    github_repo: Option<GithubRepo>,
169}
170
171impl SourceBuilder {
172    /// Sets the id for the source.
173    #[must_use]
174    pub fn id(mut self, id: impl Into<String>) -> Self {
175        self.id = Some(id.into());
176        self
177    }
178
179    /// Sets the name for the source.
180    #[must_use]
181    pub fn name(mut self, name: impl Into<String>) -> Self {
182        self.name = Some(name.into());
183        self
184    }
185
186    /// Sets the GitHub repository details for the source.
187    #[must_use]
188    pub fn github_repo(mut self, github_repo: GithubRepo) -> Self {
189        self.github_repo = Some(github_repo);
190        self
191    }
192
193    /// Builds the [`Source`] from the provided configuration.
194    ///
195    /// # Errors
196    ///
197    /// Returns a [`SourceBuildError`] if the source cannot be built from the provided configuration.
198    pub fn build(self) -> Result<Source, SourceBuildError> {
199        Ok(Source {
200            id: self.id,
201            name: self.name,
202            github_repo: self.github_repo,
203        })
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_source_builder_with_fields() {
213        let source = Source::builder()
214            .id("src-1")
215            .name("My Source")
216            .build()
217            .unwrap();
218        assert_eq!(source.id(), Some("src-1"));
219        assert_eq!(source.name(), Some("My Source"));
220    }
221
222    #[test]
223    fn test_source_builder_without_fields() {
224        let source = Source::builder().build().unwrap();
225        assert_eq!(source.id(), None);
226        assert_eq!(source.name(), None);
227    }
228
229    #[test]
230    fn test_source_builder_with_github_repo() {
231        let source = Source::builder()
232            .id("src-1")
233            .name("sources/github/example-owner/example-repo")
234            .github_repo(GithubRepo::new("example-owner", "example-repo").with_is_private(true))
235            .build()
236            .unwrap();
237
238        assert_eq!(
239            source.github_repo().and_then(GithubRepo::owner),
240            Some("example-owner")
241        );
242    }
243
244    /// Deserializes a payload shaped like the real `v1alpha` API response, proving the
245    /// `camelCase` wire format round-trips correctly into this `snake_case` model.
246    #[test]
247    fn test_source_deserializes_real_api_shape() {
248        let json = r#"{
249            "name": "sources/github/example-owner/example-repo",
250            "githubRepo": {
251                "owner": "example-owner",
252                "repo": "example-repo",
253                "isPrivate": true,
254                "defaultBranch": {
255                    "displayName": "main"
256                },
257                "branches": [
258                    {"displayName": "main"},
259                    {"displayName": "feature/x"}
260                ]
261            },
262            "id": "github/example-owner/example-repo"
263        }"#;
264
265        let source: Source = serde_json::from_str(json).unwrap();
266        assert_eq!(
267            source.name(),
268            Some("sources/github/example-owner/example-repo")
269        );
270        let repo = source.github_repo().unwrap();
271        assert_eq!(repo.owner(), Some("example-owner"));
272        assert_eq!(repo.is_private(), Some(true));
273        assert_eq!(
274            repo.default_branch().and_then(Branch::display_name),
275            Some("main")
276        );
277        assert_eq!(repo.branches().len(), 2);
278    }
279}