use std::path::{Path, PathBuf};
use crate::config::{ROOT_CONFIG_KEY, WorkspaceConfig};
use crate::content::ContentFormat;
use crate::document::{self, Document};
use crate::error::Result;
use crate::fs::Storage;
use crate::workspace::Workspace;
#[derive(Debug, Clone)]
pub struct Discovered {
pub root_dir: PathBuf,
pub root_doc: PathBuf,
pub registry: Option<PathBuf>,
pub config: WorkspaceConfig,
}
#[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();
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 && !is_meta_ext {
continue;
}
if is_meta_ext && !is_content_ext && !stem_is(path, "index") && !stem_is(path, "readme")
{
continue;
}
let Ok(text) = fs.read_to_string(path).await else {
continue;
};
let Ok(doc) = Document::parse(path, &text) else {
continue;
};
if doc.has_meta()
&& doc.meta.get("part_of").is_none()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
candidates.push(name.to_string());
}
}
let chosen = 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()));
match chosen {
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)
}
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 crate::exec::block_on;
use crate::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:?}"),
}
}
}