1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Deserialize)]
4pub struct User {
5 pub uuid: Option<String>,
6 pub account_id: Option<String>,
7 pub display_name: Option<String>,
8 pub nickname: Option<String>,
9}
10
11impl User {
12 pub fn name(&self) -> &str {
17 self.display_name
18 .as_deref()
19 .or(self.nickname.as_deref())
20 .or(self.uuid.as_deref())
21 .unwrap_or("-")
22 }
23}
24
25#[derive(Debug, Deserialize)]
26pub struct BranchName {
27 pub name: Option<String>,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct Endpoint {
32 pub branch: Option<BranchName>,
33}
34
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub struct Link {
37 pub href: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct Links {
42 pub html: Option<Link>,
43}
44
45#[derive(Debug, Deserialize)]
46pub struct Participant {
47 pub user: Option<User>,
48 pub state: Option<String>,
49 pub role: Option<String>,
50 #[serde(default)]
51 pub approved: bool,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ReviewState {
57 Approved,
58 ChangesRequested,
59 Pending,
60}
61
62impl ReviewState {
63 pub fn from_api(state: Option<&str>) -> Self {
64 match state {
65 Some("approved") => Self::Approved,
66 Some("changes_requested") => Self::ChangesRequested,
67 _ => Self::Pending,
68 }
69 }
70
71 pub fn mark(self) -> &'static str {
72 match self {
73 Self::Approved => "✓",
74 Self::ChangesRequested => "✗",
75 Self::Pending => "·",
76 }
77 }
78
79 pub fn as_str(self) -> &'static str {
80 match self {
81 Self::Approved => "approved",
82 Self::ChangesRequested => "changes_requested",
83 Self::Pending => "pending",
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct ReviewerState {
90 pub name: String,
91 pub uuid: Option<String>,
92 pub state: ReviewState,
93}
94
95#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct BuildStatus {
100 pub key: Option<String>,
101 pub name: Option<String>,
102 pub state: Option<String>,
103 pub url: Option<String>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "lowercase")]
111pub enum BuildState {
112 Failed,
113 Stopped,
114 InProgress,
115 Successful,
116 None,
117}
118
119impl BuildState {
120 pub fn from_api(state: Option<&str>) -> Self {
121 match state.map(str::to_ascii_uppercase).as_deref() {
122 Some("FAILED") => Self::Failed,
123 Some("STOPPED") => Self::Stopped,
124 Some("INPROGRESS") => Self::InProgress,
125 Some("SUCCESSFUL") => Self::Successful,
126 _ => Self::None,
127 }
128 }
129
130 pub fn rank(self) -> u8 {
132 match self {
133 Self::Failed => 0,
134 Self::Stopped => 1,
135 Self::InProgress => 2,
136 Self::Successful => 3,
137 Self::None => 4,
138 }
139 }
140
141 pub fn label(self) -> &'static str {
142 match self {
143 Self::Failed => "FAILED",
144 Self::Stopped => "STOPPED",
145 Self::InProgress => "INPROGRESS",
146 Self::Successful => "SUCCESSFUL",
147 Self::None => "-",
148 }
149 }
150
151 pub fn rollup(statuses: &[BuildStatus]) -> Self {
153 statuses
154 .iter()
155 .map(|s| Self::from_api(s.state.as_deref()))
156 .min_by_key(|s| s.rank())
157 .unwrap_or(Self::None)
158 }
159}
160
161#[derive(Debug, Deserialize)]
162pub struct PullRequest {
163 pub id: u64,
164 pub title: Option<String>,
165 pub state: Option<String>,
166 pub author: Option<User>,
167 pub source: Option<Endpoint>,
168 pub destination: Option<Endpoint>,
169 pub links: Option<Links>,
170 #[serde(default)]
171 pub reviewers: Vec<User>,
172 #[serde(default)]
173 pub participants: Vec<Participant>,
174 #[serde(default)]
175 pub draft: bool,
176 pub updated_on: Option<String>,
180 pub comment_count: Option<u64>,
186 pub description: Option<String>,
190 pub summary: Option<CommentContent>,
191}
192
193#[derive(Debug, Clone, Deserialize, Serialize)]
195pub struct Project {
196 pub key: Option<String>,
197 pub name: Option<String>,
198 pub uuid: Option<String>,
199 pub is_private: Option<bool>,
200}
201
202impl Project {
203 pub fn key_or_dash(&self) -> &str {
204 self.key.as_deref().unwrap_or("-")
205 }
206
207 pub fn name_or_dash(&self) -> &str {
208 self.name.as_deref().unwrap_or("-")
209 }
210
211 pub fn access(&self) -> &'static str {
212 access_word(self.is_private)
213 }
214}
215
216#[derive(Debug, Clone, Deserialize, Serialize)]
219pub struct CloneLink {
220 pub name: Option<String>,
221 pub href: Option<String>,
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
225pub struct RepositoryLinks {
226 pub html: Option<Link>,
227 #[serde(default)]
228 pub clone: Option<Vec<CloneLink>>,
229}
230
231fn access_word(is_private: Option<bool>) -> &'static str {
234 match is_private {
235 Some(true) => "private",
236 Some(false) => "public",
237 None => "-",
238 }
239}
240
241#[derive(Debug, Clone, Deserialize, Serialize)]
245pub struct Repository {
246 pub full_name: Option<String>,
247 pub name: Option<String>,
248 pub slug: Option<String>,
249 pub description: Option<String>,
250 pub is_private: Option<bool>,
251 pub project: Option<Project>,
252 pub updated_on: Option<String>,
253 pub links: Option<RepositoryLinks>,
254}
255
256impl Repository {
257 pub fn clone_url(&self) -> Option<&str> {
261 let clones = self.links.as_ref()?.clone.as_ref()?;
262 let by_name = |want: &str| {
263 clones
264 .iter()
265 .find(|c| c.name.as_deref() == Some(want))
266 .and_then(|c| c.href.as_deref())
267 };
268 by_name("ssh").or_else(|| by_name("https"))
269 }
270
271 pub fn html_url(&self) -> Option<&str> {
272 self.links.as_ref()?.html.as_ref()?.href.as_deref()
273 }
274
275 pub fn display_name(&self) -> &str {
276 self.slug
277 .as_deref()
278 .or(self.name.as_deref())
279 .or(self.full_name.as_deref())
280 .unwrap_or("-")
281 }
282
283 pub fn project_key(&self) -> &str {
284 self.project
285 .as_ref()
286 .map(|p| p.key_or_dash())
287 .unwrap_or("-")
288 }
289
290 pub fn access(&self) -> &'static str {
291 access_word(self.is_private)
292 }
293}
294
295impl PullRequest {
296 pub fn description_text(&self) -> &str {
299 self.summary
300 .as_ref()
301 .and_then(|s| s.raw.as_deref())
302 .or(self.description.as_deref())
303 .unwrap_or("")
304 }
305
306 pub fn source_branch(&self) -> &str {
307 self.source
308 .as_ref()
309 .and_then(|e| e.branch.as_ref())
310 .and_then(|b| b.name.as_deref())
311 .unwrap_or("-")
312 }
313
314 pub fn destination_branch(&self) -> &str {
315 self.destination
316 .as_ref()
317 .and_then(|e| e.branch.as_ref())
318 .and_then(|b| b.name.as_deref())
319 .unwrap_or("-")
320 }
321
322 pub fn html_url(&self) -> &str {
323 self.links
324 .as_ref()
325 .and_then(|l| l.html.as_ref())
326 .and_then(|l| l.href.as_deref())
327 .unwrap_or("-")
328 }
329
330 pub fn author_name(&self) -> &str {
331 self.author
332 .as_ref()
333 .and_then(|a| a.nickname.as_deref().or(a.display_name.as_deref()))
334 .unwrap_or("-")
335 }
336
337 pub fn reviewer_states(&self) -> Vec<ReviewerState> {
344 let mut out: Vec<ReviewerState> = self
345 .participants
346 .iter()
347 .filter(|p| p.role.as_deref() == Some("REVIEWER"))
348 .filter_map(|p| {
349 p.user.as_ref().map(|u| ReviewerState {
350 name: u.name().to_string(),
351 uuid: u.uuid.clone(),
352 state: ReviewState::from_api(p.state.as_deref()),
353 })
354 })
355 .collect();
356
357 for reviewer in &self.reviewers {
358 let already = out.iter().any(|seen| {
359 match (seen.uuid.as_deref(), reviewer.uuid.as_deref()) {
360 (Some(a), Some(b)) => a == b,
361 _ => seen.name == reviewer.name(),
363 }
364 });
365 if !already {
366 out.push(ReviewerState {
367 name: reviewer.name().to_string(),
368 uuid: reviewer.uuid.clone(),
369 state: ReviewState::Pending,
370 });
371 }
372 }
373
374 out
375 }
376
377 pub fn display_state(&self) -> String {
380 if self.draft {
381 return "Draft".to_string();
382 }
383 match self.state.as_deref() {
384 Some(state) if !state.is_empty() => {
385 let lower = state.to_lowercase();
386 let mut chars = lower.chars();
387 match chars.next() {
388 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
389 None => "-".to_string(),
390 }
391 }
392 _ => "-".to_string(),
393 }
394 }
395}
396
397#[derive(Debug, Deserialize)]
398pub struct CommentContent {
399 pub raw: Option<String>,
400}
401
402#[derive(Debug, Deserialize)]
403pub struct Inline {
404 pub path: Option<String>,
405 pub from: Option<u64>,
406 pub to: Option<u64>,
407}
408
409#[derive(Debug, Deserialize)]
410pub struct CommentParent {
411 pub id: u64,
412}
413
414#[derive(Debug, Deserialize)]
415pub struct Comment {
416 pub id: u64,
417 pub content: Option<CommentContent>,
418 pub user: Option<User>,
419 pub created_on: Option<String>,
420 pub inline: Option<Inline>,
421 pub parent: Option<CommentParent>,
423 #[serde(default)]
424 pub deleted: bool,
425 pub resolution: Option<serde_json::Value>,
427}
428
429impl Comment {
430 pub fn is_inline(&self) -> bool {
431 self.inline
432 .as_ref()
433 .and_then(|i| i.path.as_deref())
434 .is_some_and(|p| !p.is_empty())
435 }
436
437 pub fn is_resolved(&self) -> bool {
438 self.resolution.is_some()
439 }
440
441 pub fn parent_id(&self) -> Option<u64> {
442 self.parent.as_ref().map(|p| p.id)
443 }
444
445 pub fn body(&self) -> String {
446 if self.deleted {
447 return "[deleted]".to_string();
448 }
449 self.content
450 .as_ref()
451 .and_then(|c| c.raw.clone())
452 .unwrap_or_default()
453 }
454
455 pub fn author(&self) -> &str {
456 self.user
457 .as_ref()
458 .and_then(|u| u.display_name.as_deref())
459 .unwrap_or("Unknown")
460 }
461}
462
463#[derive(Debug, Deserialize)]
464pub struct CommitAuthor {
465 pub user: Option<User>,
466 pub raw: Option<String>,
467}
468
469#[derive(Debug, Deserialize)]
470pub struct CommitTarget {
471 pub author: Option<CommitAuthor>,
472 pub date: Option<String>,
473}
474
475#[derive(Debug, Deserialize)]
476pub struct BranchRef {
477 pub name: String,
478 pub target: Option<CommitTarget>,
479}
480
481impl BranchRef {
482 pub fn owner(&self) -> String {
483 self.target
484 .as_ref()
485 .and_then(|t| t.author.as_ref())
486 .and_then(|a| {
487 a.user
488 .as_ref()
489 .and_then(|u| u.display_name.clone())
490 .or_else(|| a.raw.clone())
491 })
492 .unwrap_or_else(|| "-".to_string())
493 }
494}
495
496#[derive(Debug, Deserialize)]
497pub struct CommitSummary {
498 pub raw: Option<String>,
499}
500
501#[derive(Debug, Deserialize)]
502pub struct Commit {
503 pub hash: Option<String>,
504 pub summary: Option<CommitSummary>,
505}
506
507#[derive(Debug, Deserialize)]
508pub struct DiffStatEntry {
509 pub status: Option<String>,
510 #[serde(rename = "new")]
511 pub new_file: Option<PathEntry>,
512 #[serde(rename = "old")]
513 pub old_file: Option<PathEntry>,
514}
515
516#[derive(Debug, Deserialize)]
517pub struct PathEntry {
518 pub path: Option<String>,
519}
520
521impl DiffStatEntry {
522 pub fn path(&self) -> &str {
523 self.new_file
524 .as_ref()
525 .and_then(|p| p.path.as_deref())
526 .or_else(|| self.old_file.as_ref().and_then(|p| p.path.as_deref()))
527 .unwrap_or("-")
528 }
529}
530
531#[derive(Debug, Serialize)]
532pub struct ReviewerRef {
533 pub uuid: String,
534}
535
536#[cfg(test)]
537#[allow(clippy::unwrap_used, clippy::expect_used)]
538mod tests {
539 use super::*;
540
541 fn pr_from(json: serde_json::Value) -> PullRequest {
542 serde_json::from_value(json).expect("fixture should deserialize")
543 }
544
545 #[test]
546 fn description_text_prefers_summary_raw() {
547 let pr = pr_from(serde_json::json!({
548 "id": 1,
549 "description": "old copy",
550 "summary": { "raw": "documented copy", "markup": "markdown" }
551 }));
552 assert_eq!(pr.description_text(), "documented copy");
553 }
554
555 #[test]
556 fn description_text_falls_back_to_description_then_empty() {
557 let pr = pr_from(serde_json::json!({ "id": 1, "description": "only this" }));
558 assert_eq!(pr.description_text(), "only this");
559 let bare = pr_from(serde_json::json!({ "id": 1 }));
560 assert_eq!(bare.description_text(), "");
561 }
562
563 #[test]
564 fn reviewer_states_reads_state_from_participants() {
565 let pr = pr_from(serde_json::json!({
566 "id": 1,
567 "reviewers": [
568 { "uuid": "{a}", "display_name": "Ana" },
569 { "uuid": "{b}", "display_name": "Bo" },
570 { "uuid": "{c}", "display_name": "Cy" }
571 ],
572 "participants": [
573 { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } },
574 { "role": "REVIEWER", "state": "changes_requested", "user": { "uuid": "{b}", "display_name": "Bo" } },
575 { "role": "REVIEWER", "state": null, "user": { "uuid": "{c}", "display_name": "Cy" } }
576 ]
577 }));
578
579 let states = pr.reviewer_states();
580 assert_eq!(states.len(), 3);
581 assert_eq!(states[0].name, "Ana");
582 assert_eq!(states[0].state, ReviewState::Approved);
583 assert_eq!(states[1].state, ReviewState::ChangesRequested);
584 assert_eq!(states[2].state, ReviewState::Pending);
585 }
586
587 #[test]
591 fn reviewer_states_includes_a_reviewer_missing_from_participants() {
592 let pr = pr_from(serde_json::json!({
593 "id": 1,
594 "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
595 "participants": []
596 }));
597
598 let states = pr.reviewer_states();
599 assert_eq!(states.len(), 1);
600 assert_eq!(states[0].name, "Ana");
601 assert_eq!(states[0].state, ReviewState::Pending);
602 }
603
604 #[test]
607 fn reviewer_states_excludes_plain_participants() {
608 let pr = pr_from(serde_json::json!({
609 "id": 1,
610 "reviewers": [],
611 "participants": [
612 { "role": "PARTICIPANT", "state": "approved", "user": { "uuid": "{z}", "display_name": "Zed" } }
613 ]
614 }));
615
616 assert!(pr.reviewer_states().is_empty());
617 }
618
619 #[test]
622 fn reviewer_states_does_not_duplicate_across_both_arrays() {
623 let pr = pr_from(serde_json::json!({
624 "id": 1,
625 "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
626 "participants": [
627 { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } }
628 ]
629 }));
630
631 let states = pr.reviewer_states();
632 assert_eq!(states.len(), 1);
633 assert_eq!(states[0].state, ReviewState::Approved);
634 }
635
636 #[test]
638 fn reviewer_states_dedups_by_name_when_a_uuid_is_absent() {
639 let pr = pr_from(serde_json::json!({
640 "id": 1,
641 "reviewers": [{ "display_name": "Ana" }],
642 "participants": [
643 { "role": "REVIEWER", "state": "approved", "user": { "display_name": "Ana" } }
644 ]
645 }));
646
647 assert_eq!(pr.reviewer_states().len(), 1);
648 }
649
650 #[test]
651 fn marks_are_stable_glyphs() {
652 assert_eq!(ReviewState::Approved.mark(), "✓");
653 assert_eq!(ReviewState::ChangesRequested.mark(), "✗");
654 assert_eq!(ReviewState::Pending.mark(), "·");
655 }
656
657 #[test]
658 fn review_state_serializes_in_snake_case() {
659 let json = serde_json::to_string(&ReviewState::ChangesRequested).unwrap();
660 assert_eq!(json, "\"changes_requested\"");
661 }
662
663 #[test]
664 fn draft_wins_over_open_state() {
665 let pr = pr_from(serde_json::json!({ "id": 1, "state": "OPEN", "draft": true }));
666 assert_eq!(pr.display_state(), "Draft");
667 }
668
669 #[test]
670 fn display_state_title_cases_the_api_value() {
671 let pr = pr_from(serde_json::json!({ "id": 1, "state": "DECLINED" }));
672 assert_eq!(pr.display_state(), "Declined");
673 }
674
675 #[test]
676 fn display_state_without_a_state_is_a_dash() {
677 let pr = pr_from(serde_json::json!({ "id": 1 }));
678 assert_eq!(pr.display_state(), "-");
679 }
680
681 #[test]
682 fn user_name_prefers_display_name_then_nickname() {
683 let full: User = serde_json::from_value(
684 serde_json::json!({ "display_name": "Ana Cruz", "nickname": "ana" }),
685 )
686 .unwrap();
687 assert_eq!(full.name(), "Ana Cruz");
688
689 let nick_only: User =
690 serde_json::from_value(serde_json::json!({ "nickname": "ana" })).unwrap();
691 assert_eq!(nick_only.name(), "ana");
692
693 let empty: User = serde_json::from_value(serde_json::json!({})).unwrap();
694 assert_eq!(empty.name(), "-");
695 }
696
697 #[test]
700 fn user_name_falls_back_to_uuid_when_no_names_are_set() {
701 let uuid_only: User =
702 serde_json::from_value(serde_json::json!({ "uuid": "{5f3a}" })).unwrap();
703 assert_eq!(uuid_only.name(), "{5f3a}");
704 }
705
706 fn status(state: Option<&str>) -> BuildStatus {
707 BuildStatus {
708 key: Some("PIPELINE".into()),
709 name: Some("Pipeline #1".into()),
710 state: state.map(str::to_string),
711 url: None,
712 }
713 }
714
715 #[test]
716 fn build_state_from_api_is_case_insensitive() {
717 assert_eq!(
718 BuildState::from_api(Some("SUCCESSFUL")),
719 BuildState::Successful
720 );
721 assert_eq!(
722 BuildState::from_api(Some("successful")),
723 BuildState::Successful
724 );
725 assert_eq!(
726 BuildState::from_api(Some("InProgress")),
727 BuildState::InProgress
728 );
729 assert_eq!(BuildState::from_api(Some("FAILED")), BuildState::Failed);
730 assert_eq!(BuildState::from_api(Some("STOPPED")), BuildState::Stopped);
731 }
732
733 #[test]
734 fn build_state_from_api_degrades_on_unknown_and_missing() {
735 assert_eq!(BuildState::from_api(Some("TELEPORTED")), BuildState::None);
736 assert_eq!(BuildState::from_api(None), BuildState::None);
737 }
738
739 #[test]
740 fn rollup_of_empty_is_none() {
741 assert_eq!(BuildState::rollup(&[]), BuildState::None);
742 }
743
744 #[test]
745 fn rollup_is_worst_wins() {
746 for other in ["SUCCESSFUL", "INPROGRESS", "STOPPED"] {
748 assert_eq!(
749 BuildState::rollup(&[status(Some(other)), status(Some("FAILED"))]),
750 BuildState::Failed
751 );
752 assert_eq!(
753 BuildState::rollup(&[status(Some("FAILED")), status(Some(other))]),
754 BuildState::Failed
755 );
756 }
757 assert_eq!(
758 BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("STOPPED"))]),
759 BuildState::Stopped
760 );
761 assert_eq!(
762 BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("INPROGRESS"))]),
763 BuildState::InProgress
764 );
765 assert_eq!(
766 BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("SUCCESSFUL"))]),
767 BuildState::Successful
768 );
769 }
770
771 #[test]
774 fn rollup_ignores_unknown_states_next_to_a_real_one() {
775 assert_eq!(
776 BuildState::rollup(&[status(None), status(Some("SUCCESSFUL"))]),
777 BuildState::Successful
778 );
779 }
780
781 #[test]
782 fn build_state_serialises_lowercase() {
783 assert_eq!(
784 serde_json::to_string(&BuildState::InProgress).unwrap(),
785 "\"inprogress\""
786 );
787 assert_eq!(
788 serde_json::to_string(&BuildState::None).unwrap(),
789 "\"none\""
790 );
791 }
792
793 #[test]
794 fn build_state_labels_match_bitbucket_wording() {
795 assert_eq!(BuildState::Failed.label(), "FAILED");
796 assert_eq!(BuildState::Stopped.label(), "STOPPED");
797 assert_eq!(BuildState::InProgress.label(), "INPROGRESS");
798 assert_eq!(BuildState::Successful.label(), "SUCCESSFUL");
799 assert_eq!(BuildState::None.label(), "-");
800 }
801
802 #[test]
803 fn pull_request_carries_updated_on() {
804 let pr: PullRequest =
805 serde_json::from_str(r#"{"id":1,"updated_on":"2026-08-10T09:00:00+00:00"}"#).unwrap();
806 assert_eq!(pr.updated_on.as_deref(), Some("2026-08-10T09:00:00+00:00"));
807 }
808
809 #[test]
810 fn pull_request_without_updated_on_is_none() {
811 let pr: PullRequest = serde_json::from_str(r#"{"id":1}"#).unwrap();
812 assert!(pr.updated_on.is_none());
813 }
814
815 #[test]
816 fn repository_deserialises() {
817 let repo: Repository = serde_json::from_str(r#"{"full_name":"acme/api"}"#).unwrap();
818 assert_eq!(repo.full_name.as_deref(), Some("acme/api"));
819 }
820
821 #[test]
822 fn repository_tolerates_a_missing_full_name() {
823 let repo: Repository = serde_json::from_str(r#"{}"#).unwrap();
824 assert!(repo.full_name.is_none());
825 }
826
827 #[test]
828 fn repository_reads_ssh_clone_url_in_preference_to_https() {
829 let json = serde_json::json!({
830 "full_name": "acme/api",
831 "links": { "clone": [
832 { "name": "https", "href": "https://bitbucket.org/acme/api.git" },
833 { "name": "ssh", "href": "git@bitbucket.org:acme/api.git" }
834 ]}
835 });
836 let repo: Repository = serde_json::from_value(json).unwrap();
837 assert_eq!(repo.clone_url(), Some("git@bitbucket.org:acme/api.git"));
838 }
839
840 #[test]
841 fn repository_falls_back_to_https_clone_url() {
842 let json = serde_json::json!({
843 "links": { "clone": [{ "name": "https", "href": "https://bitbucket.org/acme/api.git" }] }
844 });
845 let repo: Repository = serde_json::from_value(json).unwrap();
846 assert_eq!(repo.clone_url(), Some("https://bitbucket.org/acme/api.git"));
847 }
848
849 #[test]
850 fn repository_tolerates_a_response_with_nothing_in_it() {
851 let repo: Repository = serde_json::from_value(serde_json::json!({})).unwrap();
854 assert_eq!(repo.clone_url(), None);
855 assert_eq!(repo.project_key(), "-");
856 assert_eq!(repo.access(), "-");
857 }
858
859 #[test]
860 fn repository_renders_privacy_as_a_word_not_a_boolean() {
861 let private: Repository =
862 serde_json::from_value(serde_json::json!({ "is_private": true })).unwrap();
863 let public: Repository =
864 serde_json::from_value(serde_json::json!({ "is_private": false })).unwrap();
865 assert_eq!(private.access(), "private");
866 assert_eq!(public.access(), "public");
867 }
868
869 #[test]
870 fn repository_reads_its_project_key() {
871 let repo: Repository = serde_json::from_value(serde_json::json!({
872 "project": { "key": "ENG", "name": "Engineering" }
873 }))
874 .unwrap();
875 assert_eq!(repo.project_key(), "ENG");
876 }
877}