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