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 mut grouped: std::collections::BTreeMap<String, Vec<PathBuf>> =
77 std::collections::BTreeMap::new();
78 for rel in store.walk_layer(l)? {
79 if rel.components().nth(2).is_none() {
80 continue;
81 }
82 let Some(type_name) = rel
83 .components()
84 .nth(1)
85 .and_then(|component| component.as_os_str().to_str())
86 else {
87 continue;
88 };
89 if type_name == "log" {
90 continue;
91 }
92 grouped.entry(type_name.to_string()).or_default().push(rel);
93 }
94 let mut type_folders = Vec::new();
95 for (type_name, mut files) in grouped {
96 if let Some(want) = type_ {
103 files.retain(|rel| file_type_matches(store, rel, want));
104 }
105
106 if files.is_empty() {
107 continue;
108 }
109 files.sort();
110
111 type_folders.push(TreeTypeFolder {
112 path: PathBuf::from(layer_dir_name(l)).join(&type_name),
113 files,
114 });
115 }
116
117 if type_folders.is_empty() {
118 continue;
119 }
120
121 layers.push(TreeLayer {
122 layer: l,
123 type_folders,
124 });
125 }
126
127 Ok(Tree { layers })
128}
129
130fn layer_dir_name(layer: Layer) -> &'static str {
134 match layer {
135 Layer::Sources => "sources",
136 Layer::Records => "records",
137 }
138}
139
140fn file_type_matches(store: &Store, rel: &Path, want: &str) -> bool {
149 let text = match store.read_text_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES) {
150 Ok(t) => t,
151 Err(_) => return false,
152 };
153 frontmatter_type(&text).as_deref() == Some(want)
154}
155
156fn frontmatter_type(text: &str) -> Option<String> {
160 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
161 let mut lines = text.lines();
162 if lines.next()?.trim_end() != "---" {
163 return None;
164 }
165 let mut yaml = String::new();
166 let mut closed = false;
167 for line in lines {
168 if line.trim_end() == "---" {
169 closed = true;
170 break;
171 }
172 yaml.push_str(line);
173 yaml.push('\n');
174 }
175 if !closed {
176 return None;
177 }
178 let value: serde_norway::Value = serde_norway::from_str(&yaml).ok()?;
179 let s = value
180 .as_mapping()?
181 .get(serde_norway::Value::String("type".to_string()))?
182 .as_str()?
183 .trim();
184 if s.is_empty() {
185 None
186 } else {
187 Some(s.to_string())
188 }
189}
190
191pub fn outline(store: &Store, file: &Path) -> Result<Outline, StoreError> {
202 let rel = store.capability_relative(file)?.to_path_buf();
203
204 let text = store.read_text_bounded(&rel, crate::parser::MAX_DBMD_FILE_BYTES)?;
205 let body = strip_frontmatter(&text);
206 let sections = parse_sections(body);
207
208 Ok(Outline {
209 file: rel,
210 sections,
211 })
212}
213
214fn strip_frontmatter(text: &str) -> &str {
220 let after_open = match text.strip_prefix("---\n") {
222 Some(rest) => rest,
223 None => match text.strip_prefix("---\r\n") {
224 Some(rest) => rest,
225 None => return text,
226 },
227 };
228
229 let mut search_from = 0usize;
231 while let Some(rel_idx) = after_open[search_from..].find("---") {
232 let idx = search_from + rel_idx;
233 let at_line_start = idx == 0 || after_open.as_bytes()[idx - 1] == b'\n';
234 let after = &after_open[idx + 3..];
235 let line_ends = after.is_empty()
236 || after.starts_with('\n')
237 || after.starts_with("\r\n")
238 || after.starts_with('\r');
239 if at_line_start && line_ends {
240 if let Some(stripped) = after.strip_prefix("\r\n") {
242 return stripped;
243 }
244 if let Some(stripped) = after.strip_prefix('\n') {
245 return stripped;
246 }
247 if let Some(stripped) = after.strip_prefix('\r') {
248 return stripped;
249 }
250 return after; }
252 search_from = idx + 3;
253 }
254
255 text
257}
258
259fn parse_sections(body: &str) -> Vec<Section> {
264 let lines: Vec<&str> = body.split_inclusive('\n').collect();
267
268 let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
271 let mut fence: Option<(u8, usize)> = None; for line in &lines {
273 let content = line.trim_end_matches(['\n', '\r']);
274 if let Some(f) = fence {
275 if is_closing_fence(content, f) {
276 fence = None;
277 }
278 levels.push(0);
279 continue;
280 }
281 if let Some(opened) = opening_fence(content) {
282 fence = Some(opened);
283 levels.push(0);
284 continue;
285 }
286 levels.push(heading_level(content));
287 }
288
289 let mut sections = Vec::new();
293 for (i, &lvl) in levels.iter().enumerate() {
294 if lvl < 2 {
295 continue;
296 }
297 let heading_line = lines[i].trim_end_matches(['\n', '\r']);
298 let heading = heading_text(heading_line, lvl);
299
300 let mut end = lines.len();
301 for (j, &other) in levels.iter().enumerate().skip(i + 1) {
302 if other != 0 && other <= lvl {
303 end = j;
304 break;
305 }
306 }
307
308 let body_slice: String = lines[i..end].concat();
309
310 sections.push(Section {
311 heading,
312 level: lvl,
313 line: (i + 1) as u32,
314 body: body_slice,
315 });
316 }
317
318 sections
319}
320
321pub(crate) fn heading_level(line: &str) -> u8 {
328 let indent = line.len() - line.trim_start_matches(' ').len();
329 if indent > 3 {
330 return 0;
331 }
332 let rest = &line[indent..];
333 let hashes = rest.len() - rest.trim_start_matches('#').len();
334 if hashes == 0 || hashes > 6 {
335 return 0;
336 }
337 let after = &rest[hashes..];
338 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
339 hashes as u8
340 } else {
341 0
342 }
343}
344
345pub(crate) fn heading_text(line: &str, level: u8) -> String {
356 let indent = line.len() - line.trim_start_matches(' ').len();
357 let after_hashes = &line[indent + level as usize..];
358 let trimmed = after_hashes.trim();
359 let trailing_hashes = trimmed.len() - trimmed.trim_end_matches('#').len();
361 if trailing_hashes == 0 {
362 return trimmed.to_string();
363 }
364 let before_run = &trimmed[..trimmed.len() - trailing_hashes];
365 if before_run.is_empty() || before_run.ends_with([' ', '\t']) {
369 before_run.trim_end().to_string()
370 } else {
371 trimmed.to_string()
372 }
373}
374
375fn opening_fence(line: &str) -> Option<(u8, usize)> {
379 let indent = line.len() - line.trim_start_matches(' ').len();
380 if indent > 3 {
381 return None;
382 }
383 let rest = &line[indent..];
384 let byte = rest.bytes().next()?;
385 if byte != b'`' && byte != b'~' {
386 return None;
387 }
388 let run = rest.len() - rest.trim_start_matches(byte as char).len();
389 if run < 3 {
390 return None;
391 }
392 if byte == b'`' && rest[run..].contains('`') {
394 return None;
395 }
396 Some((byte, run))
397}
398
399fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
402 let (byte, open_len) = fence;
403 let indent = line.len() - line.trim_start_matches(' ').len();
404 if indent > 3 {
405 return false;
406 }
407 let rest = &line[indent..];
408 let run = rest.len() - rest.trim_start_matches(byte as char).len();
409 if run < open_len {
410 return false;
411 }
412 rest[run..].trim().is_empty()
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::parser::Config;
419 use std::fs;
420 use tempfile::TempDir;
421
422 struct Fixture {
430 _dir: TempDir,
431 store: Store,
432 }
433
434 impl Fixture {
435 fn new() -> Self {
436 let dir = tempfile::tempdir().expect("tempdir");
437 fs::write(dir.path().join("DB.md"), "---\ntype: db\n---\n").expect("write DB.md");
439 let store = Store::from_root_and_config(dir.path(), Config::default()).unwrap();
440 Fixture { _dir: dir, store }
441 }
442
443 fn write(&self, rel: &str, contents: &str) {
445 let abs = self.store.root.join(rel);
446 if let Some(parent) = abs.parent() {
447 fs::create_dir_all(parent).expect("create parents");
448 }
449 fs::write(abs, contents).expect("write file");
450 }
451
452 fn mkdir(&self, rel: &str) {
453 fs::create_dir_all(self.store.root.join(rel)).expect("mkdir");
454 }
455 }
456
457 fn doc(summary: &str) -> String {
459 format!("---\ntype: contact\nsummary: {summary}\n---\n\nbody\n")
460 }
461
462 fn shape(tree: &Tree) -> Vec<(Layer, String, Vec<String>)> {
465 let mut out = Vec::new();
466 for layer in &tree.layers {
467 for tf in &layer.type_folders {
468 let files = tf
469 .files
470 .iter()
471 .map(|p| p.to_string_lossy().into_owned())
472 .collect();
473 out.push((layer.layer, tf.path.to_string_lossy().into_owned(), files));
474 }
475 }
476 out
477 }
478
479 #[test]
482 fn tree_groups_by_layer_then_type_folder_in_canonical_order() {
483 let fx = Fixture::new();
484 fx.write("records/profiles/sarah.md", &doc("sarah bio"));
490 fx.write("records/contacts/sarah-chen.md", &doc("sarah contact"));
491 fx.write("sources/emails/a.md", &doc("an email"));
492
493 let tree = tree(&fx.store, None, None).expect("tree");
494 let layer_order: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
495 assert_eq!(
496 layer_order,
497 vec![Layer::Sources, Layer::Records],
498 "layers must come back in canonical order regardless of on-disk name order"
499 );
500
501 assert_eq!(
502 shape(&tree),
503 vec![
504 (
505 Layer::Sources,
506 "sources/emails".to_string(),
507 vec!["sources/emails/a.md".to_string()]
508 ),
509 (
510 Layer::Records,
511 "records/contacts".to_string(),
512 vec!["records/contacts/sarah-chen.md".to_string()]
513 ),
514 (
515 Layer::Records,
516 "records/profiles".to_string(),
517 vec!["records/profiles/sarah.md".to_string()]
518 ),
519 ]
520 );
521 }
522
523 #[test]
524 fn tree_type_folders_and_files_are_sorted_ascending() {
525 let fx = Fixture::new();
526 fx.write("records/expenses/z.md", &doc("z"));
528 fx.write("records/contacts/b.md", &doc("b"));
529 fx.write("records/contacts/a.md", &doc("a"));
530
531 let tree = tree(&fx.store, None, None).expect("tree");
532 let records = tree
533 .layers
534 .iter()
535 .find(|l| l.layer == Layer::Records)
536 .expect("records layer");
537
538 let folder_paths: Vec<String> = records
539 .type_folders
540 .iter()
541 .map(|tf| tf.path.to_string_lossy().into_owned())
542 .collect();
543 assert_eq!(
544 folder_paths,
545 vec![
546 "records/contacts".to_string(),
547 "records/expenses".to_string()
548 ],
549 "type-folders sorted by path ascending"
550 );
551
552 let contacts = &records.type_folders[0];
553 let files: Vec<String> = contacts
554 .files
555 .iter()
556 .map(|p| p.to_string_lossy().into_owned())
557 .collect();
558 assert_eq!(
559 files,
560 vec![
561 "records/contacts/a.md".to_string(),
562 "records/contacts/b.md".to_string()
563 ],
564 "files sorted by store-relative path ascending"
565 );
566 }
567
568 #[test]
569 fn tree_aggregates_files_across_date_shards_into_one_type_folder() {
570 let fx = Fixture::new();
571 fx.write("sources/emails/2026/05/newer.md", &doc("newer"));
572 fx.write("sources/emails/2026/04/older.md", &doc("older"));
573 fx.write("sources/emails/loose.md", &doc("loose at folder root"));
574
575 let tree = tree(&fx.store, None, None).expect("tree");
576 let emails: Vec<&TreeTypeFolder> = tree
577 .layers
578 .iter()
579 .flat_map(|l| &l.type_folders)
580 .filter(|tf| tf.path == Path::new("sources/emails"))
581 .collect();
582
583 assert_eq!(
584 emails.len(),
585 1,
586 "all shards of one type fold into a single type-folder branch, not one per shard"
587 );
588 let files: Vec<String> = emails[0]
589 .files
590 .iter()
591 .map(|p| p.to_string_lossy().into_owned())
592 .collect();
593 assert_eq!(
594 files,
595 vec![
596 "sources/emails/2026/04/older.md".to_string(),
597 "sources/emails/2026/05/newer.md".to_string(),
598 "sources/emails/loose.md".to_string(),
599 ],
600 "every file under the type-folder, across shards, appears once"
601 );
602 }
603
604 #[test]
605 fn tree_excludes_index_and_log_and_db_meta_files() {
606 let fx = Fixture::new();
607 fx.write("records/contacts/sarah.md", &doc("sarah"));
609 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");
618 let all_files: Vec<String> = tree
619 .layers
620 .iter()
621 .flat_map(|l| &l.type_folders)
622 .flat_map(|tf| &tf.files)
623 .map(|p| p.to_string_lossy().into_owned())
624 .collect();
625
626 assert_eq!(
627 all_files,
628 vec!["records/contacts/sarah.md".to_string()],
629 "only the real content file survives; no index.md/index.jsonl/log files"
630 );
631 assert!(tree
633 .layers
634 .iter()
635 .all(|l| matches!(l.layer, Layer::Sources | Layer::Records)));
636 }
637
638 #[test]
639 fn tree_omits_empty_layers_and_empty_type_folders() {
640 let fx = Fixture::new();
641 fx.write("records/contacts/a.md", &doc("a"));
642 fx.mkdir("records/companies");
644 fx.mkdir("wiki");
646 fx.write("sources/emails/index.md", "---\ntype: index\n---\n");
648
649 let tree = tree(&fx.store, None, None).expect("tree");
650
651 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
652 assert_eq!(
653 layers,
654 vec![Layer::Records],
655 "empty wiki layer and meta-only sources layer are omitted"
656 );
657 let folders: Vec<String> = tree.layers[0]
658 .type_folders
659 .iter()
660 .map(|tf| tf.path.to_string_lossy().into_owned())
661 .collect();
662 assert_eq!(
663 folders,
664 vec!["records/contacts".to_string()],
665 "the empty companies type-folder is omitted"
666 );
667 }
668
669 #[test]
670 fn tree_layer_filter_restricts_to_one_layer() {
671 let fx = Fixture::new();
672 fx.write("sources/emails/a.md", &doc("a"));
673 fx.write("records/contacts/b.md", &doc("b"));
674 fx.write("sources/notes/c.md", &doc("c"));
675
676 let tree = tree(&fx.store, Some(Layer::Records), None).expect("tree");
677 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
678 assert_eq!(
679 layers,
680 vec![Layer::Records],
681 "only the requested layer is walked"
682 );
683 }
684
685 fn typed(type_: &str, summary: &str) -> String {
687 format!("---\ntype: {type_}\nsummary: {summary}\n---\n\nbody\n")
688 }
689
690 #[test]
691 fn tree_type_filter_matches_frontmatter_type_across_layers() {
692 let fx = Fixture::new();
693 fx.write("sources/inbox/s.md", &typed("note", "source note"));
696 fx.write("records/scratch/r.md", &typed("note", "record note"));
697 fx.write("records/contacts/c.md", &typed("contact", "contact"));
698
699 let tree = tree(&fx.store, None, Some("note")).expect("tree");
700 let files: Vec<String> = tree
701 .layers
702 .iter()
703 .flat_map(|l| &l.type_folders)
704 .flat_map(|tf| &tf.files)
705 .map(|p| p.to_string_lossy().into_owned())
706 .collect();
707 assert_eq!(
708 files,
709 vec![
710 "sources/inbox/s.md".to_string(),
711 "records/scratch/r.md".to_string()
712 ],
713 "type filter matches the frontmatter type across layers, regardless of folder name"
714 );
715 }
716
717 #[test]
718 fn tree_type_filter_uses_frontmatter_type_not_folder_name() {
719 let fx = Fixture::new();
724 fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
725 fx.write("records/profiles/sarah.md", &typed("profile", "sarah bio"));
729
730 let by_type = tree(&fx.store, None, Some("contact")).expect("tree");
732 let files: Vec<String> = by_type
733 .layers
734 .iter()
735 .flat_map(|l| &l.type_folders)
736 .flat_map(|tf| &tf.files)
737 .map(|p| p.to_string_lossy().into_owned())
738 .collect();
739 assert_eq!(
740 files,
741 vec!["records/contacts/sarah.md".to_string()],
742 "--type contact lists the contact in the pluralized canonical folder"
743 );
744
745 let by_folder_name = tree(&fx.store, None, Some("contacts")).expect("tree");
747 assert!(
748 by_folder_name.layers.is_empty(),
749 "the folder directory name is not the frontmatter type and must not match"
750 );
751
752 let profiles = tree(&fx.store, None, Some("profile")).expect("tree");
755 let profile_files: Vec<String> = profiles
756 .layers
757 .iter()
758 .flat_map(|l| &l.type_folders)
759 .flat_map(|tf| &tf.files)
760 .map(|p| p.to_string_lossy().into_owned())
761 .collect();
762 assert_eq!(
763 profile_files,
764 vec!["records/profiles/sarah.md".to_string()],
765 "--type profile matches the frontmatter type under a topic folder"
766 );
767 }
768
769 #[test]
770 fn tree_type_filter_skips_untyped_and_unmatched_files() {
771 let fx = Fixture::new();
774 fx.write("records/contacts/sarah.md", &typed("contact", "sarah"));
775 fx.write("records/contacts/no-type.md", "no frontmatter at all\n");
776 fx.write("records/contacts/other.md", &typed("company", "acme"));
777
778 let tree = tree(&fx.store, None, Some("contact")).expect("tree");
779 let files: Vec<String> = tree
780 .layers
781 .iter()
782 .flat_map(|l| &l.type_folders)
783 .flat_map(|tf| &tf.files)
784 .map(|p| p.to_string_lossy().into_owned())
785 .collect();
786 assert_eq!(
787 files,
788 vec!["records/contacts/sarah.md".to_string()],
789 "only the file whose frontmatter type matches survives; untyped/other are skipped"
790 );
791 }
792
793 #[test]
794 fn tree_excludes_loose_files_directly_under_a_layer() {
795 let fx = Fixture::new();
796 fx.write("records/contacts/real.md", &doc("real"));
797 fx.write("records/stray.md", &doc("stray"));
799
800 let tree = tree(&fx.store, None, None).expect("tree");
801 let all_files: Vec<String> = tree
802 .layers
803 .iter()
804 .flat_map(|l| &l.type_folders)
805 .flat_map(|tf| &tf.files)
806 .map(|p| p.to_string_lossy().into_owned())
807 .collect();
808 assert_eq!(
809 all_files,
810 vec!["records/contacts/real.md".to_string()],
811 "a layer-direct file has no type-folder slot and is not listed"
812 );
813 }
814
815 #[test]
816 fn tree_skips_hidden_directories() {
817 let fx = Fixture::new();
818 fx.write("records/contacts/a.md", &doc("a"));
819 fx.write(".git/objects/x.md", &doc("vcs junk"));
821 fx.write("records/.hidden/h.md", &doc("hidden type folder"));
822 fx.write("sources/emails/.tmp/draft.md", &doc("hidden shard"));
823
824 let tree = tree(&fx.store, None, None).expect("tree");
825 let all_files: Vec<String> = tree
826 .layers
827 .iter()
828 .flat_map(|l| &l.type_folders)
829 .flat_map(|tf| &tf.files)
830 .map(|p| p.to_string_lossy().into_owned())
831 .collect();
832 assert_eq!(
833 all_files,
834 vec!["records/contacts/a.md".to_string()],
835 "hidden dirs are skipped at the type-folder and shard levels"
836 );
837 }
838
839 #[test]
840 fn tree_paths_are_store_relative_not_absolute() {
841 let fx = Fixture::new();
842 fx.write("records/contacts/a.md", &doc("a"));
843
844 let tree = tree(&fx.store, None, None).expect("tree");
845 let tf = &tree.layers[0].type_folders[0];
846 assert!(
847 tf.path.is_relative() && tf.files[0].is_relative(),
848 "tree paths must be store-relative"
849 );
850 let root_str = fx.store.root.to_string_lossy().into_owned();
852 assert!(!tf.files[0].to_string_lossy().contains(&root_str));
853 }
854
855 #[test]
856 fn tree_on_store_with_no_layers_is_empty() {
857 let fx = Fixture::new(); let tree = tree(&fx.store, None, None).expect("tree");
859 assert!(
860 tree.layers.is_empty(),
861 "a store with no content has an empty tree"
862 );
863 }
864
865 fn headings(o: &Outline) -> Vec<(String, u8, u32)> {
869 o.sections
870 .iter()
871 .map(|s| (s.heading.clone(), s.level, s.line))
872 .collect()
873 }
874
875 #[test]
876 fn outline_extracts_sections_with_levels_and_body_relative_lines() {
877 let fx = Fixture::new();
878 let file = "---\ntype: note\nsummary: s\n---\n\n# Title\n\n## Alpha\ntext\n### Sub\nmore\n## Beta\nend\n";
882 fx.write("records/notes/n.md", file);
883
884 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
885 assert_eq!(
886 headings(&o),
887 vec![
888 ("Alpha".to_string(), 2, 4),
889 ("Sub".to_string(), 3, 6),
890 ("Beta".to_string(), 2, 8),
891 ],
892 "only ##+ headings, with body-relative 1-based line numbers; the # title is not a section"
893 );
894 assert_eq!(o.file, PathBuf::from("records/notes/n.md"));
895 }
896
897 #[test]
898 fn outline_section_body_spans_to_next_sibling_or_shallower_heading() {
899 let fx = Fixture::new();
900 let file = "---\nx: 1\n---\n## Alpha\na1\na2\n### Sub\ns1\n## Beta\nb1\n";
901 fx.write("records/notes/n.md", file);
902
903 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
904 let alpha = &o.sections[0];
905 assert_eq!(alpha.heading, "Alpha");
907 assert_eq!(
908 alpha.body, "## Alpha\na1\na2\n### Sub\ns1\n",
909 "a ## body runs through deeper headings up to the next sibling-or-shallower heading"
910 );
911
912 let sub = &o.sections[1];
913 assert_eq!(sub.heading, "Sub");
914 assert_eq!(
915 sub.body, "### Sub\ns1\n",
916 "the nested ### body stops at the next ## (shallower) heading"
917 );
918
919 let beta = &o.sections[2];
920 assert_eq!(
921 beta.body, "## Beta\nb1\n",
922 "the trailing ## body runs to end of file"
923 );
924 }
925
926 #[test]
927 fn outline_shallower_heading_terminates_a_section_body() {
928 let fx = Fixture::new();
929 let file = "---\nx: 1\n---\n## Sec\nbody1\n# NewTitle\nafter\n";
931 fx.write("records/notes/n.md", file);
932
933 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
934 assert_eq!(headings(&o), vec![("Sec".to_string(), 2, 1)]);
935 assert_eq!(
936 o.sections[0].body, "## Sec\nbody1\n",
937 "the level-1 heading is shallower and ends the section, and is itself not a section"
938 );
939 }
940
941 #[test]
942 fn outline_ignores_headings_inside_fenced_code_blocks() {
943 let fx = Fixture::new();
944 let file = "---\nx: 1\n---\n## Real\n```\n## fake heading in code\n### also fake\n```\nafter\n## AlsoReal\n";
945 fx.write("records/notes/n.md", file);
946
947 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
948 assert_eq!(
951 headings(&o),
952 vec![("Real".to_string(), 2, 1), ("AlsoReal".to_string(), 2, 7)],
953 "## inside a ``` fence is code, not a heading"
954 );
955 assert!(o.sections[0].body.contains("## fake heading in code"));
957 }
958
959 #[test]
960 fn outline_ignores_tilde_fences_too() {
961 let fx = Fixture::new();
962 let file = "---\nx: 1\n---\n## Real\n~~~\n## fake\n~~~\ntail\n";
963 fx.write("records/notes/n.md", file);
964
965 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
966 assert_eq!(headings(&o), vec![("Real".to_string(), 2, 1)]);
967 }
968
969 #[test]
970 fn outline_rejects_non_heading_hash_lines() {
971 let fx = Fixture::new();
972 let file = "---\nx: 1\n---\n#nospace\n####### sevenhashes\n## Good\n";
974 fx.write("records/notes/n.md", file);
975
976 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
977 assert_eq!(
978 headings(&o),
979 vec![("Good".to_string(), 2, 3)],
980 "only the well-formed ## heading counts"
981 );
982 }
983
984 #[test]
985 fn outline_strips_atx_closing_hashes_from_heading_text() {
986 let fx = Fixture::new();
987 let file = "---\nx: 1\n---\n## Title ##\n";
988 fx.write("records/notes/n.md", file);
989
990 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
991 assert_eq!(o.sections[0].heading, "Title");
992 }
993
994 #[test]
995 fn outline_keeps_unspaced_trailing_hash_in_heading_text() {
996 let fx = Fixture::new();
1001 let file = "---\nx: 1\n---\n## C#\n## F#\n## Ada ##\n## ##\n";
1002 fx.write("records/notes/langs.md", file);
1003
1004 let o = outline(&fx.store, Path::new("records/notes/langs.md")).expect("outline");
1005 let texts: Vec<String> = o.sections.iter().map(|s| s.heading.clone()).collect();
1006 assert_eq!(
1007 texts,
1008 vec![
1009 "C#".to_string(),
1010 "F#".to_string(),
1011 "Ada".to_string(),
1012 "".to_string(),
1013 ],
1014 "unspaced trailing # stays; a space-preceded # run is a closing fence"
1015 );
1016 }
1017
1018 #[test]
1019 fn outline_handles_file_without_frontmatter_numbering_from_line_one() {
1020 let fx = Fixture::new();
1021 let file = "## First\ntext\n## Second\n";
1023 fx.write("records/notes/n.md", file);
1024
1025 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1026 assert_eq!(
1027 headings(&o),
1028 vec![("First".to_string(), 2, 1), ("Second".to_string(), 2, 3)],
1029 "with no frontmatter the body is the whole file and lines count from 1"
1030 );
1031 }
1032
1033 #[test]
1034 fn outline_accepts_absolute_path_and_returns_store_relative_file() {
1035 let fx = Fixture::new();
1036 fx.write("records/contacts/x.md", "---\nx: 1\n---\n## H\n");
1037 let abs = fx.store.root.join("records/contacts/x.md");
1038
1039 let o = outline(&fx.store, &abs).expect("outline");
1040 assert_eq!(
1041 o.file,
1042 PathBuf::from("records/contacts/x.md"),
1043 "an absolute input path is normalized to store-relative in the Outline"
1044 );
1045 assert_eq!(o.sections.len(), 1);
1046 }
1047
1048 #[test]
1049 fn outline_of_a_file_with_no_headings_is_empty() {
1050 let fx = Fixture::new();
1051 fx.write(
1052 "records/notes/n.md",
1053 "---\nx: 1\n---\njust prose, no headings\n",
1054 );
1055
1056 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1057 assert!(
1058 o.sections.is_empty(),
1059 "a heading-free body yields no sections"
1060 );
1061 }
1062
1063 #[test]
1064 fn outline_missing_file_is_an_io_error() {
1065 let fx = Fixture::new();
1066 let err = outline(&fx.store, Path::new("records/notes/does-not-exist.md"))
1067 .expect_err("missing file should error");
1068 assert!(
1069 matches!(err, StoreError::Io(_)),
1070 "a missing file surfaces as a StoreError::Io, got {err:?}"
1071 );
1072 }
1073
1074 #[test]
1075 fn outline_handles_crlf_frontmatter_and_indented_headings() {
1076 let fx = Fixture::new();
1077 let file = "---\r\nx: 1\r\n---\r\n ## Indented3\nbody\n ## Indented4Code\n";
1080 fx.write("records/notes/n.md", file);
1081
1082 let o = outline(&fx.store, Path::new("records/notes/n.md")).expect("outline");
1083 assert_eq!(
1084 headings(&o),
1085 vec![("Indented3".to_string(), 2, 1)],
1086 "<=3 leading spaces is a heading; 4 spaces is indented code, not a heading"
1087 );
1088 }
1089}