1use std::path::{Path, PathBuf};
10
11use crate::parser::Section;
12use crate::store::{Layer, Store, StoreError};
13
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct Tree {
17 pub layers: Vec<TreeLayer>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct TreeLayer {
24 pub layer: Layer,
26 pub type_folders: Vec<TreeTypeFolder>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TreeTypeFolder {
33 pub path: PathBuf,
35 pub files: Vec<PathBuf>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Outline {
43 pub file: PathBuf,
45 pub sections: Vec<Section>,
48}
49
50pub fn tree(store: &Store, layer: Option<Layer>, type_: Option<&str>) -> Result<Tree, StoreError> {
67 let mut layers = Vec::new();
68
69 for l in Layer::all() {
70 if let Some(want) = layer {
71 if l != want {
72 continue;
73 }
74 }
75
76 let layer_abs = store.root.join(layer_dir_name(l));
77 if !layer_abs.is_dir() {
78 continue;
79 }
80
81 let mut type_dir_names: Vec<String> = Vec::new();
84 for entry in std::fs::read_dir(&layer_abs)? {
85 let entry = entry?;
86 let file_type = entry.file_type()?;
87 if !file_type.is_dir() {
88 continue;
89 }
90 let name = entry.file_name().to_string_lossy().into_owned();
91 if is_skipped_dir(&name) {
92 continue;
93 }
94 type_dir_names.push(name);
95 }
96 type_dir_names.sort();
97
98 let mut type_folders = Vec::new();
99 for type_name in type_dir_names {
100 let type_abs = layer_abs.join(&type_name);
101 let mut files: Vec<PathBuf> = Vec::new();
102 collect_content_files(&store.root, &type_abs, &mut files)?;
103
104 if let Some(want) = type_ {
111 files.retain(|rel| file_type_matches(&store.root, rel, want));
112 }
113
114 if files.is_empty() {
115 continue;
116 }
117 files.sort();
118
119 type_folders.push(TreeTypeFolder {
120 path: PathBuf::from(layer_dir_name(l)).join(&type_name),
121 files,
122 });
123 }
124
125 if type_folders.is_empty() {
126 continue;
127 }
128
129 layers.push(TreeLayer {
130 layer: l,
131 type_folders,
132 });
133 }
134
135 Ok(Tree { layers })
136}
137
138fn layer_dir_name(layer: Layer) -> &'static str {
142 match layer {
143 Layer::Sources => "sources",
144 Layer::Records => "records",
145 }
146}
147
148fn is_skipped_dir(name: &str) -> bool {
151 name == "log" || name.starts_with('.')
152}
153
154fn is_content_md(name: &str) -> bool {
158 name.ends_with(".md") && name != "index.md"
159}
160
161fn collect_content_files(
165 store_root: &Path,
166 dir: &Path,
167 out: &mut Vec<PathBuf>,
168) -> Result<(), StoreError> {
169 for entry in std::fs::read_dir(dir)? {
170 let entry = entry?;
171 let file_type = entry.file_type()?;
172 let name = entry.file_name().to_string_lossy().into_owned();
173
174 if file_type.is_dir() {
175 if name.starts_with('.') {
176 continue;
177 }
178 collect_content_files(store_root, &entry.path(), out)?;
179 } else if file_type.is_file() && is_content_md(&name) {
180 let abs = entry.path();
181 let rel = abs.strip_prefix(store_root).unwrap_or(&abs).to_path_buf();
182 out.push(rel);
183 }
184 }
185 Ok(())
186}
187
188fn file_type_matches(store_root: &Path, rel: &Path, want: &str) -> bool {
197 let abs = store_root.join(rel);
198 let text = match std::fs::read_to_string(&abs) {
199 Ok(t) => t,
200 Err(_) => return false,
201 };
202 frontmatter_type(&text).as_deref() == Some(want)
203}
204
205fn frontmatter_type(text: &str) -> Option<String> {
209 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
210 let mut lines = text.lines();
211 if lines.next()?.trim_end() != "---" {
212 return None;
213 }
214 let mut yaml = String::new();
215 let mut closed = false;
216 for line in lines {
217 if line.trim_end() == "---" {
218 closed = true;
219 break;
220 }
221 yaml.push_str(line);
222 yaml.push('\n');
223 }
224 if !closed {
225 return None;
226 }
227 let value: serde_norway::Value = serde_norway::from_str(&yaml).ok()?;
228 let s = value
229 .as_mapping()?
230 .get(serde_norway::Value::String("type".to_string()))?
231 .as_str()?
232 .trim();
233 if s.is_empty() {
234 None
235 } else {
236 Some(s.to_string())
237 }
238}
239
240pub fn outline(store: &Store, file: &Path) -> Result<Outline, StoreError> {
251 let abs = if file.is_absolute() {
252 file.to_path_buf()
253 } else {
254 store.root.join(file)
255 };
256
257 let rel = abs.strip_prefix(&store.root).unwrap_or(file).to_path_buf();
258
259 let text = std::fs::read_to_string(&abs)?;
260 let body = strip_frontmatter(&text);
261 let sections = parse_sections(body);
262
263 Ok(Outline {
264 file: rel,
265 sections,
266 })
267}
268
269fn strip_frontmatter(text: &str) -> &str {
275 let after_open = match text.strip_prefix("---\n") {
277 Some(rest) => rest,
278 None => match text.strip_prefix("---\r\n") {
279 Some(rest) => rest,
280 None => return text,
281 },
282 };
283
284 let mut search_from = 0usize;
286 while let Some(rel_idx) = after_open[search_from..].find("---") {
287 let idx = search_from + rel_idx;
288 let at_line_start = idx == 0 || after_open.as_bytes()[idx - 1] == b'\n';
289 let after = &after_open[idx + 3..];
290 let line_ends = after.is_empty()
291 || after.starts_with('\n')
292 || after.starts_with("\r\n")
293 || after.starts_with('\r');
294 if at_line_start && line_ends {
295 if let Some(stripped) = after.strip_prefix("\r\n") {
297 return stripped;
298 }
299 if let Some(stripped) = after.strip_prefix('\n') {
300 return stripped;
301 }
302 if let Some(stripped) = after.strip_prefix('\r') {
303 return stripped;
304 }
305 return after; }
307 search_from = idx + 3;
308 }
309
310 text
312}
313
314fn parse_sections(body: &str) -> Vec<Section> {
319 let lines: Vec<&str> = body.split_inclusive('\n').collect();
322
323 let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
326 let mut fence: Option<(u8, usize)> = None; for line in &lines {
328 let content = line.trim_end_matches(['\n', '\r']);
329 if let Some(f) = fence {
330 if is_closing_fence(content, f) {
331 fence = None;
332 }
333 levels.push(0);
334 continue;
335 }
336 if let Some(opened) = opening_fence(content) {
337 fence = Some(opened);
338 levels.push(0);
339 continue;
340 }
341 levels.push(heading_level(content));
342 }
343
344 let mut sections = Vec::new();
348 for (i, &lvl) in levels.iter().enumerate() {
349 if lvl < 2 {
350 continue;
351 }
352 let heading_line = lines[i].trim_end_matches(['\n', '\r']);
353 let heading = heading_text(heading_line, lvl);
354
355 let mut end = lines.len();
356 for (j, &other) in levels.iter().enumerate().skip(i + 1) {
357 if other != 0 && other <= lvl {
358 end = j;
359 break;
360 }
361 }
362
363 let body_slice: String = lines[i..end].concat();
364
365 sections.push(Section {
366 heading,
367 level: lvl,
368 line: (i + 1) as u32,
369 body: body_slice,
370 });
371 }
372
373 sections
374}
375
376pub(crate) fn heading_level(line: &str) -> u8 {
383 let indent = line.len() - line.trim_start_matches(' ').len();
384 if indent > 3 {
385 return 0;
386 }
387 let rest = &line[indent..];
388 let hashes = rest.len() - rest.trim_start_matches('#').len();
389 if hashes == 0 || hashes > 6 {
390 return 0;
391 }
392 let after = &rest[hashes..];
393 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
394 hashes as u8
395 } else {
396 0
397 }
398}
399
400pub(crate) fn heading_text(line: &str, level: u8) -> String {
411 let indent = line.len() - line.trim_start_matches(' ').len();
412 let after_hashes = &line[indent + level as usize..];
413 let trimmed = after_hashes.trim();
414 let trailing_hashes = trimmed.len() - trimmed.trim_end_matches('#').len();
416 if trailing_hashes == 0 {
417 return trimmed.to_string();
418 }
419 let before_run = &trimmed[..trimmed.len() - trailing_hashes];
420 if before_run.is_empty() || before_run.ends_with([' ', '\t']) {
424 before_run.trim_end().to_string()
425 } else {
426 trimmed.to_string()
427 }
428}
429
430fn opening_fence(line: &str) -> Option<(u8, usize)> {
434 let indent = line.len() - line.trim_start_matches(' ').len();
435 if indent > 3 {
436 return None;
437 }
438 let rest = &line[indent..];
439 let byte = rest.bytes().next()?;
440 if byte != b'`' && byte != b'~' {
441 return None;
442 }
443 let run = rest.len() - rest.trim_start_matches(byte as char).len();
444 if run < 3 {
445 return None;
446 }
447 if byte == b'`' && rest[run..].contains('`') {
449 return None;
450 }
451 Some((byte, run))
452}
453
454fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
457 let (byte, open_len) = fence;
458 let indent = line.len() - line.trim_start_matches(' ').len();
459 if indent > 3 {
460 return false;
461 }
462 let rest = &line[indent..];
463 let run = rest.len() - rest.trim_start_matches(byte as char).len();
464 if run < open_len {
465 return false;
466 }
467 rest[run..].trim().is_empty()
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::parser::Config;
474 use std::fs;
475 use tempfile::TempDir;
476
477 struct Fixture {
485 _dir: TempDir,
486 store: Store,
487 }
488
489 impl Fixture {
490 fn new() -> Self {
491 let dir = tempfile::tempdir().expect("tempdir");
492 fs::write(dir.path().join("DB.md"), "---\ntype: db\n---\n").expect("write DB.md");
494 let store = Store {
495 root: dir.path().to_path_buf(),
496 config: Config::default(),
497 };
498 Fixture { _dir: dir, store }
499 }
500
501 fn write(&self, rel: &str, contents: &str) {
503 let abs = self.store.root.join(rel);
504 if let Some(parent) = abs.parent() {
505 fs::create_dir_all(parent).expect("create parents");
506 }
507 fs::write(abs, contents).expect("write file");
508 }
509
510 fn mkdir(&self, rel: &str) {
511 fs::create_dir_all(self.store.root.join(rel)).expect("mkdir");
512 }
513 }
514
515 fn doc(summary: &str) -> String {
517 format!("---\ntype: contact\nsummary: {summary}\n---\n\nbody\n")
518 }
519
520 fn shape(tree: &Tree) -> Vec<(Layer, String, Vec<String>)> {
523 let mut out = Vec::new();
524 for layer in &tree.layers {
525 for tf in &layer.type_folders {
526 let files = tf
527 .files
528 .iter()
529 .map(|p| p.to_string_lossy().into_owned())
530 .collect();
531 out.push((layer.layer, tf.path.to_string_lossy().into_owned(), files));
532 }
533 }
534 out
535 }
536
537 #[test]
540 fn tree_groups_by_layer_then_type_folder_in_canonical_order() {
541 let fx = Fixture::new();
542 fx.write("records/profiles/sarah.md", &doc("sarah bio"));
548 fx.write("records/contacts/sarah-chen.md", &doc("sarah contact"));
549 fx.write("sources/emails/a.md", &doc("an email"));
550
551 let tree = tree(&fx.store, None, None).expect("tree");
552 let layer_order: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
553 assert_eq!(
554 layer_order,
555 vec![Layer::Sources, Layer::Records],
556 "layers must come back in canonical order regardless of on-disk name order"
557 );
558
559 assert_eq!(
560 shape(&tree),
561 vec![
562 (
563 Layer::Sources,
564 "sources/emails".to_string(),
565 vec!["sources/emails/a.md".to_string()]
566 ),
567 (
568 Layer::Records,
569 "records/contacts".to_string(),
570 vec!["records/contacts/sarah-chen.md".to_string()]
571 ),
572 (
573 Layer::Records,
574 "records/profiles".to_string(),
575 vec!["records/profiles/sarah.md".to_string()]
576 ),
577 ]
578 );
579 }
580
581 #[test]
582 fn tree_type_folders_and_files_are_sorted_ascending() {
583 let fx = Fixture::new();
584 fx.write("records/expenses/z.md", &doc("z"));
586 fx.write("records/contacts/b.md", &doc("b"));
587 fx.write("records/contacts/a.md", &doc("a"));
588
589 let tree = tree(&fx.store, None, None).expect("tree");
590 let records = tree
591 .layers
592 .iter()
593 .find(|l| l.layer == Layer::Records)
594 .expect("records layer");
595
596 let folder_paths: Vec<String> = records
597 .type_folders
598 .iter()
599 .map(|tf| tf.path.to_string_lossy().into_owned())
600 .collect();
601 assert_eq!(
602 folder_paths,
603 vec![
604 "records/contacts".to_string(),
605 "records/expenses".to_string()
606 ],
607 "type-folders sorted by path ascending"
608 );
609
610 let contacts = &records.type_folders[0];
611 let files: Vec<String> = contacts
612 .files
613 .iter()
614 .map(|p| p.to_string_lossy().into_owned())
615 .collect();
616 assert_eq!(
617 files,
618 vec![
619 "records/contacts/a.md".to_string(),
620 "records/contacts/b.md".to_string()
621 ],
622 "files sorted by store-relative path ascending"
623 );
624 }
625
626 #[test]
627 fn tree_aggregates_files_across_date_shards_into_one_type_folder() {
628 let fx = Fixture::new();
629 fx.write("sources/emails/2026/05/newer.md", &doc("newer"));
630 fx.write("sources/emails/2026/04/older.md", &doc("older"));
631 fx.write("sources/emails/loose.md", &doc("loose at folder root"));
632
633 let tree = tree(&fx.store, None, None).expect("tree");
634 let emails: Vec<&TreeTypeFolder> = tree
635 .layers
636 .iter()
637 .flat_map(|l| &l.type_folders)
638 .filter(|tf| tf.path == Path::new("sources/emails"))
639 .collect();
640
641 assert_eq!(
642 emails.len(),
643 1,
644 "all shards of one type fold into a single type-folder branch, not one per shard"
645 );
646 let files: Vec<String> = emails[0]
647 .files
648 .iter()
649 .map(|p| p.to_string_lossy().into_owned())
650 .collect();
651 assert_eq!(
652 files,
653 vec![
654 "sources/emails/2026/04/older.md".to_string(),
655 "sources/emails/2026/05/newer.md".to_string(),
656 "sources/emails/loose.md".to_string(),
657 ],
658 "every file under the type-folder, across shards, appears once"
659 );
660 }
661
662 #[test]
663 fn tree_excludes_index_and_log_and_db_meta_files() {
664 let fx = Fixture::new();
665 fx.write("records/contacts/sarah.md", &doc("sarah"));
667 fx.write("index.md", "---\ntype: index\n---\n"); fx.write("records/index.md", "---\ntype: index\n---\n"); fx.write("records/contacts/index.md", "---\ntype: index\n---\n"); fx.write("records/contacts/index.jsonl", "{}\n"); fx.write("log.md", "log\n"); fx.write("log/2026-04.md", "rotated\n"); let tree = tree(&fx.store, None, None).expect("tree");
676 let all_files: Vec<String> = tree
677 .layers
678 .iter()
679 .flat_map(|l| &l.type_folders)
680 .flat_map(|tf| &tf.files)
681 .map(|p| p.to_string_lossy().into_owned())
682 .collect();
683
684 assert_eq!(
685 all_files,
686 vec!["records/contacts/sarah.md".to_string()],
687 "only the real content file survives; no index.md/index.jsonl/log files"
688 );
689 assert!(tree
691 .layers
692 .iter()
693 .all(|l| matches!(l.layer, Layer::Sources | Layer::Records)));
694 }
695
696 #[test]
697 fn tree_omits_empty_layers_and_empty_type_folders() {
698 let fx = Fixture::new();
699 fx.write("records/contacts/a.md", &doc("a"));
700 fx.mkdir("records/companies");
702 fx.mkdir("wiki");
704 fx.write("sources/emails/index.md", "---\ntype: index\n---\n");
706
707 let tree = tree(&fx.store, None, None).expect("tree");
708
709 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
710 assert_eq!(
711 layers,
712 vec![Layer::Records],
713 "empty wiki layer and meta-only sources layer are omitted"
714 );
715 let folders: Vec<String> = tree.layers[0]
716 .type_folders
717 .iter()
718 .map(|tf| tf.path.to_string_lossy().into_owned())
719 .collect();
720 assert_eq!(
721 folders,
722 vec!["records/contacts".to_string()],
723 "the empty companies type-folder is omitted"
724 );
725 }
726
727 #[test]
728 fn tree_layer_filter_restricts_to_one_layer() {
729 let fx = Fixture::new();
730 fx.write("sources/emails/a.md", &doc("a"));
731 fx.write("records/contacts/b.md", &doc("b"));
732 fx.write("sources/notes/c.md", &doc("c"));
733
734 let tree = tree(&fx.store, Some(Layer::Records), None).expect("tree");
735 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
736 assert_eq!(
737 layers,
738 vec![Layer::Records],
739 "only the requested layer is walked"
740 );
741 }
742
743 fn typed(type_: &str, summary: &str) -> String {
745 format!("---\ntype: {type_}\nsummary: {summary}\n---\n\nbody\n")
746 }
747
748 #[test]
749 fn tree_type_filter_matches_frontmatter_type_across_layers() {
750 let fx = Fixture::new();
751 fx.write("sources/inbox/s.md", &typed("note", "source note"));
754 fx.write("records/scratch/r.md", &typed("note", "record note"));
755 fx.write("records/contacts/c.md", &typed("contact", "contact"));
756
757 let tree = tree(&fx.store, None, Some("note")).expect("tree");
758 let files: Vec<String> = tree
759 .layers
760 .iter()
761 .flat_map(|l| &l.type_folders)
762 .flat_map(|tf| &tf.files)
763 .map(|p| p.to_string_lossy().into_owned())
764 .collect();
765 assert_eq!(
766 files,
767 vec![
768 "sources/inbox/s.md".to_string(),
769 "records/scratch/r.md".to_string()
770 ],
771 "type filter matches the frontmatter type across layers, regardless of folder name"
772 );
773 }
774
775 #[test]
776 fn tree_type_filter_uses_frontmatter_type_not_folder_name() {
777 let fx = Fixture::new();
782 fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
783 fx.write("records/profiles/sarah.md", &typed("profile", "sarah bio"));
787
788 let by_type = tree(&fx.store, None, Some("contact")).expect("tree");
790 let files: Vec<String> = by_type
791 .layers
792 .iter()
793 .flat_map(|l| &l.type_folders)
794 .flat_map(|tf| &tf.files)
795 .map(|p| p.to_string_lossy().into_owned())
796 .collect();
797 assert_eq!(
798 files,
799 vec!["records/contacts/sarah.md".to_string()],
800 "--type contact lists the contact in the pluralized canonical folder"
801 );
802
803 let by_folder_name = tree(&fx.store, None, Some("contacts")).expect("tree");
805 assert!(
806 by_folder_name.layers.is_empty(),
807 "the folder directory name is not the frontmatter type and must not match"
808 );
809
810 let profiles = tree(&fx.store, None, Some("profile")).expect("tree");
813 let profile_files: Vec<String> = profiles
814 .layers
815 .iter()
816 .flat_map(|l| &l.type_folders)
817 .flat_map(|tf| &tf.files)
818 .map(|p| p.to_string_lossy().into_owned())
819 .collect();
820 assert_eq!(
821 profile_files,
822 vec!["records/profiles/sarah.md".to_string()],
823 "--type profile matches the frontmatter type under a topic folder"
824 );
825 }
826
827 #[test]
828 fn tree_type_filter_skips_untyped_and_unmatched_files() {
829 let fx = Fixture::new();
832 fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
833 fx.write("records/contacts/no-type.md", "no frontmatter at all\n");
834 fx.write("records/contacts/other.md", &typed("company", "acme"));
835
836 let tree = tree(&fx.store, None, Some("contact")).expect("tree");
837 let files: Vec<String> = tree
838 .layers
839 .iter()
840 .flat_map(|l| &l.type_folders)
841 .flat_map(|tf| &tf.files)
842 .map(|p| p.to_string_lossy().into_owned())
843 .collect();
844 assert_eq!(
845 files,
846 vec!["records/contacts/sarah.md".to_string()],
847 "only the file whose frontmatter type matches survives; untyped/other are skipped"
848 );
849 }
850
851 #[test]
852 fn tree_excludes_loose_files_directly_under_a_layer() {
853 let fx = Fixture::new();
854 fx.write("records/contacts/real.md", &doc("real"));
855 fx.write("records/stray.md", &doc("stray"));
857
858 let tree = tree(&fx.store, None, None).expect("tree");
859 let all_files: Vec<String> = tree
860 .layers
861 .iter()
862 .flat_map(|l| &l.type_folders)
863 .flat_map(|tf| &tf.files)
864 .map(|p| p.to_string_lossy().into_owned())
865 .collect();
866 assert_eq!(
867 all_files,
868 vec!["records/contacts/real.md".to_string()],
869 "a layer-direct file has no type-folder slot and is not listed"
870 );
871 }
872
873 #[test]
874 fn tree_skips_hidden_directories() {
875 let fx = Fixture::new();
876 fx.write("records/contacts/a.md", &doc("a"));
877 fx.write(".git/objects/x.md", &doc("vcs junk"));
879 fx.write("records/.hidden/h.md", &doc("hidden type folder"));
880 fx.write("sources/emails/.tmp/draft.md", &doc("hidden shard"));
881
882 let tree = tree(&fx.store, None, None).expect("tree");
883 let all_files: Vec<String> = tree
884 .layers
885 .iter()
886 .flat_map(|l| &l.type_folders)
887 .flat_map(|tf| &tf.files)
888 .map(|p| p.to_string_lossy().into_owned())
889 .collect();
890 assert_eq!(
891 all_files,
892 vec!["records/contacts/a.md".to_string()],
893 "hidden dirs are skipped at the type-folder and shard levels"
894 );
895 }
896
897 #[test]
898 fn tree_paths_are_store_relative_not_absolute() {
899 let fx = Fixture::new();
900 fx.write("records/contacts/a.md", &doc("a"));
901
902 let tree = tree(&fx.store, None, None).expect("tree");
903 let tf = &tree.layers[0].type_folders[0];
904 assert!(
905 tf.path.is_relative() && tf.files[0].is_relative(),
906 "tree paths must be store-relative"
907 );
908 let root_str = fx.store.root.to_string_lossy().into_owned();
910 assert!(!tf.files[0].to_string_lossy().contains(&root_str));
911 }
912
913 #[test]
914 fn tree_on_store_with_no_layers_is_empty() {
915 let fx = Fixture::new(); let tree = tree(&fx.store, None, None).expect("tree");
917 assert!(
918 tree.layers.is_empty(),
919 "a store with no content has an empty tree"
920 );
921 }
922
923 fn headings(o: &Outline) -> Vec<(String, u8, u32)> {
927 o.sections
928 .iter()
929 .map(|s| (s.heading.clone(), s.level, s.line))
930 .collect()
931 }
932
933 #[test]
934 fn outline_extracts_sections_with_levels_and_body_relative_lines() {
935 let fx = Fixture::new();
936 let file = "---\ntype: note\nsummary: s\n---\n\n# Title\n\n## Alpha\ntext\n### Sub\nmore\n## Beta\nend\n";
940 fx.write("records/notes/n.md", file);
941
942 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
943 assert_eq!(
944 headings(&o),
945 vec![
946 ("Alpha".to_string(), 2, 4),
947 ("Sub".to_string(), 3, 6),
948 ("Beta".to_string(), 2, 8),
949 ],
950 "only ##+ headings, with body-relative 1-based line numbers; the # title is not a section"
951 );
952 assert_eq!(o.file, PathBuf::from("records/notes/n.md"));
953 }
954
955 #[test]
956 fn outline_section_body_spans_to_next_sibling_or_shallower_heading() {
957 let fx = Fixture::new();
958 let file = "---\nx: 1\n---\n## Alpha\na1\na2\n### Sub\ns1\n## Beta\nb1\n";
959 fx.write("records/notes/n.md", file);
960
961 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
962 let alpha = &o.sections[0];
963 assert_eq!(alpha.heading, "Alpha");
965 assert_eq!(
966 alpha.body, "## Alpha\na1\na2\n### Sub\ns1\n",
967 "a ## body runs through deeper headings up to the next sibling-or-shallower heading"
968 );
969
970 let sub = &o.sections[1];
971 assert_eq!(sub.heading, "Sub");
972 assert_eq!(
973 sub.body, "### Sub\ns1\n",
974 "the nested ### body stops at the next ## (shallower) heading"
975 );
976
977 let beta = &o.sections[2];
978 assert_eq!(
979 beta.body, "## Beta\nb1\n",
980 "the trailing ## body runs to end of file"
981 );
982 }
983
984 #[test]
985 fn outline_shallower_heading_terminates_a_section_body() {
986 let fx = Fixture::new();
987 let file = "---\nx: 1\n---\n## Sec\nbody1\n# NewTitle\nafter\n";
989 fx.write("records/notes/n.md", file);
990
991 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
992 assert_eq!(headings(&o), vec![("Sec".to_string(), 2, 1)]);
993 assert_eq!(
994 o.sections[0].body, "## Sec\nbody1\n",
995 "the level-1 heading is shallower and ends the section, and is itself not a section"
996 );
997 }
998
999 #[test]
1000 fn outline_ignores_headings_inside_fenced_code_blocks() {
1001 let fx = Fixture::new();
1002 let file = "---\nx: 1\n---\n## Real\n```\n## fake heading in code\n### also fake\n```\nafter\n## AlsoReal\n";
1003 fx.write("records/notes/n.md", file);
1004
1005 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1006 assert_eq!(
1009 headings(&o),
1010 vec![("Real".to_string(), 2, 1), ("AlsoReal".to_string(), 2, 7)],
1011 "## inside a ``` fence is code, not a heading"
1012 );
1013 assert!(o.sections[0].body.contains("## fake heading in code"));
1015 }
1016
1017 #[test]
1018 fn outline_ignores_tilde_fences_too() {
1019 let fx = Fixture::new();
1020 let file = "---\nx: 1\n---\n## Real\n~~~\n## fake\n~~~\ntail\n";
1021 fx.write("records/notes/n.md", file);
1022
1023 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1024 assert_eq!(headings(&o), vec![("Real".to_string(), 2, 1)]);
1025 }
1026
1027 #[test]
1028 fn outline_rejects_non_heading_hash_lines() {
1029 let fx = Fixture::new();
1030 let file = "---\nx: 1\n---\n#nospace\n####### sevenhashes\n## Good\n";
1032 fx.write("records/notes/n.md", file);
1033
1034 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1035 assert_eq!(
1036 headings(&o),
1037 vec![("Good".to_string(), 2, 3)],
1038 "only the well-formed ## heading counts"
1039 );
1040 }
1041
1042 #[test]
1043 fn outline_strips_atx_closing_hashes_from_heading_text() {
1044 let fx = Fixture::new();
1045 let file = "---\nx: 1\n---\n## Title ##\n";
1046 fx.write("records/notes/n.md", file);
1047
1048 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1049 assert_eq!(o.sections[0].heading, "Title");
1050 }
1051
1052 #[test]
1053 fn outline_keeps_unspaced_trailing_hash_in_heading_text() {
1054 let fx = Fixture::new();
1059 let file = "---\nx: 1\n---\n## C#\n## F#\n## Ada ##\n## ##\n";
1060 fx.write("records/notes/langs.md", file);
1061
1062 let o = outline(&fx.store, Path::new("records/notes/langs.md")).expect("outline");
1063 let texts: Vec<String> = o.sections.iter().map(|s| s.heading.clone()).collect();
1064 assert_eq!(
1065 texts,
1066 vec![
1067 "C#".to_string(),
1068 "F#".to_string(),
1069 "Ada".to_string(),
1070 "".to_string(),
1071 ],
1072 "unspaced trailing # stays; a space-preceded # run is a closing fence"
1073 );
1074 }
1075
1076 #[test]
1077 fn outline_handles_file_without_frontmatter_numbering_from_line_one() {
1078 let fx = Fixture::new();
1079 let file = "## First\ntext\n## Second\n";
1081 fx.write("records/notes/n.md", file);
1082
1083 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1084 assert_eq!(
1085 headings(&o),
1086 vec![("First".to_string(), 2, 1), ("Second".to_string(), 2, 3)],
1087 "with no frontmatter the body is the whole file and lines count from 1"
1088 );
1089 }
1090
1091 #[test]
1092 fn outline_accepts_absolute_path_and_returns_store_relative_file() {
1093 let fx = Fixture::new();
1094 fx.write("records/contacts/x.md", "---\nx: 1\n---\n## H\n");
1095 let abs = fx.store.root.join("records/contacts/x.md");
1096
1097 let o = outline(&fx.store, &abs).expect("outline");
1098 assert_eq!(
1099 o.file,
1100 PathBuf::from("records/contacts/x.md"),
1101 "an absolute input path is normalized to store-relative in the Outline"
1102 );
1103 assert_eq!(o.sections.len(), 1);
1104 }
1105
1106 #[test]
1107 fn outline_of_a_file_with_no_headings_is_empty() {
1108 let fx = Fixture::new();
1109 fx.write(
1110 "records/notes/n.md",
1111 "---\nx: 1\n---\njust prose, no headings\n",
1112 );
1113
1114 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1115 assert!(
1116 o.sections.is_empty(),
1117 "a heading-free body yields no sections"
1118 );
1119 }
1120
1121 #[test]
1122 fn outline_missing_file_is_an_io_error() {
1123 let fx = Fixture::new();
1124 let err = outline(&fx.store, Path::new("records/notes/does-not-exist.md"))
1125 .expect_err("missing file should error");
1126 assert!(
1127 matches!(err, StoreError::Io(_)),
1128 "a missing file surfaces as a StoreError::Io, got {err:?}"
1129 );
1130 }
1131
1132 #[test]
1133 fn outline_handles_crlf_frontmatter_and_indented_headings() {
1134 let fx = Fixture::new();
1135 let file = "---\r\nx: 1\r\n---\r\n ## Indented3\nbody\n ## Indented4Code\n";
1138 fx.write("records/notes/n.md", file);
1139
1140 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1141 assert_eq!(
1142 headings(&o),
1143 vec![("Indented3".to_string(), 2, 1)],
1144 "<=3 leading spaces is a heading; 4 spaces is indented code, not a heading"
1145 );
1146 }
1147}