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 Layer::Wiki => "wiki",
146 }
147}
148
149fn is_skipped_dir(name: &str) -> bool {
152 name == "log" || name.starts_with('.')
153}
154
155fn is_content_md(name: &str) -> bool {
159 name.ends_with(".md") && name != "index.md"
160}
161
162fn collect_content_files(
166 store_root: &Path,
167 dir: &Path,
168 out: &mut Vec<PathBuf>,
169) -> Result<(), StoreError> {
170 for entry in std::fs::read_dir(dir)? {
171 let entry = entry?;
172 let file_type = entry.file_type()?;
173 let name = entry.file_name().to_string_lossy().into_owned();
174
175 if file_type.is_dir() {
176 if name.starts_with('.') {
177 continue;
178 }
179 collect_content_files(store_root, &entry.path(), out)?;
180 } else if file_type.is_file() && is_content_md(&name) {
181 let abs = entry.path();
182 let rel = abs.strip_prefix(store_root).unwrap_or(&abs).to_path_buf();
183 out.push(rel);
184 }
185 }
186 Ok(())
187}
188
189fn file_type_matches(store_root: &Path, rel: &Path, want: &str) -> bool {
198 let abs = store_root.join(rel);
199 let text = match std::fs::read_to_string(&abs) {
200 Ok(t) => t,
201 Err(_) => return false,
202 };
203 frontmatter_type(&text).as_deref() == Some(want)
204}
205
206fn frontmatter_type(text: &str) -> Option<String> {
210 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
211 let mut lines = text.lines();
212 if lines.next()?.trim_end() != "---" {
213 return None;
214 }
215 let mut yaml = String::new();
216 let mut closed = false;
217 for line in lines {
218 if line.trim_end() == "---" {
219 closed = true;
220 break;
221 }
222 yaml.push_str(line);
223 yaml.push('\n');
224 }
225 if !closed {
226 return None;
227 }
228 let value: serde_norway::Value = serde_norway::from_str(&yaml).ok()?;
229 let s = value
230 .as_mapping()?
231 .get(serde_norway::Value::String("type".to_string()))?
232 .as_str()?
233 .trim();
234 if s.is_empty() {
235 None
236 } else {
237 Some(s.to_string())
238 }
239}
240
241pub fn outline(store: &Store, file: &Path) -> Result<Outline, StoreError> {
252 let abs = if file.is_absolute() {
253 file.to_path_buf()
254 } else {
255 store.root.join(file)
256 };
257
258 let rel = abs.strip_prefix(&store.root).unwrap_or(file).to_path_buf();
259
260 let text = std::fs::read_to_string(&abs)?;
261 let body = strip_frontmatter(&text);
262 let sections = parse_sections(body);
263
264 Ok(Outline {
265 file: rel,
266 sections,
267 })
268}
269
270fn strip_frontmatter(text: &str) -> &str {
276 let after_open = match text.strip_prefix("---\n") {
278 Some(rest) => rest,
279 None => match text.strip_prefix("---\r\n") {
280 Some(rest) => rest,
281 None => return text,
282 },
283 };
284
285 let mut search_from = 0usize;
287 while let Some(rel_idx) = after_open[search_from..].find("---") {
288 let idx = search_from + rel_idx;
289 let at_line_start = idx == 0 || after_open.as_bytes()[idx - 1] == b'\n';
290 let after = &after_open[idx + 3..];
291 let line_ends = after.is_empty()
292 || after.starts_with('\n')
293 || after.starts_with("\r\n")
294 || after.starts_with('\r');
295 if at_line_start && line_ends {
296 if let Some(stripped) = after.strip_prefix("\r\n") {
298 return stripped;
299 }
300 if let Some(stripped) = after.strip_prefix('\n') {
301 return stripped;
302 }
303 if let Some(stripped) = after.strip_prefix('\r') {
304 return stripped;
305 }
306 return after; }
308 search_from = idx + 3;
309 }
310
311 text
313}
314
315fn parse_sections(body: &str) -> Vec<Section> {
320 let lines: Vec<&str> = body.split_inclusive('\n').collect();
323
324 let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
327 let mut fence: Option<(u8, usize)> = None; for line in &lines {
329 let content = line.trim_end_matches(['\n', '\r']);
330 if let Some(f) = fence {
331 if is_closing_fence(content, f) {
332 fence = None;
333 }
334 levels.push(0);
335 continue;
336 }
337 if let Some(opened) = opening_fence(content) {
338 fence = Some(opened);
339 levels.push(0);
340 continue;
341 }
342 levels.push(heading_level(content));
343 }
344
345 let mut sections = Vec::new();
349 for (i, &lvl) in levels.iter().enumerate() {
350 if lvl < 2 {
351 continue;
352 }
353 let heading_line = lines[i].trim_end_matches(['\n', '\r']);
354 let heading = heading_text(heading_line, lvl);
355
356 let mut end = lines.len();
357 for (j, &other) in levels.iter().enumerate().skip(i + 1) {
358 if other != 0 && other <= lvl {
359 end = j;
360 break;
361 }
362 }
363
364 let body_slice: String = lines[i..end].concat();
365
366 sections.push(Section {
367 heading,
368 level: lvl,
369 line: (i + 1) as u32,
370 body: body_slice,
371 });
372 }
373
374 sections
375}
376
377fn heading_level(line: &str) -> u8 {
381 let indent = line.len() - line.trim_start_matches(' ').len();
382 if indent > 3 {
383 return 0;
384 }
385 let rest = &line[indent..];
386 let hashes = rest.len() - rest.trim_start_matches('#').len();
387 if hashes == 0 || hashes > 6 {
388 return 0;
389 }
390 let after = &rest[hashes..];
391 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
392 hashes as u8
393 } else {
394 0
395 }
396}
397
398fn heading_text(line: &str, level: u8) -> String {
407 let indent = line.len() - line.trim_start_matches(' ').len();
408 let after_hashes = &line[indent + level as usize..];
409 let trimmed = after_hashes.trim();
410 let trailing_hashes = trimmed.len() - trimmed.trim_end_matches('#').len();
412 if trailing_hashes == 0 {
413 return trimmed.to_string();
414 }
415 let before_run = &trimmed[..trimmed.len() - trailing_hashes];
416 if before_run.is_empty() || before_run.ends_with([' ', '\t']) {
420 before_run.trim_end().to_string()
421 } else {
422 trimmed.to_string()
423 }
424}
425
426fn opening_fence(line: &str) -> Option<(u8, usize)> {
430 let indent = line.len() - line.trim_start_matches(' ').len();
431 if indent > 3 {
432 return None;
433 }
434 let rest = &line[indent..];
435 let byte = rest.bytes().next()?;
436 if byte != b'`' && byte != b'~' {
437 return None;
438 }
439 let run = rest.len() - rest.trim_start_matches(byte as char).len();
440 if run < 3 {
441 return None;
442 }
443 if byte == b'`' && rest[run..].contains('`') {
445 return None;
446 }
447 Some((byte, run))
448}
449
450fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
453 let (byte, open_len) = fence;
454 let indent = line.len() - line.trim_start_matches(' ').len();
455 if indent > 3 {
456 return false;
457 }
458 let rest = &line[indent..];
459 let run = rest.len() - rest.trim_start_matches(byte as char).len();
460 if run < open_len {
461 return false;
462 }
463 rest[run..].trim().is_empty()
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::parser::Config;
470 use std::fs;
471 use tempfile::TempDir;
472
473 struct Fixture {
481 _dir: TempDir,
482 store: Store,
483 }
484
485 impl Fixture {
486 fn new() -> Self {
487 let dir = tempfile::tempdir().expect("tempdir");
488 fs::write(dir.path().join("DB.md"), "---\ntype: db\n---\n").expect("write DB.md");
490 let store = Store {
491 root: dir.path().to_path_buf(),
492 config: Config::default(),
493 };
494 Fixture { _dir: dir, store }
495 }
496
497 fn write(&self, rel: &str, contents: &str) {
499 let abs = self.store.root.join(rel);
500 if let Some(parent) = abs.parent() {
501 fs::create_dir_all(parent).expect("create parents");
502 }
503 fs::write(abs, contents).expect("write file");
504 }
505
506 fn mkdir(&self, rel: &str) {
507 fs::create_dir_all(self.store.root.join(rel)).expect("mkdir");
508 }
509 }
510
511 fn doc(summary: &str) -> String {
513 format!("---\ntype: contact\nsummary: {summary}\n---\n\nbody\n")
514 }
515
516 fn shape(tree: &Tree) -> Vec<(Layer, String, Vec<String>)> {
519 let mut out = Vec::new();
520 for layer in &tree.layers {
521 for tf in &layer.type_folders {
522 let files = tf
523 .files
524 .iter()
525 .map(|p| p.to_string_lossy().into_owned())
526 .collect();
527 out.push((layer.layer, tf.path.to_string_lossy().into_owned(), files));
528 }
529 }
530 out
531 }
532
533 #[test]
536 fn tree_groups_by_layer_then_type_folder_in_canonical_order() {
537 let fx = Fixture::new();
538 fx.write("wiki/people/sarah.md", &doc("sarah bio"));
542 fx.write("records/contacts/sarah-chen.md", &doc("sarah contact"));
543 fx.write("sources/emails/a.md", &doc("an email"));
544
545 let tree = tree(&fx.store, None, None).expect("tree");
546 let layer_order: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
547 assert_eq!(
548 layer_order,
549 vec![Layer::Sources, Layer::Records, Layer::Wiki],
550 "layers must come back in canonical order regardless of on-disk name order"
551 );
552
553 assert_eq!(
554 shape(&tree),
555 vec![
556 (
557 Layer::Sources,
558 "sources/emails".to_string(),
559 vec!["sources/emails/a.md".to_string()]
560 ),
561 (
562 Layer::Records,
563 "records/contacts".to_string(),
564 vec!["records/contacts/sarah-chen.md".to_string()]
565 ),
566 (
567 Layer::Wiki,
568 "wiki/people".to_string(),
569 vec!["wiki/people/sarah.md".to_string()]
570 ),
571 ]
572 );
573 }
574
575 #[test]
576 fn tree_type_folders_and_files_are_sorted_ascending() {
577 let fx = Fixture::new();
578 fx.write("records/expenses/z.md", &doc("z"));
580 fx.write("records/contacts/b.md", &doc("b"));
581 fx.write("records/contacts/a.md", &doc("a"));
582
583 let tree = tree(&fx.store, None, None).expect("tree");
584 let records = tree
585 .layers
586 .iter()
587 .find(|l| l.layer == Layer::Records)
588 .expect("records layer");
589
590 let folder_paths: Vec<String> = records
591 .type_folders
592 .iter()
593 .map(|tf| tf.path.to_string_lossy().into_owned())
594 .collect();
595 assert_eq!(
596 folder_paths,
597 vec![
598 "records/contacts".to_string(),
599 "records/expenses".to_string()
600 ],
601 "type-folders sorted by path ascending"
602 );
603
604 let contacts = &records.type_folders[0];
605 let files: Vec<String> = contacts
606 .files
607 .iter()
608 .map(|p| p.to_string_lossy().into_owned())
609 .collect();
610 assert_eq!(
611 files,
612 vec![
613 "records/contacts/a.md".to_string(),
614 "records/contacts/b.md".to_string()
615 ],
616 "files sorted by store-relative path ascending"
617 );
618 }
619
620 #[test]
621 fn tree_aggregates_files_across_date_shards_into_one_type_folder() {
622 let fx = Fixture::new();
623 fx.write("sources/emails/2026/05/newer.md", &doc("newer"));
624 fx.write("sources/emails/2026/04/older.md", &doc("older"));
625 fx.write("sources/emails/loose.md", &doc("loose at folder root"));
626
627 let tree = tree(&fx.store, None, None).expect("tree");
628 let emails: Vec<&TreeTypeFolder> = tree
629 .layers
630 .iter()
631 .flat_map(|l| &l.type_folders)
632 .filter(|tf| tf.path == Path::new("sources/emails"))
633 .collect();
634
635 assert_eq!(
636 emails.len(),
637 1,
638 "all shards of one type fold into a single type-folder branch, not one per shard"
639 );
640 let files: Vec<String> = emails[0]
641 .files
642 .iter()
643 .map(|p| p.to_string_lossy().into_owned())
644 .collect();
645 assert_eq!(
646 files,
647 vec![
648 "sources/emails/2026/04/older.md".to_string(),
649 "sources/emails/2026/05/newer.md".to_string(),
650 "sources/emails/loose.md".to_string(),
651 ],
652 "every file under the type-folder, across shards, appears once"
653 );
654 }
655
656 #[test]
657 fn tree_excludes_index_and_log_and_db_meta_files() {
658 let fx = Fixture::new();
659 fx.write("records/contacts/sarah.md", &doc("sarah"));
661 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");
670 let all_files: Vec<String> = tree
671 .layers
672 .iter()
673 .flat_map(|l| &l.type_folders)
674 .flat_map(|tf| &tf.files)
675 .map(|p| p.to_string_lossy().into_owned())
676 .collect();
677
678 assert_eq!(
679 all_files,
680 vec!["records/contacts/sarah.md".to_string()],
681 "only the real content file survives; no index.md/index.jsonl/log files"
682 );
683 assert!(tree
685 .layers
686 .iter()
687 .all(|l| matches!(l.layer, Layer::Sources | Layer::Records | Layer::Wiki)));
688 }
689
690 #[test]
691 fn tree_omits_empty_layers_and_empty_type_folders() {
692 let fx = Fixture::new();
693 fx.write("records/contacts/a.md", &doc("a"));
694 fx.mkdir("records/companies");
696 fx.mkdir("wiki");
698 fx.write("sources/emails/index.md", "---\ntype: index\n---\n");
700
701 let tree = tree(&fx.store, None, None).expect("tree");
702
703 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
704 assert_eq!(
705 layers,
706 vec![Layer::Records],
707 "empty wiki layer and meta-only sources layer are omitted"
708 );
709 let folders: Vec<String> = tree.layers[0]
710 .type_folders
711 .iter()
712 .map(|tf| tf.path.to_string_lossy().into_owned())
713 .collect();
714 assert_eq!(
715 folders,
716 vec!["records/contacts".to_string()],
717 "the empty companies type-folder is omitted"
718 );
719 }
720
721 #[test]
722 fn tree_layer_filter_restricts_to_one_layer() {
723 let fx = Fixture::new();
724 fx.write("sources/emails/a.md", &doc("a"));
725 fx.write("records/contacts/b.md", &doc("b"));
726 fx.write("wiki/people/c.md", &doc("c"));
727
728 let tree = tree(&fx.store, Some(Layer::Records), None).expect("tree");
729 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
730 assert_eq!(
731 layers,
732 vec![Layer::Records],
733 "only the requested layer is walked"
734 );
735 }
736
737 fn typed(type_: &str, summary: &str) -> String {
739 format!("---\ntype: {type_}\nsummary: {summary}\n---\n\nbody\n")
740 }
741
742 #[test]
743 fn tree_type_filter_matches_frontmatter_type_across_layers() {
744 let fx = Fixture::new();
745 fx.write("sources/inbox/s.md", &typed("note", "source note"));
748 fx.write("wiki/scratch/w.md", &typed("note", "wiki note"));
749 fx.write("records/contacts/c.md", &typed("contact", "contact"));
750
751 let tree = tree(&fx.store, None, Some("note")).expect("tree");
752 let files: Vec<String> = tree
753 .layers
754 .iter()
755 .flat_map(|l| &l.type_folders)
756 .flat_map(|tf| &tf.files)
757 .map(|p| p.to_string_lossy().into_owned())
758 .collect();
759 assert_eq!(
760 files,
761 vec![
762 "sources/inbox/s.md".to_string(),
763 "wiki/scratch/w.md".to_string()
764 ],
765 "type filter matches the frontmatter type across layers, regardless of folder name"
766 );
767 }
768
769 #[test]
770 fn tree_type_filter_uses_frontmatter_type_not_folder_name() {
771 let fx = Fixture::new();
776 fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
777 fx.write("wiki/people/sarah.md", &typed("wiki-page", "sarah bio"));
778
779 let by_type = tree(&fx.store, None, Some("contact")).expect("tree");
781 let files: Vec<String> = by_type
782 .layers
783 .iter()
784 .flat_map(|l| &l.type_folders)
785 .flat_map(|tf| &tf.files)
786 .map(|p| p.to_string_lossy().into_owned())
787 .collect();
788 assert_eq!(
789 files,
790 vec!["records/contacts/sarah.md".to_string()],
791 "--type contact lists the contact in the pluralized canonical folder"
792 );
793
794 let by_folder_name = tree(&fx.store, None, Some("contacts")).expect("tree");
796 assert!(
797 by_folder_name.layers.is_empty(),
798 "the folder directory name is not the frontmatter type and must not match"
799 );
800
801 let wiki = tree(&fx.store, None, Some("wiki-page")).expect("tree");
803 let wiki_files: Vec<String> = wiki
804 .layers
805 .iter()
806 .flat_map(|l| &l.type_folders)
807 .flat_map(|tf| &tf.files)
808 .map(|p| p.to_string_lossy().into_owned())
809 .collect();
810 assert_eq!(
811 wiki_files,
812 vec!["wiki/people/sarah.md".to_string()],
813 "--type wiki-page matches the frontmatter type under a topic folder"
814 );
815 }
816
817 #[test]
818 fn tree_type_filter_skips_untyped_and_unmatched_files() {
819 let fx = Fixture::new();
822 fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
823 fx.write("records/contacts/no-type.md", "no frontmatter at all\n");
824 fx.write("records/contacts/other.md", &typed("company", "acme"));
825
826 let tree = tree(&fx.store, None, Some("contact")).expect("tree");
827 let files: Vec<String> = tree
828 .layers
829 .iter()
830 .flat_map(|l| &l.type_folders)
831 .flat_map(|tf| &tf.files)
832 .map(|p| p.to_string_lossy().into_owned())
833 .collect();
834 assert_eq!(
835 files,
836 vec!["records/contacts/sarah.md".to_string()],
837 "only the file whose frontmatter type matches survives; untyped/other are skipped"
838 );
839 }
840
841 #[test]
842 fn tree_excludes_loose_files_directly_under_a_layer() {
843 let fx = Fixture::new();
844 fx.write("records/contacts/real.md", &doc("real"));
845 fx.write("records/stray.md", &doc("stray"));
847
848 let tree = tree(&fx.store, None, None).expect("tree");
849 let all_files: Vec<String> = tree
850 .layers
851 .iter()
852 .flat_map(|l| &l.type_folders)
853 .flat_map(|tf| &tf.files)
854 .map(|p| p.to_string_lossy().into_owned())
855 .collect();
856 assert_eq!(
857 all_files,
858 vec!["records/contacts/real.md".to_string()],
859 "a layer-direct file has no type-folder slot and is not listed"
860 );
861 }
862
863 #[test]
864 fn tree_skips_hidden_directories() {
865 let fx = Fixture::new();
866 fx.write("records/contacts/a.md", &doc("a"));
867 fx.write(".git/objects/x.md", &doc("vcs junk"));
869 fx.write("records/.hidden/h.md", &doc("hidden type folder"));
870 fx.write("sources/emails/.tmp/draft.md", &doc("hidden shard"));
871
872 let tree = tree(&fx.store, None, None).expect("tree");
873 let all_files: Vec<String> = tree
874 .layers
875 .iter()
876 .flat_map(|l| &l.type_folders)
877 .flat_map(|tf| &tf.files)
878 .map(|p| p.to_string_lossy().into_owned())
879 .collect();
880 assert_eq!(
881 all_files,
882 vec!["records/contacts/a.md".to_string()],
883 "hidden dirs are skipped at the type-folder and shard levels"
884 );
885 }
886
887 #[test]
888 fn tree_paths_are_store_relative_not_absolute() {
889 let fx = Fixture::new();
890 fx.write("records/contacts/a.md", &doc("a"));
891
892 let tree = tree(&fx.store, None, None).expect("tree");
893 let tf = &tree.layers[0].type_folders[0];
894 assert!(
895 tf.path.is_relative() && tf.files[0].is_relative(),
896 "tree paths must be store-relative"
897 );
898 let root_str = fx.store.root.to_string_lossy().into_owned();
900 assert!(!tf.files[0].to_string_lossy().contains(&root_str));
901 }
902
903 #[test]
904 fn tree_on_store_with_no_layers_is_empty() {
905 let fx = Fixture::new(); let tree = tree(&fx.store, None, None).expect("tree");
907 assert!(
908 tree.layers.is_empty(),
909 "a store with no content has an empty tree"
910 );
911 }
912
913 fn headings(o: &Outline) -> Vec<(String, u8, u32)> {
917 o.sections
918 .iter()
919 .map(|s| (s.heading.clone(), s.level, s.line))
920 .collect()
921 }
922
923 #[test]
924 fn outline_extracts_sections_with_levels_and_body_relative_lines() {
925 let fx = Fixture::new();
926 let file = "---\ntype: note\nsummary: s\n---\n\n# Title\n\n## Alpha\ntext\n### Sub\nmore\n## Beta\nend\n";
930 fx.write("wiki/notes/n.md", file);
931
932 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
933 assert_eq!(
934 headings(&o),
935 vec![
936 ("Alpha".to_string(), 2, 4),
937 ("Sub".to_string(), 3, 6),
938 ("Beta".to_string(), 2, 8),
939 ],
940 "only ##+ headings, with body-relative 1-based line numbers; the # title is not a section"
941 );
942 assert_eq!(o.file, PathBuf::from("wiki/notes/n.md"));
943 }
944
945 #[test]
946 fn outline_section_body_spans_to_next_sibling_or_shallower_heading() {
947 let fx = Fixture::new();
948 let file = "---\nx: 1\n---\n## Alpha\na1\na2\n### Sub\ns1\n## Beta\nb1\n";
949 fx.write("wiki/notes/n.md", file);
950
951 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
952 let alpha = &o.sections[0];
953 assert_eq!(alpha.heading, "Alpha");
955 assert_eq!(
956 alpha.body, "## Alpha\na1\na2\n### Sub\ns1\n",
957 "a ## body runs through deeper headings up to the next sibling-or-shallower heading"
958 );
959
960 let sub = &o.sections[1];
961 assert_eq!(sub.heading, "Sub");
962 assert_eq!(
963 sub.body, "### Sub\ns1\n",
964 "the nested ### body stops at the next ## (shallower) heading"
965 );
966
967 let beta = &o.sections[2];
968 assert_eq!(
969 beta.body, "## Beta\nb1\n",
970 "the trailing ## body runs to end of file"
971 );
972 }
973
974 #[test]
975 fn outline_shallower_heading_terminates_a_section_body() {
976 let fx = Fixture::new();
977 let file = "---\nx: 1\n---\n## Sec\nbody1\n# NewTitle\nafter\n";
979 fx.write("wiki/notes/n.md", file);
980
981 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
982 assert_eq!(headings(&o), vec![("Sec".to_string(), 2, 1)]);
983 assert_eq!(
984 o.sections[0].body, "## Sec\nbody1\n",
985 "the level-1 heading is shallower and ends the section, and is itself not a section"
986 );
987 }
988
989 #[test]
990 fn outline_ignores_headings_inside_fenced_code_blocks() {
991 let fx = Fixture::new();
992 let file = "---\nx: 1\n---\n## Real\n```\n## fake heading in code\n### also fake\n```\nafter\n## AlsoReal\n";
993 fx.write("wiki/notes/n.md", file);
994
995 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
996 assert_eq!(
999 headings(&o),
1000 vec![("Real".to_string(), 2, 1), ("AlsoReal".to_string(), 2, 7)],
1001 "## inside a ``` fence is code, not a heading"
1002 );
1003 assert!(o.sections[0].body.contains("## fake heading in code"));
1005 }
1006
1007 #[test]
1008 fn outline_ignores_tilde_fences_too() {
1009 let fx = Fixture::new();
1010 let file = "---\nx: 1\n---\n## Real\n~~~\n## fake\n~~~\ntail\n";
1011 fx.write("wiki/notes/n.md", file);
1012
1013 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1014 assert_eq!(headings(&o), vec![("Real".to_string(), 2, 1)]);
1015 }
1016
1017 #[test]
1018 fn outline_rejects_non_heading_hash_lines() {
1019 let fx = Fixture::new();
1020 let file = "---\nx: 1\n---\n#nospace\n####### sevenhashes\n## Good\n";
1022 fx.write("wiki/notes/n.md", file);
1023
1024 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1025 assert_eq!(
1026 headings(&o),
1027 vec![("Good".to_string(), 2, 3)],
1028 "only the well-formed ## heading counts"
1029 );
1030 }
1031
1032 #[test]
1033 fn outline_strips_atx_closing_hashes_from_heading_text() {
1034 let fx = Fixture::new();
1035 let file = "---\nx: 1\n---\n## Title ##\n";
1036 fx.write("wiki/notes/n.md", file);
1037
1038 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1039 assert_eq!(o.sections[0].heading, "Title");
1040 }
1041
1042 #[test]
1043 fn outline_keeps_unspaced_trailing_hash_in_heading_text() {
1044 let fx = Fixture::new();
1049 let file = "---\nx: 1\n---\n## C#\n## F#\n## Ada ##\n## ##\n";
1050 fx.write("wiki/notes/langs.md", file);
1051
1052 let o = outline(&fx.store, Path::new("wiki/notes/langs.md")).expect("outline");
1053 let texts: Vec<String> = o.sections.iter().map(|s| s.heading.clone()).collect();
1054 assert_eq!(
1055 texts,
1056 vec![
1057 "C#".to_string(),
1058 "F#".to_string(),
1059 "Ada".to_string(),
1060 "".to_string(),
1061 ],
1062 "unspaced trailing # stays; a space-preceded # run is a closing fence"
1063 );
1064 }
1065
1066 #[test]
1067 fn outline_handles_file_without_frontmatter_numbering_from_line_one() {
1068 let fx = Fixture::new();
1069 let file = "## First\ntext\n## Second\n";
1071 fx.write("wiki/notes/n.md", file);
1072
1073 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1074 assert_eq!(
1075 headings(&o),
1076 vec![("First".to_string(), 2, 1), ("Second".to_string(), 2, 3)],
1077 "with no frontmatter the body is the whole file and lines count from 1"
1078 );
1079 }
1080
1081 #[test]
1082 fn outline_accepts_absolute_path_and_returns_store_relative_file() {
1083 let fx = Fixture::new();
1084 fx.write("records/contacts/x.md", "---\nx: 1\n---\n## H\n");
1085 let abs = fx.store.root.join("records/contacts/x.md");
1086
1087 let o = outline(&fx.store, &abs).expect("outline");
1088 assert_eq!(
1089 o.file,
1090 PathBuf::from("records/contacts/x.md"),
1091 "an absolute input path is normalized to store-relative in the Outline"
1092 );
1093 assert_eq!(o.sections.len(), 1);
1094 }
1095
1096 #[test]
1097 fn outline_of_a_file_with_no_headings_is_empty() {
1098 let fx = Fixture::new();
1099 fx.write(
1100 "wiki/notes/n.md",
1101 "---\nx: 1\n---\njust prose, no headings\n",
1102 );
1103
1104 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1105 assert!(
1106 o.sections.is_empty(),
1107 "a heading-free body yields no sections"
1108 );
1109 }
1110
1111 #[test]
1112 fn outline_missing_file_is_an_io_error() {
1113 let fx = Fixture::new();
1114 let err = outline(&fx.store, Path::new("wiki/notes/does-not-exist.md"))
1115 .expect_err("missing file should error");
1116 assert!(
1117 matches!(err, StoreError::Io(_)),
1118 "a missing file surfaces as a StoreError::Io, got {err:?}"
1119 );
1120 }
1121
1122 #[test]
1123 fn outline_handles_crlf_frontmatter_and_indented_headings() {
1124 let fx = Fixture::new();
1125 let file = "---\r\nx: 1\r\n---\r\n ## Indented3\nbody\n ## Indented4Code\n";
1128 fx.write("wiki/notes/n.md", file);
1129
1130 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
1131 assert_eq!(
1132 headings(&o),
1133 vec![("Indented3".to_string(), 2, 1)],
1134 "<=3 leading spaces is a heading; 4 spaces is indented code, not a heading"
1135 );
1136 }
1137}