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