spotify_cli/cache/
metadata.rs

1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::domain::settings::Settings;
7use crate::error::Result;
8
9/// Persistent metadata for auth credentials and settings.
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct Metadata {
12    pub auth: Option<AuthTokenCache>,
13    pub client: Option<ClientIdentity>,
14    #[serde(default)]
15    pub settings: Settings,
16}
17
18/// Cached OAuth token fields stored locally.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AuthTokenCache {
21    pub access_token: String,
22    pub refresh_token: Option<String>,
23    pub expires_at: Option<u64>,
24    #[serde(default)]
25    pub granted_scopes: Option<Vec<String>>,
26}
27
28/// Stored client identity (client id) for refresh flows.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ClientIdentity {
31    pub client_id: String,
32}
33
34/// JSON-backed metadata store.
35#[derive(Debug, Clone)]
36pub struct MetadataStore {
37    path: PathBuf,
38}
39
40impl MetadataStore {
41    pub fn new(path: PathBuf) -> Self {
42        Self { path }
43    }
44
45    pub fn load(&self) -> Result<Metadata> {
46        if !self.path.exists() {
47            return Ok(Metadata::default());
48        }
49
50        let contents = fs::read_to_string(&self.path)?;
51        let metadata = serde_json::from_str(&contents)?;
52        Ok(metadata)
53    }
54
55    pub fn save(&self, metadata: &Metadata) -> Result<()> {
56        let payload = serde_json::to_string_pretty(metadata)?;
57        fs::write(&self.path, payload)?;
58        #[cfg(unix)]
59        {
60            use std::os::unix::fs::PermissionsExt;
61            let _ = fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600));
62        }
63        Ok(())
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::{AuthTokenCache, Metadata, MetadataStore};
70    use crate::domain::settings::Settings;
71    use std::fs;
72    use std::path::PathBuf;
73
74    fn temp_path(name: &str) -> PathBuf {
75        let mut path = std::env::temp_dir();
76        let stamp = std::time::SystemTime::now()
77            .duration_since(std::time::UNIX_EPOCH)
78            .unwrap()
79            .as_nanos();
80        path.push(format!("spotify-cli-{name}-{stamp}.json"));
81        path
82    }
83
84    #[test]
85    fn metadata_store_round_trip() {
86        let path = temp_path("metadata-store");
87        let store = MetadataStore::new(path.clone());
88        let metadata = Metadata {
89            auth: Some(AuthTokenCache {
90                access_token: "token".to_string(),
91                refresh_token: None,
92                expires_at: Some(1),
93                granted_scopes: Some(vec!["user-read-private".to_string()]),
94            }),
95            client: None,
96            settings: Settings {
97                country: Some("AU".to_string()),
98                user_name: Some("Me".to_string()),
99            },
100        };
101        store.save(&metadata).expect("save");
102        let loaded = store.load().expect("load");
103        assert_eq!(loaded.settings.country.as_deref(), Some("AU"));
104        assert_eq!(loaded.settings.user_name.as_deref(), Some("Me"));
105        let _ = fs::remove_file(path);
106    }
107}