Skip to main content

tse_client/
storage.rs

1//! File-based cache storage, replacing the Node `fs`/`zlib` branch of the
2//! original JS `storage` object.
3//!
4//! The original JS keeps this synchronous under the hood (the `*Async`
5//! variants merely wrap synchronous `fs` calls in a Promise), so this port is
6//! plain synchronous `std::fs` + `flate2`. Call sites that run inside the async
7//! download pipeline can offload these via `tokio::task::spawn_blocking` if a
8//! particular write is large enough to matter.
9
10use std::collections::{HashMap, HashSet};
11use std::fs;
12use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14
15use flate2::Compression;
16use flate2::read::GzDecoder;
17use flate2::write::GzEncoder;
18
19use crate::error::Result;
20
21/// Synchronous, file-backed cache.
22#[derive(Debug, Clone)]
23pub struct Storage {
24    datadir: PathBuf,
25}
26
27fn gzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
28    let mut enc = GzEncoder::new(Vec::new(), Compression::default());
29    enc.write_all(data)?;
30    enc.finish()
31}
32
33fn gunzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
34    let mut dec = GzDecoder::new(data);
35    let mut out = Vec::new();
36    dec.read_to_end(&mut out)?;
37    Ok(out)
38}
39
40impl Storage {
41    /// Create a `Storage` resolving the cache dir exactly like the JS:
42    /// honor `~/.tse` tracker file if present, else default to `~/tse-cache`.
43    pub fn new() -> Result<Self> {
44        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
45        let default_dir = home.join("tse-cache");
46        let tracker = home.join(".tse");
47
48        let datadir = if tracker.exists() {
49            let recorded = fs::read_to_string(&tracker).unwrap_or_default();
50            let p = PathBuf::from(recorded.trim());
51            if p.is_dir() { p } else { default_dir.clone() }
52        } else {
53            if !default_dir.exists() {
54                fs::create_dir_all(&default_dir)?;
55            }
56            fs::write(&tracker, default_dir.to_string_lossy().as_bytes())?;
57            default_dir.clone()
58        };
59
60        if !datadir.exists() {
61            fs::create_dir_all(&datadir)?;
62        }
63        Ok(Storage { datadir })
64    }
65
66    /// Create a `Storage` rooted at an explicit directory (useful for tests).
67    pub fn with_dir<P: AsRef<Path>>(dir: P) -> Result<Self> {
68        let datadir = dir.as_ref().to_path_buf();
69        fs::create_dir_all(&datadir)?;
70        Ok(Storage { datadir })
71    }
72
73    /// The current cache directory (JS `CACHE_DIR`).
74    pub fn cache_dir(&self) -> &Path {
75        &self.datadir
76    }
77
78    fn resolve(&self, key: &str) -> PathBuf {
79        let key = key.strip_prefix("tse.").unwrap_or(key);
80        if let Some(rest) = key.strip_prefix("prices.") {
81            self.datadir.join("prices").join(format!("{rest}.csv"))
82        } else {
83            self.datadir.join(format!("{key}.csv"))
84        }
85    }
86
87    /// Read a value, creating an empty file if missing (JS `getItem`).
88    pub fn get_item(&self, key: &str) -> Result<String> {
89        let file = self.resolve(key);
90        if let Some(parent) = file.parent() {
91            if !parent.exists() {
92                fs::create_dir_all(parent)?;
93            }
94        }
95        if !file.exists() {
96            fs::write(&file, b"")?;
97            return Ok(String::new());
98        }
99        Ok(fs::read_to_string(&file)?)
100    }
101
102    /// Write a value (JS `setItem`).
103    pub fn set_item(&self, key: &str, value: &str) -> Result<()> {
104        let file = self.resolve(key);
105        if let Some(parent) = file.parent() {
106            if !parent.exists() {
107                fs::create_dir_all(parent)?;
108            }
109        }
110        fs::write(&file, value.as_bytes())?;
111        Ok(())
112    }
113
114    fn resolve_zipped(&self, key: &str, zip: bool) -> PathBuf {
115        let mut p = self.resolve(key);
116        if zip {
117            let name = format!("{}.gz", p.file_name().unwrap().to_string_lossy());
118            p.set_file_name(name);
119        }
120        p
121    }
122
123    /// Async-equivalent read (JS `getItemAsync`). Optionally gunzips.
124    pub fn get_item_async(&self, key: &str, zip: bool) -> Result<String> {
125        let file = self.resolve_zipped(key, zip);
126        if let Some(parent) = file.parent() {
127            if !parent.exists() {
128                fs::create_dir_all(parent)?;
129            }
130        }
131        if !file.exists() {
132            if !zip {
133                fs::write(&file, b"")?;
134            }
135            return Ok(String::new());
136        }
137        let raw = fs::read(&file)?;
138        if zip {
139            Ok(String::from_utf8_lossy(&gunzip(&raw)?).into_owned())
140        } else {
141            Ok(String::from_utf8_lossy(&raw).into_owned())
142        }
143    }
144
145    /// Async-equivalent write (JS `setItemAsync`). Optionally gzips.
146    pub fn set_item_async(&self, key: &str, value: &str, zip: bool) -> Result<()> {
147        let file = self.resolve_zipped(key, zip);
148        if let Some(parent) = file.parent() {
149            if !parent.exists() {
150                fs::create_dir_all(parent)?;
151            }
152        }
153        if zip {
154            fs::write(&file, gzip(value.as_bytes())?)?;
155        } else {
156            fs::write(&file, value.as_bytes())?;
157        }
158        Ok(())
159    }
160
161    /// Load the cached price CSVs for the selected instruments (JS `getItems`).
162    /// Only the requested inscodes are read into `result`.
163    pub fn get_items(
164        &self,
165        selins: &HashSet<String>,
166        result: &mut HashMap<String, String>,
167    ) -> Result<()> {
168        let dir = self.datadir.join("prices");
169        if !dir.exists() {
170            fs::create_dir_all(&dir)?;
171            return Ok(());
172        }
173        for entry in fs::read_dir(&dir)? {
174            let entry = entry?;
175            let name = entry.file_name().to_string_lossy().into_owned();
176            let key = name.strip_suffix(".csv").unwrap_or(&name).to_string();
177            if !selins.contains(&key) {
178                continue;
179            }
180            result.insert(key, fs::read_to_string(entry.path())?);
181        }
182        Ok(())
183    }
184
185    /// Intraday: read stored devens for selected instruments (JS `itd.getItems`).
186    /// When `full` is true, the gzipped bytes are returned; otherwise each
187    /// deven maps to an empty marker (presence only).
188    pub fn itd_get_items(
189        &self,
190        selins: &HashSet<String>,
191        full: bool,
192    ) -> Result<HashMap<String, HashMap<String, ItdValue>>> {
193        let dir = self.datadir.join("intraday");
194        if !dir.exists() {
195            fs::create_dir_all(&dir)?;
196            return Ok(HashMap::new());
197        }
198        let mut result: HashMap<String, HashMap<String, ItdValue>> = HashMap::new();
199        for entry in fs::read_dir(&dir)? {
200            let entry = entry?;
201            if !entry.path().is_dir() {
202                continue;
203            }
204            let inscode = entry.file_name().to_string_lossy().into_owned();
205            if !selins.contains(&inscode) {
206                continue;
207            }
208            let mut files: HashMap<String, ItdValue> = HashMap::new();
209            for f in fs::read_dir(entry.path())? {
210                let f = f?;
211                let fname = f.file_name().to_string_lossy().into_owned();
212                let (deven, zipped) = match fname.strip_suffix(".gz") {
213                    Some(stem) => (stem.to_string(), true),
214                    None => (fname.clone(), false),
215                };
216                if full {
217                    let raw = fs::read(f.path())?;
218                    let val = if zipped {
219                        ItdValue::Gzipped(raw)
220                    } else {
221                        ItdValue::Plain(String::from_utf8_lossy(&raw).into_owned())
222                    };
223                    files.insert(deven, val);
224                } else {
225                    files.insert(deven, ItdValue::Present);
226                }
227            }
228            result.insert(inscode, files);
229        }
230        Ok(result)
231    }
232
233    /// Intraday: write one instrument's deven map (JS `itd.setItem`).
234    /// Values equal to "N/A" are stored uncompressed; everything else gzipped.
235    pub fn itd_set_item(&self, key: &str, obj: &HashMap<String, ItdValue>) -> Result<()> {
236        let key = key.strip_prefix("tse.").unwrap_or(key);
237        let dir = self.datadir.join("intraday").join(key);
238        if !dir.exists() {
239            fs::create_dir_all(&dir)?;
240        }
241        for (deven, val) in obj {
242            match val {
243                ItdValue::Plain(s) if s == "N/A" => {
244                    fs::write(dir.join(deven), s.as_bytes())?;
245                }
246                ItdValue::Plain(s) => {
247                    fs::write(dir.join(format!("{deven}.gz")), gzip(s.as_bytes())?)?;
248                }
249                ItdValue::Gzipped(bytes) => {
250                    fs::write(dir.join(format!("{deven}.gz")), bytes)?;
251                }
252                ItdValue::Present => {}
253            }
254        }
255        Ok(())
256    }
257}
258
259/// A stored intraday value: either a presence marker, raw gzipped bytes, or
260/// plain text (e.g. "N/A").
261#[derive(Debug, Clone)]
262pub enum ItdValue {
263    Present,
264    Plain(String),
265    Gzipped(Vec<u8>),
266}
267
268impl ItdValue {
269    /// Decompress to text when needed (JS `unzip`).
270    pub fn to_text(&self) -> Option<String> {
271        match self {
272            ItdValue::Plain(s) => Some(s.clone()),
273            ItdValue::Gzipped(b) => gunzip(b)
274                .ok()
275                .map(|v| String::from_utf8_lossy(&v).into_owned()),
276            ItdValue::Present => None,
277        }
278    }
279}