Skip to main content

changepacks_cli/
prompter.rs

1use anyhow::Result;
2use changepacks_core::Project;
3use thiserror::Error;
4
5/// Error type for user cancellation (Ctrl+C or ESC)
6#[derive(Debug, Error)]
7#[error("")]
8pub struct UserCancelled;
9
10/// Dependency injection interface for interactive prompts.
11///
12/// Allows commands to accept `&dyn Prompter` for testability. Production code uses
13/// `InquirePrompter`, tests use `MockPrompter` with predetermined responses.
14pub trait Prompter: Send + Sync {
15    /// # Errors
16    /// Returns error if user cancels the selection or interaction fails.
17    fn multi_select<'a>(
18        &self,
19        message: &str,
20        options: Vec<&'a Project>,
21        defaults: Vec<usize>,
22    ) -> Result<Vec<&'a Project>>;
23
24    /// # Errors
25    /// Returns error if user cancels the confirmation or interaction fails.
26    fn confirm(&self, message: &str) -> Result<bool>;
27
28    /// # Errors
29    /// Returns error if user cancels the input or interaction fails.
30    fn text(&self, message: &str) -> Result<String>;
31}
32
33/// Helper function for handling inquire result errors
34fn handle_inquire_result<T>(result: Result<T, inquire::InquireError>) -> Result<T> {
35    match result {
36        Ok(v) => Ok(v),
37        Err(
38            inquire::InquireError::OperationCanceled | inquire::InquireError::OperationInterrupted,
39        ) => Err(UserCancelled.into()),
40        Err(e) => Err(e.into()),
41    }
42}
43
44/// Score function for project selection: changed projects rank higher in the list.
45pub(crate) fn score_project(project: &Project) -> Option<i64> {
46    if project.is_changed() {
47        Some(100)
48    } else {
49        Some(0)
50    }
51}
52
53/// Format selected projects as a newline-separated display string.
54pub(crate) fn format_selected_projects(projects: &[&Project]) -> String {
55    projects
56        .iter()
57        .map(|p| format!("{p}"))
58        .collect::<Vec<_>>()
59        .join("\n")
60}
61
62/// Real implementation using inquire crate
63#[derive(Default)]
64pub struct InquirePrompter;
65
66#[cfg(not(tarpaulin_include))]
67impl Prompter for InquirePrompter {
68    fn multi_select<'a>(
69        &self,
70        message: &str,
71        options: Vec<&'a Project>,
72        defaults: Vec<usize>,
73    ) -> Result<Vec<&'a Project>> {
74        let mut selector = inquire::MultiSelect::new(message, options);
75        selector.page_size = 15;
76        selector.default = Some(defaults);
77        selector.scorer =
78            &|_input, option, _string_value, _idx| -> Option<i64> { score_project(option) };
79        selector.formatter = &|option| {
80            let projects: Vec<&Project> = option.iter().map(|o| *o.value).collect();
81            format_selected_projects(&projects)
82        };
83        handle_inquire_result(selector.prompt())
84    }
85
86    fn confirm(&self, message: &str) -> Result<bool> {
87        handle_inquire_result(inquire::Confirm::new(message).prompt())
88    }
89
90    fn text(&self, message: &str) -> Result<String> {
91        handle_inquire_result(inquire::Text::new(message).prompt())
92    }
93}
94
95/// Mock implementation that returns predefined values (for testing)
96pub struct MockPrompter {
97    pub select_all: bool,
98    pub confirm_value: bool,
99    pub text_value: String,
100}
101
102impl Default for MockPrompter {
103    fn default() -> Self {
104        Self {
105            select_all: true,
106            confirm_value: true,
107            text_value: "test note".to_string(),
108        }
109    }
110}
111
112impl Prompter for MockPrompter {
113    fn multi_select<'a>(
114        &self,
115        _message: &str,
116        options: Vec<&'a Project>,
117        _defaults: Vec<usize>,
118    ) -> Result<Vec<&'a Project>> {
119        if self.select_all {
120            Ok(options)
121        } else {
122            Ok(vec![])
123        }
124    }
125
126    fn confirm(&self, _message: &str) -> Result<bool> {
127        Ok(self.confirm_value)
128    }
129
130    fn text(&self, _message: &str) -> Result<String> {
131        Ok(self.text_value.clone())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use async_trait::async_trait;
139    use changepacks_core::{Language, Package, UpdateType};
140    use std::collections::HashSet;
141    use std::path::Path;
142
143    /// Minimal mock Package for testing scorer and formatter functions
144    #[derive(Debug)]
145    struct MockTestPackage {
146        name: Option<String>,
147        changed: bool,
148    }
149
150    impl MockTestPackage {
151        fn new(name: &str, changed: bool) -> Self {
152            Self {
153                name: Some(name.to_string()),
154                changed,
155            }
156        }
157    }
158
159    #[async_trait]
160    impl Package for MockTestPackage {
161        fn name(&self) -> Option<&str> {
162            self.name.as_deref()
163        }
164        fn version(&self) -> Option<&str> {
165            Some("1.0.0")
166        }
167        fn path(&self) -> &Path {
168            Path::new("package.json")
169        }
170        fn relative_path(&self) -> &Path {
171            Path::new("package.json")
172        }
173        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
174            Ok(())
175        }
176        fn is_changed(&self) -> bool {
177            self.changed
178        }
179        fn language(&self) -> Language {
180            Language::Node
181        }
182        fn dependencies(&self) -> &HashSet<String> {
183            static EMPTY: std::sync::LazyLock<HashSet<String>> =
184                std::sync::LazyLock::new(HashSet::new);
185            &EMPTY
186        }
187        fn add_dependency(&mut self, _dep: &str) {}
188        fn set_changed(&mut self, changed: bool) {
189            self.changed = changed;
190        }
191        fn default_publish_command(&self) -> String {
192            "echo test".to_string()
193        }
194    }
195
196    #[test]
197    fn test_mock_prompter_default() {
198        let prompter = MockPrompter::default();
199        assert!(prompter.select_all);
200        assert!(prompter.confirm_value);
201        assert_eq!(prompter.text_value, "test note");
202    }
203
204    #[test]
205    fn test_mock_prompter_confirm() {
206        let prompter = MockPrompter {
207            confirm_value: false,
208            ..Default::default()
209        };
210        assert!(!prompter.confirm("test").unwrap());
211    }
212
213    #[test]
214    fn test_mock_prompter_text() {
215        let prompter = MockPrompter {
216            text_value: "custom".to_string(),
217            ..Default::default()
218        };
219        assert_eq!(prompter.text("test").unwrap(), "custom");
220    }
221
222    #[test]
223    fn test_mock_prompter_multi_select_empty() {
224        let prompter = MockPrompter {
225            select_all: false,
226            ..Default::default()
227        };
228        let options: Vec<&Project> = vec![];
229        let result = prompter.multi_select("test", options, vec![]).unwrap();
230        assert!(result.is_empty());
231    }
232
233    #[test]
234    fn test_handle_inquire_result_ok() {
235        let result: Result<&str> = handle_inquire_result(Ok("test_value"));
236        assert_eq!(result.unwrap(), "test_value");
237    }
238
239    #[test]
240    fn test_handle_inquire_result_operation_canceled() {
241        let result: Result<()> =
242            handle_inquire_result(Err(inquire::InquireError::OperationCanceled));
243        assert!(result.is_err());
244        assert!(
245            result
246                .unwrap_err()
247                .downcast_ref::<UserCancelled>()
248                .is_some()
249        );
250    }
251
252    #[test]
253    fn test_handle_inquire_result_operation_interrupted() {
254        let result: Result<()> =
255            handle_inquire_result(Err(inquire::InquireError::OperationInterrupted));
256        assert!(result.is_err());
257        assert!(
258            result
259                .unwrap_err()
260                .downcast_ref::<UserCancelled>()
261                .is_some()
262        );
263    }
264
265    #[test]
266    fn test_handle_inquire_result_other_error() {
267        let result: Result<()> = handle_inquire_result(Err(
268            inquire::InquireError::InvalidConfiguration("test".into()),
269        ));
270        assert!(result.is_err());
271        assert!(
272            result
273                .unwrap_err()
274                .downcast_ref::<UserCancelled>()
275                .is_none()
276        );
277    }
278
279    #[test]
280    fn test_score_project_changed() {
281        let project = Project::Package(Box::new(MockTestPackage::new("pkg", true)));
282        assert_eq!(score_project(&project), Some(100));
283    }
284
285    #[test]
286    fn test_score_project_unchanged() {
287        let project = Project::Package(Box::new(MockTestPackage::new("pkg", false)));
288        assert_eq!(score_project(&project), Some(0));
289    }
290
291    #[test]
292    fn test_format_selected_projects_empty() {
293        let projects: Vec<&Project> = vec![];
294        assert_eq!(format_selected_projects(&projects), "");
295    }
296
297    #[test]
298    fn test_format_selected_projects_single() {
299        let project = Project::Package(Box::new(MockTestPackage::new("my-app", false)));
300        let projects = vec![&project];
301        let result = format_selected_projects(&projects);
302        assert!(result.contains("my-app"));
303    }
304
305    #[test]
306    fn test_format_selected_projects_multiple() {
307        let p1 = Project::Package(Box::new(MockTestPackage::new("app-a", true)));
308        let p2 = Project::Package(Box::new(MockTestPackage::new("app-b", false)));
309        let projects = vec![&p1, &p2];
310        let result = format_selected_projects(&projects);
311        assert!(result.contains('\n'));
312        let lines: Vec<&str> = result.lines().collect();
313        assert_eq!(lines.len(), 2);
314    }
315}