use std::path::{Path, PathBuf};
use crate::parser::Section;
use crate::store::{Layer, Store, StoreError};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Tree {
pub layers: Vec<TreeLayer>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeLayer {
pub layer: Layer,
pub type_folders: Vec<TreeTypeFolder>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeTypeFolder {
pub path: PathBuf,
pub files: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outline {
pub file: PathBuf,
pub sections: Vec<Section>,
}
pub fn tree(store: &Store, layer: Option<Layer>, type_: Option<&str>) -> Result<Tree, StoreError> {
let mut layers = Vec::new();
for l in Layer::all() {
if let Some(want) = layer {
if l != want {
continue;
}
}
let layer_abs = store.root.join(layer_dir_name(l));
if !layer_abs.is_dir() {
continue;
}
let mut type_dir_names: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&layer_abs)? {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if is_skipped_dir(&name) {
continue;
}
type_dir_names.push(name);
}
type_dir_names.sort();
let mut type_folders = Vec::new();
for type_name in type_dir_names {
if let Some(want) = type_ {
if type_name != want {
continue;
}
}
let type_abs = layer_abs.join(&type_name);
let mut files: Vec<PathBuf> = Vec::new();
collect_content_files(&store.root, &type_abs, &mut files)?;
if files.is_empty() {
continue;
}
files.sort();
type_folders.push(TreeTypeFolder {
path: PathBuf::from(layer_dir_name(l)).join(&type_name),
files,
});
}
if type_folders.is_empty() {
continue;
}
layers.push(TreeLayer {
layer: l,
type_folders,
});
}
Ok(Tree { layers })
}
fn layer_dir_name(layer: Layer) -> &'static str {
match layer {
Layer::Sources => "sources",
Layer::Records => "records",
Layer::Wiki => "wiki",
}
}
fn is_skipped_dir(name: &str) -> bool {
name == "log" || name.starts_with('.')
}
fn is_content_md(name: &str) -> bool {
name.ends_with(".md") && name != "index.md"
}
fn collect_content_files(
store_root: &Path,
dir: &Path,
out: &mut Vec<PathBuf>,
) -> Result<(), StoreError> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
let name = entry.file_name().to_string_lossy().into_owned();
if file_type.is_dir() {
if name.starts_with('.') {
continue;
}
collect_content_files(store_root, &entry.path(), out)?;
} else if file_type.is_file() && is_content_md(&name) {
let abs = entry.path();
let rel = abs.strip_prefix(store_root).unwrap_or(&abs).to_path_buf();
out.push(rel);
}
}
Ok(())
}
pub fn outline(store: &Store, file: &Path) -> Result<Outline, StoreError> {
let abs = if file.is_absolute() {
file.to_path_buf()
} else {
store.root.join(file)
};
let rel = abs.strip_prefix(&store.root).unwrap_or(file).to_path_buf();
let text = std::fs::read_to_string(&abs)?;
let body = strip_frontmatter(&text);
let sections = parse_sections(body);
Ok(Outline {
file: rel,
sections,
})
}
fn strip_frontmatter(text: &str) -> &str {
let after_open = match text.strip_prefix("---\n") {
Some(rest) => rest,
None => match text.strip_prefix("---\r\n") {
Some(rest) => rest,
None => return text,
},
};
let mut search_from = 0usize;
while let Some(rel_idx) = after_open[search_from..].find("---") {
let idx = search_from + rel_idx;
let at_line_start = idx == 0 || after_open.as_bytes()[idx - 1] == b'\n';
let after = &after_open[idx + 3..];
let line_ends = after.is_empty()
|| after.starts_with('\n')
|| after.starts_with("\r\n")
|| after.starts_with('\r');
if at_line_start && line_ends {
if let Some(stripped) = after.strip_prefix("\r\n") {
return stripped;
}
if let Some(stripped) = after.strip_prefix('\n') {
return stripped;
}
if let Some(stripped) = after.strip_prefix('\r') {
return stripped;
}
return after; }
search_from = idx + 3;
}
text
}
fn parse_sections(body: &str) -> Vec<Section> {
let lines: Vec<&str> = body.split_inclusive('\n').collect();
let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
let mut fence: Option<(u8, usize)> = None; for line in &lines {
let content = line.trim_end_matches(['\n', '\r']);
if let Some(f) = fence {
if is_closing_fence(content, f) {
fence = None;
}
levels.push(0);
continue;
}
if let Some(opened) = opening_fence(content) {
fence = Some(opened);
levels.push(0);
continue;
}
levels.push(heading_level(content));
}
let mut sections = Vec::new();
for (i, &lvl) in levels.iter().enumerate() {
if lvl < 2 {
continue;
}
let heading_line = lines[i].trim_end_matches(['\n', '\r']);
let heading = heading_text(heading_line, lvl);
let mut end = lines.len();
for (j, &other) in levels.iter().enumerate().skip(i + 1) {
if other != 0 && other <= lvl {
end = j;
break;
}
}
let body_slice: String = lines[i..end].concat();
sections.push(Section {
heading,
level: lvl,
line: (i + 1) as u32,
body: body_slice,
});
}
sections
}
fn heading_level(line: &str) -> u8 {
let indent = line.len() - line.trim_start_matches(' ').len();
if indent > 3 {
return 0;
}
let rest = &line[indent..];
let hashes = rest.len() - rest.trim_start_matches('#').len();
if hashes == 0 || hashes > 6 {
return 0;
}
let after = &rest[hashes..];
if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
hashes as u8
} else {
0
}
}
fn heading_text(line: &str, level: u8) -> String {
let indent = line.len() - line.trim_start_matches(' ').len();
let after_hashes = &line[indent + level as usize..];
let trimmed = after_hashes.trim();
let no_trailing = trimmed.trim_end_matches('#');
if no_trailing.len() == trimmed.len() {
trimmed.to_string()
} else {
no_trailing.trim_end().to_string()
}
}
fn opening_fence(line: &str) -> Option<(u8, usize)> {
let indent = line.len() - line.trim_start_matches(' ').len();
if indent > 3 {
return None;
}
let rest = &line[indent..];
let byte = rest.bytes().next()?;
if byte != b'`' && byte != b'~' {
return None;
}
let run = rest.len() - rest.trim_start_matches(byte as char).len();
if run < 3 {
return None;
}
if byte == b'`' && rest[run..].contains('`') {
return None;
}
Some((byte, run))
}
fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
let (byte, open_len) = fence;
let indent = line.len() - line.trim_start_matches(' ').len();
if indent > 3 {
return false;
}
let rest = &line[indent..];
let run = rest.len() - rest.trim_start_matches(byte as char).len();
if run < open_len {
return false;
}
rest[run..].trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::Config;
use std::fs;
use tempfile::TempDir;
struct Fixture {
_dir: TempDir,
store: Store,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("DB.md"), "---\ntype: db\n---\n").expect("write DB.md");
let store = Store {
root: dir.path().to_path_buf(),
config: Config::default(),
};
Fixture { _dir: dir, store }
}
fn write(&self, rel: &str, contents: &str) {
let abs = self.store.root.join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).expect("create parents");
}
fs::write(abs, contents).expect("write file");
}
fn mkdir(&self, rel: &str) {
fs::create_dir_all(self.store.root.join(rel)).expect("mkdir");
}
}
fn doc(summary: &str) -> String {
format!("---\ntype: contact\nsummary: {summary}\n---\n\nbody\n")
}
fn shape(tree: &Tree) -> Vec<(Layer, String, Vec<String>)> {
let mut out = Vec::new();
for layer in &tree.layers {
for tf in &layer.type_folders {
let files = tf
.files
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
out.push((layer.layer, tf.path.to_string_lossy().into_owned(), files));
}
}
out
}
#[test]
fn tree_groups_by_layer_then_type_folder_in_canonical_order() {
let fx = Fixture::new();
fx.write("wiki/people/sarah.md", &doc("sarah bio"));
fx.write("records/contacts/sarah-chen.md", &doc("sarah contact"));
fx.write("sources/emails/a.md", &doc("an email"));
let tree = tree(&fx.store, None, None).expect("tree");
let layer_order: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
assert_eq!(
layer_order,
vec![Layer::Sources, Layer::Records, Layer::Wiki],
"layers must come back in canonical order regardless of on-disk name order"
);
assert_eq!(
shape(&tree),
vec![
(
Layer::Sources,
"sources/emails".to_string(),
vec!["sources/emails/a.md".to_string()]
),
(
Layer::Records,
"records/contacts".to_string(),
vec!["records/contacts/sarah-chen.md".to_string()]
),
(
Layer::Wiki,
"wiki/people".to_string(),
vec!["wiki/people/sarah.md".to_string()]
),
]
);
}
#[test]
fn tree_type_folders_and_files_are_sorted_ascending() {
let fx = Fixture::new();
fx.write("records/expenses/z.md", &doc("z"));
fx.write("records/contacts/b.md", &doc("b"));
fx.write("records/contacts/a.md", &doc("a"));
let tree = tree(&fx.store, None, None).expect("tree");
let records = tree
.layers
.iter()
.find(|l| l.layer == Layer::Records)
.expect("records layer");
let folder_paths: Vec<String> = records
.type_folders
.iter()
.map(|tf| tf.path.to_string_lossy().into_owned())
.collect();
assert_eq!(
folder_paths,
vec![
"records/contacts".to_string(),
"records/expenses".to_string()
],
"type-folders sorted by path ascending"
);
let contacts = &records.type_folders[0];
let files: Vec<String> = contacts
.files
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert_eq!(
files,
vec![
"records/contacts/a.md".to_string(),
"records/contacts/b.md".to_string()
],
"files sorted by store-relative path ascending"
);
}
#[test]
fn tree_aggregates_files_across_date_shards_into_one_type_folder() {
let fx = Fixture::new();
fx.write("sources/emails/2026/05/newer.md", &doc("newer"));
fx.write("sources/emails/2026/04/older.md", &doc("older"));
fx.write("sources/emails/loose.md", &doc("loose at folder root"));
let tree = tree(&fx.store, None, None).expect("tree");
let emails: Vec<&TreeTypeFolder> = tree
.layers
.iter()
.flat_map(|l| &l.type_folders)
.filter(|tf| tf.path == *"sources/emails")
.collect();
assert_eq!(
emails.len(),
1,
"all shards of one type fold into a single type-folder branch, not one per shard"
);
let files: Vec<String> = emails[0]
.files
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert_eq!(
files,
vec![
"sources/emails/2026/04/older.md".to_string(),
"sources/emails/2026/05/newer.md".to_string(),
"sources/emails/loose.md".to_string(),
],
"every file under the type-folder, across shards, appears once"
);
}
#[test]
fn tree_excludes_index_and_log_and_db_meta_files() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", &doc("sarah"));
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");
let all_files: Vec<String> = tree
.layers
.iter()
.flat_map(|l| &l.type_folders)
.flat_map(|tf| &tf.files)
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert_eq!(
all_files,
vec!["records/contacts/sarah.md".to_string()],
"only the real content file survives; no index.md/index.jsonl/log files"
);
assert!(tree
.layers
.iter()
.all(|l| matches!(l.layer, Layer::Sources | Layer::Records | Layer::Wiki)));
}
#[test]
fn tree_omits_empty_layers_and_empty_type_folders() {
let fx = Fixture::new();
fx.write("records/contacts/a.md", &doc("a"));
fx.mkdir("records/companies");
fx.mkdir("wiki");
fx.write("sources/emails/index.md", "---\ntype: index\n---\n");
let tree = tree(&fx.store, None, None).expect("tree");
let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
assert_eq!(
layers,
vec![Layer::Records],
"empty wiki layer and meta-only sources layer are omitted"
);
let folders: Vec<String> = tree.layers[0]
.type_folders
.iter()
.map(|tf| tf.path.to_string_lossy().into_owned())
.collect();
assert_eq!(
folders,
vec!["records/contacts".to_string()],
"the empty companies type-folder is omitted"
);
}
#[test]
fn tree_layer_filter_restricts_to_one_layer() {
let fx = Fixture::new();
fx.write("sources/emails/a.md", &doc("a"));
fx.write("records/contacts/b.md", &doc("b"));
fx.write("wiki/people/c.md", &doc("c"));
let tree = tree(&fx.store, Some(Layer::Records), None).expect("tree");
let layers: Vec<Layer> = tree.layers.iter().map(|l| l.layer).collect();
assert_eq!(
layers,
vec![Layer::Records],
"only the requested layer is walked"
);
}
#[test]
fn tree_type_filter_keeps_only_matching_folder_name_across_layers() {
let fx = Fixture::new();
fx.write("sources/notes/s.md", &doc("source note"));
fx.write("wiki/notes/w.md", &doc("wiki note"));
fx.write("records/contacts/c.md", &doc("contact"));
let tree = tree(&fx.store, None, Some("notes")).expect("tree");
let folders: Vec<String> = tree
.layers
.iter()
.flat_map(|l| &l.type_folders)
.map(|tf| tf.path.to_string_lossy().into_owned())
.collect();
assert_eq!(
folders,
vec!["sources/notes".to_string(), "wiki/notes".to_string()],
"type filter matches the folder name in every layer, excludes other folders"
);
}
#[test]
fn tree_excludes_loose_files_directly_under_a_layer() {
let fx = Fixture::new();
fx.write("records/contacts/real.md", &doc("real"));
fx.write("records/stray.md", &doc("stray"));
let tree = tree(&fx.store, None, None).expect("tree");
let all_files: Vec<String> = tree
.layers
.iter()
.flat_map(|l| &l.type_folders)
.flat_map(|tf| &tf.files)
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert_eq!(
all_files,
vec!["records/contacts/real.md".to_string()],
"a layer-direct file has no type-folder slot and is not listed"
);
}
#[test]
fn tree_skips_hidden_directories() {
let fx = Fixture::new();
fx.write("records/contacts/a.md", &doc("a"));
fx.write(".git/objects/x.md", &doc("vcs junk"));
fx.write("records/.hidden/h.md", &doc("hidden type folder"));
fx.write("sources/emails/.tmp/draft.md", &doc("hidden shard"));
let tree = tree(&fx.store, None, None).expect("tree");
let all_files: Vec<String> = tree
.layers
.iter()
.flat_map(|l| &l.type_folders)
.flat_map(|tf| &tf.files)
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert_eq!(
all_files,
vec!["records/contacts/a.md".to_string()],
"hidden dirs are skipped at the type-folder and shard levels"
);
}
#[test]
fn tree_paths_are_store_relative_not_absolute() {
let fx = Fixture::new();
fx.write("records/contacts/a.md", &doc("a"));
let tree = tree(&fx.store, None, None).expect("tree");
let tf = &tree.layers[0].type_folders[0];
assert!(
tf.path.is_relative() && tf.files[0].is_relative(),
"tree paths must be store-relative"
);
let root_str = fx.store.root.to_string_lossy().into_owned();
assert!(!tf.files[0].to_string_lossy().contains(&root_str));
}
#[test]
fn tree_on_store_with_no_layers_is_empty() {
let fx = Fixture::new(); let tree = tree(&fx.store, None, None).expect("tree");
assert!(
tree.layers.is_empty(),
"a store with no content has an empty tree"
);
}
fn headings(o: &Outline) -> Vec<(String, u8, u32)> {
o.sections
.iter()
.map(|s| (s.heading.clone(), s.level, s.line))
.collect()
}
#[test]
fn outline_extracts_sections_with_levels_and_body_relative_lines() {
let fx = Fixture::new();
let file = "---\ntype: note\nsummary: s\n---\n\n# Title\n\n## Alpha\ntext\n### Sub\nmore\n## Beta\nend\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert_eq!(
headings(&o),
vec![
("Alpha".to_string(), 2, 4),
("Sub".to_string(), 3, 6),
("Beta".to_string(), 2, 8),
],
"only ##+ headings, with body-relative 1-based line numbers; the # title is not a section"
);
assert_eq!(o.file, PathBuf::from("wiki/notes/n.md"));
}
#[test]
fn outline_section_body_spans_to_next_sibling_or_shallower_heading() {
let fx = Fixture::new();
let file = "---\nx: 1\n---\n## Alpha\na1\na2\n### Sub\ns1\n## Beta\nb1\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
let alpha = &o.sections[0];
assert_eq!(alpha.heading, "Alpha");
assert_eq!(
alpha.body, "## Alpha\na1\na2\n### Sub\ns1\n",
"a ## body runs through deeper headings up to the next sibling-or-shallower heading"
);
let sub = &o.sections[1];
assert_eq!(sub.heading, "Sub");
assert_eq!(
sub.body, "### Sub\ns1\n",
"the nested ### body stops at the next ## (shallower) heading"
);
let beta = &o.sections[2];
assert_eq!(
beta.body, "## Beta\nb1\n",
"the trailing ## body runs to end of file"
);
}
#[test]
fn outline_shallower_heading_terminates_a_section_body() {
let fx = Fixture::new();
let file = "---\nx: 1\n---\n## Sec\nbody1\n# NewTitle\nafter\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert_eq!(headings(&o), vec![("Sec".to_string(), 2, 1)]);
assert_eq!(
o.sections[0].body, "## Sec\nbody1\n",
"the level-1 heading is shallower and ends the section, and is itself not a section"
);
}
#[test]
fn outline_ignores_headings_inside_fenced_code_blocks() {
let fx = Fixture::new();
let file = "---\nx: 1\n---\n## Real\n```\n## fake heading in code\n### also fake\n```\nafter\n## AlsoReal\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert_eq!(
headings(&o),
vec![("Real".to_string(), 2, 1), ("AlsoReal".to_string(), 2, 7)],
"## inside a ``` fence is code, not a heading"
);
assert!(o.sections[0].body.contains("## fake heading in code"));
}
#[test]
fn outline_ignores_tilde_fences_too() {
let fx = Fixture::new();
let file = "---\nx: 1\n---\n## Real\n~~~\n## fake\n~~~\ntail\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert_eq!(headings(&o), vec![("Real".to_string(), 2, 1)]);
}
#[test]
fn outline_rejects_non_heading_hash_lines() {
let fx = Fixture::new();
let file = "---\nx: 1\n---\n#nospace\n####### sevenhashes\n## Good\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert_eq!(
headings(&o),
vec![("Good".to_string(), 2, 3)],
"only the well-formed ## heading counts"
);
}
#[test]
fn outline_strips_atx_closing_hashes_from_heading_text() {
let fx = Fixture::new();
let file = "---\nx: 1\n---\n## Title ##\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert_eq!(o.sections[0].heading, "Title");
}
#[test]
fn outline_handles_file_without_frontmatter_numbering_from_line_one() {
let fx = Fixture::new();
let file = "## First\ntext\n## Second\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert_eq!(
headings(&o),
vec![("First".to_string(), 2, 1), ("Second".to_string(), 2, 3)],
"with no frontmatter the body is the whole file and lines count from 1"
);
}
#[test]
fn outline_accepts_absolute_path_and_returns_store_relative_file() {
let fx = Fixture::new();
fx.write("records/contacts/x.md", "---\nx: 1\n---\n## H\n");
let abs = fx.store.root.join("records/contacts/x.md");
let o = outline(&fx.store, &abs).expect("outline");
assert_eq!(
o.file,
PathBuf::from("records/contacts/x.md"),
"an absolute input path is normalized to store-relative in the Outline"
);
assert_eq!(o.sections.len(), 1);
}
#[test]
fn outline_of_a_file_with_no_headings_is_empty() {
let fx = Fixture::new();
fx.write(
"wiki/notes/n.md",
"---\nx: 1\n---\njust prose, no headings\n",
);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert!(
o.sections.is_empty(),
"a heading-free body yields no sections"
);
}
#[test]
fn outline_missing_file_is_an_io_error() {
let fx = Fixture::new();
let err = outline(&fx.store, Path::new("wiki/notes/does-not-exist.md"))
.expect_err("missing file should error");
assert!(
matches!(err, StoreError::Io(_)),
"a missing file surfaces as a StoreError::Io, got {err:?}"
);
}
#[test]
fn outline_handles_crlf_frontmatter_and_indented_headings() {
let fx = Fixture::new();
let file = "---\r\nx: 1\r\n---\r\n ## Indented3\nbody\n ## Indented4Code\n";
fx.write("wiki/notes/n.md", file);
let o = outline(&fx.store, Path::new("wiki/notes/n.md")).expect("outline");
assert_eq!(
headings(&o),
vec![("Indented3".to_string(), 2, 1)],
"<=3 leading spaces is a heading; 4 spaces is indented code, not a heading"
);
}
}