spotify_cli/storage/pins/
store.rs

1use 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        // Check for duplicate alias
62        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    /// Fuzzy search pins by query matching against alias and tags
103    /// Returns pins with a relevance score (higher = better match)
104    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    /// Create a PinStore with a custom path (for testing)
122    #[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        // Create and add pins
361        {
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        // Reload and verify
369        {
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        // Write a pins file manually
382        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}