gr/cmds/
project.rs

1use crate::api_traits::{ProjectMember, RemoteProject, RemoteTag, Timestamp};
2use crate::cli::project::ProjectOptions;
3use crate::config::ConfigProperties;
4use crate::display::{self, Column, DisplayBody};
5use crate::error;
6use crate::io::CmdInfo;
7use crate::remote::{self, CacheType, GetRemoteCliArgs, ListBodyArgs, ListRemoteCliArgs};
8use crate::Result;
9use std::io::Write;
10use std::sync::Arc;
11
12use super::common;
13
14#[derive(Builder, Clone, Debug, Default, PartialEq)]
15pub struct Project {
16    pub id: i64,
17    default_branch: String,
18    #[builder(default)]
19    members: Vec<Member>,
20    html_url: String,
21    created_at: String,
22    description: String,
23    // Field not available in Gitlab. Set to empty string.
24    #[builder(default)]
25    language: String,
26}
27
28impl Project {
29    pub fn builder() -> ProjectBuilder {
30        ProjectBuilder::default()
31    }
32
33    pub fn new(id: i64, default_branch: &str) -> Self {
34        Project {
35            id,
36            default_branch: default_branch.to_string(),
37            members: Vec::new(),
38            html_url: String::new(),
39            created_at: String::new(),
40            description: String::new(),
41            language: String::new(),
42        }
43    }
44
45    pub fn with_html_url(mut self, html_url: &str) -> Self {
46        self.html_url = html_url.to_string();
47        self
48    }
49
50    // TODO - builder pattern
51    pub fn with_created_at(mut self, created_at: &str) -> Self {
52        self.created_at = created_at.to_string();
53        self
54    }
55
56    pub fn default_branch(&self) -> &str {
57        &self.default_branch
58    }
59}
60
61impl From<Project> for DisplayBody {
62    fn from(p: Project) -> DisplayBody {
63        DisplayBody {
64            columns: vec![
65                Column::new("ID", p.id.to_string()),
66                Column::new("Default Branch", p.default_branch),
67                Column::new("URL", p.html_url),
68                Column::new("Created at", p.created_at),
69                Column::builder()
70                    .name("Description".to_string())
71                    .value(p.description)
72                    .optional(true)
73                    .build()
74                    .unwrap(),
75                Column::builder()
76                    .name("Language".to_string())
77                    .value(p.language)
78                    .optional(true)
79                    .build()
80                    .unwrap(),
81            ],
82        }
83    }
84}
85
86impl Timestamp for Project {
87    fn created_at(&self) -> String {
88        self.created_at.clone()
89    }
90}
91
92/// MrMemberType is a merge request user type. The client might leave the
93/// assignee or reviewer empty.
94#[derive(Clone, Debug, PartialEq, Default)]
95pub enum MrMemberType {
96    Filled,
97    #[default]
98    Empty,
99}
100
101#[derive(Builder, Clone, Debug, PartialEq, Default)]
102pub struct Member {
103    #[builder(default)]
104    pub id: i64,
105    #[builder(default)]
106    pub name: String,
107    #[builder(default)]
108    pub username: String,
109    #[builder(default = "String::from(\"1970-01-01T00:00:00Z\")")]
110    pub created_at: String,
111    #[builder(default)]
112    pub mr_member_type: MrMemberType,
113}
114
115impl Member {
116    pub fn builder() -> MemberBuilder {
117        MemberBuilder::default()
118    }
119}
120
121impl Timestamp for Member {
122    fn created_at(&self) -> String {
123        self.created_at.clone()
124    }
125}
126
127impl From<Member> for DisplayBody {
128    fn from(m: Member) -> DisplayBody {
129        DisplayBody {
130            columns: vec![
131                Column::new("ID", m.id.to_string()),
132                Column::builder()
133                    .name("Name".to_string())
134                    .value(m.name)
135                    .optional(true)
136                    .build()
137                    .unwrap(),
138                Column::new("Username", m.username),
139            ],
140        }
141    }
142}
143
144#[derive(Builder)]
145pub struct ProjectListCliArgs {
146    pub list_args: ListRemoteCliArgs,
147    #[builder(default)]
148    pub stars: bool,
149    #[builder(default)]
150    pub tags: bool,
151    #[builder(default)]
152    pub members: bool,
153}
154
155impl ProjectListCliArgs {
156    pub fn builder() -> ProjectListCliArgsBuilder {
157        ProjectListCliArgsBuilder::default()
158    }
159}
160
161#[derive(Builder)]
162pub struct ProjectListBodyArgs {
163    pub from_to_page: Option<ListBodyArgs>,
164    pub user: Option<Member>,
165    #[builder(default)]
166    pub stars: bool,
167    #[builder(default)]
168    pub tags: bool,
169    #[builder(default)]
170    pub members: bool,
171}
172
173impl ProjectListBodyArgs {
174    pub fn builder() -> ProjectListBodyArgsBuilder {
175        ProjectListBodyArgsBuilder::default()
176    }
177}
178
179#[derive(Builder)]
180pub struct ProjectMetadataGetCliArgs {
181    pub id: Option<i64>,
182    #[builder(default)]
183    pub path: Option<String>,
184    pub get_args: GetRemoteCliArgs,
185}
186
187impl ProjectMetadataGetCliArgs {
188    pub fn builder() -> ProjectMetadataGetCliArgsBuilder {
189        ProjectMetadataGetCliArgsBuilder::default()
190    }
191}
192
193#[derive(Builder, Clone)]
194pub struct Tag {
195    pub name: String,
196    pub sha: String,
197    pub created_at: String,
198}
199
200impl Tag {
201    pub fn builder() -> TagBuilder {
202        TagBuilder::default()
203    }
204}
205
206impl Timestamp for Tag {
207    fn created_at(&self) -> String {
208        self.created_at.clone()
209    }
210}
211
212impl From<Tag> for DisplayBody {
213    fn from(t: Tag) -> DisplayBody {
214        DisplayBody {
215            columns: vec![
216                Column::new("Name", t.name),
217                Column::new("SHA", t.sha),
218                Column::builder()
219                    .name("Created at".to_string())
220                    .value(t.created_at)
221                    .optional(true)
222                    .build()
223                    .unwrap(),
224            ],
225        }
226    }
227}
228
229pub fn execute(
230    options: ProjectOptions,
231    config: Arc<dyn ConfigProperties>,
232    domain: String,
233    path: String,
234) -> Result<()> {
235    match options {
236        ProjectOptions::Info(cli_args) => {
237            let remote = remote::get_project(
238                domain,
239                path,
240                config,
241                Some(&cli_args.get_args.cache_args),
242                CacheType::File,
243            )?;
244            project_info(remote, std::io::stdout(), cli_args)
245        }
246        ProjectOptions::Members(cli_args) => {
247            let remote = remote::get_project_member(
248                domain,
249                path,
250                config,
251                Some(&cli_args.list_args.get_args.cache_args),
252                CacheType::File,
253            )?;
254            let from_to_args = remote::validate_from_to_page(&cli_args.list_args)?;
255            let body_args = ProjectListBodyArgs::builder()
256                .members(true)
257                .from_to_page(from_to_args)
258                .user(None)
259                .build()?;
260            if cli_args.list_args.num_pages {
261                return common::num_project_member_pages(remote, body_args, std::io::stdout());
262            }
263            if cli_args.list_args.num_resources {
264                return common::num_project_member_pages(remote, body_args, std::io::stdout());
265            }
266            list_project_members(remote, body_args, cli_args, std::io::stdout())
267        }
268        ProjectOptions::Tags(cli_args) => {
269            let remote = remote::get_tag(
270                domain,
271                path,
272                config,
273                Some(&cli_args.list_args.get_args.cache_args),
274                CacheType::File,
275            )?;
276            let from_to_args = remote::validate_from_to_page(&cli_args.list_args)?;
277            let body_args = ProjectListBodyArgs::builder()
278                .tags(true)
279                .from_to_page(from_to_args)
280                .user(None)
281                .build()?;
282            if cli_args.list_args.num_pages {
283                return common::num_tag_pages(remote, body_args, std::io::stdout());
284            }
285            if cli_args.list_args.num_resources {
286                return common::num_tag_resources(remote, body_args, std::io::stdout());
287            }
288            list_project_tags(remote, body_args, cli_args, std::io::stdout())
289        }
290    }
291}
292
293fn project_info<W: Write>(
294    remote: Arc<dyn RemoteProject>,
295    mut writer: W,
296    cli_args: ProjectMetadataGetCliArgs,
297) -> Result<()> {
298    // check if cli_args.path is present and remove the domain from it
299    let path = if let Some(path) = &cli_args.path {
300        // path github.com/jordilin/gitar
301        debug_assert!(path.matches('/').count() >= 2);
302        Some(path.split('/').skip(1).collect::<Vec<&str>>().join("/"))
303    } else {
304        None
305    };
306    let CmdInfo::Project(project_data) = remote.get_project_data(cli_args.id, path.as_deref())?
307    else {
308        return Err(error::GRError::ApplicationError(
309            "remote.get_project_data expects CmdInfo::Project invariant".to_string(),
310        )
311        .into());
312    };
313    display::print(&mut writer, vec![project_data], cli_args.get_args)?;
314    Ok(())
315}
316
317fn list_project_tags<W: Write>(
318    remote: Arc<dyn RemoteTag>,
319    body_args: ProjectListBodyArgs,
320    cli_args: ProjectListCliArgs,
321    mut writer: W,
322) -> Result<()> {
323    common::list_project_tags(remote, body_args, cli_args, &mut writer)
324}
325
326fn list_project_members<W: Write>(
327    remote: Arc<dyn ProjectMember>,
328    body_args: ProjectListBodyArgs,
329    cli_args: ProjectListCliArgs,
330    mut writer: W,
331) -> Result<()> {
332    common::list_project_members(remote, body_args, cli_args, &mut writer)
333}
334
335#[cfg(test)]
336mod test {
337
338    use std::cell::RefCell;
339
340    use super::*;
341    use crate::cli::browse::BrowseOptions;
342
343    #[derive(Builder)]
344    struct ProjectDataProvider {
345        #[builder(default = "false")]
346        error: bool,
347        #[builder(default = "CmdInfo::Ignore")]
348        cmd_info: CmdInfo,
349        #[builder(default = "RefCell::new(false)")]
350        project_data_with_id_called: RefCell<bool>,
351        #[builder(default = "RefCell::new(false)")]
352        project_data_with_path_called: RefCell<bool>,
353    }
354
355    impl ProjectDataProvider {
356        pub fn builder() -> ProjectDataProviderBuilder {
357            ProjectDataProviderBuilder::default()
358        }
359    }
360
361    impl RemoteProject for ProjectDataProvider {
362        fn get_project_data(&self, id: Option<i64>, path: Option<&str>) -> crate::Result<CmdInfo> {
363            if id.is_some() {
364                *self.project_data_with_id_called.borrow_mut() = true;
365            }
366            if path.is_some() {
367                *self.project_data_with_path_called.borrow_mut() = true;
368            }
369            if self.error {
370                return Err(error::gen("Error"));
371            }
372            match self.cmd_info {
373                CmdInfo::Project(_) => Ok(self.cmd_info.clone()),
374                _ => Ok(CmdInfo::Ignore),
375            }
376        }
377
378        fn get_project_members(&self) -> crate::Result<CmdInfo> {
379            todo!()
380        }
381
382        fn get_url(&self, _option: BrowseOptions) -> String {
383            todo!()
384        }
385
386        fn list(&self, _args: ProjectListBodyArgs) -> Result<Vec<Project>> {
387            todo!()
388        }
389
390        fn num_pages(&self, _args: ProjectListBodyArgs) -> Result<Option<u32>> {
391            todo!()
392        }
393
394        fn num_resources(
395            &self,
396            _args: ProjectListBodyArgs,
397        ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
398            todo!()
399        }
400    }
401
402    impl RemoteTag for ProjectDataProvider {
403        fn list(&self, _args: ProjectListBodyArgs) -> Result<Vec<Tag>> {
404            let tag = Tag::builder()
405                .name("v1.0.0".to_string())
406                .sha("123456".to_string())
407                .created_at("2021-01-01".to_string())
408                .build()
409                .unwrap();
410            Ok(vec![tag])
411        }
412    }
413
414    impl ProjectMember for ProjectDataProvider {
415        fn list(&self, _args: ProjectListBodyArgs) -> Result<Vec<Member>> {
416            Ok(vec![Member::builder()
417                .id(1)
418                .name("Tom".to_string())
419                .username("tomsawyer".to_string())
420                .build()
421                .unwrap()])
422        }
423    }
424
425    #[test]
426    fn test_project_data_gets_persisted() {
427        let remote = ProjectDataProviderBuilder::default()
428            .cmd_info(CmdInfo::Project(Project::default()))
429            .build()
430            .unwrap();
431        let remote = Arc::new(remote);
432        let mut writer = Vec::new();
433        let get_args = GetRemoteCliArgs::default();
434        let cli_args = ProjectMetadataGetCliArgs::builder()
435            .id(Some(1))
436            .get_args(get_args)
437            .build()
438            .unwrap();
439        project_info(remote.clone(), &mut writer, cli_args).unwrap();
440        assert!(!writer.is_empty());
441        assert!(*remote.project_data_with_id_called.borrow());
442    }
443
444    #[test]
445    fn test_project_data_called_by_repo_path() {
446        let remote = ProjectDataProviderBuilder::default()
447            .cmd_info(CmdInfo::Project(Project::default()))
448            .build()
449            .unwrap();
450        let remote = Arc::new(remote);
451        let mut writer = Vec::new();
452        let get_args = GetRemoteCliArgs::default();
453        let cli_args = ProjectMetadataGetCliArgs::builder()
454            .id(None)
455            .path(Some("github.com/jordilin/gitar".to_string()))
456            .get_args(get_args)
457            .build()
458            .unwrap();
459        project_info(remote.clone(), &mut writer, cli_args).unwrap();
460        assert!(!writer.is_empty());
461        assert!(*remote.project_data_with_path_called.borrow());
462    }
463
464    #[test]
465    fn test_project_data_error() {
466        let remote = ProjectDataProviderBuilder::default()
467            .cmd_info(CmdInfo::Project(Project::default()))
468            .error(true)
469            .build()
470            .unwrap();
471        let remote = Arc::new(remote);
472        let mut writer = Vec::new();
473        let get_args = GetRemoteCliArgs::default();
474        let cli_args = ProjectMetadataGetCliArgs::builder()
475            .id(Some(1))
476            .get_args(get_args)
477            .build()
478            .unwrap();
479        project_info(remote, &mut writer, cli_args).unwrap_err();
480        assert!(writer.is_empty());
481    }
482
483    #[test]
484    fn test_get_project_data_wrong_cmdinfo_invariant() {
485        let remote = ProjectDataProviderBuilder::default()
486            .cmd_info(CmdInfo::Ignore)
487            .build()
488            .unwrap();
489        let remote = Arc::new(remote);
490        let mut writer = Vec::new();
491        let get_args = GetRemoteCliArgs::default();
492        let cli_args = ProjectMetadataGetCliArgs::builder()
493            .id(Some(1))
494            .get_args(get_args)
495            .build()
496            .unwrap();
497        let result = project_info(remote, &mut writer, cli_args);
498        match result {
499            Ok(_) => panic!("Expected error"),
500            Err(err) => match err.downcast_ref::<error::GRError>() {
501                Some(error::GRError::ApplicationError(_)) => (),
502                _ => panic!("Expected error::GRError::ApplicationError"),
503            },
504        }
505    }
506
507    #[test]
508    fn test_list_project_tags() {
509        let remote = ProjectDataProvider::builder().build().unwrap();
510        let remote = Arc::new(remote);
511        let mut writer = Vec::new();
512        let body_args = ProjectListBodyArgs::builder()
513            .tags(true)
514            .from_to_page(None)
515            .user(None)
516            .build()
517            .unwrap();
518        let cli_args = ProjectListCliArgs::builder()
519            .tags(true)
520            .list_args(ListRemoteCliArgs::builder().build().unwrap())
521            .build()
522            .unwrap();
523        list_project_tags(remote, body_args, cli_args, &mut writer).unwrap();
524        assert_eq!(
525            "Name|SHA\nv1.0.0|123456\n",
526            String::from_utf8(writer).unwrap()
527        );
528    }
529
530    #[test]
531    fn test_display_all_columns_project_tags() {
532        let remote = ProjectDataProvider::builder().build().unwrap();
533        let remote = Arc::new(remote);
534        let mut writer = Vec::new();
535        let body_args = ProjectListBodyArgs::builder()
536            .tags(true)
537            .from_to_page(None)
538            .user(None)
539            .build()
540            .unwrap();
541        let cli_args = ProjectListCliArgs::builder()
542            .tags(true)
543            .list_args(
544                ListRemoteCliArgs::builder()
545                    .get_args(
546                        GetRemoteCliArgs::builder()
547                            .display_optional(true)
548                            .build()
549                            .unwrap(),
550                    )
551                    .build()
552                    .unwrap(),
553            )
554            .build()
555            .unwrap();
556        list_project_tags(remote, body_args, cli_args, &mut writer).unwrap();
557        assert_eq!(
558            "Name|SHA|Created at\nv1.0.0|123456|2021-01-01\n",
559            String::from_utf8(writer).unwrap()
560        );
561    }
562
563    #[test]
564    fn test_display_all_columns_project_members() {
565        let remote = ProjectDataProvider::builder().build().unwrap();
566        let remote = Arc::new(remote);
567        let mut writer = Vec::new();
568        let body_args = ProjectListBodyArgs::builder()
569            .members(true)
570            .from_to_page(None)
571            .user(None)
572            .build()
573            .unwrap();
574        let cli_args = ProjectListCliArgs::builder()
575            .members(true)
576            .list_args(
577                ListRemoteCliArgs::builder()
578                    .get_args(GetRemoteCliArgs::builder().build().unwrap())
579                    .build()
580                    .unwrap(),
581            )
582            .build()
583            .unwrap();
584        list_project_members(remote, body_args, cli_args, &mut writer).unwrap();
585        assert_eq!(
586            "ID|Username\n1|tomsawyer\n",
587            String::from_utf8(writer).unwrap()
588        );
589    }
590}