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
376fn heading_level(line: &str) -> u8 {
380 let indent = line.len() - line.trim_start_matches(' ').len();
381 if indent > 3 {
382 return 0;
383 }
384 let rest = &line[indent..];
385 let hashes = rest.len() - rest.trim_start_matches('#').len();
386 if hashes == 0 || hashes > 6 {
387 return 0;
388 }
389 let after = &rest[hashes..];
390 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
391 hashes as u8
392 } else {
393 0
394 }
395}
396
397fn heading_text(line: &str, level: u8) -> String {
406 let indent = line.len() - line.trim_start_matches(' ').len();
407 let after_hashes = &line[indent + level as usize..];
408 let trimmed = after_hashes.trim();
409 let trailing_hashes = trimmed.len() - trimmed.trim_end_matches('#').len();
411 if trailing_hashes == 0 {
412 return trimmed.to_string();
413 }
414 let before_run = &trimmed[..trimmed.len() - trailing_hashes];
415 if before_run.is_empty() || before_run.ends_with([' ', '\t']) {
419 before_run.trim_end().to_string()
420 } else {
421 trimmed.to_string()
422 }
423}
424
425fn opening_fence(line: &str) -> Option<(u8, usize)> {
429 let indent = line.len() - line.trim_start_matches(' ').len();
430 if indent > 3 {
431 return None;
432 }
433 let rest = &line[indent..];
434 let byte = rest.bytes().next()?;
435 if byte != b'`' && byte != b'~' {
436 return None;
437 }
438 let run = rest.len() - rest.trim_start_matches(byte as char).len();
439 if run < 3 {
440 return None;
441 }
442 if byte == b'`' && rest[run..].contains('`') {
444 return None;
445 }
446 Some((byte, run))
447}
448
449fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
452 let (byte, open_len) = fence;
453 let indent = line.len() - line.trim_start_matches(' ').len();
454 if indent > 3 {
455 return false;
456 }
457 let rest = &line[indent..];
458 let run = rest.len() - rest.trim_start_matches(byte as char).len();
459 if run < open_len {
460 return false;
461 }
462 rest[run..].trim().is_empty()
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use crate::parser::Config;
469 use std::fs;
470 use tempfile::TempDir;
471
472 struct Fixture {
480 _dir: TempDir,
481 store: Store,
482 }
483
484 impl Fixture {
485 fn new() -> Self {
486 let dir = tempfile::tempdir().expect("tempdir");
487 fs::write(dir.path().join("DB.md"), "---\ntype: db\n---\n").expect("write DB.md");
489 let store = Store {
490 root: dir.path().to_path_buf(),
491 config: Config::default(),
492 };
493 Fixture { _dir: dir, store }
494 }
495
496 fn write(&self, rel: &str, contents: &str) {
498 let abs = self.store.root.join(rel);
499 if let Some(parent) = abs.parent() {
500 fs::create_dir_all(parent).expect("create parents");
501 }
502 fs::write(abs, contents).expect("write file");
503 }
504
505 fn mkdir(&self, rel: &str) {
506 fs::create_dir_all(self.store.root.join(rel)).expect("mkdir");
507 }
508 }
509
510 fn doc(summary: &str) -> String {
512 format!("---\ntype: contact\nsummary: {summary}\n---\n\nbody\n")
513 }
514
515 fn shape(tree: &Tree) -> Vec<(Layer, String, Vec<String>)> {
518 let mut out = Vec::new();
519 for layer in &tree.layers {
520 for tf in &layer.type_folders {
521 let files = tf
522 .files
523 .iter()
524 .map(|p| p.to_string_lossy().into_owned())
525 .collect();
526 out.push((layer.layer, tf.path.to_string_lossy().into_owned(), files));
527 }
528 }
529 out
530 }
531
532 #[test]
535 fn tree_groups_by_layer_then_type_folder_in_canonical_order() {
536 let fx = Fixture::new();
537 fx.write("records/profiles/sarah.md", &doc("sarah bio"));
543 fx.write("records/contacts/sarah-chen.md", &doc("sarah contact"));
544 fx.write("sources/emails/a.md", &doc("an email"));
545
546 let tree = tree(&fx.store, None, None).expect("tree");
547 let layer_order: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
548 assert_eq!(
549 layer_order,
550 vec![Layer::Sources, Layer::Records],
551 "layers must come back in canonical order regardless of on-disk name order"
552 );
553
554 assert_eq!(
555 shape(&tree),
556 vec![
557 (
558 Layer::Sources,
559 "sources/emails".to_string(),
560 vec!["sources/emails/a.md".to_string()]
561 ),
562 (
563 Layer::Records,
564 "records/contacts".to_string(),
565 vec!["records/contacts/sarah-chen.md".to_string()]
566 ),
567 (
568 Layer::Records,
569 "records/profiles".to_string(),
570 vec!["records/profiles/sarah.md".to_string()]
571 ),
572 ]
573 );
574 }
575
576 #[test]
577 fn tree_type_folders_and_files_are_sorted_ascending() {
578 let fx = Fixture::new();
579 fx.write("records/expenses/z.md", &doc("z"));
581 fx.write("records/contacts/b.md", &doc("b"));
582 fx.write("records/contacts/a.md", &doc("a"));
583
584 let tree = tree(&fx.store, None, None).expect("tree");
585 let records = tree
586 .layers
587 .iter()
588 .find(|l| l.layer == Layer::Records)
589 .expect("records layer");
590
591 let folder_paths: Vec<String> = records
592 .type_folders
593 .iter()
594 .map(|tf| tf.path.to_string_lossy().into_owned())
595 .collect();
596 assert_eq!(
597 folder_paths,
598 vec![
599 "records/contacts".to_string(),
600 "records/expenses".to_string()
601 ],
602 "type-folders sorted by path ascending"
603 );
604
605 let contacts = &records.type_folders[0];
606 let files: Vec<String> = contacts
607 .files
608 .iter()
609 .map(|p| p.to_string_lossy().into_owned())
610 .collect();
611 assert_eq!(
612 files,
613 vec![
614 "records/contacts/a.md".to_string(),
615 "records/contacts/b.md".to_string()
616 ],
617 "files sorted by store-relative path ascending"
618 );
619 }
620
621 #[test]
622 fn tree_aggregates_files_across_date_shards_into_one_type_folder() {
623 let fx = Fixture::new();
624 fx.write("sources/emails/2026/05/newer.md", &doc("newer"));
625 fx.write("sources/emails/2026/04/older.md", &doc("older"));
626 fx.write("sources/emails/loose.md", &doc("loose at folder root"));
627
628 let tree = tree(&fx.store, None, None).expect("tree");
629 let emails: Vec<&TreeTypeFolder> = tree
630 .layers
631 .iter()
632 .flat_map(|l| &l.type_folders)
633 .filter(|tf| tf.path == Path::new("sources/emails"))
634 .collect();
635
636 assert_eq!(
637 emails.len(),
638 1,
639 "all shards of one type fold into a single type-folder branch, not one per shard"
640 );
641 let files: Vec<String> = emails[0]
642 .files
643 .iter()
644 .map(|p| p.to_string_lossy().into_owned())
645 .collect();
646 assert_eq!(
647 files,
648 vec![
649 "sources/emails/2026/04/older.md".to_string(),
650 "sources/emails/2026/05/newer.md".to_string(),
651 "sources/emails/loose.md".to_string(),
652 ],
653 "every file under the type-folder, across shards, appears once"
654 );
655 }
656
657 #[test]
658 fn tree_excludes_index_and_log_and_db_meta_files() {
659 let fx = Fixture::new();
660 fx.write("records/contacts/sarah.md", &doc("sarah"));
662 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");
671 let all_files: Vec<String> = tree
672 .layers
673 .iter()
674 .flat_map(|l| &l.type_folders)
675 .flat_map(|tf| &tf.files)
676 .map(|p| p.to_string_lossy().into_owned())
677 .collect();
678
679 assert_eq!(
680 all_files,
681 vec!["records/contacts/sarah.md".to_string()],
682 "only the real content file survives; no index.md/index.jsonl/log files"
683 );
684 assert!(tree
686 .layers
687 .iter()
688 .all(|l| matches!(l.layer, Layer::Sources | Layer::Records)));
689 }
690
691 #[test]
692 fn tree_omits_empty_layers_and_empty_type_folders() {
693 let fx = Fixture::new();
694 fx.write("records/contacts/a.md", &doc("a"));
695 fx.mkdir("records/companies");
697 fx.mkdir("wiki");
699 fx.write("sources/emails/index.md", "---\ntype: index\n---\n");
701
702 let tree = tree(&fx.store, None, None).expect("tree");
703
704 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
705 assert_eq!(
706 layers,
707 vec![Layer::Records],
708 "empty wiki layer and meta-only sources layer are omitted"
709 );
710 let folders: Vec<String> = tree.layers[0]
711 .type_folders
712 .iter()
713 .map(|tf| tf.path.to_string_lossy().into_owned())
714 .collect();
715 assert_eq!(
716 folders,
717 vec!["records/contacts".to_string()],
718 "the empty companies type-folder is omitted"
719 );
720 }
721
722 #[test]
723 fn tree_layer_filter_restricts_to_one_layer() {
724 let fx = Fixture::new();
725 fx.write("sources/emails/a.md", &doc("a"));
726 fx.write("records/contacts/b.md", &doc("b"));
727 fx.write("sources/notes/c.md", &doc("c"));
728
729 let tree = tree(&fx.store, Some(Layer::Records), None).expect("tree");
730 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
731 assert_eq!(
732 layers,
733 vec![Layer::Records],
734 "only the requested layer is walked"
735 );
736 }
737
738 fn typed(type_: &str, summary: &str) -> String {
740 format!("---\ntype: {type_}\nsummary: {summary}\n---\n\nbody\n")
741 }
742
743 #[test]
744 fn tree_type_filter_matches_frontmatter_type_across_layers() {
745 let fx = Fixture::new();
746 fx.write("sources/inbox/s.md", &typed("note", "source note"));
749 fx.write("records/scratch/r.md", &typed("note", "record note"));
750 fx.write("records/contacts/c.md", &typed("contact", "contact"));
751
752 let tree = tree(&fx.store, None, Some("note")).expect("tree");
753 let files: Vec<String> = tree
754 .layers
755 .iter()
756 .flat_map(|l| &l.type_folders)
757 .flat_map(|tf| &tf.files)
758 .map(|p| p.to_string_lossy().into_owned())
759 .collect();
760 assert_eq!(
761 files,
762 vec![
763 "sources/inbox/s.md".to_string(),
764 "records/scratch/r.md".to_string()
765 ],
766 "type filter matches the frontmatter type across layers, regardless of folder name"
767 );
768 }
769
770 #[test]
771 fn tree_type_filter_uses_frontmatter_type_not_folder_name() {
772 let fx = Fixture::new();
777 fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
778 fx.write("records/profiles/sarah.md", &typed("profile", "sarah bio"));
782
783 let by_type = tree(&fx.store, None, Some("contact")).expect("tree");
785 let files: Vec<String> = by_type
786 .layers
787 .iter()
788 .flat_map(|l| &l.type_folders)
789 .flat_map(|tf| &tf.files)
790 .map(|p| p.to_string_lossy().into_owned())
791 .collect();
792 assert_eq!(
793 files,
794 vec!["records/contacts/sarah.md".to_string()],
795 "--type contact lists the contact in the pluralized canonical folder"
796 );
797
798 let by_folder_name = tree(&fx.store, None, Some("contacts")).expect("tree");
800 assert!(
801 by_folder_name.layers.is_empty(),
802 "the folder directory name is not the frontmatter type and must not match"
803 );
804
805 let profiles = tree(&fx.store, None, Some("profile")).expect("tree");
808 let profile_files: Vec<String> = profiles
809 .layers
810 .iter()
811 .flat_map(|l| &l.type_folders)
812 .flat_map(|tf| &tf.files)
813 .map(|p| p.to_string_lossy().into_owned())
814 .collect();
815 assert_eq!(
816 profile_files,
817 vec!["records/profiles/sarah.md".to_string()],
818 "--type profile matches the frontmatter type under a topic folder"
819 );
820 }
821
822 #[test]
823 fn tree_type_filter_skips_untyped_and_unmatched_files() {
824 let fx = Fixture::new();
827 fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
828 fx.write("records/contacts/no-type.md", "no frontmatter at all\n");
829 fx.write("records/contacts/other.md", &typed("company", "acme"));
830
831 let tree = tree(&fx.store, None, Some("contact")).expect("tree");
832 let files: Vec<String> = tree
833 .layers
834 .iter()
835 .flat_map(|l| &l.type_folders)
836 .flat_map(|tf| &tf.files)
837 .map(|p| p.to_string_lossy().into_owned())
838 .collect();
839 assert_eq!(
840 files,
841 vec!["records/contacts/sarah.md".to_string()],
842 "only the file whose frontmatter type matches survives; untyped/other are skipped"
843 );
844 }
845
846 #[test]
847 fn tree_excludes_loose_files_directly_under_a_layer() {
848 let fx = Fixture::new();
849 fx.write("records/contacts/real.md", &doc("real"));
850 fx.write("records/stray.md", &doc("stray"));
852
853 let tree = tree(&fx.store, None, None).expect("tree");
854 let all_files: Vec<String> = tree
855 .layers
856 .iter()
857 .flat_map(|l| &l.type_folders)
858 .flat_map(|tf| &tf.files)
859 .map(|p| p.to_string_lossy().into_owned())
860 .collect();
861 assert_eq!(
862 all_files,
863 vec!["records/contacts/real.md".to_string()],
864 "a layer-direct file has no type-folder slot and is not listed"
865 );
866 }
867
868 #[test]
869 fn tree_skips_hidden_directories() {
870 let fx = Fixture::new();
871 fx.write("records/contacts/a.md", &doc("a"));
872 fx.write(".git/objects/x.md", &doc("vcs junk"));
874 fx.write("records/.hidden/h.md", &doc("hidden type folder"));
875 fx.write("sources/emails/.tmp/draft.md", &doc("hidden shard"));
876
877 let tree = tree(&fx.store, None, None).expect("tree");
878 let all_files: Vec<String> = tree
879 .layers
880 .iter()
881 .flat_map(|l| &l.type_folders)
882 .flat_map(|tf| &tf.files)
883 .map(|p| p.to_string_lossy().into_owned())
884 .collect();
885 assert_eq!(
886 all_files,
887 vec!["records/contacts/a.md".to_string()],
888 "hidden dirs are skipped at the type-folder and shard levels"
889 );
890 }
891
892 #[test]
893 fn tree_paths_are_store_relative_not_absolute() {
894 let fx = Fixture::new();
895 fx.write("records/contacts/a.md", &doc("a"));
896
897 let tree = tree(&fx.store, None, None).expect("tree");
898 let tf = &tree.layers[0].type_folders[0];
899 assert!(
900 tf.path.is_relative() && tf.files[0].is_relative(),
901 "tree paths must be store-relative"
902 );
903 let root_str = fx.store.root.to_string_lossy().into_owned();
905 assert!(!tf.files[0].to_string_lossy().contains(&root_str));
906 }
907
908 #[test]
909 fn tree_on_store_with_no_layers_is_empty() {
910 let fx = Fixture::new(); let tree = tree(&fx.store, None, None).expect("tree");
912 assert!(
913 tree.layers.is_empty(),
914 "a store with no content has an empty tree"
915 );
916 }
917
918 fn headings(o: &Outline) -> Vec<(String, u8, u32)> {
922 o.sections
923 .iter()
924 .map(|s| (s.heading.clone(), s.level, s.line))
925 .collect()
926 }
927
928 #[test]
929 fn outline_extracts_sections_with_levels_and_body_relative_lines() {
930 let fx = Fixture::new();
931 let file = "---\ntype: note\nsummary: s\n---\n\n# Title\n\n## Alpha\ntext\n### Sub\nmore\n## Beta\nend\n";
935 fx.write("records/notes/n.md", file);
936
937 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
938 assert_eq!(
939 headings(&o),
940 vec![
941 ("Alpha".to_string(), 2, 4),
942 ("Sub".to_string(), 3, 6),
943 ("Beta".to_string(), 2, 8),
944 ],
945 "only ##+ headings, with body-relative 1-based line numbers; the # title is not a section"
946 );
947 assert_eq!(o.file, PathBuf::from("records/notes/n.md"));
948 }
949
950 #[test]
951 fn outline_section_body_spans_to_next_sibling_or_shallower_heading() {
952 let fx = Fixture::new();
953 let file = "---\nx: 1\n---\n## Alpha\na1\na2\n### Sub\ns1\n## Beta\nb1\n";
954 fx.write("records/notes/n.md", file);
955
956 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
957 let alpha = &o.sections[0];
958 assert_eq!(alpha.heading, "Alpha");
960 assert_eq!(
961 alpha.body, "## Alpha\na1\na2\n### Sub\ns1\n",
962 "a ## body runs through deeper headings up to the next sibling-or-shallower heading"
963 );
964
965 let sub = &o.sections[1];
966 assert_eq!(sub.heading, "Sub");
967 assert_eq!(
968 sub.body, "### Sub\ns1\n",
969 "the nested ### body stops at the next ## (shallower) heading"
970 );
971
972 let beta = &o.sections[2];
973 assert_eq!(
974 beta.body, "## Beta\nb1\n",
975 "the trailing ## body runs to end of file"
976 );
977 }
978
979 #[test]
980 fn outline_shallower_heading_terminates_a_section_body() {
981 let fx = Fixture::new();
982 let file = "---\nx: 1\n---\n## Sec\nbody1\n# NewTitle\nafter\n";
984 fx.write("records/notes/n.md", file);
985
986 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
987 assert_eq!(headings(&o), vec![("Sec".to_string(), 2, 1)]);
988 assert_eq!(
989 o.sections[0].body, "## Sec\nbody1\n",
990 "the level-1 heading is shallower and ends the section, and is itself not a section"
991 );
992 }
993
994 #[test]
995 fn outline_ignores_headings_inside_fenced_code_blocks() {
996 let fx = Fixture::new();
997 let file = "---\nx: 1\n---\n## Real\n```\n## fake heading in code\n### also fake\n```\nafter\n## AlsoReal\n";
998 fx.write("records/notes/n.md", file);
999
1000 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1001 assert_eq!(
1004 headings(&o),
1005 vec![("Real".to_string(), 2, 1), ("AlsoReal".to_string(), 2, 7)],
1006 "## inside a ``` fence is code, not a heading"
1007 );
1008 assert!(o.sections[0].body.contains("## fake heading in code"));
1010 }
1011
1012 #[test]
1013 fn outline_ignores_tilde_fences_too() {
1014 let fx = Fixture::new();
1015 let file = "---\nx: 1\n---\n## Real\n~~~\n## fake\n~~~\ntail\n";
1016 fx.write("records/notes/n.md", file);
1017
1018 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1019 assert_eq!(headings(&o), vec![("Real".to_string(), 2, 1)]);
1020 }
1021
1022 #[test]
1023 fn outline_rejects_non_heading_hash_lines() {
1024 let fx = Fixture::new();
1025 let file = "---\nx: 1\n---\n#nospace\n####### sevenhashes\n## Good\n";
1027 fx.write("records/notes/n.md", file);
1028
1029 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1030 assert_eq!(
1031 headings(&o),
1032 vec![("Good".to_string(), 2, 3)],
1033 "only the well-formed ## heading counts"
1034 );
1035 }
1036
1037 #[test]
1038 fn outline_strips_atx_closing_hashes_from_heading_text() {
1039 let fx = Fixture::new();
1040 let file = "---\nx: 1\n---\n## Title ##\n";
1041 fx.write("records/notes/n.md", file);
1042
1043 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1044 assert_eq!(o.sections[0].heading, "Title");
1045 }
1046
1047 #[test]
1048 fn outline_keeps_unspaced_trailing_hash_in_heading_text() {
1049 let fx = Fixture::new();
1054 let file = "---\nx: 1\n---\n## C#\n## F#\n## Ada ##\n## ##\n";
1055 fx.write("records/notes/langs.md", file);
1056
1057 let o = outline(&fx.store, Path::new("records/notes/langs.md")).expect("outline");
1058 let texts: Vec<String> = o.sections.iter().map(|s| s.heading.clone()).collect();
1059 assert_eq!(
1060 texts,
1061 vec![
1062 "C#".to_string(),
1063 "F#".to_string(),
1064 "Ada".to_string(),
1065 "".to_string(),
1066 ],
1067 "unspaced trailing # stays; a space-preceded # run is a closing fence"
1068 );
1069 }
1070
1071 #[test]
1072 fn outline_handles_file_without_frontmatter_numbering_from_line_one() {
1073 let fx = Fixture::new();
1074 let file = "## First\ntext\n## Second\n";
1076 fx.write("records/notes/n.md", file);
1077
1078 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1079 assert_eq!(
1080 headings(&o),
1081 vec![("First".to_string(), 2, 1), ("Second".to_string(), 2, 3)],
1082 "with no frontmatter the body is the whole file and lines count from 1"
1083 );
1084 }
1085
1086 #[test]
1087 fn outline_accepts_absolute_path_and_returns_store_relative_file() {
1088 let fx = Fixture::new();
1089 fx.write("records/contacts/x.md", "---\nx: 1\n---\n## H\n");
1090 let abs = fx.store.root.join("records/contacts/x.md");
1091
1092 let o = outline(&fx.store, &abs).expect("outline");
1093 assert_eq!(
1094 o.file,
1095 PathBuf::from("records/contacts/x.md"),
1096 "an absolute input path is normalized to store-relative in the Outline"
1097 );
1098 assert_eq!(o.sections.len(), 1);
1099 }
1100
1101 #[test]
1102 fn outline_of_a_file_with_no_headings_is_empty() {
1103 let fx = Fixture::new();
1104 fx.write(
1105 "records/notes/n.md",
1106 "---\nx: 1\n---\njust prose, no headings\n",
1107 );
1108
1109 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1110 assert!(
1111 o.sections.is_empty(),
1112 "a heading-free body yields no sections"
1113 );
1114 }
1115
1116 #[test]
1117 fn outline_missing_file_is_an_io_error() {
1118 let fx = Fixture::new();
1119 let err = outline(&fx.store, Path::new("records/notes/does-not-exist.md"))
1120 .expect_err("missing file should error");
1121 assert!(
1122 matches!(err, StoreError::Io(_)),
1123 "a missing file surfaces as a StoreError::Io, got {err:?}"
1124 );
1125 }
1126
1127 #[test]
1128 fn outline_handles_crlf_frontmatter_and_indented_headings() {
1129 let fx = Fixture::new();
1130 let file = "---\r\nx: 1\r\n---\r\n ## Indented3\nbody\n ## Indented4Code\n";
1133 fx.write("records/notes/n.md", file);
1134
1135 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1136 assert_eq!(
1137 headings(&o),
1138 vec![("Indented3".to_string(), 2, 1)],
1139 "<=3 leading spaces is a heading; 4 spaces is indented code, not a heading"
1140 );
1141 }
1142}