1use chrono::{DateTime, Utc};
8use derive_builder::Builder;
9
10use crate::api::common::NameOrId;
11use crate::api::endpoint_prelude::*;
12use crate::api::ParamValue;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum LineType {
18 Old,
20 New,
22}
23
24impl LineType {
25 fn as_str(self) -> &'static str {
26 match self {
27 LineType::Old => "old",
28 LineType::New => "new",
29 }
30 }
31}
32
33impl ParamValue<'static> for LineType {
34 fn as_value(&self) -> Cow<'static, str> {
35 self.as_str().into()
36 }
37}
38
39#[derive(Debug, Clone, Builder)]
41pub struct LineCode<'a> {
42 #[builder(setter(into))]
46 line_code: Cow<'a, str>,
47 type_: LineType,
49}
50
51impl<'a> LineCode<'a> {
52 pub fn builder() -> LineCodeBuilder<'a> {
54 LineCodeBuilder::default()
55 }
56}
57
58#[derive(Debug, Clone, Builder)]
60#[builder(setter(strip_option))]
61pub struct LineRange<'a> {
62 #[builder(setter(into))]
64 start: LineCode<'a>,
65 #[builder(setter(into))]
67 end: LineCode<'a>,
68}
69
70impl<'a> LineRange<'a> {
71 pub fn builder() -> LineRangeBuilder<'a> {
73 LineRangeBuilder::default()
74 }
75
76 fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
77 params
78 .push(
79 "position[line_range][start][line_code]",
80 self.start.line_code.as_ref(),
81 )
82 .push("position[line_range][start][type]", self.start.type_)
83 .push(
84 "position[line_range][end][line_code]",
85 self.end.line_code.as_ref(),
86 )
87 .push("position[line_range][end][type]", self.end.type_);
88 }
89}
90
91#[derive(Debug, Clone, Builder)]
93#[builder(setter(strip_option))]
94pub struct TextPosition<'a> {
95 #[builder(setter(into), default)]
97 new_path: Option<Cow<'a, str>>,
98 #[builder(default)]
100 new_line: Option<u64>,
101 #[builder(setter(into), default)]
103 old_path: Option<Cow<'a, str>>,
104 #[builder(default)]
106 old_line: Option<u64>,
107 #[builder(default)]
109 line_range: Option<LineRange<'a>>,
110}
111
112impl<'a> TextPosition<'a> {
113 pub fn builder() -> TextPositionBuilder<'a> {
115 TextPositionBuilder::default()
116 }
117
118 fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
119 params
120 .push_opt("position[new_path]", self.new_path.as_ref())
121 .push_opt("position[new_line]", self.new_line)
122 .push_opt("position[old_path]", self.old_path.as_ref())
123 .push_opt("position[old_line]", self.old_line);
124
125 if let Some(line_range) = self.line_range.as_ref() {
126 line_range.add_params(params);
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, Builder)]
133#[builder(setter(strip_option))]
134pub struct ImagePosition {
135 #[builder(default)]
137 width: Option<u64>,
138 #[builder(default)]
140 height: Option<u64>,
141 #[builder(default)]
143 x: Option<u64>,
144 #[builder(default)]
146 y: Option<u64>,
147}
148
149impl ImagePosition {
150 pub fn builder() -> ImagePositionBuilder {
152 ImagePositionBuilder::default()
153 }
154
155 fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
156 params
157 .push_opt("position[width]", self.width)
158 .push_opt("position[height]", self.height)
159 .push_opt("position[x]", self.x)
160 .push_opt("position[y]", self.y);
161 }
162}
163
164#[derive(Debug, Clone)]
165#[non_exhaustive]
166enum FilePosition<'a> {
167 Text(TextPosition<'a>),
168 Image(ImagePosition),
169}
170
171impl FilePosition<'_> {
172 fn type_str(&self) -> &'static str {
173 match self {
174 Self::Text(_) => "text",
175 Self::Image(_) => "image",
176 }
177 }
178
179 fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
180 match self {
181 Self::Text(text) => text.add_params(params),
182 Self::Image(image) => image.add_params(params),
183 }
184 }
185}
186
187#[derive(Debug, Clone, Builder)]
189#[builder(setter(strip_option))]
190pub struct Position<'a> {
191 #[builder(setter(into))]
193 base_sha: Cow<'a, str>,
194 #[builder(setter(into))]
196 start_sha: Cow<'a, str>,
197 #[builder(setter(into))]
199 head_sha: Cow<'a, str>,
200 #[builder(setter(name = "_position"), private)]
202 position: FilePosition<'a>,
203}
204
205impl<'a> PositionBuilder<'a> {
206 pub fn text_position(&mut self, position: TextPosition<'a>) -> &mut Self {
208 self.position = Some(FilePosition::Text(position));
209 self
210 }
211
212 pub fn image_position(&mut self, position: ImagePosition) -> &mut Self {
214 self.position = Some(FilePosition::Image(position));
215 self
216 }
217}
218
219impl<'a> Position<'a> {
220 pub fn builder() -> PositionBuilder<'a> {
222 PositionBuilder::default()
223 }
224
225 fn add_params<'b>(&'b self, params: &mut FormParams<'b>) {
226 params
227 .push("position[base_sha]", self.base_sha.as_ref())
228 .push("position[start_sha]", self.start_sha.as_ref())
229 .push("position[head_sha]", self.head_sha.as_ref())
230 .push("position[position_type]", self.position.type_str());
231
232 self.position.add_params(params);
233 }
234}
235
236#[derive(Debug, Builder, Clone)]
238#[builder(setter(strip_option))]
239pub struct CreateMergeRequestDiscussion<'a> {
240 #[builder(setter(into))]
242 project: NameOrId<'a>,
243 merge_request: u64,
245 #[builder(setter(into))]
247 body: Cow<'a, str>,
248 #[builder(setter(into), default)]
249 commit_id: Option<Cow<'a, str>>,
251
252 #[builder(default)]
256 created_at: Option<DateTime<Utc>>,
257 #[builder(default)]
259 position: Option<Position<'a>>,
260}
261
262impl<'a> CreateMergeRequestDiscussion<'a> {
263 pub fn builder() -> CreateMergeRequestDiscussionBuilder<'a> {
265 CreateMergeRequestDiscussionBuilder::default()
266 }
267}
268
269impl Endpoint for CreateMergeRequestDiscussion<'_> {
270 fn method(&self) -> Method {
271 Method::POST
272 }
273
274 fn endpoint(&self) -> Cow<'static, str> {
275 format!(
276 "projects/{}/merge_requests/{}/discussions",
277 self.project, self.merge_request,
278 )
279 .into()
280 }
281
282 fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
283 let mut params = FormParams::default();
284
285 params
286 .push("body", self.body.as_ref())
287 .push_opt("commit_id", self.commit_id.as_ref())
288 .push_opt("created_at", self.created_at);
289
290 if let Some(position) = self.position.as_ref() {
291 position.add_params(&mut params);
292 }
293
294 params.into_body()
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use chrono::{TimeZone, Utc};
301 use http::Method;
302
303 use crate::api::projects::merge_requests::discussions::{
304 CreateMergeRequestDiscussion, CreateMergeRequestDiscussionBuilderError, ImagePosition,
305 LineCode, LineCodeBuilderError, LineRange, LineRangeBuilderError, LineType, Position,
306 PositionBuilderError, TextPosition,
307 };
308 use crate::api::{self, Query};
309 use crate::test::client::{ExpectedUrl, SingleTestClient};
310
311 use super::FilePosition;
312
313 #[test]
314 fn line_type_as_str() {
315 let items = &[(LineType::Old, "old"), (LineType::New, "new")];
316
317 for (i, s) in items {
318 assert_eq!(i.as_str(), *s);
319 }
320 }
321
322 #[test]
323 fn line_code_line_code_and_type_are_necessary() {
324 let err = LineCode::builder().build().unwrap_err();
325 crate::test::assert_missing_field!(err, LineCodeBuilderError, "line_code");
326 }
327
328 #[test]
329 fn line_code_line_code_is_necessary() {
330 let err = LineCode::builder()
331 .type_(LineType::Old)
332 .build()
333 .unwrap_err();
334 crate::test::assert_missing_field!(err, LineCodeBuilderError, "line_code");
335 }
336
337 #[test]
338 fn line_code_type_is_necessary() {
339 let err = LineCode::builder().line_code("code").build().unwrap_err();
340 crate::test::assert_missing_field!(err, LineCodeBuilderError, "type_");
341 }
342
343 #[test]
344 fn line_code_line_code_and_type_are_sufficient() {
345 LineCode::builder()
346 .line_code("start")
347 .type_(LineType::Old)
348 .build()
349 .unwrap();
350 }
351
352 #[test]
353 fn line_range_start_and_end_are_necessary() {
354 let err = LineRange::builder().build().unwrap_err();
355 crate::test::assert_missing_field!(err, LineRangeBuilderError, "start");
356 }
357
358 #[test]
359 fn line_range_start_is_necessary() {
360 let err = LineRange::builder()
361 .end(
362 LineCode::builder()
363 .line_code("end")
364 .type_(LineType::Old)
365 .build()
366 .unwrap(),
367 )
368 .build()
369 .unwrap_err();
370 crate::test::assert_missing_field!(err, LineRangeBuilderError, "start");
371 }
372
373 #[test]
374 fn line_range_end_is_necessary() {
375 let err = LineRange::builder()
376 .start(
377 LineCode::builder()
378 .line_code("start")
379 .type_(LineType::Old)
380 .build()
381 .unwrap(),
382 )
383 .build()
384 .unwrap_err();
385 crate::test::assert_missing_field!(err, LineRangeBuilderError, "end");
386 }
387
388 #[test]
389 fn line_range_start_and_end_are_sufficient() {
390 LineRange::builder()
391 .start(
392 LineCode::builder()
393 .line_code("start")
394 .type_(LineType::Old)
395 .build()
396 .unwrap(),
397 )
398 .end(
399 LineCode::builder()
400 .line_code("end")
401 .type_(LineType::Old)
402 .build()
403 .unwrap(),
404 )
405 .build()
406 .unwrap();
407 }
408
409 #[test]
410 fn text_position_defaults_are_sufficient() {
411 TextPosition::builder().build().unwrap();
412 }
413
414 #[test]
415 fn image_position_defaults_are_sufficient() {
416 ImagePosition::builder().build().unwrap();
417 }
418
419 #[test]
420 fn file_position_type_str() {
421 let items = &[
422 (
423 FilePosition::Text(TextPosition::builder().build().unwrap()),
424 "text",
425 ),
426 (
427 FilePosition::Image(ImagePosition::builder().build().unwrap()),
428 "image",
429 ),
430 ];
431
432 for (i, s) in items {
433 assert_eq!(i.type_str(), *s);
434 }
435 }
436
437 #[test]
438 fn position_base_start_head_and_position_are_necessary() {
439 let err = Position::builder().build().unwrap_err();
440 crate::test::assert_missing_field!(err, PositionBuilderError, "base_sha");
441 }
442
443 #[test]
444 fn position_base_sha_is_necessary() {
445 let err = Position::builder()
446 .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
447 .head_sha("cafebabecafebabecafebabecafebabecafebabe")
448 .text_position(TextPosition::builder().build().unwrap())
449 .build()
450 .unwrap_err();
451 crate::test::assert_missing_field!(err, PositionBuilderError, "base_sha");
452 }
453
454 #[test]
455 fn position_start_sha_is_necessary() {
456 let err = Position::builder()
457 .base_sha("0000000000000000000000000000000000000000")
458 .head_sha("cafebabecafebabecafebabecafebabecafebabe")
459 .text_position(TextPosition::builder().build().unwrap())
460 .build()
461 .unwrap_err();
462 crate::test::assert_missing_field!(err, PositionBuilderError, "start_sha");
463 }
464
465 #[test]
466 fn position_head_sha_is_necessary() {
467 let err = Position::builder()
468 .base_sha("0000000000000000000000000000000000000000")
469 .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
470 .text_position(TextPosition::builder().build().unwrap())
471 .build()
472 .unwrap_err();
473 crate::test::assert_missing_field!(err, PositionBuilderError, "head_sha");
474 }
475
476 #[test]
477 fn position_position_is_necessary() {
478 let err = Position::builder()
479 .base_sha("0000000000000000000000000000000000000000")
480 .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
481 .head_sha("cafebabecafebabecafebabecafebabecafebabe")
482 .build()
483 .unwrap_err();
484 crate::test::assert_missing_field!(err, PositionBuilderError, "position");
485 }
486
487 #[test]
488 fn position_base_start_head_and_position_are_sufficient() {
489 Position::builder()
490 .base_sha("0000000000000000000000000000000000000000")
491 .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
492 .head_sha("cafebabecafebabecafebabecafebabecafebabe")
493 .text_position(TextPosition::builder().build().unwrap())
494 .build()
495 .unwrap();
496 }
497
498 #[test]
499 fn project_merge_request_and_body_are_necessary() {
500 let err = CreateMergeRequestDiscussion::builder().build().unwrap_err();
501 crate::test::assert_missing_field!(
502 err,
503 CreateMergeRequestDiscussionBuilderError,
504 "project",
505 );
506 }
507
508 #[test]
509 fn project_is_necessary() {
510 let err = CreateMergeRequestDiscussion::builder()
511 .merge_request(1)
512 .body("body")
513 .build()
514 .unwrap_err();
515 crate::test::assert_missing_field!(
516 err,
517 CreateMergeRequestDiscussionBuilderError,
518 "project",
519 );
520 }
521
522 #[test]
523 fn merge_request_is_necessary() {
524 let err = CreateMergeRequestDiscussion::builder()
525 .project(1)
526 .body("body")
527 .build()
528 .unwrap_err();
529 crate::test::assert_missing_field!(
530 err,
531 CreateMergeRequestDiscussionBuilderError,
532 "merge_request",
533 );
534 }
535
536 #[test]
537 fn body_is_necessary() {
538 let err = CreateMergeRequestDiscussion::builder()
539 .project(1)
540 .merge_request(1)
541 .build()
542 .unwrap_err();
543 crate::test::assert_missing_field!(err, CreateMergeRequestDiscussionBuilderError, "body");
544 }
545
546 #[test]
547 fn project_merge_request_and_body_are_sufficient() {
548 CreateMergeRequestDiscussion::builder()
549 .project(1)
550 .merge_request(1)
551 .body("body")
552 .build()
553 .unwrap();
554 }
555
556 #[test]
557 fn endpoint() {
558 let endpoint = ExpectedUrl::builder()
559 .method(Method::POST)
560 .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
561 .content_type("application/x-www-form-urlencoded")
562 .body_str("body=body")
563 .build()
564 .unwrap();
565 let client = SingleTestClient::new_raw(endpoint, "");
566
567 let endpoint = CreateMergeRequestDiscussion::builder()
568 .project("simple/project")
569 .merge_request(1)
570 .body("body")
571 .build()
572 .unwrap();
573 api::ignore(endpoint).query(&client).unwrap();
574 }
575
576 #[test]
577 fn endpoint_commit_id() {
578 let endpoint = ExpectedUrl::builder()
579 .method(Method::POST)
580 .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
581 .content_type("application/x-www-form-urlencoded")
582 .body_str(concat!(
583 "body=body",
584 "&commit_id=0000000000000000000000000000000000000000"
585 ))
586 .build()
587 .unwrap();
588 let client = SingleTestClient::new_raw(endpoint, "");
589
590 let endpoint = CreateMergeRequestDiscussion::builder()
591 .project("simple/project")
592 .merge_request(1)
593 .body("body")
594 .commit_id("0000000000000000000000000000000000000000")
595 .build()
596 .unwrap();
597 api::ignore(endpoint).query(&client).unwrap();
598 }
599
600 #[test]
601 fn endpoint_created_at() {
602 let endpoint = ExpectedUrl::builder()
603 .method(Method::POST)
604 .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
605 .content_type("application/x-www-form-urlencoded")
606 .body_str(concat!("body=body", "&created_at=2020-01-01T00%3A00%3A00Z"))
607 .build()
608 .unwrap();
609 let client = SingleTestClient::new_raw(endpoint, "");
610
611 let endpoint = CreateMergeRequestDiscussion::builder()
612 .project("simple/project")
613 .merge_request(1)
614 .body("body")
615 .created_at(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap())
616 .build()
617 .unwrap();
618 api::ignore(endpoint).query(&client).unwrap();
619 }
620
621 #[test]
622 fn endpoint_position_file() {
623 let endpoint = ExpectedUrl::builder()
624 .method(Method::POST)
625 .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
626 .content_type("application/x-www-form-urlencoded")
627 .body_str(concat!(
628 "body=body",
629 "&position%5Bbase_sha%5D=0000000000000000000000000000000000000000",
630 "&position%5Bstart_sha%5D=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
631 "&position%5Bhead_sha%5D=cafebabecafebabecafebabecafebabecafebabe",
632 "&position%5Bposition_type%5D=text",
633 ))
634 .build()
635 .unwrap();
636 let client = SingleTestClient::new_raw(endpoint, "");
637
638 let endpoint = CreateMergeRequestDiscussion::builder()
639 .project("simple/project")
640 .merge_request(1)
641 .body("body")
642 .position(
643 Position::builder()
644 .base_sha("0000000000000000000000000000000000000000")
645 .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
646 .head_sha("cafebabecafebabecafebabecafebabecafebabe")
647 .text_position(TextPosition::builder().build().unwrap())
648 .build()
649 .unwrap(),
650 )
651 .build()
652 .unwrap();
653 api::ignore(endpoint).query(&client).unwrap();
654 }
655
656 #[test]
657 fn endpoint_position_file_full() {
658 let endpoint = ExpectedUrl::builder()
659 .method(Method::POST)
660 .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
661 .content_type("application/x-www-form-urlencoded")
662 .body_str(concat!(
663 "body=body",
664 "&position%5Bbase_sha%5D=0000000000000000000000000000000000000000",
665 "&position%5Bstart_sha%5D=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
666 "&position%5Bhead_sha%5D=cafebabecafebabecafebabecafebabecafebabe",
667 "&position%5Bposition_type%5D=text",
668 "&position%5Bnew_path%5D=path%2Fto%2Ffile%2Fnew",
669 "&position%5Bnew_line%5D=0",
670 "&position%5Bold_path%5D=path%2Fto%2Ffile%2Fold",
671 "&position%5Bold_line%5D=0",
672 "&position%5Bline_range%5D%5Bstart%5D%5Bline_code%5D=some_complicated_line_code_thing",
673 "&position%5Bline_range%5D%5Bstart%5D%5Btype%5D=old",
674 "&position%5Bline_range%5D%5Bend%5D%5Bline_code%5D=some_complicated_line_code_thing",
675 "&position%5Bline_range%5D%5Bend%5D%5Btype%5D=new",
676 ))
677 .build()
678 .unwrap();
679 let client = SingleTestClient::new_raw(endpoint, "");
680
681 let endpoint = CreateMergeRequestDiscussion::builder()
682 .project("simple/project")
683 .merge_request(1)
684 .body("body")
685 .position(
686 Position::builder()
687 .base_sha("0000000000000000000000000000000000000000")
688 .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
689 .head_sha("cafebabecafebabecafebabecafebabecafebabe")
690 .text_position(
691 TextPosition::builder()
692 .new_path("path/to/file/new")
693 .new_line(0)
694 .old_path("path/to/file/old")
695 .old_line(0)
696 .line_range(
697 LineRange::builder()
698 .start(
699 LineCode::builder()
700 .line_code("some_complicated_line_code_thing")
701 .type_(LineType::Old)
702 .build()
703 .unwrap(),
704 )
705 .end(
706 LineCode::builder()
707 .line_code("some_complicated_line_code_thing")
708 .type_(LineType::New)
709 .build()
710 .unwrap(),
711 )
712 .build()
713 .unwrap(),
714 )
715 .build()
716 .unwrap(),
717 )
718 .build()
719 .unwrap(),
720 )
721 .build()
722 .unwrap();
723 api::ignore(endpoint).query(&client).unwrap();
724 }
725
726 #[test]
727 fn endpoint_position_image() {
728 let endpoint = ExpectedUrl::builder()
729 .method(Method::POST)
730 .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
731 .content_type("application/x-www-form-urlencoded")
732 .body_str(concat!(
733 "body=body",
734 "&position%5Bbase_sha%5D=0000000000000000000000000000000000000000",
735 "&position%5Bstart_sha%5D=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
736 "&position%5Bhead_sha%5D=cafebabecafebabecafebabecafebabecafebabe",
737 "&position%5Bposition_type%5D=image",
738 ))
739 .build()
740 .unwrap();
741 let client = SingleTestClient::new_raw(endpoint, "");
742
743 let endpoint = CreateMergeRequestDiscussion::builder()
744 .project("simple/project")
745 .merge_request(1)
746 .body("body")
747 .position(
748 Position::builder()
749 .base_sha("0000000000000000000000000000000000000000")
750 .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
751 .head_sha("cafebabecafebabecafebabecafebabecafebabe")
752 .image_position(ImagePosition::builder().build().unwrap())
753 .build()
754 .unwrap(),
755 )
756 .build()
757 .unwrap();
758 api::ignore(endpoint).query(&client).unwrap();
759 }
760
761 #[test]
762 fn endpoint_position_image_full() {
763 let endpoint = ExpectedUrl::builder()
764 .method(Method::POST)
765 .endpoint("projects/simple%2Fproject/merge_requests/1/discussions")
766 .content_type("application/x-www-form-urlencoded")
767 .body_str(concat!(
768 "body=body",
769 "&position%5Bbase_sha%5D=0000000000000000000000000000000000000000",
770 "&position%5Bstart_sha%5D=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
771 "&position%5Bhead_sha%5D=cafebabecafebabecafebabecafebabecafebabe",
772 "&position%5Bposition_type%5D=image",
773 "&position%5Bwidth%5D=100",
774 "&position%5Bheight%5D=100",
775 "&position%5Bx%5D=0",
776 "&position%5By%5D=0",
777 ))
778 .build()
779 .unwrap();
780 let client = SingleTestClient::new_raw(endpoint, "");
781
782 let endpoint = CreateMergeRequestDiscussion::builder()
783 .project("simple/project")
784 .merge_request(1)
785 .body("body")
786 .position(
787 Position::builder()
788 .base_sha("0000000000000000000000000000000000000000")
789 .start_sha("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
790 .head_sha("cafebabecafebabecafebabecafebabecafebabe")
791 .image_position(
792 ImagePosition::builder()
793 .width(100)
794 .height(100)
795 .x(0)
796 .y(0)
797 .build()
798 .unwrap(),
799 )
800 .build()
801 .unwrap(),
802 )
803 .build()
804 .unwrap();
805 api::ignore(endpoint).query(&client).unwrap();
806 }
807}