1use crate::{ShellError, ShellResult};
2use serde::{Deserialize, Serialize};
3
4pub const MAX_SHELL_PINS: usize = 8;
5pub(crate) const PIN_STATE_VERSION: u32 = 1;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(tag = "kind", rename_all = "lowercase")]
9pub enum ShellPinTarget {
10 Lxapp { key: String },
14 Bookmark { key: String },
16}
17
18impl ShellPinTarget {
19 fn validate(mut self) -> ShellResult<Self> {
20 let key = match &mut self {
21 Self::Lxapp { key } | Self::Bookmark { key } => key,
22 };
23 *key = key.trim().to_string();
24 if key.is_empty() {
25 return Err(ShellError::InvalidState(
26 "shell Pin target must not be empty".to_string(),
27 ));
28 }
29 Ok(self)
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(transparent)]
35pub struct ShellPin(pub ShellPinTarget);
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PinMutation {
39 Changed,
40 Unchanged,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct PinCollection {
46 pub version: u32,
47 pub items: Vec<ShellPin>,
48}
49
50impl Default for PinCollection {
51 fn default() -> Self {
52 Self {
53 version: PIN_STATE_VERSION,
54 items: Vec::new(),
55 }
56 }
57}
58
59impl PinCollection {
60 pub fn restore(mut self) -> ShellResult<Self> {
61 if self.version != PIN_STATE_VERSION {
62 return Err(ShellError::UnsupportedVersion {
63 version: self.version,
64 });
65 }
66 let mut normalized = Vec::with_capacity(self.items.len());
67 for pin in self.items {
68 let pin = ShellPin(pin.0.validate()?);
69 if normalized.contains(&pin) {
70 return Err(ShellError::InvalidState(
71 "shell Pin store contains duplicate targets".to_string(),
72 ));
73 }
74 normalized.push(pin);
75 if normalized.len() > MAX_SHELL_PINS {
76 return Err(ShellError::LimitReached {
77 max: MAX_SHELL_PINS,
78 });
79 }
80 }
81 self.items = normalized;
82 Ok(self)
83 }
84
85 pub fn pin(&mut self, target: ShellPinTarget) -> ShellResult<PinMutation> {
86 let pin = ShellPin(target.validate()?);
87 if self.items.contains(&pin) {
88 return Ok(PinMutation::Unchanged);
89 }
90 if self.items.len() >= MAX_SHELL_PINS {
91 return Err(ShellError::LimitReached {
92 max: MAX_SHELL_PINS,
93 });
94 }
95 self.items.push(pin);
96 Ok(PinMutation::Changed)
97 }
98
99 pub fn unpin(&mut self, target: &ShellPinTarget) -> PinMutation {
100 let before = self.items.len();
101 self.items.retain(|pin| &pin.0 != target);
102 if self.items.len() == before {
103 PinMutation::Unchanged
104 } else {
105 PinMutation::Changed
106 }
107 }
108
109 pub fn reorder(&mut self, items: Vec<ShellPin>) -> ShellResult<PinMutation> {
111 let next = Self {
112 version: self.version,
113 items,
114 }
115 .restore()?;
116 if next.items.len() != self.items.len()
117 || next.items.iter().any(|pin| !self.items.contains(pin))
118 {
119 return Err(ShellError::InvalidState(
120 "reorder requires every current Pin exactly once".to_string(),
121 ));
122 }
123 if next == *self {
124 return Ok(PinMutation::Unchanged);
125 }
126 *self = next;
127 Ok(PinMutation::Changed)
128 }
129
130 pub fn is_pinned(&self, target: &ShellPinTarget) -> bool {
131 self.items.iter().any(|pin| &pin.0 == target)
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 fn lxapp(index: usize) -> ShellPinTarget {
140 ShellPinTarget::Lxapp {
141 key: format!("app.{index}"),
142 }
143 }
144
145 #[test]
146 fn mixed_pin_order_is_preserved() {
147 let mut pins = PinCollection::default();
148 pins.pin(lxapp(1)).unwrap();
149 pins.pin(ShellPinTarget::Bookmark {
150 key: "bookmark-a".to_string(),
151 })
152 .unwrap();
153 pins.pin(lxapp(2)).unwrap();
154
155 assert!(matches!(pins.items[0].0, ShellPinTarget::Lxapp { .. }));
156 assert!(matches!(pins.items[1].0, ShellPinTarget::Bookmark { .. }));
157 assert!(matches!(pins.items[2].0, ShellPinTarget::Lxapp { .. }));
158 }
159
160 #[test]
161 fn reorder_rejects_non_permutations_without_mutating() {
162 let mut pins = PinCollection::default();
163 pins.pin(lxapp(1)).unwrap();
164 pins.pin(ShellPinTarget::Bookmark { key: "site".into() })
165 .unwrap();
166 let before = pins.clone();
167 for invalid in [
168 vec![],
169 vec![before.items[0].clone(); 2],
170 vec![before.items[0].clone(), ShellPin(lxapp(9))],
171 ] {
172 assert!(pins.reorder(invalid).is_err());
173 assert_eq!(pins, before);
174 }
175 let reversed = before.items.iter().rev().cloned().collect();
176 assert_eq!(pins.reorder(reversed), Ok(PinMutation::Changed));
177 assert_eq!(pins.items[0], before.items[1]);
178 assert_eq!(pins.reorder(pins.items.clone()), Ok(PinMutation::Unchanged));
179 }
180
181 #[test]
182 fn ninth_pin_returns_typed_limit_error() {
183 let mut pins = PinCollection::default();
184 for index in 0..MAX_SHELL_PINS {
185 pins.pin(lxapp(index)).unwrap();
186 }
187
188 assert_eq!(
189 pins.pin(lxapp(MAX_SHELL_PINS)),
190 Err(ShellError::LimitReached {
191 max: MAX_SHELL_PINS
192 })
193 );
194 assert_eq!(pins.items.len(), MAX_SHELL_PINS);
195 }
196
197 #[test]
198 fn restore_rejects_overflow_instead_of_migrating_it() {
199 let stored = PinCollection {
200 version: PIN_STATE_VERSION,
201 items: (0..=MAX_SHELL_PINS)
202 .map(|index| ShellPin(lxapp(index)))
203 .collect(),
204 };
205
206 assert_eq!(
207 stored.restore(),
208 Err(ShellError::LimitReached {
209 max: MAX_SHELL_PINS,
210 })
211 );
212 }
213}