Skip to main content

file_backed_value/
lib.rs

1use std::{fs, io::{self, BufReader, BufWriter}, path::{Path, PathBuf}, time::{Duration, SystemTime}};
2
3use serde::{de::DeserializeOwned, Serialize};
4
5#[derive(Clone, Debug)]
6pub struct FileBackedValue {
7    path: PathBuf,
8    dirty_time: Option<Duration>,
9}
10
11#[derive(Debug)]
12pub enum FileBackedValueError {
13    FileError(io::Error),
14    JsonError(serde_json::Error),
15}
16
17pub type FileBackedValueResult<T> = Result<T, FileBackedValueError>;
18
19impl FileBackedValue
20{
21    /// Create a file-backed value in the user's data directory.
22    ///
23    /// ### Example
24    /// - Linux: `/home/alice/.local/share`
25    /// - MacOS: `/Users/Alice/Library/Application Support`
26    /// - Windows: `C:\Users\Alice\AppData\Roaming`
27    pub fn new(filename: &str) -> Self {
28        let parent = PathBuf::from(directories::BaseDirs::new().expect("No valid home directory found").data_dir());
29        let filename = sanitize_filename::sanitize(filename);
30        Self {
31            path: parent.join(filename),
32            dirty_time: None,
33        }
34    }
35
36    /// Create a file-backed value in the specified directory.
37    pub fn new_at(filename: &str, parent: &Path) -> Self {
38        let parent = PathBuf::from(parent);
39        let filename = sanitize_filename::sanitize(filename);
40        Self {
41            path: parent.join(filename),
42            dirty_time: None,
43        }
44    }
45
46    /// Set the duration after which the file is considered dirty and needs to be recomputed.
47    pub fn set_dirty_time(&mut self, dirty_time: Duration) {
48        self.dirty_time = Some(dirty_time);
49    }
50
51    /// Path to the backing file.
52    pub fn path(&self) -> &PathBuf {
53        &self.path
54    }
55
56    /// Clear the currently stored value and remove the backing file.
57    pub fn clear(&mut self) -> io::Result<()> {
58        fs::remove_file(self.path())
59    }
60
61    /// Get the current value, which might be None if the backing file does not yet exist.
62    pub fn get<T>(&mut self) -> FileBackedValueResult<Option<T>>
63    where
64        T: DeserializeOwned
65    {
66        if self.file_is_dirty() {
67            Ok(None)
68        } else {
69            read_file(&self.path)
70        }
71    }
72
73    /// Get the current value, or insert `default` if the backing file does not exist or is dirty.
74    pub fn get_or_insert<T>(&mut self, default: T) -> FileBackedValueResult<T>
75    where
76        T: DeserializeOwned + Serialize
77    {
78        if self.file_is_dirty() {
79            self.insert(&default);
80            Ok(default)
81        } else {
82            // The file has not been read before; read it now and store the value.
83            // The file must exists because otherwise it will have been marked as dirty.
84            let res: Option<T> = read_file(&self.path)?;
85            Ok(res.unwrap())
86        }
87    }
88
89    /// Get the current value, or insert `default()` if the backing file does not exist or is dirty.
90    pub fn get_or_insert_with<F, T>(&mut self, default: F) -> FileBackedValueResult<T>
91    where
92        F: FnOnce() -> T,
93        T: DeserializeOwned + Serialize,
94    {
95        if self.file_is_dirty() {
96            let res = default();
97            self.insert(&res);
98            Ok(res)
99        } else {
100            // The file has not been read before; read it now and store the value.
101            // The file must exists because otherwise it will have been marked as dirty.
102            let res: Option<T> = read_file(&self.path)?;
103            Ok(res.unwrap())
104        }
105    }
106
107    /// Inserts `value` into the option and writes it to the backing file.
108    pub fn insert<T>(&mut self, value: &T)
109    where
110        T: Serialize
111    {
112        write_file(&self.path, value).unwrap();
113    }
114
115    /// Check whether the backing file was last modified longer than `dirty_time` ago.
116    /// If the file does not exist or the modification time could otherwise not be retrieved, true is returned.
117    fn file_is_dirty(&self) -> bool {
118        self.dirty_time.is_some_and(|dirty_time|
119            file_needs_recomputation(&self.path(), dirty_time))
120    }
121}
122
123/// Read a value of type `T` from the backing file as a JSON string.
124fn read_file<T>(path: &PathBuf) -> FileBackedValueResult<Option<T>>
125where
126    T: DeserializeOwned
127{
128    match fs::OpenOptions::new().read(true).open(path) {
129        Ok(f) => {
130            let rdr = BufReader::new(f);
131            serde_json::from_reader(rdr)
132                .map_err(Into::into)
133                .map(|json| Some(json))
134        },
135        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
136        Err(e) => Err(FileBackedValueError::FileError(e))
137    }
138}
139
140/// Write `value` to the backing file as a JSON string.
141fn write_file<T>(path: &PathBuf, value: &T) -> FileBackedValueResult<()>
142where
143    T: Serialize
144{
145    // Create parent directories if necessary.
146    if let Some(dir) = path.parent() {
147        fs::create_dir_all(dir).map_err(Into::into)?;
148    }
149
150    let file = fs::OpenOptions::new()
151        .create(true)
152        .write(true)
153        .truncate(true)
154        .open(path)
155        .map_err(Into::into)?;
156
157    let wtr = BufWriter::new(file);
158    serde_json::to_writer(wtr, value).map_err(Into::into)
159}
160
161/// Check whether the file at `path` was last modified longer than `dirty_time` ago.
162/// If the file does not exist or the modification time could otherwise not be retrieved, true is returned.
163fn file_needs_recomputation(path: &PathBuf, dirty_time: Duration) -> bool {
164    time_since_modified(path).is_none_or(|last_modified|
165        last_modified >= dirty_time)
166}
167
168/// Get the duration since the file at `path` was last modified.
169fn time_since_modified(path: &PathBuf) -> Option<Duration> {
170    if let Ok(time) = fs::metadata(path) {
171        let now = SystemTime::now();
172        let last_modified = time.modified().or_else(|_| time.created()).ok()?;
173        now.duration_since(last_modified).ok()
174    } else {
175        None
176    }
177}
178
179impl Into<FileBackedValueError> for io::Error {
180    fn into(self) -> FileBackedValueError {
181        FileBackedValueError::FileError(self)
182    }
183}
184
185impl Into<FileBackedValueError> for serde_json::Error {
186    fn into(self) -> FileBackedValueError {
187        FileBackedValueError::JsonError(self)
188    }
189}