lingxia_shell/
sidebar_action.rs1use crate::{ShellError, ShellResult};
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4
5pub const MAX_HEADER_SIDEBAR_ACTIONS: usize = 2;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub enum SidebarActionPlacement {
24 Header,
25 Footer,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ShellSidebarAction {
31 pub id: String,
32 pub placement: SidebarActionPlacement,
33 pub label: String,
34 pub icon: String,
35 #[serde(default)]
36 pub disabled: bool,
37}
38
39impl ShellSidebarAction {
40 pub fn validate(mut self) -> ShellResult<Self> {
41 self.id = required(self.id, ShellError::EmptySidebarActionId)?;
42 self.label = required_field(self.label, "label")?;
43 self.icon = required_field(self.icon, "icon")?;
44 Ok(self)
45 }
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct ShellSidebarActionUpdate {
50 pub label: Option<String>,
51 pub icon: Option<String>,
52 pub disabled: Option<bool>,
53}
54
55impl ShellSidebarActionUpdate {
56 fn validate(mut self, id: &str) -> ShellResult<Self> {
57 if self.label.is_none() && self.icon.is_none() && self.disabled.is_none() {
58 return Err(ShellError::EmptySidebarActionUpdate { id: id.to_string() });
59 }
60 self.label = optional(self.label, "label")?;
61 self.icon = optional(self.icon, "icon")?;
62 Ok(self)
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct ResolvedShellSidebarAction {
69 pub generation: u64,
70 pub id: String,
71 pub placement: SidebarActionPlacement,
72 pub label: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub icon_path: Option<String>,
75 pub disabled: bool,
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct SidebarActionCollection {
80 generation: u64,
81 declared: bool,
82 items: Vec<ShellSidebarAction>,
83}
84
85impl SidebarActionCollection {
86 pub fn generation(&self) -> u64 {
87 self.generation
88 }
89
90 pub fn declared(&self) -> bool {
91 self.declared
92 }
93
94 pub fn items(&self) -> &[ShellSidebarAction] {
95 &self.items
96 }
97
98 pub fn replace(&mut self, items: Vec<ShellSidebarAction>) -> ShellResult<()> {
99 let items = validate_generation(items)?;
100 self.items = items;
101 self.declared = true;
102 self.generation = self.generation.wrapping_add(1);
103 Ok(())
104 }
105
106 pub fn update(&mut self, id: &str, patch: ShellSidebarActionUpdate) -> ShellResult<()> {
107 let id = id.trim();
108 if id.is_empty() {
109 return Err(ShellError::EmptySidebarActionId);
110 }
111 let patch = patch.validate(id)?;
112 let Some(item) = self.items.iter_mut().find(|item| item.id == id) else {
113 return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
114 };
115 if let Some(label) = patch.label {
116 item.label = label;
117 }
118 if let Some(icon) = patch.icon {
119 item.icon = icon;
120 }
121 if let Some(disabled) = patch.disabled {
122 item.disabled = disabled;
123 }
124 self.declared = true;
125 self.generation = self.generation.wrapping_add(1);
126 Ok(())
127 }
128
129 pub fn remove(&mut self, id: &str) -> ShellResult<()> {
130 let id = id.trim();
131 if id.is_empty() {
132 return Err(ShellError::EmptySidebarActionId);
133 }
134 let before = self.items.len();
135 self.items.retain(|item| item.id != id);
136 if self.items.len() == before {
137 return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
138 }
139 self.declared = true;
140 self.generation = self.generation.wrapping_add(1);
141 Ok(())
142 }
143
144 pub fn clear(&mut self) {
145 self.items.clear();
146 self.declared = true;
147 self.generation = self.generation.wrapping_add(1);
148 }
149}
150
151fn required(value: String, error: ShellError) -> ShellResult<String> {
152 let value = value.trim();
153 if value.is_empty() {
154 Err(error)
155 } else {
156 Ok(value.to_string())
157 }
158}
159
160fn required_field(value: String, field: &'static str) -> ShellResult<String> {
161 let value = value.trim();
162 if value.is_empty() {
163 Err(ShellError::EmptySidebarActionField { field })
164 } else {
165 Ok(value.to_string())
166 }
167}
168
169fn optional(value: Option<String>, field: &'static str) -> ShellResult<Option<String>> {
170 value.map(|value| required_field(value, field)).transpose()
171}
172
173fn validate_generation(items: Vec<ShellSidebarAction>) -> ShellResult<Vec<ShellSidebarAction>> {
174 let mut ids = HashSet::with_capacity(items.len());
175 let items = items
176 .into_iter()
177 .map(ShellSidebarAction::validate)
178 .map(|result| {
179 let item = result?;
180 if !ids.insert(item.id.clone()) {
181 return Err(ShellError::DuplicateSidebarActionId { id: item.id });
182 }
183 Ok(item)
184 })
185 .collect::<ShellResult<Vec<_>>>()?;
186 if items
187 .iter()
188 .filter(|item| item.placement == SidebarActionPlacement::Header)
189 .count()
190 > MAX_HEADER_SIDEBAR_ACTIONS
191 {
192 return Err(ShellError::SidebarActionHeaderLimit {
193 max: MAX_HEADER_SIDEBAR_ACTIONS,
194 });
195 }
196 Ok(items)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn action(id: &str) -> ShellSidebarAction {
204 ShellSidebarAction {
205 id: id.to_string(),
206 placement: SidebarActionPlacement::Footer,
207 label: format!("Label {id}"),
208 icon: "icons/action.svg".to_string(),
209 disabled: false,
210 }
211 }
212
213 #[test]
214 fn replace_is_atomic_when_a_later_item_is_invalid() {
215 let mut state = SidebarActionCollection::default();
216 state.replace(vec![action("chat")]).unwrap();
217 let before = state.clone();
218
219 let result = state.replace(vec![action("ok"), action("")]);
220
221 assert_eq!(result, Err(ShellError::EmptySidebarActionId));
222 assert_eq!(state, before);
223 }
224
225 #[test]
226 fn clear_is_an_explicit_empty_declaration() {
227 let mut state = SidebarActionCollection::default();
228 state.clear();
229
230 assert!(state.declared());
231 assert!(state.items().is_empty());
232 }
233
234 #[test]
235 fn label_and_icon_are_required() {
236 let mut missing_label = action("sync");
237 missing_label.label.clear();
238 assert_eq!(
239 missing_label.validate(),
240 Err(ShellError::EmptySidebarActionField { field: "label" })
241 );
242
243 let mut missing_icon = action("sync");
244 missing_icon.icon.clear();
245 assert_eq!(
246 missing_icon.validate(),
247 Err(ShellError::EmptySidebarActionField { field: "icon" })
248 );
249 }
250
251 #[test]
252 fn stable_ids_are_unique() {
253 let mut state = SidebarActionCollection::default();
254 let result = state.replace(vec![action("same"), action("same")]);
255
256 assert_eq!(
257 result,
258 Err(ShellError::DuplicateSidebarActionId {
259 id: "same".to_string()
260 })
261 );
262 }
263
264 #[test]
265 fn the_header_limit_rejects_rather_than_truncates() {
266 let mut state = SidebarActionCollection::default();
267 let over_limit: Vec<_> = (0..=MAX_HEADER_SIDEBAR_ACTIONS)
268 .map(|index| {
269 let mut item = action(&format!("header-{index}"));
270 item.placement = SidebarActionPlacement::Header;
271 item
272 })
273 .collect();
274
275 assert_eq!(
276 state.replace(over_limit),
277 Err(ShellError::SidebarActionHeaderLimit {
278 max: MAX_HEADER_SIDEBAR_ACTIONS
279 })
280 );
281 assert_eq!(state.generation(), 0);
282 }
283
284 #[test]
286 fn the_header_limit_is_inclusive() {
287 let mut state = SidebarActionCollection::default();
288 let at_limit: Vec<_> = (0..MAX_HEADER_SIDEBAR_ACTIONS)
289 .map(|index| {
290 let mut item = action(&format!("header-{index}"));
291 item.placement = SidebarActionPlacement::Header;
292 item
293 })
294 .collect();
295
296 assert!(state.replace(at_limit).is_ok());
297 assert_eq!(state.generation(), 1);
298 }
299}