1pub mod catalog;
2pub mod codec;
3pub mod page;
4
5use std::{collections::HashSet, path::Path};
6
7use crate::{
8 block::Block,
9 document::Document,
10 error::MqdbError,
11 storage::{
12 catalog::{CatalogEntry, CustomTableEntry, read_catalog, write_catalog},
13 codec::{decode_block, decode_table_rows, encode_block, encode_table_rows},
14 page::{
15 PAGE_BODY_SIZE, PAGE_HEADER_SIZE, PAGE_TYPE_BLOCK_DATA, PAGE_TYPE_CATALOG,
16 PAGE_TYPE_INDEX, PAGE_TYPE_OVERFLOW, PAGE_TYPE_TABLE_DATA, PageFile, make_page,
17 parse_page_header,
18 },
19 },
20};
21
22const TABLE_ROW_PAGE_CAPACITY: usize = PAGE_BODY_SIZE - 2;
25
26pub struct Storage {
27 page_file: PageFile,
28}
29
30fn invalid_data(message: impl Into<String>) -> MqdbError {
31 MqdbError::Storage(message.into())
32}
33
34impl Storage {
35 pub fn create(path: &Path) -> Result<Self, MqdbError> {
37 let mut page_file = PageFile::create(path)?;
38 let empty_catalog = 0u32.to_le_bytes();
39 let page = make_page(PAGE_TYPE_CATALOG, 1, 0, &empty_catalog);
40 let page_id = page_file.append_page(&page)?;
41 if page_id != 1 {
42 return Err(invalid_data(format!(
43 "expected catalog page id 1, found {page_id}"
44 )));
45 }
46 Ok(Self { page_file })
47 }
48
49 pub fn open(path: &Path) -> Result<Self, MqdbError> {
51 Ok(Self {
52 page_file: PageFile::open(path)?,
53 })
54 }
55
56 pub fn write_document(&mut self, doc: &Document) -> Result<u32, MqdbError> {
58 let mut bytes = Vec::new();
59 for block in &doc.blocks {
60 bytes.extend_from_slice(&encode_block(block));
61 }
62
63 let chunks: Vec<&[u8]> = if bytes.is_empty() {
64 vec![&[]]
65 } else {
66 bytes.chunks(PAGE_BODY_SIZE).collect()
67 };
68
69 let placeholder = make_page(PAGE_TYPE_BLOCK_DATA, 0, 0, &[]);
70 let mut page_ids = Vec::with_capacity(chunks.len());
71 for _ in 0..chunks.len() {
72 page_ids.push(self.page_file.append_page(&placeholder)?);
73 }
74
75 for (index, chunk) in chunks.iter().enumerate() {
76 let page_id = page_ids[index];
77 let next_page = page_ids.get(index + 1).copied().unwrap_or(0);
78 let page_type = if index == 0 {
79 PAGE_TYPE_BLOCK_DATA
80 } else {
81 PAGE_TYPE_OVERFLOW
82 };
83 let page = make_page(page_type, page_id, next_page, chunk);
84 self.page_file.write_page(page_id, &page)?;
85 }
86
87 page_ids
88 .first()
89 .copied()
90 .ok_or_else(|| invalid_data("document page chain is empty"))
91 }
92
93 pub fn read_blocks(
95 &mut self,
96 first_page: u32,
97 num_blocks: u32,
98 ) -> Result<Vec<Block>, MqdbError> {
99 if num_blocks == 0 {
100 return Ok(Vec::new());
101 }
102
103 let mut bytes = Vec::new();
104 let mut page_id = first_page;
105 let mut visited = HashSet::new();
106 let mut first = true;
107
108 loop {
109 if !visited.insert(page_id) {
110 return Err(invalid_data("block page chain contains a cycle"));
111 }
112
113 let page = self.page_file.read_page(page_id)?;
114 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
115 let expected_type = if first {
116 PAGE_TYPE_BLOCK_DATA
117 } else {
118 PAGE_TYPE_OVERFLOW
119 };
120 if page_type != expected_type {
121 return Err(invalid_data(format!(
122 "unexpected page type {page_type} in block chain; expected {expected_type}"
123 )));
124 }
125 if stored_page_id != page_id {
126 return Err(invalid_data(format!(
127 "block page header mismatch: expected {page_id}, found {stored_page_id}"
128 )));
129 }
130
131 bytes.extend_from_slice(&page[PAGE_HEADER_SIZE..]);
132
133 if next_page == 0 {
134 break;
135 }
136 page_id = next_page;
137 first = false;
138 }
139
140 let mut blocks = Vec::with_capacity(num_blocks as usize);
141 let mut offset = 0usize;
142 for _ in 0..num_blocks {
143 let (block, consumed) = decode_block(&bytes[offset..])?;
144 offset = offset
145 .checked_add(consumed)
146 .ok_or_else(|| invalid_data("block byte offset overflow"))?;
147 blocks.push(block);
148 }
149
150 Ok(blocks)
151 }
152
153 pub fn flush_catalog(
155 &mut self,
156 entries: &[CatalogEntry],
157 custom_tables: &[CustomTableEntry],
158 ) -> Result<(), MqdbError> {
159 write_catalog(&mut self.page_file, entries, custom_tables)
160 }
161
162 pub fn load_catalog(
164 &mut self,
165 ) -> Result<(Vec<CatalogEntry>, Vec<CustomTableEntry>), MqdbError> {
166 read_catalog(&mut self.page_file)
167 }
168
169 pub fn write_index(&mut self, bytes: &[u8]) -> Result<u32, MqdbError> {
171 let chunks: Vec<&[u8]> = if bytes.is_empty() {
172 vec![&[]]
173 } else {
174 bytes.chunks(PAGE_BODY_SIZE).collect()
175 };
176
177 let placeholder = make_page(PAGE_TYPE_INDEX, 0, 0, &[]);
178 let mut page_ids = Vec::with_capacity(chunks.len());
179 for _ in 0..chunks.len() {
180 page_ids.push(self.page_file.append_page(&placeholder)?);
181 }
182
183 for (i, chunk) in chunks.iter().enumerate() {
184 let page_id = page_ids[i];
185 let next_page = page_ids.get(i + 1).copied().unwrap_or(0);
186 let page_type = if i == 0 {
187 PAGE_TYPE_INDEX
188 } else {
189 PAGE_TYPE_OVERFLOW
190 };
191 let page = make_page(page_type, page_id, next_page, chunk);
192 self.page_file.write_page(page_id, &page)?;
193 }
194
195 page_ids
196 .first()
197 .copied()
198 .ok_or_else(|| invalid_data("empty index page chain"))
199 }
200
201 pub fn read_index_bytes(&mut self, first_page: u32) -> Result<Vec<u8>, MqdbError> {
203 let mut bytes = Vec::new();
204 let mut page_id = first_page;
205 let mut visited = HashSet::new();
206 let mut first = true;
207
208 loop {
209 if !visited.insert(page_id) {
210 return Err(invalid_data("index page chain contains a cycle"));
211 }
212
213 let page = self.page_file.read_page(page_id)?;
214 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
215
216 let expected = if first {
217 PAGE_TYPE_INDEX
218 } else {
219 PAGE_TYPE_OVERFLOW
220 };
221 if page_type != expected {
222 return Err(invalid_data(format!(
223 "unexpected page type {page_type} in index chain; expected {expected}"
224 )));
225 }
226 if stored_page_id != page_id {
227 return Err(invalid_data(format!(
228 "index page header mismatch: expected {page_id}, found {stored_page_id}"
229 )));
230 }
231
232 bytes.extend_from_slice(&page[PAGE_HEADER_SIZE..]);
233
234 if next_page == 0 {
235 break;
236 }
237 page_id = next_page;
238 first = false;
239 }
240
241 Ok(bytes)
242 }
243
244 pub fn write_table_rows(&mut self, rows: &[Vec<String>]) -> Result<(u32, u32), MqdbError> {
248 self.write_table_row_chunks(rows, PAGE_TYPE_TABLE_DATA)
249 }
250
251 pub fn append_table_rows(
255 &mut self,
256 tail_page: u32,
257 rows: &[Vec<String>],
258 ) -> Result<u32, MqdbError> {
259 if rows.is_empty() {
260 return Ok(tail_page);
261 }
262
263 let (first_new, last_new) = self.write_table_row_chunks(rows, PAGE_TYPE_OVERFLOW)?;
267 self.relink_next(tail_page, first_new)?;
268 Ok(last_new)
269 }
270
271 fn write_table_row_chunks(
279 &mut self,
280 rows: &[Vec<String>],
281 head_page_type: u32,
282 ) -> Result<(u32, u32), MqdbError> {
283 if rows.is_empty() {
284 return Ok((0, 0));
285 }
286
287 let bytes = encode_table_rows(rows);
288 let chunks: Vec<&[u8]> = bytes.chunks(TABLE_ROW_PAGE_CAPACITY).collect();
289
290 let placeholder = make_page(PAGE_TYPE_OVERFLOW, 0, 0, &[]);
291 let mut page_ids = Vec::with_capacity(chunks.len());
292 for _ in 0..chunks.len() {
293 page_ids.push(self.page_file.append_page(&placeholder)?);
294 }
295
296 for (index, chunk) in chunks.iter().enumerate() {
297 let page_id = page_ids[index];
298 let next_page = page_ids.get(index + 1).copied().unwrap_or(0);
299 let page_type = if index == 0 {
300 head_page_type
301 } else {
302 PAGE_TYPE_OVERFLOW
303 };
304 let mut body = Vec::with_capacity(2 + chunk.len());
305 body.extend_from_slice(&(chunk.len() as u16).to_le_bytes());
306 body.extend_from_slice(chunk);
307 let page = make_page(page_type, page_id, next_page, &body);
308 self.page_file.write_page(page_id, &page)?;
309 }
310
311 let first = *page_ids.first().expect("checked non-empty above");
312 let last = *page_ids.last().expect("checked non-empty above");
313 Ok((first, last))
314 }
315
316 fn relink_next(&mut self, page_id: u32, next_page: u32) -> Result<(), MqdbError> {
320 let page = self.page_file.read_page(page_id)?;
321 let (page_type, _, stored_page_id, _) = parse_page_header(&page);
322 let body = &page[PAGE_HEADER_SIZE..];
323 let relinked = make_page(page_type, stored_page_id, next_page, body);
324 self.page_file.write_page(page_id, &relinked)
325 }
326
327 pub fn read_table_rows(
329 &mut self,
330 first_page: u32,
331 num_rows: u32,
332 num_cols: usize,
333 ) -> Result<Vec<Vec<String>>, MqdbError> {
334 if first_page == 0 || num_rows == 0 {
335 return Ok(Vec::new());
336 }
337
338 let mut bytes = Vec::new();
339 let mut page_id = first_page;
340 let mut visited = HashSet::new();
341 let mut first = true;
342
343 loop {
344 if !visited.insert(page_id) {
345 return Err(invalid_data("table row page chain contains a cycle"));
346 }
347
348 let page = self.page_file.read_page(page_id)?;
349 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
350 let expected_type = if first {
351 PAGE_TYPE_TABLE_DATA
352 } else {
353 PAGE_TYPE_OVERFLOW
354 };
355 if page_type != expected_type {
356 return Err(invalid_data(format!(
357 "unexpected page type {page_type} in table row chain; expected {expected_type}"
358 )));
359 }
360 if stored_page_id != page_id {
361 return Err(invalid_data(format!(
362 "table row page header mismatch: expected {page_id}, found {stored_page_id}"
363 )));
364 }
365
366 let body = &page[PAGE_HEADER_SIZE..];
367 let chunk_len = usize::from(u16::from_le_bytes([body[0], body[1]]));
368 let chunk_end = chunk_len
369 .checked_add(2)
370 .ok_or_else(|| invalid_data("table row page chunk length overflow"))?;
371 if chunk_end > body.len() {
372 return Err(invalid_data("table row page chunk length out of bounds"));
373 }
374 bytes.extend_from_slice(&body[2..chunk_end]);
375
376 if next_page == 0 {
377 break;
378 }
379 page_id = next_page;
380 first = false;
381 }
382
383 decode_table_rows(&bytes, num_rows as usize, num_cols)
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use std::{
390 path::{Path, PathBuf},
391 sync::atomic::{AtomicU64, Ordering},
392 time::{SystemTime, UNIX_EPOCH},
393 };
394
395 use super::*;
396 use rstest::rstest;
397
398 use crate::{
399 DocumentStore,
400 block::{BlockType, Properties, PropertyValue, Span},
401 document::{Document, ZoneMaps},
402 storage::{
403 catalog::CatalogEntry,
404 codec::{decode_block, decode_zone_map, encode_block, encode_zone_map},
405 },
406 };
407
408 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
409
410 fn test_file_path(name: &str) -> PathBuf {
411 let unique = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
412 let timestamp = SystemTime::now()
413 .duration_since(UNIX_EPOCH)
414 .expect("system time before UNIX_EPOCH")
415 .as_nanos();
416 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
417 .join("target")
418 .join("mq-db-storage-tests");
419 std::fs::create_dir_all(&dir).unwrap();
420 dir.join(format!("{name}-{timestamp}-{unique}.mq-db"))
421 }
422
423 fn cleanup(path: &Path) {
424 let _ = std::fs::remove_file(path);
425 let tmp_path = PathBuf::from(format!("{}.tmp", path.to_string_lossy()));
426 let _ = std::fs::remove_file(tmp_path);
427 }
428
429 fn sample_block(block_type: BlockType, id: u32) -> Block {
430 let mut properties = Properties::new();
431 properties.set("name", format!("block-{id}"));
432 properties.set("count", i64::from(id));
433 properties.set("score", PropertyValue::Float(1.5f64 + f64::from(id)));
434 properties.set("flag", id.is_multiple_of(2));
435 properties.set(
436 "items",
437 PropertyValue::Array(vec![
438 PropertyValue::Null,
439 PropertyValue::String("value".to_string()),
440 PropertyValue::Int(-3),
441 PropertyValue::Float(2.25),
442 PropertyValue::Bool(true),
443 PropertyValue::Array(vec![PropertyValue::String("nested".to_string())]),
444 ]),
445 );
446
447 Block {
448 id,
449 document_id: 7,
450 block_type,
451 content: format!("content-{id}"),
452 span: Some(Span {
453 start_line: 1,
454 start_col: 2,
455 end_line: 3,
456 end_col: 4,
457 }),
458 pre: id * 2,
459 post: id * 2 + 1,
460 properties,
461 }
462 }
463
464 #[test]
465 fn block_codec_round_trip_all_block_types() {
466 let block_types = [
467 BlockType::Heading,
468 BlockType::Paragraph,
469 BlockType::Code,
470 BlockType::List,
471 BlockType::TableCell,
472 BlockType::TableRow,
473 BlockType::TableAlign,
474 BlockType::Blockquote,
475 BlockType::HorizontalRule,
476 BlockType::Html,
477 BlockType::Yaml,
478 BlockType::Toml,
479 BlockType::Math,
480 BlockType::Definition,
481 BlockType::Footnote,
482 ];
483
484 for (index, block_type) in block_types.into_iter().enumerate() {
485 let block = sample_block(block_type, index as u32 + 1);
486 let encoded = encode_block(&block);
487 let (decoded, consumed) = decode_block(&encoded).unwrap();
488 assert_eq!(consumed, encoded.len());
489 assert_eq!(decoded, block);
490 }
491 }
492
493 #[test]
494 fn zone_map_codec_round_trip() {
495 let mut zone_maps = ZoneMaps {
496 max_heading_depth: 4,
497 heading_slugs: ["intro".to_string(), "usage".to_string()]
498 .into_iter()
499 .collect(),
500 heading_contents: ["Intro".to_string(), "Usage".to_string()]
501 .into_iter()
502 .collect(),
503 code_languages: ["rust".to_string(), "python".to_string()]
504 .into_iter()
505 .collect(),
506 frontmatter_keys: ["title".to_string(), "tags".to_string()]
507 .into_iter()
508 .collect(),
509 title: Some("Storage Spec".to_string()),
510 tags: vec!["db".to_string(), "markdown".to_string()],
511 };
512 let encoded = encode_zone_map(&zone_maps);
513 let decoded = decode_zone_map(&encoded).unwrap();
514 assert_eq!(decoded, zone_maps);
515
516 zone_maps.title = None;
517 let encoded_without_title = encode_zone_map(&zone_maps);
518 let decoded_without_title = decode_zone_map(&encoded_without_title).unwrap();
519 assert_eq!(decoded_without_title, zone_maps);
520 }
521
522 #[test]
523 fn storage_round_trip_multi_page_document() {
524 let path = test_file_path("multi-page");
525 cleanup(&path);
526
527 let blocks: Vec<Block> = (0..32)
528 .map(|id| {
529 let mut block = sample_block(BlockType::Paragraph, id + 1);
530 block.content = "x".repeat(PAGE_BODY_SIZE / 2);
531 block.pre = id * 2;
532 block.post = id * 2 + 1;
533 block
534 })
535 .collect();
536 let document = Document::new(1, None, blocks.clone());
537
538 let mut storage = Storage::create(&path).unwrap();
539 let first_page = storage.write_document(&document).unwrap();
540 let catalog_entry = CatalogEntry {
541 document_id: document.id,
542 path: None,
543 first_block_page: first_page,
544 num_blocks: document.blocks.len() as u32,
545 zone_map_bytes: encode_zone_map(&document.zone_maps),
546 index_start_page: 0,
547 };
548 storage.flush_catalog(&[catalog_entry], &[]).unwrap();
549 drop(storage);
550
551 let mut reopened = Storage::open(&path).unwrap();
552 let (catalog, _) = reopened.load_catalog().unwrap();
553 assert_eq!(catalog.len(), 1);
554 assert_eq!(
555 decode_zone_map(&catalog[0].zone_map_bytes).unwrap(),
556 document.zone_maps
557 );
558 let decoded_blocks = reopened
559 .read_blocks(first_page, document.blocks.len() as u32)
560 .unwrap();
561 assert_eq!(decoded_blocks, blocks);
562
563 cleanup(&path);
564 }
565
566 #[test]
567 fn document_store_save_load_round_trip() {
568 let path = test_file_path("store-save-load");
569 cleanup(&path);
570
571 let mut store = DocumentStore::new();
572 store
573 .add_str(
574 "---\ntitle: Demo\ntags: [db, rust]\n---\n# Intro\n\nParagraph\n\n```rust\nfn main() {}\n```\n",
575 )
576 .unwrap();
577 store
578 .add_str("## Usage\n\n- item one\n- item two\n")
579 .unwrap();
580
581 store.save(&path).unwrap();
582 let loaded = DocumentStore::load(&path).unwrap();
583
584 assert_eq!(loaded.len(), store.len());
585 for (l, s) in loaded.documents().iter().zip(store.documents().iter()) {
588 assert_eq!(l.id, s.id);
589 assert_eq!(l.blocks, s.blocks);
590 assert_eq!(l.zone_maps, s.zone_maps);
591 }
592
593 cleanup(&path);
594 }
595
596 #[test]
597 fn persisted_index_round_trip() {
598 use crate::{SqlEngine, indexes::DocumentIndex};
599
600 let path = test_file_path("index-round-trip");
601 cleanup(&path);
602
603 let mut store = DocumentStore::new();
604 store
605 .add_str("# Hello\n\n## Arch\n\nDetails\n\n```rust\ncode\n```\n")
606 .unwrap();
607 store.add_str("## Usage\n\n- item\n").unwrap();
608 store.save(&path).unwrap();
609
610 let mut opened = DocumentStore::open(&path).unwrap();
612 assert!(
613 opened.documents()[0].blocks.is_empty(),
614 "blocks not loaded yet"
615 );
616
617 opened.load_all_blocks().unwrap();
619 opened.load_all_indexes().unwrap();
620
621 assert!(!opened.documents()[0].blocks.is_empty(), "blocks loaded");
622 assert!(opened.get_doc_index(0).is_some(), "index cached");
623
624 for (i, doc) in opened.documents().iter().enumerate() {
626 let from_file = opened.get_doc_index(i).unwrap().clone();
627 let from_blocks = DocumentIndex::build(&doc.blocks);
628 assert_eq!(
629 from_file.to_bytes(),
630 from_blocks.to_bytes(),
631 "index mismatch for doc {i}"
632 );
633 }
634
635 let engine = SqlEngine::new(&opened).unwrap();
637 let out = engine.execute("SELECT count(*) FROM blocks").unwrap();
638 assert!(!out.rows.is_empty());
639
640 cleanup(&path);
641 }
642
643 #[rstest]
644 #[case(BlockType::Heading)]
645 #[case(BlockType::Paragraph)]
646 #[case(BlockType::Code)]
647 #[case(BlockType::List)]
648 #[case(BlockType::TableCell)]
649 #[case(BlockType::TableRow)]
650 #[case(BlockType::TableAlign)]
651 #[case(BlockType::Blockquote)]
652 #[case(BlockType::HorizontalRule)]
653 #[case(BlockType::Html)]
654 #[case(BlockType::Yaml)]
655 #[case(BlockType::Toml)]
656 #[case(BlockType::Math)]
657 #[case(BlockType::Definition)]
658 #[case(BlockType::Footnote)]
659 fn block_codec_round_trip_param(#[case] block_type: BlockType) {
660 let block = sample_block(block_type, 42);
661 let encoded = encode_block(&block);
662 let (decoded, consumed) = decode_block(&encoded).unwrap();
663 assert_eq!(consumed, encoded.len());
664 assert_eq!(decoded, block);
665 }
666
667 #[test]
668 fn table_row_chain_round_trip_across_multiple_appends() {
669 let path = test_file_path("table-row-chain-append");
676 cleanup(&path);
677
678 let mut storage = Storage::create(&path).unwrap();
679 storage.flush_catalog(&[], &[]).unwrap();
680
681 let batch1 = vec![
682 vec!["1".to_string(), "a".to_string()],
683 vec!["2".to_string(), "b".to_string()],
684 ];
685 let batch2 = vec![vec!["3".to_string(), "c".to_string()]];
686 let batch3 = vec![
687 vec!["4".to_string(), "d".to_string()],
688 vec!["5".to_string(), "e".to_string()],
689 ];
690
691 let (first_page, last_page) = storage.write_table_rows(&batch1).unwrap();
692 let last_page = storage.append_table_rows(last_page, &batch2).unwrap();
693 let last_page = storage.append_table_rows(last_page, &batch3).unwrap();
694 assert_ne!(last_page, 0);
695
696 let all_rows = storage.read_table_rows(first_page, 5, 2).unwrap();
697 let expected: Vec<Vec<String>> = batch1.into_iter().chain(batch2).chain(batch3).collect();
698 assert_eq!(all_rows, expected);
699
700 let big_batch: Vec<Vec<String>> = (0..10)
703 .map(|i| vec![i.to_string(), "x".repeat(PAGE_BODY_SIZE)])
704 .collect();
705 let last_page = storage.append_table_rows(last_page, &big_batch).unwrap();
706 assert_ne!(last_page, 0);
707
708 let all_rows = storage.read_table_rows(first_page, 15, 2).unwrap();
709 let expected: Vec<Vec<String>> = expected.into_iter().chain(big_batch).collect();
710 assert_eq!(all_rows, expected);
711
712 cleanup(&path);
713 }
714
715 #[test]
716 fn custom_table_round_trip() {
717 let path = test_file_path("custom-table-round-trip");
718 cleanup(&path);
719
720 let mut store = DocumentStore::new();
721 store.add_str("# Hello\n\nWorld\n").unwrap();
722 store.save(&path).unwrap();
723
724 let mut opened = DocumentStore::open(&path).unwrap();
726 opened.load_all_blocks().unwrap();
727 opened.load_all_indexes().unwrap();
728 let engine = crate::SqlEngine::new(&opened).unwrap();
729 engine
730 .execute("CREATE TABLE notes (id TEXT, body TEXT)")
731 .unwrap();
732 engine
733 .execute("INSERT INTO notes VALUES ('1', 'hello')")
734 .unwrap();
735 engine
736 .execute("INSERT INTO notes VALUES ('2', 'world')")
737 .unwrap();
738 drop(engine);
739 drop(opened);
740
741 let mut reopened = DocumentStore::open(&path).unwrap();
743 reopened.load_all_blocks().unwrap();
744 reopened.load_all_indexes().unwrap();
745 let engine2 = crate::SqlEngine::new(&reopened).unwrap();
746 let out = engine2
747 .execute("SELECT body FROM notes WHERE id = '1'")
748 .unwrap();
749 assert_eq!(out.rows.len(), 1);
750 assert_eq!(out.rows[0][0], "hello");
751 let all = engine2.execute("SELECT * FROM notes").unwrap();
752 assert_eq!(all.rows.len(), 2);
753
754 cleanup(&path);
755 }
756
757 #[rstest]
758 #[case(Some("My Title"))]
759 #[case(None)]
760 #[case(Some("title with spaces and unicode: こんにちは"))]
761 fn zone_map_title_round_trip_param(#[case] title: Option<&str>) {
762 let zone_maps = ZoneMaps {
763 max_heading_depth: 3,
764 heading_slugs: ["intro", "usage"].iter().map(|s| s.to_string()).collect(),
765 heading_contents: ["Intro", "Usage"].iter().map(|s| s.to_string()).collect(),
766 code_languages: ["rust", "python"].iter().map(|s| s.to_string()).collect(),
767 frontmatter_keys: ["title", "tags"].iter().map(|s| s.to_string()).collect(),
768 title: title.map(|s| s.to_string()),
769 tags: vec!["db".to_string(), "markdown".to_string()],
770 };
771 let encoded = encode_zone_map(&zone_maps);
772 let decoded = decode_zone_map(&encoded).unwrap();
773 assert_eq!(decoded, zone_maps);
774 }
775}