use std::path::{Path, PathBuf};
use crate::config::{ROOT_CONFIG_KEY, WorkspaceConfig};
use crate::workspace::Workspace;
use prov_graph::content::ContentFormat;
use prov_graph::document::{self, Document};
use prov_graph::error::Result;
use prov_store::fs::Storage;
#[derive(Debug, Clone)]
pub struct Discovered {
pub root_dir: PathBuf,
pub root_doc: PathBuf,
pub registry: Option<PathBuf>,
pub node: crate::node::Located,
pub config: WorkspaceConfig,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum Discovery {
Found(Discovered),
Ambiguous {
dir: PathBuf,
candidates: Vec<String>,
},
NotFound,
}
fn stem_is(name: &Path, want: &str) -> bool {
name.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.eq_ignore_ascii_case(want))
}
pub async fn discover<FS: Storage + Clone>(fs: &FS, from: &Path) -> Result<Discovery> {
for dir in from.ancestors() {
let Ok(entries) = fs.read_dir(dir).await else {
continue;
};
let located = crate::node::locate_in(fs, dir, &entries).await;
if let Some(root_doc) = node_named_root(fs, dir, &located).await {
let discovered = build(fs, dir.to_path_buf(), root_doc, located).await?;
return Ok(Discovery::Found(discovered));
}
let shaped: Vec<PathBuf> = entries
.iter()
.map(|entry| entry.path().to_path_buf())
.filter(|path| can_be_root(path))
.collect();
for preferred in ["index", "readme"] {
for path in shaped.iter().filter(|path| stem_is(path, preferred)) {
if root_candidate_name(fs, path).await.is_some() {
let root_doc = path.file_name().expect("candidate has a filename");
let discovered =
build(fs, dir.to_path_buf(), PathBuf::from(root_doc), located).await?;
return Ok(Discovery::Found(discovered));
}
}
}
let mut candidates: Vec<String> = Vec::new();
for path in shaped
.iter()
.filter(|path| !stem_is(path, "index") && !stem_is(path, "readme"))
{
if let Some(name) = root_candidate_name(fs, path).await {
candidates.push(name);
}
}
match choose_root(&candidates) {
Some(root_doc) => {
let discovered =
build(fs, dir.to_path_buf(), PathBuf::from(root_doc), located).await?;
return Ok(Discovery::Found(discovered));
}
None if candidates.len() > 1 => {
return Ok(Discovery::Ambiguous {
dir: dir.to_path_buf(),
candidates,
});
}
None => continue,
}
}
Ok(Discovery::NotFound)
}
async fn root_candidate_name<FS: Storage>(fs: &FS, path: &Path) -> Option<String> {
let text = fs.read_to_string(path).await.ok()?;
let doc = Document::parse(path, &text).ok()?;
is_root_candidate(&doc)
.then(|| path.file_name()?.to_str().map(str::to_owned))
.flatten()
}
fn can_be_root(path: &Path) -> bool {
let is_content_ext = ContentFormat::from_extension(path).is_some();
let is_meta_ext = document::whole_file_format(path).is_some();
if is_content_ext {
return true;
}
is_meta_ext && (stem_is(path, "index") || stem_is(path, "readme"))
}
fn is_root_candidate(doc: &Document) -> bool {
doc.has_meta() && doc.meta.get("part_of").is_none() && !crate::about::is_generated(&doc.meta)
}
fn choose_root(candidates: &[String]) -> Option<String> {
candidates
.iter()
.find(|n| stem_is(Path::new(n), "index"))
.or_else(|| candidates.iter().find(|n| stem_is(Path::new(n), "readme")))
.cloned()
.or_else(|| (candidates.len() == 1).then(|| candidates[0].clone()))
}
impl<FS: prov_graph::fs::ReadStorage, Id, Ix: prov_graph::index::IdIndex> Workspace<FS, Id, Ix> {
pub async fn root_document(&self) -> Result<Option<PathBuf>> {
if let Some(named) = self.named_root().map(Path::to_path_buf)
&& let Ok((_, doc)) = self.load(&named).await
&& doc.has_meta()
{
return Ok(Some(named));
}
let mut candidates = Vec::new();
for entry in self.listing(Path::new("")).await? {
if entry.file_type().is_dir() {
continue;
}
let Some(name) = entry.file_name().and_then(|n| n.to_str()) else {
continue;
};
let path = PathBuf::from(name);
if !can_be_root(&path) {
continue;
}
let Ok((_, doc)) = self.load(&path).await else {
continue;
};
if is_root_candidate(&doc) {
candidates.push(name.to_string());
}
}
Ok(choose_root(&candidates).map(PathBuf::from))
}
}
async fn node_named_root<FS: Storage>(
fs: &FS,
dir: &Path,
located: &crate::node::Located,
) -> Option<PathBuf> {
let node = located.node.as_ref()?;
let text = fs.read_to_string(&dir.join(node)).await.ok()?;
let doc = Document::parse(node, &text).ok()?;
let mut config = WorkspaceConfig::default();
config.apply(&doc.meta);
let named = PathBuf::from(config.root?);
let text = fs.read_to_string(&dir.join(&named)).await.ok()?;
Document::parse(&named, &text)
.ok()
.filter(Document::has_meta)
.map(|_| named)
}
async fn build<FS: Storage + Clone>(
fs: &FS,
root_dir: PathBuf,
root_doc: PathBuf,
node: crate::node::Located,
) -> Result<Discovered> {
let probe: Workspace<FS> = Workspace::builder(fs.clone()).root(&root_dir).build();
let registry = probe.registry_path(&root_doc).await?;
let mut config = WorkspaceConfig::default();
if let Ok(text) = fs.read_to_string(&root_dir.join(&root_doc)).await
&& let Ok(doc) = Document::parse(&root_doc, &text)
&& let Some(block) = doc.meta.get(ROOT_CONFIG_KEY)
{
config.apply(block);
}
if let Some(node_doc) = &node.node
&& let Ok(text) = fs.read_to_string(&root_dir.join(node_doc)).await
&& let Ok(doc) = Document::parse(node_doc, &text)
{
config.apply(&doc.meta);
}
if let Ok(Some(config_doc)) = probe.config_path(&root_doc).await
&& let Ok(text) = fs.read_to_string(&root_dir.join(&config_doc)).await
&& let Ok(doc) = Document::parse(&config_doc, &text)
{
config.apply(&doc.meta);
}
Ok(Discovered {
root_dir,
root_doc,
registry,
node,
config,
})
}
#[cfg(test)]
mod tests {
use super::*;
use prov_graph::exec::block_on;
use prov_graph::fs::StdFs;
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("prov-discover-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn finds_the_root_by_walking_up_from_a_nested_dir() {
let root = tmp("walk-up");
std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n# Home\n").unwrap();
std::fs::create_dir_all(root.join("a/b")).unwrap();
std::fs::write(
root.join("a/child.md"),
"---\ntitle: Child\npart_of: '[Home](/index.md)'\n---\n",
)
.unwrap();
let outcome = block_on(discover(&StdFs, &root.join("a/b"))).unwrap();
match outcome {
Discovery::Found(d) => {
assert_eq!(d.root_dir, root);
assert_eq!(d.root_doc, Path::new("index.md"));
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn two_unnamed_candidates_are_ambiguous() {
let root = tmp("ambiguous");
std::fs::write(root.join("one.md"), "---\ntitle: One\n---\n").unwrap();
std::fs::write(root.join("two.md"), "---\ntitle: Two\n---\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Ambiguous { candidates, .. } => assert_eq!(candidates.len(), 2),
other => panic!("expected Ambiguous, got {other:?}"),
}
}
#[test]
fn index_stem_breaks_a_tie() {
let root = tmp("index-wins");
std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n").unwrap();
std::fs::write(root.join("other.md"), "---\ntitle: Other\n---\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => assert_eq!(d.root_doc, Path::new("index.md")),
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn a_directory_holding_no_document_yields_no_candidate_there() {
let root = tmp("no-doc-here");
std::fs::write(root.join("plain.txt"), "not a document").unwrap();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/index.md"), "---\ntitle: Sub\n---\n").unwrap();
match block_on(discover(&StdFs, &root.join("sub"))).unwrap() {
Discovery::Found(d) => assert_eq!(d.root_dir, root.join("sub")),
other => panic!("expected Found at sub, got {other:?}"),
}
}
fn probe(dir: &Path) -> Workspace<StdFs> {
Workspace::builder(StdFs).root(dir).build()
}
fn probe_with_node(dir: &Path) -> Workspace<StdFs> {
let config = match block_on(discover(&StdFs, dir)).unwrap() {
Discovery::Found(d) => d.config,
other => panic!("expected a discovered workspace, got {other:?}"),
};
Workspace::builder(StdFs)
.root(dir)
.settings((&config).into())
.build()
}
#[test]
fn root_document_names_the_root_of_a_located_workspace() {
let root = tmp("root-doc");
std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n").unwrap();
std::fs::write(root.join("about.md"), "---\ntitle: About\n---\n").unwrap();
std::fs::write(
root.join("child.md"),
"---\ntitle: Child\npart_of: index.md\n---\n",
)
.unwrap();
assert_eq!(
block_on(probe(&root).root_document()).unwrap(),
Some(PathBuf::from("index.md"))
);
}
#[test]
fn the_generated_page_is_never_a_root_candidate() {
let root = tmp("generated-page");
std::fs::write(
root.join("root.md"),
"---\ntitle: Home\nabout: about.md\n---\n",
)
.unwrap();
std::fs::write(
root.join("about.md"),
"---\ntitle: How this workspace is organized\ngenerated_by: prov 0.5.0\n---\n",
)
.unwrap();
assert_eq!(
block_on(probe(&root).root_document()).unwrap(),
Some(PathBuf::from("root.md"))
);
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => assert_eq!(d.root_doc, PathBuf::from("root.md")),
other => panic!("expected root.md, got {other:?}"),
}
}
#[test]
fn another_tool_s_byline_does_not_disqualify_a_root() {
let root = tmp("foreign-byline");
std::fs::write(
root.join("readme.md"),
"---\ntitle: Home\ngenerated_by: some-site-generator 2.0\n---\n",
)
.unwrap();
assert_eq!(
block_on(probe(&root).root_document()).unwrap(),
Some(PathBuf::from("readme.md"))
);
}
#[test]
fn a_named_root_settles_a_directory_that_cannot_be_chosen_in() {
let root = tmp("named-root-tie");
std::fs::write(root.join("one.md"), "---\ntitle: One\n---\n").unwrap();
std::fs::write(root.join("two.md"), "---\ntitle: Two\n---\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Ambiguous { .. } => {}
other => panic!("expected a tie before the node exists, got {other:?}"),
}
std::fs::write(root.join("prov.yaml"), "root: two.md\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => {
assert_eq!(d.root_doc, PathBuf::from("two.md"));
assert_eq!(d.node.node, Some(PathBuf::from("prov.yaml")));
}
other => panic!("expected two.md, got {other:?}"),
}
assert_eq!(
block_on(probe_with_node(&root).root_document()).unwrap(),
Some(PathBuf::from("two.md")),
"a located workspace makes the same judgment"
);
}
#[test]
fn a_named_root_beats_the_conventional_stem() {
let root = tmp("named-root-wins");
std::fs::write(root.join("index.md"), "---\ntitle: Index\n---\n").unwrap();
std::fs::write(root.join("home.md"), "---\ntitle: Home\n---\n").unwrap();
std::fs::write(root.join("prov.yaml"), "root: home.md\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => assert_eq!(d.root_doc, PathBuf::from("home.md")),
other => panic!("expected home.md, got {other:?}"),
}
}
#[test]
fn a_named_root_that_is_not_there_falls_back_to_the_scan() {
let root = tmp("named-root-dangling");
std::fs::write(root.join("index.md"), "---\ntitle: Index\n---\n").unwrap();
std::fs::write(root.join("prov.yaml"), "root: hoem.md\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => assert_eq!(d.root_doc, PathBuf::from("index.md")),
other => panic!("expected the scan's answer, got {other:?}"),
}
}
#[test]
fn a_malformed_named_root_is_ignored_like_a_malformed_workspace_id() {
let root = tmp("named-root-malformed");
std::fs::write(root.join("index.md"), "---\ntitle: Index\n---\n").unwrap();
std::fs::write(root.join("prov.yaml"), "root: docs/index.md\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => assert_eq!(d.root_doc, PathBuf::from("index.md")),
other => panic!("expected the scan's answer, got {other:?}"),
}
}
#[test]
fn the_node_is_policy_without_the_root_pointing_at_it() {
let root = tmp("node-policy");
std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n").unwrap();
std::fs::write(root.join("prov.yaml"), "workspace_id: notes\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => {
assert_eq!(d.config.workspace_id, "notes");
assert_eq!(d.node.node, Some(PathBuf::from("prov.yaml")));
}
other => panic!("expected a discovered workspace, got {other:?}"),
}
}
#[test]
fn a_node_under_config_is_read_the_same_way() {
let root = tmp("node-under-config");
std::fs::create_dir_all(root.join("config")).unwrap();
std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n").unwrap();
std::fs::write(root.join("config/prov.yaml"), "workspace_id: notes\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => {
assert_eq!(d.config.workspace_id, "notes");
assert_eq!(d.node.node, Some(PathBuf::from("config/prov.yaml")));
}
other => panic!("expected a discovered workspace, got {other:?}"),
}
}
#[test]
fn a_pointed_config_document_outranks_the_node() {
let root = tmp("node-vs-pointer");
std::fs::write(
root.join("index.md"),
"---\ntitle: Home\nconfig: settings.yaml\n---\n",
)
.unwrap();
std::fs::write(root.join("prov.yaml"), "workspace_id: from_node\n").unwrap();
std::fs::write(root.join("settings.yaml"), "workspace_id: from_pointer\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => assert_eq!(d.config.workspace_id, "from_pointer"),
other => panic!("expected a discovered workspace, got {other:?}"),
}
}
#[test]
fn a_workspace_with_no_node_discovers_exactly_as_before() {
let root = tmp("no-node");
std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n").unwrap();
match block_on(discover(&StdFs, &root)).unwrap() {
Discovery::Found(d) => {
assert_eq!(d.root_doc, PathBuf::from("index.md"));
assert_eq!(d.node, crate::node::Located::default());
}
other => panic!("expected index.md, got {other:?}"),
}
}
fn nested_workspaces(tag: &str, with_node: bool) -> PathBuf {
let outer = tmp(tag);
std::fs::write(outer.join("index.md"), "---\ntitle: Outer\n---\n").unwrap();
std::fs::create_dir_all(outer.join("sub")).unwrap();
std::fs::write(
outer.join("sub/README.md"),
"---\ntitle: Inner\npart_of: id:outer/abc123\n---\n",
)
.unwrap();
if with_node {
std::fs::write(
outer.join("sub/prov.yaml"),
"workspace_id: inner\nroot: README.md\n",
)
.unwrap();
}
outer
}
#[test]
fn a_named_root_may_say_what_contains_it() {
let outer = nested_workspaces("nested-named", true);
std::fs::create_dir_all(outer.join("sub/deep")).unwrap();
match block_on(discover(&StdFs, &outer.join("sub/deep"))).unwrap() {
Discovery::Found(d) => {
assert_eq!(d.root_dir, outer.join("sub"));
assert_eq!(d.root_doc, PathBuf::from("README.md"));
assert_eq!(d.config.workspace_id, "inner");
}
other => panic!("expected the inner root, got {other:?}"),
}
}
#[test]
fn a_foreign_parent_alone_does_not_make_a_root() {
let outer = nested_workspaces("nested-anonymous", false);
match block_on(discover(&StdFs, &outer.join("sub"))).unwrap() {
Discovery::Found(d) => {
assert_eq!(d.root_dir, outer);
assert_eq!(d.root_doc, PathBuf::from("index.md"));
}
other => panic!("expected the outer root, got {other:?}"),
}
}
#[test]
fn root_document_honors_a_named_root_that_says_what_contains_it() {
let outer = nested_workspaces("nested-root-doc", true);
let inner = outer.join("sub");
assert_eq!(
block_on(probe_with_node(&inner).root_document()).unwrap(),
Some(PathBuf::from("README.md"))
);
let bare = nested_workspaces("nested-root-doc-bare", false).join("sub");
assert_eq!(block_on(probe(&bare).root_document()).unwrap(), None);
}
#[test]
fn root_document_declines_to_guess() {
let root = tmp("root-doc-tie");
std::fs::write(root.join("one.md"), "---\ntitle: One\n---\n").unwrap();
std::fs::write(root.join("two.md"), "---\ntitle: Two\n---\n").unwrap();
assert_eq!(block_on(probe(&root).root_document()).unwrap(), None);
let bare = tmp("root-doc-bare");
std::fs::write(bare.join("plain.txt"), "not a document").unwrap();
assert_eq!(block_on(probe(&bare).root_document()).unwrap(), None);
}
}