changepacks_cli/
prompter.rs1use anyhow::Result;
2use changepacks_core::Project;
3use thiserror::Error;
4
5#[derive(Debug, Error)]
7#[error("")]
8pub struct UserCancelled;
9
10pub trait Prompter: Send + Sync {
15 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 fn confirm(&self, message: &str) -> Result<bool>;
27
28 fn text(&self, message: &str) -> Result<String>;
31}
32
33fn 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
44pub(crate) fn score_project(project: &Project) -> Option<i64> {
46 if project.is_changed() {
47 Some(100)
48 } else {
49 Some(0)
50 }
51}
52
53pub(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#[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
95pub 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 #[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}