use std::path::{Path, PathBuf};
use crate::content::ContentFormat;
use crate::document::MetaCarrier;
use crate::error::{Error, Result};
use crate::fs::Storage;
use crate::identity::IdentityPolicy;
use crate::index::IndexStore;
use crate::intake::SynthNode;
use crate::link::{self, Link};
use crate::meta::Value;
use crate::workspace::{Target, Workspace};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Layout {
Flat,
#[default]
Nested,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoutePlan {
pub resolved: Vec<PathBuf>,
pub synthesize: Vec<SynthNode>,
pub terminal: PathBuf,
}
impl RoutePlan {
pub fn is_complete(&self) -> bool {
self.synthesize.is_empty()
}
}
fn title_text(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Int(i) => Some(i.to_string()),
Value::Float(f) => Some(f.to_string()),
Value::Bool(b) => Some(b.to_string()),
Value::Null | Value::Sequence(_) | Value::Mapping(_) => None,
}
}
fn synth_path(parent: &Path, segment: &str, layout: Layout, ext: &str) -> PathBuf {
let dir = parent.parent().unwrap_or(Path::new(""));
let stem = link::slug(segment);
match layout {
Layout::Flat => link::normalize(dir.join(format!("{stem}.{ext}"))),
Layout::Nested => link::normalize(dir.join(stem).join(format!("index.{ext}"))),
}
}
impl<FS: Storage, Id, Ix: IndexStore> Workspace<FS, Id, Ix> {
pub fn route_segments(route: &str) -> Vec<&str> {
route
.split('/')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect()
}
async fn child_titled(&self, parent: &Path, segment: &str) -> Result<Option<PathBuf>> {
let (_, doc) = self.load(parent).await?;
let mut matches: Vec<PathBuf> = Vec::new();
for raw in self.relations().children(&doc.meta) {
let link = Link::parse(&raw);
let Target::Path(path) = self.resolve_link(parent, &link) else {
continue;
};
let Ok((_, child)) = self.load(&path).await else {
continue;
};
if child.meta.get("title").and_then(title_text).as_deref() == Some(segment) {
matches.push(path);
}
}
match matches.len() {
0 => Ok(None),
1 => Ok(Some(matches.remove(0))),
_ => Err(Error::Structure(format!(
"{} has {} children titled {segment:?} ({}); a route cannot say which",
parent.display(),
matches.len(),
matches
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", "),
))),
}
}
pub async fn plan_route(
&self,
start: &Path,
segments: &[&str],
layout: Layout,
) -> Result<RoutePlan> {
let start = link::normalize(start);
if !self.fs().try_exists(&self.root().join(&start)).await? {
return Err(Error::NotFound(start.clone()));
}
let ext = start
.extension()
.and_then(|e| e.to_str())
.unwrap_or("md")
.to_string();
let mut current = start.clone();
let mut resolved = vec![start];
let mut synthesize: Vec<SynthNode> = Vec::new();
for segment in segments {
let found = if synthesize.is_empty() {
self.child_titled(¤t, segment).await?
} else {
None
};
match found {
Some(child) => {
current = child.clone();
resolved.push(child);
}
None => {
if synthesize.is_empty() {
self.assert_combined(¤t).await?;
}
let path = synth_path(¤t, segment, layout, &ext);
self.assert_vacant(&path, segment, layout).await?;
synthesize.push(SynthNode {
path: path.clone(),
parent: current.clone(),
title: (*segment).to_string(),
});
current = path;
}
}
}
Ok(RoutePlan {
resolved,
synthesize,
terminal: current,
})
}
fn declares_containment(&self, meta: &Value) -> bool {
let rels = self.relations();
let Some(spanning) = rels.spanning_relation() else {
return false;
};
if meta.get(spanning).is_some() {
return true;
}
rels.relations()
.iter()
.find(|r| r.name == spanning)
.and_then(|r| r.inverse.as_deref())
.is_some_and(|inverse| meta.get(inverse).is_some())
}
async fn assert_vacant(&self, path: &Path, segment: &str, layout: Layout) -> Result<()> {
if self.fs().try_exists(&self.root().join(path)).await? {
return Err(Error::Structure(format!(
"route segment {segment:?} would create {}, but a file is already there",
path.display()
)));
}
if layout != Layout::Nested {
return Ok(());
}
let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) else {
return Ok(());
};
let abs = self.root().join(dir);
if !self.fs().try_exists(&abs).await? {
return Ok(());
}
let Ok(entries) = self.fs().read_dir(&abs).await else {
return Ok(());
};
for entry in entries {
let Some(name) = entry.file_name() else {
continue;
};
let rel = link::normalize(dir.join(name));
if ContentFormat::from_extension(&rel).is_none() {
continue;
}
let Ok((_, doc)) = self.load(&rel).await else {
continue;
};
if !self.declares_containment(&doc.meta) {
continue;
}
let titled = doc
.meta
.get("title")
.and_then(title_text)
.map(|t| format!(" (titled {t:?})"))
.unwrap_or_default();
return Err(Error::Structure(format!(
"route segment {segment:?} would create {}, but {}{} is already a node there; \
route to its title instead",
path.display(),
rel.display(),
titled,
)));
}
Ok(())
}
async fn assert_combined(&self, parent: &Path) -> Result<()> {
let (_, doc) = self.load(parent).await?;
if doc.content_attr().is_some() || matches!(doc.carrier, Some(MetaCarrier::WholeFile(_))) {
return Err(Error::Structure(format!(
"cannot create a route node under {} — a separated or whole-file parent has no \
combined grammar for the new node to inherit; create it explicitly instead",
parent.display()
)));
}
Ok(())
}
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub async fn apply_route(&mut self, plan: &RoutePlan) -> Result<PathBuf> {
for synth in &plan.synthesize {
self.create_titled(&synth.path, &synth.parent, Some(&synth.title))
.await?;
}
Ok(plan.terminal.clone())
}
pub async fn ensure_route(
&mut self,
start: &Path,
segments: &[&str],
layout: Layout,
) -> Result<PathBuf> {
let plan = self.plan_route(start, segments, layout).await?;
self.apply_route(&plan).await
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
use crate::exec::block_on;
use crate::fs::StdFs;
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
fn read(dir: &Path, rel: &str) -> String {
std::fs::read_to_string(dir.join(rel)).unwrap()
}
fn tempdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("prov-route-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn ws(dir: &Path) -> Workspace<StdFs> {
Workspace::builder(StdFs).root(dir).build()
}
#[test]
fn segments_ignore_empty_and_surrounding_separators() {
assert_eq!(
Workspace::<StdFs>::route_segments("/Daily//2026/ 2026-07 /"),
vec!["Daily", "2026", "2026-07"]
);
assert!(Workspace::<StdFs>::route_segments("").is_empty());
}
#[test]
fn a_fully_existing_route_resolves_and_plans_nothing() {
let dir = tempdir("resolve");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- daily.md\n---\n",
);
write(
&dir,
"daily.md",
"---\ntitle: Daily\npart_of: index.md\ncontents:\n- y2026.md\n---\n",
);
write(
&dir,
"y2026.md",
"---\ntitle: '2026'\npart_of: daily.md\n---\n",
);
let plan = block_on(ws(&dir).plan_route(
Path::new("index.md"),
&["Daily", "2026"],
Layout::Nested,
))
.unwrap();
assert!(plan.is_complete(), "nothing to create");
assert_eq!(
plan.resolved,
vec![
PathBuf::from("index.md"),
PathBuf::from("daily.md"),
PathBuf::from("y2026.md")
]
);
assert_eq!(plan.terminal, PathBuf::from("y2026.md"));
}
#[test]
fn nested_layout_synthesizes_a_directory_per_segment() {
let dir = tempdir("nested");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
let plan = block_on(ws(&dir).plan_route(
Path::new("index.md"),
&["Daily", "2026", "2026-07"],
Layout::Nested,
))
.unwrap();
assert!(!plan.is_complete());
let paths: Vec<_> = plan.synthesize.iter().map(|s| s.path.clone()).collect();
assert_eq!(
paths,
vec![
PathBuf::from("daily/index.md"),
PathBuf::from("daily/2026/index.md"),
PathBuf::from("daily/2026/2026-07/index.md"),
],
"parents-first, a directory per segment"
);
assert_eq!(plan.terminal, PathBuf::from("daily/2026/2026-07/index.md"));
let terminal = block_on(ws(&dir).apply_route(&plan)).unwrap();
assert!(read(&dir, "daily/index.md").contains("title: Daily"));
assert!(read(&dir, "daily/2026/2026-07/index.md").contains("title: 2026-07"));
assert!(read(&dir, "index.md").contains("[Daily](/daily/index.md)"));
assert!(read(&dir, "daily/index.md").contains("2026/index.md"));
assert!(read(&dir, "daily/2026/index.md").contains("2026-07/index.md"));
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
let created = block_on(ws(&dir).create_with_title(
Path::new("daily/2026/2026-07/2026-07-14.md"),
&terminal,
"2026-07-14",
))
.unwrap();
assert_eq!(
created.node,
PathBuf::from("daily/2026/2026-07/2026-07-14.md")
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn flat_layout_keeps_synthesized_nodes_beside_the_start() {
let dir = tempdir("flat");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
let plan =
block_on(ws(&dir).plan_route(Path::new("index.md"), &["Daily", "2026"], Layout::Flat))
.unwrap();
let paths: Vec<_> = plan.synthesize.iter().map(|s| s.path.clone()).collect();
assert_eq!(
paths,
vec![PathBuf::from("daily.md"), PathBuf::from("2026.md")]
);
block_on(ws(&dir).apply_route(&plan)).unwrap();
assert!(
read(&dir, "2026.md").contains("title: '2026'"),
"{}",
read(&dir, "2026.md")
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn a_partial_route_resolves_what_exists_and_synthesizes_the_rest() {
let dir = tempdir("partial");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- daily/index.md\n---\n",
);
write(
&dir,
"daily/index.md",
"---\ntitle: Daily\npart_of: ../index.md\ncontents:\n- 2026/index.md\n---\n",
);
write(
&dir,
"daily/2026/index.md",
"---\ntitle: '2026'\npart_of: ../index.md\n---\n",
);
let plan = block_on(ws(&dir).plan_route(
Path::new("index.md"),
&["Daily", "2026", "2026-08"],
Layout::Nested,
))
.unwrap();
assert_eq!(plan.resolved.len(), 3, "start + Daily + 2026 all existed");
assert_eq!(plan.synthesize.len(), 1, "only the month is new");
assert_eq!(
plan.synthesize[0].path,
PathBuf::from("daily/2026/2026-08/index.md")
);
assert_eq!(
plan.synthesize[0].parent,
PathBuf::from("daily/2026/index.md")
);
block_on(ws(&dir).apply_route(&plan)).unwrap();
assert!(read(&dir, "daily/2026/index.md").contains("2026-08/index.md"));
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn applying_the_same_route_twice_resolves_instead_of_recreating() {
let dir = tempdir("idempotent");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
let segs = ["Daily", "2026", "2026-07"];
let first =
block_on(ws(&dir).ensure_route(Path::new("index.md"), &segs, Layout::Nested)).unwrap();
let second =
block_on(ws(&dir).ensure_route(Path::new("index.md"), &segs, Layout::Nested)).unwrap();
assert_eq!(
first, second,
"the second run resolves to the same terminal"
);
let plan =
block_on(ws(&dir).plan_route(Path::new("index.md"), &segs, Layout::Nested)).unwrap();
assert!(
plan.is_complete(),
"second time around there is nothing to create"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn an_empty_route_is_the_start_document() {
let dir = tempdir("empty");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
let plan =
block_on(ws(&dir).plan_route(Path::new("index.md"), &[], Layout::Nested)).unwrap();
assert!(plan.is_complete());
assert_eq!(plan.terminal, PathBuf::from("index.md"));
}
#[test]
fn a_route_matches_titles_not_filenames() {
let dir = tempdir("titles");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- journal.md\n---\n",
);
write(
&dir,
"journal.md",
"---\ntitle: Daily\npart_of: index.md\n---\n",
);
let by_title =
block_on(ws(&dir).plan_route(Path::new("index.md"), &["Daily"], Layout::Nested))
.unwrap();
assert!(
by_title.is_complete(),
"'Daily' resolves to journal.md by title"
);
assert_eq!(by_title.terminal, PathBuf::from("journal.md"));
let by_stem =
block_on(ws(&dir).plan_route(Path::new("index.md"), &["journal"], Layout::Nested))
.unwrap();
assert!(!by_stem.is_complete(), "the filename is not an address");
}
#[test]
fn a_year_titled_without_quotes_still_matches_its_segment() {
let dir = tempdir("untyped");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- 2026_index.md\n---\n",
);
write(
&dir,
"2026_index.md",
"---\ntitle: 2026\npart_of: index.md\n---\n",
);
let plan = block_on(ws(&dir).plan_route(Path::new("index.md"), &["2026"], Layout::Nested))
.unwrap();
assert!(
plan.is_complete(),
"an unquoted year title is still the title '2026'"
);
assert_eq!(plan.terminal, PathBuf::from("2026_index.md"));
}
#[test]
fn two_children_with_one_title_are_an_error_not_a_guess() {
let dir = tempdir("ambiguous");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- a.md\n- b.md\n---\n",
);
write(&dir, "a.md", "---\ntitle: Daily\npart_of: index.md\n---\n");
write(&dir, "b.md", "---\ntitle: Daily\npart_of: index.md\n---\n");
let err = block_on(ws(&dir).plan_route(Path::new("index.md"), &["Daily"], Layout::Nested))
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("2 children titled \"Daily\""), "{msg}");
assert!(
msg.contains("a.md") && msg.contains("b.md"),
"names both: {msg}"
);
}
#[test]
fn a_broken_sibling_does_not_derail_the_route() {
let dir = tempdir("broken-sibling");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- gone.md\n- https://example.com\n- daily.md\n---\n",
);
write(
&dir,
"daily.md",
"---\ntitle: Daily\npart_of: index.md\n---\n",
);
let plan = block_on(ws(&dir).plan_route(Path::new("index.md"), &["Daily"], Layout::Nested))
.unwrap();
assert!(plan.is_complete());
assert_eq!(plan.terminal, PathBuf::from("daily.md"));
}
#[test]
fn a_separated_parent_is_refused_only_when_it_must_synthesize() {
let dir = tempdir("separated");
write(
&dir,
"index.yaml",
"title: Root\ncontent: index.md\ncontents:\n- daily.yaml\n",
);
write(&dir, "index.md", "# Root\n");
write(&dir, "daily.yaml", "title: Daily\npart_of: index.yaml\n");
let ok = block_on(ws(&dir).plan_route(Path::new("index.yaml"), &["Daily"], Layout::Nested))
.unwrap();
assert!(
ok.is_complete(),
"an existing route resolves under a separated root"
);
let err = block_on(ws(&dir).plan_route(
Path::new("index.yaml"),
&["Daily", "2026"],
Layout::Nested,
))
.unwrap_err();
assert!(
err.to_string().contains("separated or whole-file parent"),
"{err}"
);
}
#[test]
fn a_missing_start_document_is_an_error() {
let dir = tempdir("no-start");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
let err = block_on(ws(&dir).plan_route(Path::new("nope.md"), &["Daily"], Layout::Nested))
.unwrap_err();
assert!(err.to_string().contains("does not exist"), "{err}");
}
#[test]
fn a_directory_already_holding_a_node_refuses_instead_of_minting_a_competitor() {
let dir = tempdir("occupied");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- '[Daily Index](/daily/daily_index.md)'\n---\n",
);
write(
&dir,
"daily/daily_index.md",
"---\ntitle: Daily Index\npart_of: '[Home](/index.md)'\ncontents:\n---\n",
);
let err = block_on(ws(&dir).plan_route(Path::new("index.md"), &["Daily"], Layout::Nested))
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("daily/daily_index.md"),
"names what is in the way: {msg}"
);
assert!(
msg.contains("Daily Index"),
"names the title to route to instead: {msg}"
);
}
#[test]
fn a_directory_of_plain_documents_does_not_block_a_route_node() {
let dir = tempdir("vacant");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
write(&dir, "notes/loose.md", "---\ntitle: Loose\n---\n");
let plan = block_on(ws(&dir).plan_route(Path::new("index.md"), &["Notes"], Layout::Nested))
.unwrap();
assert_eq!(
plan.synthesize
.iter()
.map(|s| s.path.clone())
.collect::<Vec<_>>(),
vec![PathBuf::from("notes/index.md")]
);
}
#[test]
fn a_file_squatting_the_synthesized_path_is_refused_while_planning() {
let dir = tempdir("squat");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
write(&dir, "daily/index.md", "---\ntitle: Unrelated\n---\n");
let err = block_on(ws(&dir).plan_route(Path::new("index.md"), &["Daily"], Layout::Nested))
.unwrap_err();
assert!(err.to_string().contains("a file is already there"), "{err}");
}
#[test]
fn flat_layout_is_not_blocked_by_the_nodes_beside_it() {
let dir = tempdir("flat-vacant");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- '[Other](/other.md)'\n---\n",
);
write(
&dir,
"other.md",
"---\ntitle: Other\npart_of: '[Home](/index.md)'\n---\n",
);
let plan =
block_on(ws(&dir).plan_route(Path::new("index.md"), &["Daily"], Layout::Flat)).unwrap();
assert_eq!(plan.synthesize[0].path, PathBuf::from("daily.md"));
}
#[test]
fn a_later_segment_landing_on_an_occupied_directory_is_refused_too() {
let dir = tempdir("deep-occupied");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- '[Daily](/daily/index.md)'\n---\n",
);
write(
&dir,
"daily/index.md",
"---\ntitle: Daily\npart_of: '[Home](/index.md)'\ncontents:\n---\n",
);
write(
&dir,
"daily/2026/2026_index.md",
"---\ntitle: '2026 Index'\ncontents:\n---\n",
);
let err = block_on(ws(&dir).plan_route(
Path::new("index.md"),
&["Daily", "2026"],
Layout::Nested,
))
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("daily/2026/2026_index.md"),
"names what is in the way: {msg}"
);
assert!(
msg.contains("2026 Index"),
"names the title to route to instead: {msg}"
);
}
}