synd_term/application/cache/
mod.rs

1use std::{
2    borrow::Borrow,
3    io,
4    path::{Path, PathBuf},
5};
6
7use serde::{de::DeserializeOwned, Serialize};
8use synd_stdx::fs::{fsimpl, FileSystem};
9use thiserror::Error;
10
11use crate::{
12    auth::{Credential, Unverified},
13    config,
14    ui::components::gh_notifications::GhNotificationFilterOptions,
15};
16
17#[derive(Debug, Error)]
18pub enum PersistCacheError {
19    #[error("io error: {path} {io} ")]
20    Io { path: PathBuf, io: io::Error },
21    #[error("serialize error: {0}")]
22    Serialize(#[from] serde_json::Error),
23}
24
25#[derive(Debug, Error)]
26pub enum LoadCacheError {
27    #[error("cache entry not found")]
28    NotFound,
29    #[error("io error: {path} {io}")]
30    Io { path: PathBuf, io: io::Error },
31    #[error("deserialize error: {0}")]
32    Deserialize(#[from] serde_json::Error),
33}
34
35pub struct Cache<FS = fsimpl::FileSystem> {
36    dir: PathBuf,
37    fs: FS,
38}
39
40impl Cache<fsimpl::FileSystem> {
41    pub fn new(dir: impl Into<PathBuf>) -> Self {
42        Self::with(dir, fsimpl::FileSystem::new())
43    }
44}
45
46impl<FS> Cache<FS>
47where
48    FS: FileSystem,
49{
50    pub fn with(dir: impl Into<PathBuf>, fs: FS) -> Self {
51        Self {
52            dir: dir.into(),
53            fs,
54        }
55    }
56
57    /// Persist credential in filesystem.
58    /// This is blocking operation.
59    pub fn persist_credential(
60        &self,
61        cred: impl Borrow<Credential>,
62    ) -> Result<(), PersistCacheError> {
63        self.persist(&self.credential_file(), cred.borrow())
64    }
65
66    pub(crate) fn persist_gh_notification_filter_options(
67        &self,
68        options: impl Borrow<GhNotificationFilterOptions>,
69    ) -> Result<(), PersistCacheError> {
70        self.persist(&self.gh_notification_filter_option_file(), options.borrow())
71    }
72
73    fn persist<T>(&self, path: &Path, entry: &T) -> Result<(), PersistCacheError>
74    where
75        T: ?Sized + Serialize,
76    {
77        if let Some(parent) = path.parent() {
78            self.fs
79                .create_dir_all(parent)
80                .map_err(|err| PersistCacheError::Io {
81                    path: parent.to_path_buf(),
82                    io: err,
83                })?;
84        }
85
86        self.fs
87            .create_file(path)
88            .map_err(|err| PersistCacheError::Io {
89                path: path.to_path_buf(),
90                io: err,
91            })
92            .and_then(|mut file| {
93                serde_json::to_writer(&mut file, entry).map_err(PersistCacheError::Serialize)
94            })
95    }
96
97    /// Load credential from filesystem.
98    /// This is blocking operation.
99    pub fn load_credential(&self) -> Result<Unverified<Credential>, LoadCacheError> {
100        self.load::<Credential>(&self.credential_file())
101            .map(Unverified::from)
102    }
103
104    pub(crate) fn load_gh_notification_filter_options(
105        &self,
106    ) -> Result<GhNotificationFilterOptions, LoadCacheError> {
107        self.load(&self.gh_notification_filter_option_file())
108    }
109
110    fn load<T>(&self, path: &Path) -> Result<T, LoadCacheError>
111    where
112        T: DeserializeOwned,
113    {
114        self.fs
115            .open_file(path)
116            .map_err(|err| LoadCacheError::Io {
117                io: err,
118                path: path.to_path_buf(),
119            })
120            .and_then(|mut file| {
121                serde_json::from_reader::<_, T>(&mut file).map_err(LoadCacheError::Deserialize)
122            })
123    }
124
125    fn credential_file(&self) -> PathBuf {
126        self.dir.join(config::cache::CREDENTIAL_FILE)
127    }
128
129    fn gh_notification_filter_option_file(&self) -> PathBuf {
130        self.dir
131            .join(config::cache::GH_NOTIFICATION_FILTER_OPTION_FILE)
132    }
133
134    /// Remove all cache files
135    pub(crate) fn clean(&self) -> io::Result<()> {
136        // User can specify any directory as the cache
137        // so instead of deleting the entire directory with `remove_dir_all`, delete files individually.
138        match self.fs.remove_file(self.credential_file()) {
139            Ok(()) => Ok(()),
140            Err(err) => match err.kind() {
141                io::ErrorKind::NotFound => Ok(()),
142                _ => Err(err),
143            },
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150
151    use crate::auth::Credential;
152
153    use super::*;
154
155    #[test]
156    fn persist_then_load_credential() {
157        let tmp = temp_dir();
158        let cache = Cache::new(tmp);
159        let cred = Credential::Github {
160            access_token: "rust is fun".into(),
161        };
162        assert!(cache.persist_credential(&cred).is_ok());
163
164        let loaded = cache.load_credential().unwrap();
165        assert_eq!(loaded, Unverified::from(cred),);
166    }
167
168    #[test]
169    fn filesystem_error() {
170        let cache = Cache::new("/dev/null");
171        assert!(cache
172            .persist_credential(Credential::Github {
173                access_token: "dummy".into(),
174            })
175            .is_err());
176    }
177
178    fn temp_dir() -> PathBuf {
179        tempfile::TempDir::new().unwrap().into_path()
180    }
181}