gr/
dialog.rs

1use std::sync::Arc;
2
3use console::style;
4
5use dialoguer::theme::ColorfulTheme;
6use dialoguer::Confirm;
7use dialoguer::Editor;
8use dialoguer::FuzzySelect;
9use dialoguer::Input;
10
11use crate::cmds::merge_request::MergeRequestBodyArgs;
12use crate::cmds::project::Member;
13use crate::config::ConfigProperties;
14use crate::error;
15use crate::Result;
16
17#[derive(Builder)]
18pub struct MergeRequestUserInput {
19    pub title: String,
20    pub description: String,
21    pub assignee: Member,
22    #[builder(default)]
23    pub reviewer: Member,
24}
25
26impl MergeRequestUserInput {
27    pub fn builder() -> MergeRequestUserInputBuilder {
28        MergeRequestUserInputBuilder::default()
29    }
30
31    pub fn new(title: &str, description: &str, user_id: i64, username: &str) -> Self {
32        MergeRequestUserInput {
33            title: title.to_string(),
34            description: description.to_string(),
35            assignee: Member::builder()
36                .id(user_id)
37                .username(username.to_string())
38                .build()
39                .unwrap(),
40            reviewer: Member::default(),
41        }
42    }
43}
44
45struct MemberSelector {
46    members: Vec<Member>,
47}
48
49impl MemberSelector {
50    pub fn new(members: Vec<Member>) -> Self {
51        Self { members }
52    }
53
54    /// Determines the assignee based on priority:
55    /// 1. CLI provided assignee (if present)
56    /// 2. Config preferred assignee (if present)
57    /// 3. Empty list with default unassigned member
58    pub fn prepare_assignee_list(
59        &self,
60        cli_assignee: Option<&Member>,
61        config_assignee: Option<Member>,
62    ) -> Vec<Member> {
63        // Start with the original members list
64        let mut selection_list = self.members.clone();
65
66        match (cli_assignee, config_assignee) {
67            (Some(cli), _) => {
68                // CLI assignee takes precedence
69                selection_list.insert(0, cli.clone());
70                selection_list.insert(1, Member::default()); // Allow unassigning
71            }
72            (None, Some(config)) => {
73                // Config assignee is secondary
74                selection_list.insert(0, config);
75                selection_list.insert(1, Member::default()); // Allow unassigning
76            }
77            (None, None) => {
78                // No defaults - start with unassigned
79                selection_list.insert(0, Member::default());
80            }
81        }
82
83        selection_list
84    }
85
86    /// Prepares reviewer list by excluding the selected assignee
87    pub fn prepare_reviewer_list(
88        &self,
89        default_cli_reviewer: Option<&Member>,
90        assigned_member: &Member,
91    ) -> Vec<Member> {
92        let mut selection_list = if default_cli_reviewer.is_some() {
93            vec![default_cli_reviewer.unwrap().clone(), Member::default()]
94        } else {
95            vec![Member::default()]
96        };
97        selection_list.extend(
98            self.members
99                .iter()
100                .filter(|m| m != &assigned_member)
101                .cloned(),
102        );
103        selection_list
104    }
105}
106
107/// Given a new merge request, prompt user for assignee, title and description.
108pub fn prompt_user_merge_request_info(
109    default_title: &str,
110    default_description: &str,
111    default_cli_assignee: Option<&Member>,
112    default_cli_reviewer: Option<&Member>,
113    config: &Arc<dyn ConfigProperties>,
114) -> Result<MergeRequestUserInput> {
115    let (title, description) = prompt_user_title_description(default_title, default_description);
116
117    // Initialize member selector with available members
118    let selector = MemberSelector::new(config.merge_request_members());
119
120    // Prepare assignee selection list with priorities
121    let assignee_list =
122        selector.prepare_assignee_list(default_cli_assignee, config.preferred_assignee_username());
123
124    // Get assignee selection
125    let assignee_index = gather_member(&assignee_list, "Assignee:");
126    let assigned_member = assignee_list[assignee_index].clone();
127
128    // Prepare reviewer list excluding the selected assignee
129    let reviewer_list = selector.prepare_reviewer_list(default_cli_reviewer, &assigned_member);
130    let reviewer_index = gather_member(&reviewer_list, "Reviewer:");
131
132    Ok(MergeRequestUserInput::builder()
133        .title(title)
134        .description(description)
135        .assignee(assigned_member)
136        .reviewer(reviewer_list[reviewer_index].clone())
137        .build()
138        .unwrap())
139}
140
141fn gather_member(members: &[Member], prompt: &str) -> usize {
142    let usernames = members
143        .iter()
144        .map(|member| member.username.as_str())
145        .collect::<Vec<&str>>();
146
147    let assignee_selection_id = FuzzySelect::with_theme(&ColorfulTheme::default())
148        .with_prompt(prompt)
149        .default(0)
150        .items(&usernames)
151        .interact()
152        .unwrap();
153
154    if assignee_selection_id != 0 {
155        assignee_selection_id
156    } else {
157        // The preferred one has been selected
158        0
159    }
160}
161
162pub fn prompt_user_title_description(
163    default_title: &str,
164    default_description: &str,
165) -> (String, String) {
166    let title: String = Input::with_theme(&ColorfulTheme::default())
167        .with_prompt("Title: ")
168        .default(default_title.to_string())
169        .interact_text()
170        .unwrap();
171
172    let description = get_description(default_description);
173    (title, description)
174}
175
176fn get_description(default_description: &str) -> String {
177    show_input("Description: ", default_description, true, Style::Bold);
178    let mut description = default_description.to_string();
179    let prompt = "Edit description";
180    while !confirm(prompt, false) {
181        description = if let Some(entry_msg) = Editor::new().edit(&description).unwrap() {
182            entry_msg
183        } else {
184            "".to_string()
185        };
186        show_input("Description: ", &description, true, Style::Bold);
187    }
188    description
189}
190
191pub enum Style {
192    Bold,
193    Light,
194}
195
196pub fn show_input(prompt: &str, data: &str, new_line: bool, font_style: Style) {
197    let mut prompt_style = style(prompt);
198    if let Style::Bold = font_style {
199        prompt_style = prompt_style.bold()
200    }
201    if new_line {
202        println!("{prompt_style}");
203        println!("\n{data}\n");
204    } else {
205        print!("{prompt_style}: ");
206        println!("{data}")
207    }
208}
209
210fn confirm(prompt: &str, default_answer: bool) -> bool {
211    if Confirm::with_theme(&ColorfulTheme::default())
212        .with_prompt(prompt)
213        .default(default_answer)
214        .interact()
215        .unwrap()
216    {
217        return default_answer;
218    }
219    !default_answer
220}
221
222pub fn show_summary_merge_request(
223    commit_str: &str,
224    args: &MergeRequestBodyArgs,
225    accept: bool,
226) -> Result<()> {
227    show_outgoing_changes_summary(commit_str);
228    show_input("Target branch", &args.target_branch, false, Style::Bold);
229    show_input("Assignee", &args.assignee.username, false, Style::Bold);
230    show_input("Reviewer", &args.reviewer.username, false, Style::Bold);
231    show_input("Title", &args.title, false, Style::Bold);
232    if !args.description.is_empty() {
233        show_input("Description:", &args.description, true, Style::Bold);
234    } else {
235        show_input("Description", "None", false, Style::Bold);
236    }
237    println!();
238    if accept || confirm("Confirm summary", true) {
239        Ok(())
240    } else {
241        Err(error::gen("User cancelled"))
242    }
243}
244
245pub fn show_outgoing_changes_summary(commit_str: &str) {
246    show_input(
247        "\nSummary of outgoing changes:",
248        commit_str,
249        true,
250        Style::Bold,
251    );
252}
253
254pub fn prompt_args() -> String {
255    Input::with_theme(&ColorfulTheme::default())
256        .with_prompt("args: ")
257        .allow_empty(true)
258        .interact_text()
259        .unwrap()
260}
261
262pub fn fuzzy_select(amps: Vec<String>) -> Result<String> {
263    let selection = dialoguer::FuzzySelect::with_theme(&ColorfulTheme::default())
264        .with_prompt("amp:")
265        .default(0)
266        .items(&amps)
267        .interact()
268        .unwrap();
269    Ok(amps[selection].to_string())
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    use crate::cmds::project::Member;
277
278    fn create_test_member(id: i64, username: &str) -> Member {
279        Member::builder()
280            .id(id)
281            .username(username.to_string())
282            .build()
283            .unwrap()
284    }
285
286    fn create_test_members() -> Vec<Member> {
287        vec![
288            create_test_member(1, "alice"),
289            create_test_member(2, "bob"),
290            create_test_member(3, "charlie"),
291        ]
292    }
293
294    #[test]
295    fn test_prepare_assignee_list_with_cli_assignee() {
296        let members = create_test_members();
297        let original_members = members.clone();
298        let selector = MemberSelector::new(members);
299        let cli_assignee = create_test_member(4, "david");
300
301        let result = selector.prepare_assignee_list(
302            Some(&cli_assignee),
303            Some(create_test_member(5, "eve")), // Should be ignored when CLI assignee is present
304        );
305
306        // Verify the exact ordering
307        assert_eq!(result[0], cli_assignee); // CLI assignee first
308        assert_eq!(result[1], Member::default()); // Unassigned option second
309        assert_eq!(&result[2..], &original_members[..]); // Original list preserved after
310        assert_eq!(result.len(), original_members.len() + 2); // Original + 2 inserted items
311    }
312
313    #[test]
314    fn test_prepare_assignee_list_with_config_assignee() {
315        let members = create_test_members();
316        let original_members = members.clone();
317        let selector = MemberSelector::new(members);
318        let config_assignee = create_test_member(4, "eve");
319
320        let result = selector.prepare_assignee_list(None, Some(config_assignee.clone()));
321
322        // Verify the exact ordering
323        assert_eq!(result[0], config_assignee); // Config assignee first
324        assert_eq!(result[1], Member::default()); // Unassigned option second
325        assert_eq!(&result[2..], &original_members[..]); // Original list preserved after
326        assert_eq!(result.len(), original_members.len() + 2); // Original + 2 inserted items
327    }
328
329    #[test]
330    fn test_prepare_assignee_list_with_no_defaults() {
331        let members = create_test_members();
332        let original_members = members.clone();
333        let selector = MemberSelector::new(members);
334
335        let result = selector.prepare_assignee_list(None, None);
336
337        // Verify the exact ordering
338        assert_eq!(result[0], Member::default()); // Unassigned option first
339        assert_eq!(&result[1..], &original_members[..]); // Original list preserved after
340        assert_eq!(result.len(), original_members.len() + 1); // Original + 1 inserted item
341    }
342
343    #[test]
344    fn test_prepare_assignee_list_with_existing_member() {
345        let members = create_test_members();
346        let original_members = members.clone();
347        let selector = MemberSelector::new(members);
348
349        // Use the first member from the list as CLI assignee
350        let cli_assignee = &original_members[0];
351        let result = selector.prepare_assignee_list(Some(cli_assignee), None);
352
353        // Verify the exact ordering
354        assert_eq!(result[0], cli_assignee.clone()); // CLI assignee first
355        assert_eq!(result[1], Member::default()); // Unassigned option second
356        assert_eq!(&result[2..], &original_members[..]); // Original list preserved after
357        assert_eq!(result.len(), original_members.len() + 2); // Original + 2 inserted items
358    }
359
360    #[test]
361    fn test_prepare_reviewer_list_with_cli_reviewer_provided() {
362        let members = create_test_members();
363        let selector = MemberSelector::new(members);
364        let assignee = create_test_member(1, "alice");
365        let reviewer = create_test_member(2, "charlie");
366
367        let result = selector.prepare_reviewer_list(Some(&reviewer), &assignee);
368
369        assert_eq!(result[0], reviewer);
370        assert!(!result.contains(&assignee));
371        // Reviewer (id = 2), unassigned, bob (id = 2) and charlie (id = 3)
372        assert_eq!(result.len(), 4);
373    }
374
375    #[test]
376    fn test_prepare_reviewer_list_no_cli_reviewer_provided() {
377        let members = create_test_members();
378        let selector = MemberSelector::new(members);
379        let assignee = create_test_member(1, "alice");
380
381        let result = selector.prepare_reviewer_list(None::<&Member>, &assignee);
382
383        assert_eq!(result[0], Member::default());
384        assert!(!result.contains(&assignee));
385        assert_eq!(result.len(), 3); // Unassigned + 2 remaining members
386    }
387}