kitchen_fridge/
cache.rs

1//! This module provides a local cache for CalDAV data
2
3use std::path::PathBuf;
4use std::path::Path;
5use std::error::Error;
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex};
8use std::ffi::OsStr;
9
10use serde::{Deserialize, Serialize};
11use async_trait::async_trait;
12use csscolorparser::Color;
13use url::Url;
14
15use crate::traits::CalDavSource;
16use crate::traits::BaseCalendar;
17use crate::traits::CompleteCalendar;
18use crate::calendar::cached_calendar::CachedCalendar;
19use crate::calendar::SupportedComponents;
20
21#[cfg(feature = "local_calendar_mocks_remote_calendars")]
22use crate::mock_behaviour::MockBehaviour;
23
24const MAIN_FILE: &str = "data.json";
25
26/// A CalDAV source that stores its items in a local folder.
27///
28/// It automatically updates the content of the folder when dropped (see its `Drop` implementation), but you can also manually call [`Cache::save_to_folder`]
29///
30/// Most of its functionality is provided by the `CalDavSource` async trait it implements.
31/// However, since these functions do not _need_ to be actually async, non-async versions of them are also provided for better convenience. See [`Cache::get_calendar_sync`] for example
32#[derive(Debug)]
33pub struct Cache {
34    backing_folder: PathBuf,
35    data: CachedData,
36
37    /// In tests, we may add forced errors to this object
38    #[cfg(feature = "local_calendar_mocks_remote_calendars")]
39    mock_behaviour: Option<Arc<Mutex<MockBehaviour>>>,
40}
41
42#[derive(Default, Debug, Serialize, Deserialize)]
43struct CachedData {
44    #[serde(skip)]
45    calendars: HashMap<Url, Arc<Mutex<CachedCalendar>>>,
46}
47
48impl Cache {
49    /// Activate the "mocking remote source" features (i.e. tell its children calendars that they are mocked remote calendars)
50    #[cfg(feature = "local_calendar_mocks_remote_calendars")]
51    pub fn set_mock_behaviour(&mut self, mock_behaviour: Option<Arc<Mutex<MockBehaviour>>>) {
52        self.mock_behaviour = mock_behaviour;
53    }
54
55
56    /// Get the path to the cache folder
57    pub fn cache_folder() -> PathBuf {
58        return PathBuf::from(String::from("~/.config/my-tasks/cache/"))
59    }
60
61    /// Initialize a cache from the content of a valid backing folder if it exists.
62    /// Returns an error otherwise
63    pub fn from_folder(folder: &Path) -> Result<Self, Box<dyn Error>> {
64        // Load shared data...
65        let main_file = folder.join(MAIN_FILE);
66        let mut data: CachedData = match std::fs::File::open(&main_file) {
67            Err(err) => {
68                return Err(format!("Unable to open file {:?}: {}", main_file, err).into());
69            },
70            Ok(file) => serde_json::from_reader(file)?,
71        };
72
73        // ...and every calendar
74        for entry in std::fs::read_dir(folder)? {
75            match entry {
76                Err(err) => {
77                    log::error!("Unable to read dir: {:?}", err);
78                    continue;
79                },
80                Ok(entry) => {
81                    let cal_path = entry.path();
82                    log::debug!("Considering {:?}", cal_path);
83                    if cal_path.extension() == Some(OsStr::new("cal")) {
84                        match Self::load_calendar(&cal_path) {
85                            Err(err) => {
86                                log::error!("Unable to load calendar {:?} from cache: {:?}", cal_path, err);
87                                continue;
88                            },
89                            Ok(cal) =>
90                                data.calendars.insert(cal.url().clone(), Arc::new(Mutex::new(cal))),
91                        };
92                    }
93                },
94            }
95        }
96
97        Ok(Self{
98            backing_folder: PathBuf::from(folder),
99            data,
100
101            #[cfg(feature = "local_calendar_mocks_remote_calendars")]
102            mock_behaviour: None,
103        })
104    }
105
106    fn load_calendar(path: &Path) -> Result<CachedCalendar, Box<dyn Error>> {
107        let file = std::fs::File::open(&path)?;
108        Ok(serde_json::from_reader(file)?)
109    }
110
111    /// Initialize a cache with the default contents
112    pub fn new(folder_path: &Path) -> Self {
113        Self{
114            backing_folder: PathBuf::from(folder_path),
115            data: CachedData::default(),
116
117            #[cfg(feature = "local_calendar_mocks_remote_calendars")]
118            mock_behaviour: None,
119        }
120    }
121
122    /// Store the current Cache to its backing folder
123    ///
124    /// Note that this is automatically called when `self` is `drop`ped
125    pub fn save_to_folder(&self) -> Result<(), std::io::Error> {
126        let folder = &self.backing_folder;
127        std::fs::create_dir_all(folder)?;
128
129        // Save the general data
130        let main_file_path = folder.join(MAIN_FILE);
131        let file = std::fs::File::create(&main_file_path)?;
132        serde_json::to_writer(file, &self.data)?;
133
134        // Save each calendar
135        for (cal_url, cal_mutex) in &self.data.calendars {
136            let file_name = sanitize_filename::sanitize(cal_url.as_str()) + ".cal";
137            let cal_file = folder.join(file_name);
138            let file = std::fs::File::create(&cal_file)?;
139            let cal = cal_mutex.lock().unwrap();
140            serde_json::to_writer(file, &*cal)?;
141        }
142
143        Ok(())
144    }
145
146
147    /// Compares two Caches to check they have the same current content
148    ///
149    /// This is not a complete equality test: some attributes (sync status...) may differ. This should mostly be used in tests
150    #[cfg(any(test, feature = "integration_tests"))]
151    pub async fn has_same_observable_content_as(&self, other: &Self) -> Result<bool, Box<dyn Error>> {
152        let calendars_l = self.get_calendars().await?;
153        let calendars_r = other.get_calendars().await?;
154
155        if crate::utils::keys_are_the_same(&calendars_l, &calendars_r) == false {
156            log::debug!("Different keys for calendars");
157            return Ok(false);
158        }
159
160        for (calendar_url, cal_l) in calendars_l {
161            log::debug!("Comparing calendars {}", calendar_url);
162            let cal_l = cal_l.lock().unwrap();
163            let cal_r = match calendars_r.get(&calendar_url) {
164                Some(c) => c.lock().unwrap(),
165                None => return Err("should not happen, we've just tested keys are the same".into()),
166            };
167
168            // TODO: check calendars have the same names/ID/whatever
169            if cal_l.has_same_observable_content_as(&cal_r).await? == false {
170                log::debug!("Different calendars");
171                return Ok(false)
172            }
173
174        }
175        Ok(true)
176    }
177}
178
179impl Drop for Cache {
180    fn drop(&mut self) {
181        if let Err(err) = self.save_to_folder() {
182            log::error!("Unable to automatically save the cache when it's no longer required: {}", err);
183        }
184    }
185}
186
187impl Cache {
188    /// The non-async version of [`crate::traits::CalDavSource::get_calendars`]
189    pub fn get_calendars_sync(&self) -> Result<HashMap<Url, Arc<Mutex<CachedCalendar>>>, Box<dyn Error>> {
190        #[cfg(feature = "local_calendar_mocks_remote_calendars")]
191        self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_calendars())?;
192
193        Ok(self.data.calendars.iter()
194            .map(|(url, cal)| (url.clone(), cal.clone()))
195            .collect()
196        )
197    }
198
199    /// The non-async version of [`crate::traits::CalDavSource::get_calendar`]
200    pub fn get_calendar_sync(&self, url: &Url) -> Option<Arc<Mutex<CachedCalendar>>> {
201        self.data.calendars.get(url).map(|arc| arc.clone())
202    }
203}
204
205#[async_trait]
206impl CalDavSource<CachedCalendar> for Cache {
207    async fn get_calendars(&self) -> Result<HashMap<Url, Arc<Mutex<CachedCalendar>>>, Box<dyn Error>> {
208        self.get_calendars_sync()
209    }
210
211    async fn get_calendar(&self, url: &Url) -> Option<Arc<Mutex<CachedCalendar>>> {
212        self.get_calendar_sync(url)
213    }
214
215    async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option<Color>) -> Result<Arc<Mutex<CachedCalendar>>, Box<dyn Error>> {
216        log::debug!("Inserting local calendar {}", url);
217        #[cfg(feature = "local_calendar_mocks_remote_calendars")]
218        self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_create_calendar())?;
219
220        let new_calendar = CachedCalendar::new(name, url.clone(), supported_components, color);
221        let arc = Arc::new(Mutex::new(new_calendar));
222
223        #[cfg(feature = "local_calendar_mocks_remote_calendars")]
224        if let Some(behaviour) = &self.mock_behaviour {
225            arc.lock().unwrap().set_mock_behaviour(Some(Arc::clone(behaviour)));
226        };
227
228        match self.data.calendars.insert(url, arc.clone()) {
229            Some(_) => Err("Attempt to insert calendar failed: there is alredy such a calendar.".into()),
230            None => Ok(arc),
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    use url::Url;
240    use crate::calendar::SupportedComponents;
241    use crate::item::Item;
242    use crate::task::Task;
243
244    async fn populate_cache(cache_path: &Path) -> Cache {
245        let mut cache = Cache::new(&cache_path);
246
247        let _shopping_list = cache.create_calendar(
248            Url::parse("https://caldav.com/shopping").unwrap(),
249            "My shopping list".to_string(),
250            SupportedComponents::TODO,
251            Some(csscolorparser::parse("lime").unwrap()),
252        ).await.unwrap();
253
254        let bucket_list = cache.create_calendar(
255            Url::parse("https://caldav.com/bucket-list").unwrap(),
256            "My bucket list".to_string(),
257            SupportedComponents::TODO,
258            Some(csscolorparser::parse("#ff8000").unwrap()),
259        ).await.unwrap();
260
261        {
262            let mut bucket_list = bucket_list.lock().unwrap();
263            let cal_url = bucket_list.url().clone();
264            bucket_list.add_item(Item::Task(Task::new(
265                String::from("Attend a concert of JS Bach"), false, &cal_url
266            ))).await.unwrap();
267
268            bucket_list.add_item(Item::Task(Task::new(
269                String::from("Climb the Lighthouse of Alexandria"), true, &cal_url
270            ))).await.unwrap();
271        }
272
273        cache
274    }
275
276    #[tokio::test]
277    async fn cache_serde() {
278        let _ = env_logger::builder().is_test(true).try_init();
279        let cache_path = PathBuf::from(String::from("test_cache/serde_test"));
280        let cache = populate_cache(&cache_path).await;
281
282        cache.save_to_folder().unwrap();
283
284        let retrieved_cache = Cache::from_folder(&cache_path).unwrap();
285        assert_eq!(cache.backing_folder, retrieved_cache.backing_folder);
286        let test = cache.has_same_observable_content_as(&retrieved_cache).await;
287        println!("Equal? {:?}", test);
288        assert_eq!(test.unwrap(), true);
289    }
290
291    #[tokio::test]
292    async fn cache_sanity_checks() {
293        let _ = env_logger::builder().is_test(true).try_init();
294        let cache_path = PathBuf::from(String::from("test_cache/sanity_tests"));
295        let mut cache = populate_cache(&cache_path).await;
296
297        // We should not be able to add a second calendar with the same URL
298        let second_addition_same_calendar = cache.create_calendar(
299            Url::parse("https://caldav.com/shopping").unwrap(),
300            "My shopping list".to_string(),
301            SupportedComponents::TODO,
302            None,
303        ).await;
304        assert!(second_addition_same_calendar.is_err());
305    }
306}