Skip to main content

pomelo_data/
source.rs

1use crate::error::DataError;
2use crate::loader::PRICES_DIR;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6/// Read-only byte store. `Ok(None)` means the key is absent (fail-soft).
7pub trait ObjectSource {
8    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, DataError>;
9}
10
11/// Files under a root directory; `key` is a relative path (e.g. "prices/AAPL.csv.gz").
12pub struct LocalSource {
13    root: PathBuf,
14}
15
16impl LocalSource {
17    pub fn new(root: impl Into<PathBuf>) -> Self {
18        LocalSource { root: root.into() }
19    }
20}
21
22impl ObjectSource for LocalSource {
23    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, DataError> {
24        match fs::read(self.root.join(key)) {
25            Ok(bytes) => Ok(Some(bytes)),
26            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
27            Err(e) => Err(DataError::Io(e.to_string())),
28        }
29    }
30}
31
32/// Write-side counterpart to [`ObjectSource`]. Kept separate so the read path
33/// (and OSS consumers) stay write-free; only the panel rebuild needs this.
34pub trait ObjectSink {
35    fn put(&self, key: &str, bytes: &[u8]) -> Result<(), DataError>;
36}
37
38impl ObjectSink for LocalSource {
39    fn put(&self, key: &str, bytes: &[u8]) -> Result<(), DataError> {
40        let path = self.root.join(key);
41        if let Some(parent) = path.parent() {
42            fs::create_dir_all(parent).map_err(|e| DataError::Io(e.to_string()))?;
43        }
44        fs::write(path, bytes).map_err(|e| DataError::Io(e.to_string()))
45    }
46}
47
48/// Discovery side of an [`ObjectSource`]: which keys exist under a prefix.
49/// Kept separate so pure-read consumers that never need discovery (only
50/// `get`) aren't forced to implement it. Returned keys are full keys (the
51/// `prefix` joined with each entry's name), matching what [`ObjectSource::get`]
52/// expects — never bare file names.
53pub trait ObjectLister {
54    fn list(&self, prefix: &str) -> Result<Vec<String>, DataError>;
55}
56
57impl ObjectLister for LocalSource {
58    fn list(&self, prefix: &str) -> Result<Vec<String>, DataError> {
59        let trimmed = prefix.trim_end_matches('/');
60        let rd = match fs::read_dir(self.root.join(trimmed)) {
61            Ok(rd) => rd,
62            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
63            Err(e) => return Err(DataError::Io(e.to_string())),
64        };
65        let mut out = Vec::new();
66        for entry in rd {
67            let entry = entry.map_err(|e| DataError::Io(e.to_string()))?;
68            if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
69                continue;
70            }
71            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
72                continue;
73            };
74            out.push(if trimmed.is_empty() {
75                name
76            } else {
77                format!("{trimmed}/{name}")
78            });
79        }
80        out.sort();
81        Ok(out)
82    }
83}
84
85/// Symbols with a per-symbol price file under `root/prices`, sorted and
86/// de-duplicated. Recognizes `.csv.gz`, `.parquet`, and `.csv`; the loaders
87/// detect the actual format from content.
88pub fn list_symbols(root: &Path) -> std::io::Result<Vec<String>> {
89    // `.csv.gz` before `.csv` so a gzip file isn't mis-stripped to "<sym>.csv".
90    const EXTS: &[&str] = &[".csv.gz", ".parquet", ".csv"];
91    let mut syms = std::collections::BTreeSet::new();
92    let prices = root.join(PRICES_DIR);
93    if !prices.exists() {
94        return Ok(Vec::new());
95    }
96    for entry in fs::read_dir(prices)? {
97        let entry = entry?;
98        if !entry.file_type()?.is_file() {
99            continue;
100        }
101        if let Some(name) = entry.file_name().to_str() {
102            if let Some(sym) = EXTS.iter().find_map(|ext| name.strip_suffix(ext)) {
103                syms.insert(sym.to_string());
104            }
105        }
106    }
107    Ok(syms.into_iter().collect())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::fs;
114
115    #[test]
116    fn local_source_reads_present_and_missing() {
117        let dir = std::env::temp_dir().join("pomelo_data_source_test");
118        fs::create_dir_all(&dir).unwrap();
119        fs::write(dir.join("hello.bin"), b"hi").unwrap();
120        let src = LocalSource::new(&dir);
121        assert_eq!(src.get("hello.bin").unwrap(), Some(b"hi".to_vec()));
122        assert_eq!(src.get("nope.bin").unwrap(), None);
123    }
124
125    #[test]
126    fn local_source_put_writes_and_creates_parents() {
127        let dir = std::env::temp_dir().join("pomelo_data_sink_test");
128        let _ = fs::remove_dir_all(&dir);
129        fs::create_dir_all(&dir).unwrap();
130        let src = LocalSource::new(&dir);
131        // a key with a missing parent dir ("panels/") must still write
132        src.put("panels/close.csv.gz", b"data").unwrap();
133        assert_eq!(
134            src.get("panels/close.csv.gz").unwrap(),
135            Some(b"data".to_vec())
136        );
137    }
138
139    #[test]
140    fn local_source_list_returns_full_keys_sorted() {
141        let dir = std::env::temp_dir().join("pomelo_data_list_test");
142        let _ = fs::remove_dir_all(&dir);
143        fs::create_dir_all(dir.join("prices")).unwrap();
144        fs::write(dir.join("prices/MSFT.csv.gz"), b"x").unwrap();
145        fs::write(dir.join("prices/AAPL.csv.gz"), b"x").unwrap();
146        fs::create_dir_all(dir.join("prices/nested")).unwrap(); // dirs are skipped
147        let src = LocalSource::new(&dir);
148        assert_eq!(
149            src.list("prices").unwrap(),
150            vec![
151                "prices/AAPL.csv.gz".to_string(),
152                "prices/MSFT.csv.gz".to_string(),
153            ]
154        );
155        // Trailing slash is equivalent.
156        assert_eq!(src.list("prices/").unwrap().len(), 2);
157    }
158
159    #[test]
160    fn local_source_list_missing_dir_is_empty() {
161        let dir = std::env::temp_dir().join("pomelo_data_list_missing_test");
162        let _ = fs::remove_dir_all(&dir);
163        fs::create_dir_all(&dir).unwrap();
164        let src = LocalSource::new(&dir);
165        assert_eq!(src.list("nope").unwrap(), Vec::<String>::new());
166    }
167
168    #[test]
169    fn list_symbols_finds_and_dedups_price_stems() {
170        let dir = std::env::temp_dir().join("pomelo_data_list_symbols_test");
171        let _ = fs::remove_dir_all(&dir);
172        let prices = dir.join(PRICES_DIR);
173        fs::create_dir_all(&prices).unwrap();
174        fs::write(prices.join("AAPL.csv.gz"), b"x").unwrap();
175        fs::write(prices.join("MSFT.csv"), b"x").unwrap();
176        fs::write(prices.join("GOOG.parquet"), b"x").unwrap();
177        fs::write(prices.join("notes.txt"), b"x").unwrap();
178        assert_eq!(
179            list_symbols(&dir).unwrap(),
180            vec!["AAPL".to_string(), "GOOG".to_string(), "MSFT".to_string()]
181        );
182    }
183
184    #[test]
185    fn list_symbols_missing_prices_dir_is_empty() {
186        let dir = std::env::temp_dir().join("pomelo_data_list_symbols_missing_test");
187        let _ = fs::remove_dir_all(&dir);
188        fs::create_dir_all(&dir).unwrap();
189        assert_eq!(list_symbols(&dir).unwrap(), Vec::<String>::new());
190    }
191}