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 is_pinned(&self, target: &ShellPinTarget) -> bool {
110 self.items.iter().any(|pin| &pin.0 == target)
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 fn lxapp(index: usize) -> ShellPinTarget {
119 ShellPinTarget::Lxapp {
120 key: format!("app.{index}"),
121 }
122 }
123
124 #[test]
125 fn mixed_pin_order_is_preserved() {
126 let mut pins = PinCollection::default();
127 pins.pin(lxapp(1)).unwrap();
128 pins.pin(ShellPinTarget::Bookmark {
129 key: "bookmark-a".to_string(),
130 })
131 .unwrap();
132 pins.pin(lxapp(2)).unwrap();
133
134 assert!(matches!(pins.items[0].0, ShellPinTarget::Lxapp { .. }));
135 assert!(matches!(pins.items[1].0, ShellPinTarget::Bookmark { .. }));
136 assert!(matches!(pins.items[2].0, ShellPinTarget::Lxapp { .. }));
137 }
138
139 #[test]
140 fn ninth_pin_returns_typed_limit_error() {
141 let mut pins = PinCollection::default();
142 for index in 0..MAX_SHELL_PINS {
143 pins.pin(lxapp(index)).unwrap();
144 }
145
146 assert_eq!(
147 pins.pin(lxapp(MAX_SHELL_PINS)),
148 Err(ShellError::LimitReached {
149 max: MAX_SHELL_PINS
150 })
151 );
152 assert_eq!(pins.items.len(), MAX_SHELL_PINS);
153 }
154
155 #[test]
156 fn restore_rejects_overflow_instead_of_migrating_it() {
157 let stored = PinCollection {
158 version: PIN_STATE_VERSION,
159 items: (0..=MAX_SHELL_PINS)
160 .map(|index| ShellPin(lxapp(index)))
161 .collect(),
162 };
163
164 assert_eq!(
165 stored.restore(),
166 Err(ShellError::LimitReached {
167 max: MAX_SHELL_PINS,
168 })
169 );
170 }
171}