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 #[serde(default)]
429 pub pending: bool,
430}
431
432impl Comment {
433 pub fn is_inline(&self) -> bool {
434 self.inline
435 .as_ref()
436 .and_then(|i| i.path.as_deref())
437 .is_some_and(|p| !p.is_empty())
438 }
439
440 pub fn is_resolved(&self) -> bool {
441 self.resolution.is_some()
442 }
443
444 pub fn parent_id(&self) -> Option<u64> {
445 self.parent.as_ref().map(|p| p.id)
446 }
447
448 pub fn body(&self) -> String {
449 if self.deleted {
450 return "[deleted]".to_string();
451 }
452 self.content
453 .as_ref()
454 .and_then(|c| c.raw.clone())
455 .unwrap_or_default()
456 }
457
458 pub fn author(&self) -> &str {
459 self.user
460 .as_ref()
461 .and_then(|u| u.display_name.as_deref())
462 .unwrap_or("Unknown")
463 }
464}
465
466#[derive(Debug, Deserialize)]
467pub struct CommitAuthor {
468 pub user: Option<User>,
469 pub raw: Option<String>,
470}
471
472#[derive(Debug, Deserialize)]
473pub struct CommitTarget {
474 pub author: Option<CommitAuthor>,
475 pub date: Option<String>,
476}
477
478#[derive(Debug, Deserialize)]
479pub struct BranchRef {
480 pub name: String,
481 pub target: Option<CommitTarget>,
482}
483
484impl BranchRef {
485 pub fn owner(&self) -> String {
486 self.target
487 .as_ref()
488 .and_then(|t| t.author.as_ref())
489 .and_then(|a| {
490 a.user
491 .as_ref()
492 .and_then(|u| u.display_name.clone())
493 .or_else(|| a.raw.clone())
494 })
495 .unwrap_or_else(|| "-".to_string())
496 }
497}
498
499#[derive(Debug, Deserialize)]
500pub struct CommitSummary {
501 pub raw: Option<String>,
502}
503
504#[derive(Debug, Deserialize)]
505pub struct Commit {
506 pub hash: Option<String>,
507 pub summary: Option<CommitSummary>,
508}
509
510#[derive(Debug, Deserialize)]
511pub struct DiffStatEntry {
512 pub status: Option<String>,
513 #[serde(rename = "new")]
514 pub new_file: Option<PathEntry>,
515 #[serde(rename = "old")]
516 pub old_file: Option<PathEntry>,
517}
518
519#[derive(Debug, Deserialize)]
520pub struct PathEntry {
521 pub path: Option<String>,
522}
523
524impl DiffStatEntry {
525 pub fn path(&self) -> &str {
526 self.new_file
527 .as_ref()
528 .and_then(|p| p.path.as_deref())
529 .or_else(|| self.old_file.as_ref().and_then(|p| p.path.as_deref()))
530 .unwrap_or("-")
531 }
532}
533
534#[derive(Debug, Serialize)]
535pub struct ReviewerRef {
536 pub uuid: String,
537}
538
539#[cfg(test)]
540#[allow(clippy::unwrap_used, clippy::expect_used)]
541mod tests {
542 use super::*;
543
544 fn pr_from(json: serde_json::Value) -> PullRequest {
545 serde_json::from_value(json).expect("fixture should deserialize")
546 }
547
548 #[test]
549 fn description_text_prefers_summary_raw() {
550 let pr = pr_from(serde_json::json!({
551 "id": 1,
552 "description": "old copy",
553 "summary": { "raw": "documented copy", "markup": "markdown" }
554 }));
555 assert_eq!(pr.description_text(), "documented copy");
556 }
557
558 #[test]
559 fn description_text_falls_back_to_description_then_empty() {
560 let pr = pr_from(serde_json::json!({ "id": 1, "description": "only this" }));
561 assert_eq!(pr.description_text(), "only this");
562 let bare = pr_from(serde_json::json!({ "id": 1 }));
563 assert_eq!(bare.description_text(), "");
564 }
565
566 #[test]
567 fn reviewer_states_reads_state_from_participants() {
568 let pr = pr_from(serde_json::json!({
569 "id": 1,
570 "reviewers": [
571 { "uuid": "{a}", "display_name": "Ana" },
572 { "uuid": "{b}", "display_name": "Bo" },
573 { "uuid": "{c}", "display_name": "Cy" }
574 ],
575 "participants": [
576 { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } },
577 { "role": "REVIEWER", "state": "changes_requested", "user": { "uuid": "{b}", "display_name": "Bo" } },
578 { "role": "REVIEWER", "state": null, "user": { "uuid": "{c}", "display_name": "Cy" } }
579 ]
580 }));
581
582 let states = pr.reviewer_states();
583 assert_eq!(states.len(), 3);
584 assert_eq!(states[0].name, "Ana");
585 assert_eq!(states[0].state, ReviewState::Approved);
586 assert_eq!(states[1].state, ReviewState::ChangesRequested);
587 assert_eq!(states[2].state, ReviewState::Pending);
588 }
589
590 #[test]
594 fn reviewer_states_includes_a_reviewer_missing_from_participants() {
595 let pr = pr_from(serde_json::json!({
596 "id": 1,
597 "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
598 "participants": []
599 }));
600
601 let states = pr.reviewer_states();
602 assert_eq!(states.len(), 1);
603 assert_eq!(states[0].name, "Ana");
604 assert_eq!(states[0].state, ReviewState::Pending);
605 }
606
607 #[test]
610 fn reviewer_states_excludes_plain_participants() {
611 let pr = pr_from(serde_json::json!({
612 "id": 1,
613 "reviewers": [],
614 "participants": [
615 { "role": "PARTICIPANT", "state": "approved", "user": { "uuid": "{z}", "display_name": "Zed" } }
616 ]
617 }));
618
619 assert!(pr.reviewer_states().is_empty());
620 }
621
622 #[test]
625 fn reviewer_states_does_not_duplicate_across_both_arrays() {
626 let pr = pr_from(serde_json::json!({
627 "id": 1,
628 "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
629 "participants": [
630 { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } }
631 ]
632 }));
633
634 let states = pr.reviewer_states();
635 assert_eq!(states.len(), 1);
636 assert_eq!(states[0].state, ReviewState::Approved);
637 }
638
639 #[test]
641 fn reviewer_states_dedups_by_name_when_a_uuid_is_absent() {
642 let pr = pr_from(serde_json::json!({
643 "id": 1,
644 "reviewers": [{ "display_name": "Ana" }],
645 "participants": [
646 { "role": "REVIEWER", "state": "approved", "user": { "display_name": "Ana" } }
647 ]
648 }));
649
650 assert_eq!(pr.reviewer_states().len(), 1);
651 }
652
653 #[test]
654 fn marks_are_stable_glyphs() {
655 assert_eq!(ReviewState::Approved.mark(), "✓");
656 assert_eq!(ReviewState::ChangesRequested.mark(), "✗");
657 assert_eq!(ReviewState::Pending.mark(), "·");
658 }
659
660 #[test]
661 fn review_state_serializes_in_snake_case() {
662 let json = serde_json::to_string(&ReviewState::ChangesRequested).unwrap();
663 assert_eq!(json, "\"changes_requested\"");
664 }
665
666 #[test]
667 fn draft_wins_over_open_state() {
668 let pr = pr_from(serde_json::json!({ "id": 1, "state": "OPEN", "draft": true }));
669 assert_eq!(pr.display_state(), "Draft");
670 }
671
672 #[test]
673 fn display_state_title_cases_the_api_value() {
674 let pr = pr_from(serde_json::json!({ "id": 1, "state": "DECLINED" }));
675 assert_eq!(pr.display_state(), "Declined");
676 }
677
678 #[test]
679 fn display_state_without_a_state_is_a_dash() {
680 let pr = pr_from(serde_json::json!({ "id": 1 }));
681 assert_eq!(pr.display_state(), "-");
682 }
683
684 #[test]
685 fn user_name_prefers_display_name_then_nickname() {
686 let full: User = serde_json::from_value(
687 serde_json::json!({ "display_name": "Ana Cruz", "nickname": "ana" }),
688 )
689 .unwrap();
690 assert_eq!(full.name(), "Ana Cruz");
691
692 let nick_only: User =
693 serde_json::from_value(serde_json::json!({ "nickname": "ana" })).unwrap();
694 assert_eq!(nick_only.name(), "ana");
695
696 let empty: User = serde_json::from_value(serde_json::json!({})).unwrap();
697 assert_eq!(empty.name(), "-");
698 }
699
700 #[test]
703 fn user_name_falls_back_to_uuid_when_no_names_are_set() {
704 let uuid_only: User =
705 serde_json::from_value(serde_json::json!({ "uuid": "{5f3a}" })).unwrap();
706 assert_eq!(uuid_only.name(), "{5f3a}");
707 }
708
709 fn status(state: Option<&str>) -> BuildStatus {
710 BuildStatus {
711 key: Some("PIPELINE".into()),
712 name: Some("Pipeline #1".into()),
713 state: state.map(str::to_string),
714 url: None,
715 }
716 }
717
718 #[test]
719 fn build_state_from_api_is_case_insensitive() {
720 assert_eq!(
721 BuildState::from_api(Some("SUCCESSFUL")),
722 BuildState::Successful
723 );
724 assert_eq!(
725 BuildState::from_api(Some("successful")),
726 BuildState::Successful
727 );
728 assert_eq!(
729 BuildState::from_api(Some("InProgress")),
730 BuildState::InProgress
731 );
732 assert_eq!(BuildState::from_api(Some("FAILED")), BuildState::Failed);
733 assert_eq!(BuildState::from_api(Some("STOPPED")), BuildState::Stopped);
734 }
735
736 #[test]
737 fn build_state_from_api_degrades_on_unknown_and_missing() {
738 assert_eq!(BuildState::from_api(Some("TELEPORTED")), BuildState::None);
739 assert_eq!(BuildState::from_api(None), BuildState::None);
740 }
741
742 #[test]
743 fn rollup_of_empty_is_none() {
744 assert_eq!(BuildState::rollup(&[]), BuildState::None);
745 }
746
747 #[test]
748 fn rollup_is_worst_wins() {
749 for other in ["SUCCESSFUL", "INPROGRESS", "STOPPED"] {
751 assert_eq!(
752 BuildState::rollup(&[status(Some(other)), status(Some("FAILED"))]),
753 BuildState::Failed
754 );
755 assert_eq!(
756 BuildState::rollup(&[status(Some("FAILED")), status(Some(other))]),
757 BuildState::Failed
758 );
759 }
760 assert_eq!(
761 BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("STOPPED"))]),
762 BuildState::Stopped
763 );
764 assert_eq!(
765 BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("INPROGRESS"))]),
766 BuildState::InProgress
767 );
768 assert_eq!(
769 BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("SUCCESSFUL"))]),
770 BuildState::Successful
771 );
772 }
773
774 #[test]
777 fn rollup_ignores_unknown_states_next_to_a_real_one() {
778 assert_eq!(
779 BuildState::rollup(&[status(None), status(Some("SUCCESSFUL"))]),
780 BuildState::Successful
781 );
782 }
783
784 #[test]
785 fn build_state_serialises_lowercase() {
786 assert_eq!(
787 serde_json::to_string(&BuildState::InProgress).unwrap(),
788 "\"inprogress\""
789 );
790 assert_eq!(
791 serde_json::to_string(&BuildState::None).unwrap(),
792 "\"none\""
793 );
794 }
795
796 #[test]
797 fn build_state_labels_match_bitbucket_wording() {
798 assert_eq!(BuildState::Failed.label(), "FAILED");
799 assert_eq!(BuildState::Stopped.label(), "STOPPED");
800 assert_eq!(BuildState::InProgress.label(), "INPROGRESS");
801 assert_eq!(BuildState::Successful.label(), "SUCCESSFUL");
802 assert_eq!(BuildState::None.label(), "-");
803 }
804
805 #[test]
806 fn pull_request_carries_updated_on() {
807 let pr: PullRequest =
808 serde_json::from_str(r#"{"id":1,"updated_on":"2026-08-10T09:00:00+00:00"}"#).unwrap();
809 assert_eq!(pr.updated_on.as_deref(), Some("2026-08-10T09:00:00+00:00"));
810 }
811
812 #[test]
813 fn pull_request_without_updated_on_is_none() {
814 let pr: PullRequest = serde_json::from_str(r#"{"id":1}"#).unwrap();
815 assert!(pr.updated_on.is_none());
816 }
817
818 #[test]
819 fn repository_deserialises() {
820 let repo: Repository = serde_json::from_str(r#"{"full_name":"acme/api"}"#).unwrap();
821 assert_eq!(repo.full_name.as_deref(), Some("acme/api"));
822 }
823
824 #[test]
825 fn repository_tolerates_a_missing_full_name() {
826 let repo: Repository = serde_json::from_str(r#"{}"#).unwrap();
827 assert!(repo.full_name.is_none());
828 }
829
830 #[test]
831 fn repository_reads_ssh_clone_url_in_preference_to_https() {
832 let json = serde_json::json!({
833 "full_name": "acme/api",
834 "links": { "clone": [
835 { "name": "https", "href": "https://bitbucket.org/acme/api.git" },
836 { "name": "ssh", "href": "git@bitbucket.org:acme/api.git" }
837 ]}
838 });
839 let repo: Repository = serde_json::from_value(json).unwrap();
840 assert_eq!(repo.clone_url(), Some("git@bitbucket.org:acme/api.git"));
841 }
842
843 #[test]
844 fn repository_falls_back_to_https_clone_url() {
845 let json = serde_json::json!({
846 "links": { "clone": [{ "name": "https", "href": "https://bitbucket.org/acme/api.git" }] }
847 });
848 let repo: Repository = serde_json::from_value(json).unwrap();
849 assert_eq!(repo.clone_url(), Some("https://bitbucket.org/acme/api.git"));
850 }
851
852 #[test]
853 fn repository_tolerates_a_response_with_nothing_in_it() {
854 let repo: Repository = serde_json::from_value(serde_json::json!({})).unwrap();
857 assert_eq!(repo.clone_url(), None);
858 assert_eq!(repo.project_key(), "-");
859 assert_eq!(repo.access(), "-");
860 }
861
862 #[test]
863 fn repository_renders_privacy_as_a_word_not_a_boolean() {
864 let private: Repository =
865 serde_json::from_value(serde_json::json!({ "is_private": true })).unwrap();
866 let public: Repository =
867 serde_json::from_value(serde_json::json!({ "is_private": false })).unwrap();
868 assert_eq!(private.access(), "private");
869 assert_eq!(public.access(), "public");
870 }
871
872 #[test]
873 fn repository_reads_its_project_key() {
874 let repo: Repository = serde_json::from_value(serde_json::json!({
875 "project": { "key": "ENG", "name": "Engineering" }
876 }))
877 .unwrap();
878 assert_eq!(repo.project_key(), "ENG");
879 }
880}