1use crate::error::DataError;
2use crate::loader::PRICES_DIR;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6pub trait ObjectSource {
8 fn get(&self, key: &str) -> Result<Option<Vec<u8>>, DataError>;
9}
10
11pub 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
32pub 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
48pub 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
85pub fn list_symbols(root: &Path) -> std::io::Result<Vec<String>> {
89 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 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(); 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 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}