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 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 mut candidates: Vec<String> = Vec::new();
for entry in entries {
let path = entry.path();
if !can_be_root(path) {
continue;
}
let Ok(text) = fs.read_to_string(path).await else {
continue;
};
let Ok(doc) = Document::parse(path, &text) else {
continue;
};
if is_root_candidate(&doc)
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
candidates.push(name.to_string());
}
}
match choose_root(&candidates) {
Some(root_doc) => {
let discovered = build(fs, dir.to_path_buf(), PathBuf::from(root_doc)).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)
}
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>> {
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 build<FS: Storage + Clone>(
fs: &FS,
root_dir: PathBuf,
root_doc: PathBuf,
) -> 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 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,
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()
}
#[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 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);
}
}