1use anyhow::{bail, Result};
7use serde::{Deserialize, Serialize};
8
9use crate::input::{input, InputProvider};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct Menu {
13 pub id: String,
14 pub title: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub description: Option<String>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub default_item_id: Option<String>,
19 #[serde(default)]
20 pub items: Vec<MenuItem>,
21}
22
23impl Menu {
24 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
25 Self {
26 id: id.into(),
27 title: title.into(),
28 description: None,
29 default_item_id: None,
30 items: Vec::new(),
31 }
32 }
33
34 pub fn description(mut self, description: impl Into<String>) -> Self {
35 self.description = Some(description.into());
36 self
37 }
38
39 pub fn default_item(mut self, item_id: impl Into<String>) -> Self {
40 self.default_item_id = Some(item_id.into());
41 self
42 }
43
44 pub fn item(mut self, item: MenuItem) -> Self {
45 self.items.push(item);
46 self
47 }
48
49 pub fn selectable_items(&self) -> Vec<(usize, &MenuItem)> {
50 self.items
51 .iter()
52 .enumerate()
53 .filter(|(_, item)| !item.disabled)
54 .collect()
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct MenuItem {
60 pub id: String,
61 pub label: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub description: Option<String>,
64 #[serde(default)]
65 pub disabled: bool,
66}
67
68impl MenuItem {
69 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
70 Self {
71 id: id.into(),
72 label: label.into(),
73 description: None,
74 disabled: false,
75 }
76 }
77
78 pub fn description(mut self, description: impl Into<String>) -> Self {
79 self.description = Some(description.into());
80 self
81 }
82
83 pub fn disabled(mut self, disabled: bool) -> Self {
84 self.disabled = disabled;
85 self
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct MenuSelection {
91 pub menu_id: String,
92 pub item_id: String,
93 pub item_index: usize,
94}
95
96pub trait MenuPresenter {
97 fn select(&self, menu: &Menu) -> Result<MenuSelection>;
98}
99
100pub struct InputMenuPresenter<'a> {
101 input: &'a dyn InputProvider,
102}
103
104impl<'a> InputMenuPresenter<'a> {
105 pub fn new(input: &'a dyn InputProvider) -> Self {
106 Self { input }
107 }
108}
109
110impl Default for InputMenuPresenter<'static> {
111 fn default() -> Self {
112 Self { input: input() }
113 }
114}
115
116impl MenuPresenter for InputMenuPresenter<'_> {
117 fn select(&self, menu: &Menu) -> Result<MenuSelection> {
118 let selectable = menu.selectable_items();
119 if selectable.is_empty() {
120 bail!("menu {} has no selectable items", menu.id);
121 }
122
123 let labels: Vec<String> = selectable
124 .iter()
125 .map(|(_, item)| render_label(item))
126 .collect();
127 let default = default_selectable_index(menu, &selectable);
128 let selected = self.input.select(&menu.title, &labels, default)?;
129 let (item_index, item) = selectable
130 .get(selected)
131 .copied()
132 .unwrap_or_else(|| selectable[default]);
133
134 Ok(MenuSelection {
135 menu_id: menu.id.clone(),
136 item_id: item.id.clone(),
137 item_index,
138 })
139 }
140}
141
142pub fn select(menu: &Menu) -> Result<MenuSelection> {
143 InputMenuPresenter::default().select(menu)
144}
145
146fn render_label(item: &MenuItem) -> String {
147 match &item.description {
148 Some(description) if !description.is_empty() => {
149 format!("{} - {}", item.label, description)
150 }
151 _ => item.label.clone(),
152 }
153}
154
155fn default_selectable_index(menu: &Menu, selectable: &[(usize, &MenuItem)]) -> usize {
156 let Some(default_id) = menu.default_item_id.as_deref() else {
157 return 0;
158 };
159
160 selectable
161 .iter()
162 .position(|(_, item)| item.id == default_id)
163 .unwrap_or(0)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use anyhow::Context;
170
171 struct FixedSelect(usize);
172
173 impl InputProvider for FixedSelect {
174 fn password(&self, _prompt: &str) -> Result<String> {
175 Ok(String::new())
176 }
177
178 fn password_with_confirm(&self, _prompt: &str) -> Result<String> {
179 Ok(String::new())
180 }
181
182 fn confirm(&self, _prompt: &str, default: bool) -> Result<bool> {
183 Ok(default)
184 }
185
186 fn input_text(&self, _prompt: &str, default: Option<&str>) -> Result<String> {
187 Ok(default.unwrap_or_default().to_string())
188 }
189
190 fn select(&self, _prompt: &str, _items: &[String], _default: usize) -> Result<usize> {
191 Ok(self.0)
192 }
193 }
194
195 #[test]
196 fn selects_enabled_item_by_rendered_index() -> Result<()> {
197 let menu = Menu::new("forge-target", "Forge target")
198 .item(MenuItem::new("bundle", "Bundle only"))
199 .item(MenuItem::new("usb", "USB installer"));
200
201 let selection = InputMenuPresenter::new(&FixedSelect(1)).select(&menu)?;
202
203 assert_eq!(selection.menu_id, "forge-target");
204 assert_eq!(selection.item_id, "usb");
205 assert_eq!(selection.item_index, 1);
206 Ok(())
207 }
208
209 #[test]
210 fn skips_disabled_items() -> Result<()> {
211 let menu = Menu::new("forge-target", "Forge target")
212 .item(MenuItem::new("bundle", "Bundle only").disabled(true))
213 .item(MenuItem::new("usb", "USB installer"));
214
215 let selection = InputMenuPresenter::new(&FixedSelect(0)).select(&menu)?;
216
217 assert_eq!(selection.item_id, "usb");
218 assert_eq!(selection.item_index, 1);
219 Ok(())
220 }
221
222 #[test]
223 fn serializes_as_plain_data() -> Result<()> {
224 let menu = Menu::new("forge-target", "Forge target")
225 .description("Select the artifact to produce")
226 .default_item("bundle")
227 .item(MenuItem::new("bundle", "Bundle only"));
228
229 let encoded = serde_json::to_string(&menu).context("serialize menu")?;
230 let decoded: Menu = serde_json::from_str(&encoded).context("decode menu")?;
231
232 assert_eq!(decoded, menu);
233 Ok(())
234 }
235}