Skip to main content

nex_pkg/
menu.rs

1//! Minimal menu model shared by CLI prompts and richer TUIs.
2//!
3//! This is intentionally a supporting structure, not a UI framework. Callers
4//! build small serializable menus; frontends decide how to render them.
5
6use 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}