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 if let Some(want) = type_ {
101 if type_name != want {
102 continue;
103 }
104 }
105
106 let type_abs = layer_abs.join(&type_name);
107 let mut files: Vec<PathBuf> = Vec::new();
108 collect_content_files(&store.root, &type_abs, &mut files)?;
109 if files.is_empty() {
110 continue;
111 }
112 files.sort();
113
114 type_folders.push(TreeTypeFolder {
115 path: PathBuf::from(layer_dir_name(l)).join(&type_name),
116 files,
117 });
118 }
119
120 if type_folders.is_empty() {
121 continue;
122 }
123
124 layers.push(TreeLayer {
125 layer: l,
126 type_folders,
127 });
128 }
129
130 Ok(Tree { layers })
131}
132
133fn layer_dir_name(layer: Layer) -> &'static str {
137 match layer {
138 Layer::Sources => "sources",
139 Layer::Records => "records",
140 Layer::Wiki => "wiki",
141 }
142}
143
144fn is_skipped_dir(name: &str) -> bool {
147 name == "log" || name.starts_with('.')
148}
149
150fn is_content_md(name: &str) -> bool {
154 name.ends_with(".md") && name != "index.md"
155}
156
157fn collect_content_files(
161 store_root: &Path,
162 dir: &Path,
163 out: &mut Vec<PathBuf>,
164) -> Result<(), StoreError> {
165 for entry in std::fs::read_dir(dir)? {
166 let entry = entry?;
167 let file_type = entry.file_type()?;
168 let name = entry.file_name().to_string_lossy().into_owned();
169
170 if file_type.is_dir() {
171 if name.starts_with('.') {
172 continue;
173 }
174 collect_content_files(store_root, &entry.path(), out)?;
175 } else if file_type.is_file() && is_content_md(&name) {
176 let abs = entry.path();
177 let rel = abs.strip_prefix(store_root).unwrap_or(&abs).to_path_buf();
178 out.push(rel);
179 }
180 }
181 Ok(())
182}
183
184pub fn outline(store: &Store, file: &Path) -> Result<Outline, StoreError> {
195 let abs = if file.is_absolute() {
196 file.to_path_buf()
197 } else {
198 store.root.join(file)
199 };
200
201 let rel = abs.strip_prefix(&store.root).unwrap_or(file).to_path_buf();
202
203 let text = std::fs::read_to_string(&abs)?;
204 let body = strip_frontmatter(&text);
205 let sections = parse_sections(body);
206
207 Ok(Outline {
208 file: rel,
209 sections,
210 })
211}
212
213fn strip_frontmatter(text: &str) -> &str {
219 let after_open = match text.strip_prefix("---\n") {
221 Some(rest) => rest,
222 None => match text.strip_prefix("---\r\n") {
223 Some(rest) => rest,
224 None => return text,
225 },
226 };
227
228 let mut search_from = 0usize;
230 while let Some(rel_idx) = after_open[search_from..].find("---") {
231 let idx = search_from + rel_idx;
232 let at_line_start = idx == 0 || after_open.as_bytes()[idx - 1] == b'\n';
233 let after = &after_open[idx + 3..];
234 let line_ends = after.is_empty()
235 || after.starts_with('\n')
236 || after.starts_with("\r\n")
237 || after.starts_with('\r');
238 if at_line_start && line_ends {
239 if let Some(stripped) = after.strip_prefix("\r\n") {
241 return stripped;
242 }
243 if let Some(stripped) = after.strip_prefix('\n') {
244 return stripped;
245 }
246 if let Some(stripped) = after.strip_prefix('\r') {
247 return stripped;
248 }
249 return after; }
251 search_from = idx + 3;
252 }
253
254 text
256}
257
258fn parse_sections(body: &str) -> Vec<Section> {
263 let lines: Vec<&str> = body.split_inclusive('\n').collect();
266
267 let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
270 let mut fence: Option<(u8, usize)> = None; for line in &lines {
272 let content = line.trim_end_matches(['\n', '\r']);
273 if let Some(f) = fence {
274 if is_closing_fence(content, f) {
275 fence = None;
276 }
277 levels.push(0);
278 continue;
279 }
280 if let Some(opened) = opening_fence(content) {
281 fence = Some(opened);
282 levels.push(0);
283 continue;
284 }
285 levels.push(heading_level(content));
286 }
287
288 let mut sections = Vec::new();
292 for (i, &lvl) in levels.iter().enumerate() {
293 if lvl < 2 {
294 continue;
295 }
296 let heading_line = lines[i].trim_end_matches(['\n', '\r']);
297 let heading = heading_text(heading_line, lvl);
298
299 let mut end = lines.len();
300 for (j, &other) in levels.iter().enumerate().skip(i + 1) {
301 if other != 0 && other <= lvl {
302 end = j;
303 break;
304 }
305 }
306
307 let body_slice: String = lines[i..end].concat();
308
309 sections.push(Section {
310 heading,
311 level: lvl,
312 line: (i + 1) as u32,
313 body: body_slice,
314 });
315 }
316
317 sections
318}
319
320fn heading_level(line: &str) -> u8 {
324 let indent = line.len() - line.trim_start_matches(' ').len();
325 if indent > 3 {
326 return 0;
327 }
328 let rest = &line[indent..];
329 let hashes = rest.len() - rest.trim_start_matches('#').len();
330 if hashes == 0 || hashes > 6 {
331 return 0;
332 }
333 let after = &rest[hashes..];
334 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
335 hashes as u8
336 } else {
337 0
338 }
339}
340
341fn heading_text(line: &str, level: u8) -> String {
344 let indent = line.len() - line.trim_start_matches(' ').len();
345 let after_hashes = &line[indent + level as usize..];
346 let trimmed = after_hashes.trim();
347 let no_trailing = trimmed.trim_end_matches('#');
350 if no_trailing.len() == trimmed.len() {
351 trimmed.to_string()
352 } else {
353 no_trailing.trim_end().to_string()
354 }
355}
356
357fn opening_fence(line: &str) -> Option<(u8, usize)> {
361 let indent = line.len() - line.trim_start_matches(' ').len();
362 if indent > 3 {
363 return None;
364 }
365 let rest = &line[indent..];
366 let byte = rest.bytes().next()?;
367 if byte != b'`' && byte != b'~' {
368 return None;
369 }
370 let run = rest.len() - rest.trim_start_matches(byte as char).len();
371 if run < 3 {
372 return None;
373 }
374 if byte == b'`' && rest[run..].contains('`') {
376 return None;
377 }
378 Some((byte, run))
379}
380
381fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
384 let (byte, open_len) = fence;
385 let indent = line.len() - line.trim_start_matches(' ').len();
386 if indent > 3 {
387 return false;
388 }
389 let rest = &line[indent..];
390 let run = rest.len() - rest.trim_start_matches(byte as char).len();
391 if run < open_len {
392 return false;
393 }
394 rest[run..].trim().is_empty()
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::parser::Config;
401 use std::fs;
402 use tempfile::TempDir;
403
404 struct Fixture {
412 _dir: TempDir,
413 store: Store,
414 }
415
416 impl Fixture {
417 fn new() -> Self {
418 let dir = tempfile::tempdir().expect("tempdir");
419 fs::write(dir.path().join("DB.md"), "---\ntype: db\n---\n").expect("write DB.md");
421 let store = Store {
422 root: dir.path().to_path_buf(),
423 config: Config::default(),
424 };
425 Fixture { _dir: dir, store }
426 }
427
428 fn write(&self, rel: &str, contents: &str) {
430 let abs = self.store.root.join(rel);
431 if let Some(parent) = abs.parent() {
432 fs::create_dir_all(parent).expect("create parents");
433 }
434 fs::write(abs, contents).expect("write file");
435 }
436
437 fn mkdir(&self, rel: &str) {
438 fs::create_dir_all(self.store.root.join(rel)).expect("mkdir");
439 }
440 }
441
442 fn doc(summary: &str) -> String {
444 format!("---\ntype: contact\nsummary: {summary}\n---\n\nbody\n")
445 }
446
447 fn shape(tree: &Tree) -> Vec<(Layer, String, Vec<String>)> {
450 let mut out = Vec::new();
451 for layer in &tree.layers {
452 for tf in &layer.type_folders {
453 let files = tf
454 .files
455 .iter()
456 .map(|p| p.to_string_lossy().into_owned())
457 .collect();
458 out.push((layer.layer, tf.path.to_string_lossy().into_owned(), files));
459 }
460 }
461 out
462 }
463
464 #[test]
467 fn tree_groups_by_layer_then_type_folder_in_canonical_order() {
468 let fx = Fixture::new();
469 fx.write("wiki/people/sarah.md", &doc("sarah bio"));
473 fx.write("records/contacts/sarah-chen.md", &doc("sarah contact"));
474 fx.write("sources/emails/a.md", &doc("an email"));
475
476 let tree = tree(&fx.store, None, None).expect("tree");
477 let layer_order: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
478 assert_eq!(
479 layer_order,
480 vec![Layer::Sources, Layer::Records, Layer::Wiki],
481 "layers must come back in canonical order regardless of on-disk name order"
482 );
483
484 assert_eq!(
485 shape(&tree),
486 vec![
487 (
488 Layer::Sources,
489 "sources/emails".to_string(),
490 vec!["sources/emails/a.md".to_string()]
491 ),
492 (
493 Layer::Records,
494 "records/contacts".to_string(),
495 vec!["records/contacts/sarah-chen.md".to_string()]
496 ),
497 (
498 Layer::Wiki,
499 "wiki/people".to_string(),
500 vec!["wiki/people/sarah.md".to_string()]
501 ),
502 ]
503 );
504 }
505
506 #[test]
507 fn tree_type_folders_and_files_are_sorted_ascending() {
508 let fx = Fixture::new();
509 fx.write("records/expenses/z.md", &doc("z"));
511 fx.write("records/contacts/b.md", &doc("b"));
512 fx.write("records/contacts/a.md", &doc("a"));
513
514 let tree = tree(&fx.store, None, None).expect("tree");
515 let records = tree
516 .layers
517 .iter()
518 .find(|l| l.layer == Layer::Records)
519 .expect("records layer");
520
521 let folder_paths: Vec<String> = records
522 .type_folders
523 .iter()
524 .map(|tf| tf.path.to_string_lossy().into_owned())
525 .collect();
526 assert_eq!(
527 folder_paths,
528 vec![
529 "records/contacts".to_string(),
530 "records/expenses".to_string()
531 ],
532 "type-folders sorted by path ascending"
533 );
534
535 let contacts = &records.type_folders[0];
536 let files: Vec<String> = contacts
537 .files
538 .iter()
539 .map(|p| p.to_string_lossy().into_owned())
540 .collect();
541 assert_eq!(
542 files,
543 vec![
544 "records/contacts/a.md".to_string(),
545 "records/contacts/b.md".to_string()
546 ],
547 "files sorted by store-relative path ascending"
548 );
549 }
550
551 #[test]
552 fn tree_aggregates_files_across_date_shards_into_one_type_folder() {
553 let fx = Fixture::new();
554 fx.write("sources/emails/2026/05/newer.md", &doc("newer"));
555 fx.write("sources/emails/2026/04/older.md", &doc("older"));
556 fx.write("sources/emails/loose.md", &doc("loose at folder root"));
557
558 let tree = tree(&fx.store, None, None).expect("tree");
559 let emails: Vec<&TreeTypeFolder> = tree
560 .layers
561 .iter()
562 .flat_map(|l| &l.type_folders)
563 .filter(|tf| tf.path == *"sources/emails")
564 .collect();
565
566 assert_eq!(
567 emails.len(),
568 1,
569 "all shards of one type fold into a single type-folder branch, not one per shard"
570 );
571 let files: Vec<String> = emails[0]
572 .files
573 .iter()
574 .map(|p| p.to_string_lossy().into_owned())
575 .collect();
576 assert_eq!(
577 files,
578 vec![
579 "sources/emails/2026/04/older.md".to_string(),
580 "sources/emails/2026/05/newer.md".to_string(),
581 "sources/emails/loose.md".to_string(),
582 ],
583 "every file under the type-folder, across shards, appears once"
584 );
585 }
586
587 #[test]
588 fn tree_excludes_index_and_log_and_db_meta_files() {
589 let fx = Fixture::new();
590 fx.write("records/contacts/sarah.md", &doc("sarah"));
592 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");
601 let all_files: Vec<String> = tree
602 .layers
603 .iter()
604 .flat_map(|l| &l.type_folders)
605 .flat_map(|tf| &tf.files)
606 .map(|p| p.to_string_lossy().into_owned())
607 .collect();
608
609 assert_eq!(
610 all_files,
611 vec!["records/contacts/sarah.md".to_string()],
612 "only the real content file survives; no index.md/index.jsonl/log files"
613 );
614 assert!(tree
616 .layers
617 .iter()
618 .all(|l| matches!(l.layer, Layer::Sources | Layer::Records | Layer::Wiki)));
619 }
620
621 #[test]
622 fn tree_omits_empty_layers_and_empty_type_folders() {
623 let fx = Fixture::new();
624 fx.write("records/contacts/a.md", &doc("a"));
625 fx.mkdir("records/companies");
627 fx.mkdir("wiki");
629 fx.write("sources/emails/index.md", "---\ntype: index\n---\n");
631
632 let tree = tree(&fx.store, None, None).expect("tree");
633
634 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
635 assert_eq!(
636 layers,
637 vec![Layer::Records],
638 "empty wiki layer and meta-only sources layer are omitted"
639 );
640 let folders: Vec<String> = tree.layers[0]
641 .type_folders
642 .iter()
643 .map(|tf| tf.path.to_string_lossy().into_owned())
644 .collect();
645 assert_eq!(
646 folders,
647 vec!["records/contacts".to_string()],
648 "the empty companies type-folder is omitted"
649 );
650 }
651
652 #[test]
653 fn tree_layer_filter_restricts_to_one_layer() {
654 let fx = Fixture::new();
655 fx.write("sources/emails/a.md", &doc("a"));
656 fx.write("records/contacts/b.md", &doc("b"));
657 fx.write("wiki/people/c.md", &doc("c"));
658
659 let tree = tree(&fx.store, Some(Layer::Records), None).expect("tree");
660 let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
661 assert_eq!(
662 layers,
663 vec![Layer::Records],
664 "only the requested layer is walked"
665 );
666 }
667
668 #[test]
669 fn tree_type_filter_keeps_only_matching_folder_name_across_layers() {
670 let fx = Fixture::new();
671 fx.write("sources/notes/s.md", &doc("source note"));
673 fx.write("wiki/notes/w.md", &doc("wiki note"));
674 fx.write("records/contacts/c.md", &doc("contact"));
675
676 let tree = tree(&fx.store, None, Some("notes")).expect("tree");
677 let folders: Vec<String> = tree
678 .layers
679 .iter()
680 .flat_map(|l| &l.type_folders)
681 .map(|tf| tf.path.to_string_lossy().into_owned())
682 .collect();
683 assert_eq!(
684 folders,
685 vec!["sources/notes".to_string(), "wiki/notes".to_string()],
686 "type filter matches the folder name in every layer, excludes other folders"
687 );
688 }
689
690 #[test]
691 fn tree_excludes_loose_files_directly_under_a_layer() {
692 let fx = Fixture::new();
693 fx.write("records/contacts/real.md", &doc("real"));
694 fx.write("records/stray.md", &doc("stray"));
696
697 let tree = tree(&fx.store, None, None).expect("tree");
698 let all_files: Vec<String> = tree
699 .layers
700 .iter()
701 .flat_map(|l| &l.type_folders)
702 .flat_map(|tf| &tf.files)
703 .map(|p| p.to_string_lossy().into_owned())
704 .collect();
705 assert_eq!(
706 all_files,
707 vec!["records/contacts/real.md".to_string()],
708 "a layer-direct file has no type-folder slot and is not listed"
709 );
710 }
711
712 #[test]
713 fn tree_skips_hidden_directories() {
714 let fx = Fixture::new();
715 fx.write("records/contacts/a.md", &doc("a"));
716 fx.write(".git/objects/x.md", &doc("vcs junk"));
718 fx.write("records/.hidden/h.md", &doc("hidden type folder"));
719 fx.write("sources/emails/.tmp/draft.md", &doc("hidden shard"));
720
721 let tree = tree(&fx.store, None, None).expect("tree");
722 let all_files: Vec<String> = tree
723 .layers
724 .iter()
725 .flat_map(|l| &l.type_folders)
726 .flat_map(|tf| &tf.files)
727 .map(|p| p.to_string_lossy().into_owned())
728 .collect();
729 assert_eq!(
730 all_files,
731 vec!["records/contacts/a.md".to_string()],
732 "hidden dirs are skipped at the type-folder and shard levels"
733 );
734 }
735
736 #[test]
737 fn tree_paths_are_store_relative_not_absolute() {
738 let fx = Fixture::new();
739 fx.write("records/contacts/a.md", &doc("a"));
740
741 let tree = tree(&fx.store, None, None).expect("tree");
742 let tf = &tree.layers[0].type_folders[0];
743 assert!(
744 tf.path.is_relative() && tf.files[0].is_relative(),
745 "tree paths must be store-relative"
746 );
747 let root_str = fx.store.root.to_string_lossy().into_owned();
749 assert!(!tf.files[0].to_string_lossy().contains(&root_str));
750 }
751
752 #[test]
753 fn tree_on_store_with_no_layers_is_empty() {
754 let fx = Fixture::new(); let tree = tree(&fx.store, None, None).expect("tree");
756 assert!(
757 tree.layers.is_empty(),
758 "a store with no content has an empty tree"
759 );
760 }
761
762 fn headings(o: &Outline) -> Vec<(String, u8, u32)> {
766 o.sections
767 .iter()
768 .map(|s| (s.heading.clone(), s.level, s.line))
769 .collect()
770 }
771
772 #[test]
773 fn outline_extracts_sections_with_levels_and_body_relative_lines() {
774 let fx = Fixture::new();
775 let file = "---\ntype: note\nsummary: s\n---\n\n# Title\n\n## Alpha\ntext\n### Sub\nmore\n## Beta\nend\n";
779 fx.write("wiki/notes/n.md", file);
780
781 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
782 assert_eq!(
783 headings(&o),
784 vec![
785 ("Alpha".to_string(), 2, 4),
786 ("Sub".to_string(), 3, 6),
787 ("Beta".to_string(), 2, 8),
788 ],
789 "only ##+ headings, with body-relative 1-based line numbers; the # title is not a section"
790 );
791 assert_eq!(o.file, PathBuf::from("wiki/notes/n.md"));
792 }
793
794 #[test]
795 fn outline_section_body_spans_to_next_sibling_or_shallower_heading() {
796 let fx = Fixture::new();
797 let file = "---\nx: 1\n---\n## Alpha\na1\na2\n### Sub\ns1\n## Beta\nb1\n";
798 fx.write("wiki/notes/n.md", file);
799
800 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
801 let alpha = &o.sections[0];
802 assert_eq!(alpha.heading, "Alpha");
804 assert_eq!(
805 alpha.body, "## Alpha\na1\na2\n### Sub\ns1\n",
806 "a ## body runs through deeper headings up to the next sibling-or-shallower heading"
807 );
808
809 let sub = &o.sections[1];
810 assert_eq!(sub.heading, "Sub");
811 assert_eq!(
812 sub.body, "### Sub\ns1\n",
813 "the nested ### body stops at the next ## (shallower) heading"
814 );
815
816 let beta = &o.sections[2];
817 assert_eq!(
818 beta.body, "## Beta\nb1\n",
819 "the trailing ## body runs to end of file"
820 );
821 }
822
823 #[test]
824 fn outline_shallower_heading_terminates_a_section_body() {
825 let fx = Fixture::new();
826 let file = "---\nx: 1\n---\n## Sec\nbody1\n# NewTitle\nafter\n";
828 fx.write("wiki/notes/n.md", file);
829
830 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
831 assert_eq!(headings(&o), vec![("Sec".to_string(), 2, 1)]);
832 assert_eq!(
833 o.sections[0].body, "## Sec\nbody1\n",
834 "the level-1 heading is shallower and ends the section, and is itself not a section"
835 );
836 }
837
838 #[test]
839 fn outline_ignores_headings_inside_fenced_code_blocks() {
840 let fx = Fixture::new();
841 let file = "---\nx: 1\n---\n## Real\n```\n## fake heading in code\n### also fake\n```\nafter\n## AlsoReal\n";
842 fx.write("wiki/notes/n.md", file);
843
844 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
845 assert_eq!(
848 headings(&o),
849 vec![("Real".to_string(), 2, 1), ("AlsoReal".to_string(), 2, 7)],
850 "## inside a ``` fence is code, not a heading"
851 );
852 assert!(o.sections[0].body.contains("## fake heading in code"));
854 }
855
856 #[test]
857 fn outline_ignores_tilde_fences_too() {
858 let fx = Fixture::new();
859 let file = "---\nx: 1\n---\n## Real\n~~~\n## fake\n~~~\ntail\n";
860 fx.write("wiki/notes/n.md", file);
861
862 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
863 assert_eq!(headings(&o), vec![("Real".to_string(), 2, 1)]);
864 }
865
866 #[test]
867 fn outline_rejects_non_heading_hash_lines() {
868 let fx = Fixture::new();
869 let file = "---\nx: 1\n---\n#nospace\n####### sevenhashes\n## Good\n";
871 fx.write("wiki/notes/n.md", file);
872
873 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
874 assert_eq!(
875 headings(&o),
876 vec![("Good".to_string(), 2, 3)],
877 "only the well-formed ## heading counts"
878 );
879 }
880
881 #[test]
882 fn outline_strips_atx_closing_hashes_from_heading_text() {
883 let fx = Fixture::new();
884 let file = "---\nx: 1\n---\n## Title ##\n";
885 fx.write("wiki/notes/n.md", file);
886
887 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
888 assert_eq!(o.sections[0].heading, "Title");
889 }
890
891 #[test]
892 fn outline_handles_file_without_frontmatter_numbering_from_line_one() {
893 let fx = Fixture::new();
894 let file = "## First\ntext\n## Second\n";
896 fx.write("wiki/notes/n.md", file);
897
898 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
899 assert_eq!(
900 headings(&o),
901 vec![("First".to_string(), 2, 1), ("Second".to_string(), 2, 3)],
902 "with no frontmatter the body is the whole file and lines count from 1"
903 );
904 }
905
906 #[test]
907 fn outline_accepts_absolute_path_and_returns_store_relative_file() {
908 let fx = Fixture::new();
909 fx.write("records/contacts/x.md", "---\nx: 1\n---\n## H\n");
910 let abs = fx.store.root.join("records/contacts/x.md");
911
912 let o = outline(&fx.store, &abs).expect("outline");
913 assert_eq!(
914 o.file,
915 PathBuf::from("records/contacts/x.md"),
916 "an absolute input path is normalized to store-relative in the Outline"
917 );
918 assert_eq!(o.sections.len(), 1);
919 }
920
921 #[test]
922 fn outline_of_a_file_with_no_headings_is_empty() {
923 let fx = Fixture::new();
924 fx.write(
925 "wiki/notes/n.md",
926 "---\nx: 1\n---\njust prose, no headings\n",
927 );
928
929 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
930 assert!(
931 o.sections.is_empty(),
932 "a heading-free body yields no sections"
933 );
934 }
935
936 #[test]
937 fn outline_missing_file_is_an_io_error() {
938 let fx = Fixture::new();
939 let err = outline(&fx.store, Path::new("wiki/notes/does-not-exist.md"))
940 .expect_err("missing file should error");
941 assert!(
942 matches!(err, StoreError::Io(_)),
943 "a missing file surfaces as a StoreError::Io, got {err:?}"
944 );
945 }
946
947 #[test]
948 fn outline_handles_crlf_frontmatter_and_indented_headings() {
949 let fx = Fixture::new();
950 let file = "---\r\nx: 1\r\n---\r\n ## Indented3\nbody\n ## Indented4Code\n";
953 fx.write("wiki/notes/n.md", file);
954
955 let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
956 assert_eq!(
957 headings(&o),
958 vec![("Indented3".to_string(), 2, 1)],
959 "<=3 leading spaces is a heading; 4 spaces is indented code, not a heading"
960 );
961 }
962}