1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde_json::Value;
5
6use crate::catalog::CatalogLayout;
7use crate::errors::HaysonSchemaError;
8use crate::lock::LOCK_FILE;
9use crate::txn::JOURNAL_FILE;
10
11pub const DEFAULT_METADATA_FILE: &str = "hayson.json";
14pub const LOG_SUFFIX: &str = ".jsonl";
15pub const INDEX_SUFFIX: &str = ".idx";
16pub const SCHEMA_SUFFIX: &str = ".schema.json";
19
20const NESTED_TABLES_DIR: &str = "tables";
21const NESTED_INDEX_DIR: &str = "index";
22const NESTED_RUNTIME_DIR: &str = "runtime";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum LayoutKind {
31 #[default]
32 Flat,
33 Nested,
34}
35
36impl LayoutKind {
37 pub fn as_str(self) -> &'static str {
38 match self {
39 LayoutKind::Flat => "flat",
40 LayoutKind::Nested => "nested",
41 }
42 }
43}
44
45impl From<LayoutKind> for CatalogLayout {
46 fn from(kind: LayoutKind) -> Self {
47 match kind {
48 LayoutKind::Flat => CatalogLayout::Flat,
49 LayoutKind::Nested => CatalogLayout::Nested,
50 }
51 }
52}
53
54impl From<CatalogLayout> for LayoutKind {
55 fn from(layout: CatalogLayout) -> Self {
56 match layout {
57 CatalogLayout::Flat => LayoutKind::Flat,
58 CatalogLayout::Nested => LayoutKind::Nested,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct LayoutOptions {
68 pub kind: Option<LayoutKind>,
70 pub metadata_file: Option<String>,
73 pub manifest: Option<String>,
76}
77
78impl LayoutOptions {
79 pub fn metadata_file(&self) -> Result<Option<&str>, HaysonSchemaError> {
84 match (self.metadata_file.as_deref(), self.manifest.as_deref()) {
85 (Some(current), Some(legacy)) if current != legacy => Err(HaysonSchemaError(format!(
86 "\"metadataFile\" is \"{current}\" and \"manifest\" is \"{legacy}\" — \"manifest\" is the deprecated alias of \"metadataFile\" and both name the same file, so one of the two would be silently ignored; pass only one"
87 ))),
88 (Some(current), _) => Ok(Some(current)),
89 (None, legacy) => Ok(legacy),
90 }
91 }
92}
93
94pub fn resolve_kind(
101 root: &Path,
102 stored: Option<LayoutKind>,
103 requested: Option<LayoutKind>,
104) -> Result<LayoutKind, HaysonSchemaError> {
105 let Some(stored) = stored else {
106 return Ok(requested.unwrap_or_default());
107 };
108 match requested {
109 Some(requested) if requested != stored => Err(HaysonSchemaError(format!(
110 "database \"{}\" is stored in the \"{}\" layout but was opened as \"{}\" — move it instead of reopening it differently",
111 root.display(),
112 stored.as_str(),
113 requested.as_str()
114 ))),
115 _ => Ok(stored),
116 }
117}
118
119pub fn stored_kind_of(path: &Path) -> Option<LayoutKind> {
126 let contents = fs::read_to_string(path).ok()?;
127 let value: Value = serde_json::from_str(&contents).ok()?;
128 match value.get("layout").and_then(Value::as_str) {
129 Some("nested") => Some(LayoutKind::Nested),
130 _ => Some(LayoutKind::Flat),
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct Layout {
140 kind: LayoutKind,
141 root: PathBuf,
142 metadata_file: String,
143 metadata_path: PathBuf,
144 tables: PathBuf,
145 indexes: PathBuf,
146 runtime: PathBuf,
147}
148
149impl Layout {
150 pub fn new(root: &Path, kind: LayoutKind, metadata_file: &str) -> Layout {
151 let nested = kind == LayoutKind::Nested;
152 let under = |name: &str| if nested { root.join(name) } else { root.to_path_buf() };
153 Layout {
154 kind,
155 root: root.to_path_buf(),
156 metadata_file: metadata_file.to_string(),
157 metadata_path: root.join(metadata_file),
158 tables: under(NESTED_TABLES_DIR),
159 indexes: under(NESTED_INDEX_DIR),
160 runtime: under(NESTED_RUNTIME_DIR),
161 }
162 }
163
164 pub fn kind(&self) -> LayoutKind {
165 self.kind
166 }
167
168 pub fn root(&self) -> &Path {
169 &self.root
170 }
171
172 pub fn metadata_file(&self) -> &str {
175 &self.metadata_file
176 }
177
178 pub fn metadata_path(&self) -> &Path {
181 &self.metadata_path
182 }
183
184 pub fn tables(&self) -> &Path {
187 &self.tables
188 }
189
190 pub fn indexes(&self) -> &Path {
192 &self.indexes
193 }
194
195 pub fn runtime(&self) -> &Path {
197 &self.runtime
198 }
199
200 pub fn log_path(&self, table: &str) -> PathBuf {
201 self.tables.join(format!("{table}{LOG_SUFFIX}"))
202 }
203
204 pub fn index_path(&self, table: &str) -> PathBuf {
205 self.indexes.join(format!("{table}{INDEX_SUFFIX}"))
206 }
207
208 pub fn lock_path(&self) -> PathBuf {
209 self.runtime.join(LOCK_FILE)
210 }
211
212 pub fn journal_path(&self) -> PathBuf {
213 self.runtime.join(JOURNAL_FILE)
214 }
215
216 pub fn directories(&self) -> Vec<PathBuf> {
220 if self.kind == LayoutKind::Nested {
221 vec![
222 self.root.clone(),
223 self.tables.clone(),
224 self.indexes.clone(),
225 self.runtime.clone(),
226 ]
227 } else {
228 vec![self.root.clone()]
229 }
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use std::path::PathBuf;
236
237 use super::*;
238
239 fn root() -> PathBuf {
240 PathBuf::from("/db")
241 }
242
243 #[test]
244 fn the_flat_layout_keeps_every_role_in_the_database_directory() {
245 let layout = Layout::new(&root(), LayoutKind::Flat, DEFAULT_METADATA_FILE);
246
247 assert_eq!(layout.metadata_path(), Path::new("/db/hayson.json"));
248 assert_eq!(layout.log_path("users"), PathBuf::from("/db/users.jsonl"));
249 assert_eq!(layout.index_path("users"), PathBuf::from("/db/users.idx"));
250 assert_eq!(layout.lock_path(), PathBuf::from("/db").join(LOCK_FILE));
251 assert_eq!(layout.journal_path(), PathBuf::from("/db").join(JOURNAL_FILE));
252 assert_eq!(layout.directories(), vec![root()]);
253 }
254
255 #[test]
256 fn the_nested_layout_sorts_the_roles_into_subdirectories_but_keeps_the_metadata_at_the_root() {
257 let layout = Layout::new(&root(), LayoutKind::Nested, DEFAULT_METADATA_FILE);
258
259 assert_eq!(layout.metadata_path(), Path::new("/db/hayson.json"));
260 assert_eq!(layout.log_path("users"), PathBuf::from("/db/tables/users.jsonl"));
261 assert_eq!(layout.index_path("users"), PathBuf::from("/db/index/users.idx"));
262 assert_eq!(layout.lock_path(), PathBuf::from("/db/runtime").join(LOCK_FILE));
263 assert_eq!(layout.journal_path(), PathBuf::from("/db/runtime").join(JOURNAL_FILE));
264 assert_eq!(
265 layout.directories(),
266 vec![
267 root(),
268 PathBuf::from("/db/tables"),
269 PathBuf::from("/db/index"),
270 PathBuf::from("/db/runtime"),
271 ]
272 );
273 }
274
275 #[test]
276 fn a_custom_metadata_file_moves_only_that_document() {
277 let layout = Layout::new(&root(), LayoutKind::Nested, "cerebro.json");
278
279 assert_eq!(layout.metadata_file(), "cerebro.json");
280 assert_eq!(layout.metadata_path(), Path::new("/db/cerebro.json"));
281 assert_eq!(layout.log_path("users"), PathBuf::from("/db/tables/users.jsonl"));
282 }
283
284 #[test]
285 fn the_deprecated_alias_names_the_document_on_its_own_and_alongside_an_agreeing_new_name() {
286 let legacy = LayoutOptions {
287 manifest: Some("cerebro.json".into()),
288 ..Default::default()
289 };
290 assert_eq!(legacy.metadata_file().unwrap(), Some("cerebro.json"));
291
292 let both = LayoutOptions {
293 metadata_file: Some("cerebro.json".into()),
294 manifest: Some("cerebro.json".into()),
295 ..Default::default()
296 };
297 assert_eq!(both.metadata_file().unwrap(), Some("cerebro.json"));
298
299 assert_eq!(LayoutOptions::default().metadata_file().unwrap(), None);
300 }
301
302 #[test]
303 fn two_disagreeing_names_for_the_metadata_document_are_refused_naming_both() {
304 let options = LayoutOptions {
305 metadata_file: Some("cerebro.json".into()),
306 manifest: Some("hayson.json".into()),
307 ..Default::default()
308 };
309
310 let error = options.metadata_file().unwrap_err();
311
312 assert!(error.0.contains("\"metadataFile\""), "{}", error.0);
313 assert!(error.0.contains("\"manifest\""), "{}", error.0);
314 assert!(
315 error.0.contains("cerebro.json") && error.0.contains("hayson.json"),
316 "{}",
317 error.0
318 );
319 }
320
321 #[test]
322 fn the_kind_on_disk_wins_over_an_absent_request_and_the_request_wins_over_nothing_on_disk() {
323 assert_eq!(resolve_kind(&root(), None, None).unwrap(), LayoutKind::Flat);
324 assert_eq!(
325 resolve_kind(&root(), None, Some(LayoutKind::Nested)).unwrap(),
326 LayoutKind::Nested
327 );
328 assert_eq!(
329 resolve_kind(&root(), Some(LayoutKind::Nested), None).unwrap(),
330 LayoutKind::Nested
331 );
332 assert_eq!(
333 resolve_kind(&root(), Some(LayoutKind::Nested), Some(LayoutKind::Nested)).unwrap(),
334 LayoutKind::Nested
335 );
336 }
337
338 #[test]
339 fn reopening_a_stored_database_under_the_other_kind_is_refused_naming_both() {
340 let error = resolve_kind(&root(), Some(LayoutKind::Nested), Some(LayoutKind::Flat)).unwrap_err();
341
342 assert!(
343 error.0.contains("\"nested\"") && error.0.contains("\"flat\""),
344 "{}",
345 error.0
346 );
347 }
348}