lingxia_shell/
activator.rs1use crate::{ShellError, ShellResult};
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "camelCase")]
7pub struct ShellActivator {
8 pub id: String,
9 pub label: String,
10 pub icon: String,
11 #[serde(default)]
12 pub disabled: bool,
13}
14
15impl ShellActivator {
16 pub fn validate(mut self) -> ShellResult<Self> {
17 self.id = required(self.id, ShellError::EmptyActivatorId)?;
18 self.label = required_field(self.label, "label")?;
19 self.icon = required_field(self.icon, "icon")?;
20 Ok(self)
21 }
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct ShellActivatorUpdate {
26 pub label: Option<String>,
27 pub icon: Option<String>,
28 pub disabled: Option<bool>,
29}
30
31impl ShellActivatorUpdate {
32 fn validate(mut self, id: &str) -> ShellResult<Self> {
33 if self.label.is_none() && self.icon.is_none() && self.disabled.is_none() {
34 return Err(ShellError::EmptyActivatorUpdate { id: id.to_string() });
35 }
36 self.label = optional(self.label, "label")?;
37 self.icon = optional(self.icon, "icon")?;
38 Ok(self)
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ResolvedShellActivator {
45 pub id: String,
46 pub label: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub icon_path: Option<String>,
49 pub disabled: bool,
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct ActivatorCollection {
54 generation: u64,
55 declared: bool,
56 items: Vec<ShellActivator>,
57}
58
59impl ActivatorCollection {
60 pub fn generation(&self) -> u64 {
61 self.generation
62 }
63
64 pub fn declared(&self) -> bool {
65 self.declared
66 }
67
68 pub fn items(&self) -> &[ShellActivator] {
69 &self.items
70 }
71
72 pub fn replace(&mut self, items: Vec<ShellActivator>) -> ShellResult<()> {
73 let items = validate_generation(items)?;
74 self.items = items;
75 self.declared = true;
76 self.generation = self.generation.wrapping_add(1);
77 Ok(())
78 }
79
80 pub fn update(&mut self, id: &str, patch: ShellActivatorUpdate) -> ShellResult<()> {
81 let id = id.trim();
82 if id.is_empty() {
83 return Err(ShellError::EmptyActivatorId);
84 }
85 let patch = patch.validate(id)?;
86 let Some(item) = self.items.iter_mut().find(|item| item.id == id) else {
87 return Err(ShellError::ActivatorNotFound { id: id.to_string() });
88 };
89 if let Some(label) = patch.label {
90 item.label = label;
91 }
92 if let Some(icon) = patch.icon {
93 item.icon = icon;
94 }
95 if let Some(disabled) = patch.disabled {
96 item.disabled = disabled;
97 }
98 self.declared = true;
99 self.generation = self.generation.wrapping_add(1);
100 Ok(())
101 }
102
103 pub fn remove(&mut self, id: &str) -> ShellResult<()> {
104 let id = id.trim();
105 if id.is_empty() {
106 return Err(ShellError::EmptyActivatorId);
107 }
108 let before = self.items.len();
109 self.items.retain(|item| item.id != id);
110 if self.items.len() == before {
111 return Err(ShellError::ActivatorNotFound { id: id.to_string() });
112 }
113 self.declared = true;
114 self.generation = self.generation.wrapping_add(1);
115 Ok(())
116 }
117
118 pub fn clear(&mut self) {
119 self.items.clear();
120 self.declared = true;
121 self.generation = self.generation.wrapping_add(1);
122 }
123}
124
125fn required(value: String, error: ShellError) -> ShellResult<String> {
126 let value = value.trim();
127 if value.is_empty() {
128 Err(error)
129 } else {
130 Ok(value.to_string())
131 }
132}
133
134fn required_field(value: String, field: &'static str) -> ShellResult<String> {
135 let value = value.trim();
136 if value.is_empty() {
137 Err(ShellError::EmptyActivatorField { field })
138 } else {
139 Ok(value.to_string())
140 }
141}
142
143fn optional(value: Option<String>, field: &'static str) -> ShellResult<Option<String>> {
144 value.map(|value| required_field(value, field)).transpose()
145}
146
147fn validate_generation(items: Vec<ShellActivator>) -> ShellResult<Vec<ShellActivator>> {
148 let mut ids = HashSet::with_capacity(items.len());
149 items
150 .into_iter()
151 .map(ShellActivator::validate)
152 .map(|result| {
153 let item = result?;
154 if !ids.insert(item.id.clone()) {
155 return Err(ShellError::DuplicateActivatorId { id: item.id });
156 }
157 Ok(item)
158 })
159 .collect()
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn activator(id: &str) -> ShellActivator {
167 ShellActivator {
168 id: id.to_string(),
169 label: format!("Label {id}"),
170 icon: "icons/activator.svg".to_string(),
171 disabled: false,
172 }
173 }
174
175 #[test]
176 fn replace_is_atomic_when_a_later_item_is_invalid() {
177 let mut state = ActivatorCollection::default();
178 state.replace(vec![activator("chat")]).unwrap();
179 let before = state.clone();
180
181 let result = state.replace(vec![activator("ok"), activator("")]);
182
183 assert_eq!(result, Err(ShellError::EmptyActivatorId));
184 assert_eq!(state, before);
185 }
186
187 #[test]
188 fn clear_is_an_explicit_empty_declaration() {
189 let mut state = ActivatorCollection::default();
190 state.clear();
191
192 assert!(state.declared());
193 assert!(state.items().is_empty());
194 }
195
196 #[test]
197 fn label_and_icon_are_required() {
198 let mut missing_label = activator("sync");
199 missing_label.label.clear();
200 assert_eq!(
201 missing_label.validate(),
202 Err(ShellError::EmptyActivatorField { field: "label" })
203 );
204
205 let mut missing_icon = activator("sync");
206 missing_icon.icon.clear();
207 assert_eq!(
208 missing_icon.validate(),
209 Err(ShellError::EmptyActivatorField { field: "icon" })
210 );
211 }
212
213 #[test]
214 fn stable_ids_are_unique() {
215 let mut state = ActivatorCollection::default();
216 let result = state.replace(vec![activator("same"), activator("same")]);
217
218 assert_eq!(
219 result,
220 Err(ShellError::DuplicateActivatorId {
221 id: "same".to_string()
222 })
223 );
224 }
225}