use std::path::{Path, PathBuf};
use prov_graph::document;
use prov_graph::fs::ReadStorage;
pub const NODE_STEM: &str = "prov";
pub const NODE_DIRS: [&str; 3] = ["", "config", ".config"];
const NODE_EXTS: [&str; 6] = ["yaml", "yml", "json", "toml", "fig", "figl"];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Located {
pub node: Option<PathBuf>,
pub shadowed: Vec<PathBuf>,
}
impl Located {
pub fn all(&self) -> impl Iterator<Item = &PathBuf> {
self.node.iter().chain(&self.shadowed)
}
}
fn is_node_file(path: &Path) -> bool {
let stem_matches = path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.eq_ignore_ascii_case(NODE_STEM));
stem_matches && document::whole_file_format(path).is_some()
}
fn ext_rank(path: &Path) -> usize {
path.extension()
.and_then(|e| e.to_str())
.and_then(|e| {
let lower = e.to_ascii_lowercase();
NODE_EXTS.iter().position(|known| *known == lower)
})
.unwrap_or(NODE_EXTS.len())
}
async fn nodes_in<FS: ReadStorage>(fs: &FS, dir: &Path, prefix: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs.read_dir(dir).await else {
return Vec::new();
};
let mut found: Vec<PathBuf> = entries
.into_iter()
.filter(|entry| !entry.file_type().is_dir())
.filter_map(|entry| {
let name = entry.file_name()?;
is_node_file(Path::new(name)).then(|| prefix.join(name))
})
.collect();
found.sort_by_key(|path| (ext_rank(path), path.clone()));
found
}
pub async fn locate_in<FS: ReadStorage>(
fs: &FS,
root_dir: &Path,
entries: &[prov_graph::fs::DirEntry],
) -> Located {
let mut found = Vec::new();
for dir in NODE_DIRS {
if dir.is_empty() {
let mut top: Vec<PathBuf> = entries
.iter()
.filter(|entry| !entry.file_type().is_dir())
.filter_map(|entry| {
let name = entry.file_name()?;
is_node_file(Path::new(name)).then(|| PathBuf::from(name))
})
.collect();
top.sort_by_key(|path| (ext_rank(path), path.clone()));
found.append(&mut top);
continue;
}
let present = entries.iter().any(|entry| {
entry.file_type().is_dir() && entry.file_name().and_then(|n| n.to_str()) == Some(dir)
});
if !present {
continue;
}
found.append(&mut nodes_in(fs, &root_dir.join(dir), Path::new(dir)).await);
}
let mut found = found.into_iter();
Located {
node: found.next(),
shadowed: found.collect(),
}
}
pub async fn locate<FS: ReadStorage>(fs: &FS, root_dir: &Path) -> Located {
let Ok(entries) = fs.read_dir(root_dir).await else {
return Located::default();
};
locate_in(fs, root_dir, &entries).await
}
impl<FS: ReadStorage, Id, Ix: prov_graph::index::IdIndex> crate::workspace::Workspace<FS, Id, Ix> {
pub async fn workspace_node(&self) -> Located {
locate(self.fs(), self.root()).await
}
}
#[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-node-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn found(dir: &Path) -> Located {
block_on(locate(&StdFs, dir))
}
#[test]
fn a_top_level_node_is_the_node() {
let dir = tmp("top");
std::fs::write(dir.join("prov.yaml"), "workspace_id: notes\n").unwrap();
let located = found(&dir);
assert_eq!(located.node, Some(PathBuf::from("prov.yaml")));
assert!(located.shadowed.is_empty());
}
#[test]
fn a_workspace_with_no_node_locates_nothing() {
let dir = tmp("none");
std::fs::write(dir.join("README.md"), "---\ntitle: Home\n---\n").unwrap();
assert_eq!(found(&dir), Located::default());
}
#[test]
fn config_and_hidden_config_are_alternatives() {
for sub in ["config", ".config"] {
let dir = tmp(&format!("sub-{}", sub.trim_start_matches('.')));
std::fs::create_dir_all(dir.join(sub)).unwrap();
std::fs::write(dir.join(sub).join("prov.yaml"), "workspace_id: notes\n").unwrap();
assert_eq!(
found(&dir).node,
Some(PathBuf::from(sub).join("prov.yaml")),
"{sub} should hold the node"
);
}
}
#[test]
fn top_level_wins_and_the_rest_are_shadowed_in_order() {
let dir = tmp("precedence");
std::fs::create_dir_all(dir.join("config")).unwrap();
std::fs::create_dir_all(dir.join(".config")).unwrap();
std::fs::write(dir.join("prov.yaml"), "workspace_id: top\n").unwrap();
std::fs::write(dir.join("config/prov.yaml"), "workspace_id: mid\n").unwrap();
std::fs::write(dir.join(".config/prov.yaml"), "workspace_id: low\n").unwrap();
let located = found(&dir);
assert_eq!(located.node, Some(PathBuf::from("prov.yaml")));
assert_eq!(
located.shadowed,
vec![
PathBuf::from("config/prov.yaml"),
PathBuf::from(".config/prov.yaml")
]
);
}
#[test]
fn two_formats_in_one_directory_resolve_by_a_fixed_order() {
let dir = tmp("formats");
std::fs::write(dir.join("prov.yml"), "workspace_id: notes\n").unwrap();
std::fs::write(dir.join("prov.yaml"), "workspace_id: notes\n").unwrap();
let located = found(&dir);
assert_eq!(located.node, Some(PathBuf::from("prov.yaml")));
assert_eq!(located.shadowed, vec![PathBuf::from("prov.yml")]);
}
#[test]
fn a_format_this_build_cannot_parse_is_not_a_node() {
let dir = tmp("unbuilt-format");
std::fs::write(dir.join("prov.ini"), "workspace_id = notes\n").unwrap();
assert_eq!(found(&dir).node, None);
}
#[test]
fn a_directory_named_prov_is_not_a_node() {
let dir = tmp("dir-named-prov");
std::fs::create_dir_all(dir.join("prov.yaml")).unwrap();
assert_eq!(found(&dir).node, None);
}
#[test]
fn a_content_document_stemmed_prov_is_not_a_node() {
let dir = tmp("prose");
std::fs::write(dir.join("prov.md"), "---\ntitle: prov\n---\n# prov\n").unwrap();
assert_eq!(found(&dir).node, None);
}
}