spotify_cli/storage/pins/
store.rs1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::PathBuf;
4use thiserror::Error;
5
6use super::PINS_FILE;
7use super::fuzzy::calculate_fuzzy_score;
8use super::pin::Pin;
9use super::resource_type::ResourceType;
10use crate::storage::paths;
11
12#[derive(Debug, Error)]
13pub enum PinError {
14 #[error("Failed to get config directory: {0}")]
15 PathError(#[from] paths::PathError),
16 #[error("IO error: {0}")]
17 IoError(#[from] std::io::Error),
18 #[error("JSON error: {0}")]
19 JsonError(#[from] serde_json::Error),
20 #[error("Pin not found: {0}")]
21 NotFound(String),
22 #[error("Pin already exists with alias: {0}")]
23 AlreadyExists(String),
24}
25
26#[derive(Debug, Default, Serialize, Deserialize)]
27pub struct PinStore {
28 pins: Vec<Pin>,
29 #[serde(skip)]
30 path: Option<PathBuf>,
31}
32
33impl PinStore {
34 pub fn new() -> Result<Self, PinError> {
35 let config_dir = paths::config_dir()?;
36 let path = config_dir.join(PINS_FILE);
37
38 let mut store = if path.exists() {
39 let contents = fs::read_to_string(&path)?;
40 serde_json::from_str(&contents)?
41 } else {
42 PinStore::default()
43 };
44
45 store.path = Some(path);
46 Ok(store)
47 }
48
49 fn save(&self) -> Result<(), PinError> {
50 if let Some(path) = &self.path {
51 if let Some(parent) = path.parent() {
52 fs::create_dir_all(parent)?;
53 }
54 let contents = serde_json::to_string_pretty(&self)?;
55 fs::write(path, contents)?;
56 }
57 Ok(())
58 }
59
60 pub fn add(&mut self, pin: Pin) -> Result<(), PinError> {
61 if self
63 .pins
64 .iter()
65 .any(|p| p.alias.to_lowercase() == pin.alias.to_lowercase())
66 {
67 return Err(PinError::AlreadyExists(pin.alias));
68 }
69 self.pins.push(pin);
70 self.save()
71 }
72
73 pub fn remove(&mut self, alias_or_id: &str) -> Result<Pin, PinError> {
74 let lower = alias_or_id.to_lowercase();
75 let idx = self
76 .pins
77 .iter()
78 .position(|p| p.alias.to_lowercase() == lower || p.id == alias_or_id)
79 .ok_or_else(|| PinError::NotFound(alias_or_id.to_string()))?;
80
81 let removed = self.pins.remove(idx);
82 self.save()?;
83 Ok(removed)
84 }
85
86 pub fn list(&self, filter_type: Option<ResourceType>) -> Vec<&Pin> {
87 self.pins
88 .iter()
89 .filter(|p| filter_type.is_none_or(|t| p.resource_type == t))
90 .collect()
91 }
92
93 pub fn find_by_alias(&self, alias: &str) -> Option<&Pin> {
94 let lower = alias.to_lowercase();
95 self.pins.iter().find(|p| p.alias.to_lowercase() == lower)
96 }
97
98 pub fn all(&self) -> &[Pin] {
99 &self.pins
100 }
101
102 pub fn fuzzy_search(&self, query: &str) -> Vec<(&Pin, f64)> {
105 let query_lower = query.to_lowercase();
106 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
107
108 self.pins
109 .iter()
110 .filter_map(|pin| {
111 let score = calculate_fuzzy_score(pin, &query_lower, &query_words);
112 if score > 0.0 {
113 Some((pin, score))
114 } else {
115 None
116 }
117 })
118 .collect()
119 }
120
121 #[cfg(test)]
123 pub fn with_path(path: PathBuf) -> Result<Self, PinError> {
124 let mut store = if path.exists() {
125 let contents = fs::read_to_string(&path)?;
126 serde_json::from_str(&contents)?
127 } else {
128 PinStore::default()
129 };
130 store.path = Some(path);
131 Ok(store)
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use std::io::Write;
139 use tempfile::tempdir;
140
141 fn make_pin(alias: &str, id: &str, resource_type: ResourceType, tags: Vec<&str>) -> Pin {
142 Pin::new(
143 resource_type,
144 id.to_string(),
145 alias.to_string(),
146 tags.into_iter().map(String::from).collect(),
147 )
148 }
149
150 #[test]
151 fn pin_store_add_and_list() {
152 let dir = tempdir().unwrap();
153 let path = dir.path().join("pins.json");
154 let mut store = PinStore::with_path(path).unwrap();
155
156 let pin = make_pin("favorite", "track123", ResourceType::Track, vec![]);
157 store.add(pin).unwrap();
158
159 let pins = store.list(None);
160 assert_eq!(pins.len(), 1);
161 assert_eq!(pins[0].alias, "favorite");
162 }
163
164 #[test]
165 fn pin_store_add_duplicate_fails() {
166 let dir = tempdir().unwrap();
167 let path = dir.path().join("pins.json");
168 let mut store = PinStore::with_path(path).unwrap();
169
170 let pin1 = make_pin("favorite", "track123", ResourceType::Track, vec![]);
171 let pin2 = make_pin("FAVORITE", "track456", ResourceType::Track, vec![]);
172
173 store.add(pin1).unwrap();
174 let result = store.add(pin2);
175
176 assert!(matches!(result, Err(PinError::AlreadyExists(_))));
177 }
178
179 #[test]
180 fn pin_store_remove_by_alias() {
181 let dir = tempdir().unwrap();
182 let path = dir.path().join("pins.json");
183 let mut store = PinStore::with_path(path).unwrap();
184
185 store
186 .add(make_pin(
187 "favorite",
188 "track123",
189 ResourceType::Track,
190 vec![],
191 ))
192 .unwrap();
193 store
194 .add(make_pin("chill", "track456", ResourceType::Track, vec![]))
195 .unwrap();
196
197 let removed = store.remove("favorite").unwrap();
198 assert_eq!(removed.alias, "favorite");
199 assert_eq!(store.list(None).len(), 1);
200 }
201
202 #[test]
203 fn pin_store_remove_by_id() {
204 let dir = tempdir().unwrap();
205 let path = dir.path().join("pins.json");
206 let mut store = PinStore::with_path(path).unwrap();
207
208 store
209 .add(make_pin(
210 "favorite",
211 "track123",
212 ResourceType::Track,
213 vec![],
214 ))
215 .unwrap();
216
217 let removed = store.remove("track123").unwrap();
218 assert_eq!(removed.id, "track123");
219 }
220
221 #[test]
222 fn pin_store_remove_not_found() {
223 let dir = tempdir().unwrap();
224 let path = dir.path().join("pins.json");
225 let mut store = PinStore::with_path(path).unwrap();
226
227 let result = store.remove("nonexistent");
228 assert!(matches!(result, Err(PinError::NotFound(_))));
229 }
230
231 #[test]
232 fn pin_store_list_with_filter() {
233 let dir = tempdir().unwrap();
234 let path = dir.path().join("pins.json");
235 let mut store = PinStore::with_path(path).unwrap();
236
237 store
238 .add(make_pin("track1", "t1", ResourceType::Track, vec![]))
239 .unwrap();
240 store
241 .add(make_pin("playlist1", "p1", ResourceType::Playlist, vec![]))
242 .unwrap();
243 store
244 .add(make_pin("track2", "t2", ResourceType::Track, vec![]))
245 .unwrap();
246
247 let tracks = store.list(Some(ResourceType::Track));
248 assert_eq!(tracks.len(), 2);
249
250 let playlists = store.list(Some(ResourceType::Playlist));
251 assert_eq!(playlists.len(), 1);
252 }
253
254 #[test]
255 fn pin_store_find_by_alias() {
256 let dir = tempdir().unwrap();
257 let path = dir.path().join("pins.json");
258 let mut store = PinStore::with_path(path).unwrap();
259
260 store
261 .add(make_pin(
262 "MyFavorite",
263 "track123",
264 ResourceType::Track,
265 vec![],
266 ))
267 .unwrap();
268
269 let found = store.find_by_alias("myfavorite");
270 assert!(found.is_some());
271 assert_eq!(found.unwrap().id, "track123");
272 }
273
274 #[test]
275 fn pin_store_find_by_alias_not_found() {
276 let dir = tempdir().unwrap();
277 let path = dir.path().join("pins.json");
278 let store = PinStore::with_path(path).unwrap();
279
280 let found = store.find_by_alias("nonexistent");
281 assert!(found.is_none());
282 }
283
284 #[test]
285 fn pin_store_all() {
286 let dir = tempdir().unwrap();
287 let path = dir.path().join("pins.json");
288 let mut store = PinStore::with_path(path).unwrap();
289
290 store
291 .add(make_pin("pin1", "id1", ResourceType::Track, vec![]))
292 .unwrap();
293 store
294 .add(make_pin("pin2", "id2", ResourceType::Album, vec![]))
295 .unwrap();
296
297 assert_eq!(store.all().len(), 2);
298 }
299
300 #[test]
301 fn pin_store_fuzzy_search() {
302 let dir = tempdir().unwrap();
303 let path = dir.path().join("pins.json");
304 let mut store = PinStore::with_path(path).unwrap();
305
306 store
307 .add(make_pin(
308 "favorite song",
309 "t1",
310 ResourceType::Track,
311 vec!["rock"],
312 ))
313 .unwrap();
314 store
315 .add(make_pin(
316 "chill vibes",
317 "t2",
318 ResourceType::Track,
319 vec!["chill"],
320 ))
321 .unwrap();
322 store
323 .add(make_pin(
324 "workout mix",
325 "p1",
326 ResourceType::Playlist,
327 vec!["gym"],
328 ))
329 .unwrap();
330
331 let results = store.fuzzy_search("favorite");
332 assert!(!results.is_empty());
333 assert_eq!(results[0].0.alias, "favorite song");
334 }
335
336 #[test]
337 fn pin_store_fuzzy_search_by_tag() {
338 let dir = tempdir().unwrap();
339 let path = dir.path().join("pins.json");
340 let mut store = PinStore::with_path(path).unwrap();
341
342 store
343 .add(make_pin(
344 "some track",
345 "t1",
346 ResourceType::Track,
347 vec!["rock", "metal"],
348 ))
349 .unwrap();
350
351 let results = store.fuzzy_search("rock");
352 assert!(!results.is_empty());
353 }
354
355 #[test]
356 fn pin_store_persistence() {
357 let dir = tempdir().unwrap();
358 let path = dir.path().join("pins.json");
359
360 {
362 let mut store = PinStore::with_path(path.clone()).unwrap();
363 store
364 .add(make_pin("persistent", "id123", ResourceType::Track, vec![]))
365 .unwrap();
366 }
367
368 {
370 let store = PinStore::with_path(path).unwrap();
371 assert_eq!(store.all().len(), 1);
372 assert_eq!(store.all()[0].alias, "persistent");
373 }
374 }
375
376 #[test]
377 fn pin_store_loads_existing_file() {
378 let dir = tempdir().unwrap();
379 let path = dir.path().join("pins.json");
380
381 let mut file = fs::File::create(&path).unwrap();
383 writeln!(
384 file,
385 r#"{{"pins":[{{"resource_type":"track","id":"abc","alias":"test","tags":[]}}]}}"#
386 )
387 .unwrap();
388
389 let store = PinStore::with_path(path).unwrap();
390 assert_eq!(store.all().len(), 1);
391 assert_eq!(store.all()[0].id, "abc");
392 }
393
394 #[test]
395 fn pin_error_display() {
396 let err = PinError::NotFound("test".to_string());
397 assert!(err.to_string().contains("test"));
398
399 let err = PinError::AlreadyExists("alias".to_string());
400 assert!(err.to_string().contains("alias"));
401 }
402}