use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use flower_core::Schema;
use prov::index::FileIndex;
use prov::{
Backlink, Discovery, Settings, StdFs, Target, Vocabulary, Workspace, WorkspaceConfig, block_on,
discover,
};
use crate::facets::Facets;
use crate::links::MetaLink;
use crate::session::{DocumentSession, SessionError};
fn we(e: impl std::fmt::Display) -> SessionError {
SessionError(e.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Destination {
Document {
path: PathBuf,
exists: bool,
},
SameDocument,
External(String),
Foreign {
workspace: String,
id: String,
},
UnresolvedId(String),
AmbiguousAlias(String),
Unresolvable {
target: String,
why: String,
},
}
impl Destination {
pub fn openable(&self) -> Option<&Path> {
match self {
Destination::Document { path, exists: true } => Some(path),
_ => None,
}
}
pub fn describe(&self) -> String {
match self {
Destination::Document { path, exists: true } => path.display().to_string(),
Destination::Document {
path,
exists: false,
} => format!("{} — not on disk", path.display()),
Destination::SameDocument => "a place inside this document".to_string(),
Destination::External(url) => format!("{url} — outside the workspace"),
Destination::Foreign { workspace, id } => {
format!("{id} in workspace `{workspace}` — not locatable from here")
}
Destination::UnresolvedId(id) => format!("id {id} — no registry entry"),
Destination::AmbiguousAlias(name) => {
format!("`{name}` — several documents claim that name")
}
Destination::Unresolvable { target, why } => format!("{target} — {why}"),
}
}
}
pub struct WorkspaceView {
ws: Workspace<StdFs, prov::identity::NoIdentity, FileIndex>,
root_doc: PathBuf,
config_doc: Option<PathBuf>,
config: WorkspaceConfig,
vocabularies: BTreeMap<String, Vocabulary>,
facets: Facets,
}
impl WorkspaceView {
pub fn discover(from: &Path) -> Result<Option<Self>, SessionError> {
let start = starting_dir(from)?;
match block_on(discover(&StdFs, &start)).map_err(we)? {
Discovery::Found(found) => Ok(Some(Self::open(
found.root_dir,
found.root_doc,
found.config,
)?)),
Discovery::NotFound => Ok(None),
Discovery::Ambiguous { dir, candidates } => Err(SessionError(format!(
"{} holds {} root candidates and no index/readme to choose between them: {}",
dir.display(),
candidates.len(),
candidates.join(", ")
))),
}
}
pub fn open(
root_dir: impl Into<PathBuf>,
root_doc: impl Into<PathBuf>,
config: WorkspaceConfig,
) -> Result<Self, SessionError> {
let root_dir = root_dir.into();
let root_doc = prov::link::normalize(root_doc.into());
let probe: Workspace<StdFs> = Workspace::builder(StdFs).root(&root_dir).build();
let index = load_registry(&probe, &root_doc, &config)?;
let ws = Workspace::builder(StdFs)
.root(&root_dir)
.settings(Settings::from(&config))
.index(index)
.build();
let config_doc = block_on(ws.config_path(&root_doc)).map_err(we)?;
let vocabularies = load_vocabularies(&ws, &root_doc, &config);
let facets = Facets::from_config(&config);
Ok(Self {
ws,
root_doc,
config_doc,
config,
vocabularies,
facets,
})
}
pub fn root_dir(&self) -> &Path {
self.ws.root()
}
pub fn root_document(&self) -> PathBuf {
self.ws.fs_path(&self.root_doc)
}
pub fn config_document(&self) -> Option<PathBuf> {
self.config_doc.as_ref().map(|rel| self.ws.fs_path(rel))
}
pub fn config(&self) -> &WorkspaceConfig {
&self.config
}
pub fn facets(&self) -> &Facets {
&self.facets
}
pub fn vocabularies(&self) -> &BTreeMap<String, Vocabulary> {
&self.vocabularies
}
pub fn prov(&self) -> &Workspace<StdFs, prov::identity::NoIdentity, FileIndex> {
&self.ws
}
pub fn content_schema(&self) -> Schema {
crate::schema_from_config(&self.config, &self.vocabularies)
}
pub fn schema_for(&self, path: &Path) -> Schema {
match self.config_document() {
Some(config_doc) if same_file(&config_doc, path) => crate::config_schema(&self.config),
_ => self.content_schema(),
}
}
pub fn open_document(&self, path: impl AsRef<Path>) -> Result<DocumentSession, SessionError> {
let path = self.absolute(path.as_ref());
let schema = self.schema_for(&path);
DocumentSession::open_with_schema(path, schema)
}
pub fn resolve(&self, doc: &Path, link: &MetaLink) -> Destination {
self.destination(self.ws.resolve_link(&self.relative(doc), &link.link), link)
}
pub fn resolve_nominal(
&self,
doc: &Path,
link: &MetaLink,
) -> Result<Destination, SessionError> {
let index = block_on(self.ws.title_index()).map_err(we)?;
let target = self
.ws
.resolve_link_with(&self.relative(doc), &link.link, Some(&index));
Ok(self.destination(target, link))
}
pub fn backlinks_to(&self, target: impl AsRef<Path>) -> Result<Vec<Backlink>, SessionError> {
let target = self.relative(target.as_ref());
block_on(self.ws.backlinks_to(&self.root_doc, &target)).map_err(we)
}
fn destination(&self, target: Target, link: &MetaLink) -> Destination {
match target {
Target::Path(rel) => {
let path = self.ws.fs_path(&rel);
let exists = path.is_file();
Destination::Document { path, exists }
}
Target::SameDocument => Destination::SameDocument,
Target::External => Destination::External(link.target().to_string()),
Target::Foreign { workspace, id } => Destination::Foreign {
workspace,
id: id.to_string(),
},
Target::UnresolvedId(id) => Destination::UnresolvedId(id.to_string()),
Target::AmbiguousAlias(name) => Destination::AmbiguousAlias(name),
}
}
fn relative(&self, path: &Path) -> PathBuf {
let rel = path.strip_prefix(self.ws.root()).unwrap_or(path);
prov::link::normalize(rel)
}
fn absolute(&self, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
self.ws.fs_path(path)
}
}
}
pub fn resolve_without_workspace(doc: &Path, link: &MetaLink) -> Destination {
use crate::links::TargetKind;
match &link.kind {
TargetKind::SameDocument => Destination::SameDocument,
TargetKind::External => Destination::External(link.target().to_string()),
TargetKind::Foreign { workspace } => Destination::Foreign {
workspace: workspace.clone(),
id: link.target().to_string(),
},
TargetKind::Id => Destination::Unresolvable {
target: link.target().to_string(),
why: "an id needs the workspace's registry to resolve".to_string(),
},
TargetKind::MalformedId => Destination::Unresolvable {
target: link.target().to_string(),
why: "an `id:` target with no id in it".to_string(),
},
TargetKind::Path if link.target().starts_with('/') => Destination::Unresolvable {
target: link.target().to_string(),
why: "a workspace-absolute path needs a workspace root".to_string(),
},
TargetKind::Path => {
let path = prov::link::resolve(doc, link.target());
let exists = path.is_file();
Destination::Document { path, exists }
}
}
}
fn starting_dir(from: &Path) -> Result<PathBuf, SessionError> {
let absolute = if from.is_absolute() {
from.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| SessionError(format!("resolving {}: {e}", from.display())))?
.join(from)
};
Ok(if absolute.is_dir() {
absolute
} else {
absolute.parent().map(Path::to_path_buf).unwrap_or(absolute)
})
}
fn load_registry(
probe: &Workspace<StdFs>,
root_doc: &Path,
config: &WorkspaceConfig,
) -> Result<FileIndex, SessionError> {
let empty = || FileIndex::new(config.default_embed_format);
let Some(rel) = block_on(probe.registry_path(root_doc)).map_err(we)? else {
return Ok(empty());
};
match block_on(probe.read_text(&rel)) {
Ok(text) => FileIndex::parse(&rel, &text).map_err(we),
Err(_) => Ok(empty()),
}
}
fn load_vocabularies(
ws: &Workspace<StdFs, prov::identity::NoIdentity, FileIndex>,
root_doc: &Path,
config: &WorkspaceConfig,
) -> BTreeMap<String, Vocabulary> {
let mut loaded = BTreeMap::new();
for (field, spec) in &config.fields {
let Some(pointer) = spec.vocabulary.as_deref() else {
continue;
};
let vocabulary = if spec.reify {
block_on(ws.load_reified_vocabulary(root_doc, field, spec))
} else {
block_on(ws.load_vocabulary(root_doc, pointer))
};
if let Ok(Some(vocabulary)) = vocabulary {
loaded.insert(field.clone(), vocabulary);
}
}
loaded
}
fn same_file(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::links::links_in;
use flower_core::Seg;
struct Vault(PathBuf);
impl Vault {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("provui_workspace_{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("notes")).unwrap();
std::fs::write(
dir.join("README.md"),
"---\ntitle: The Vault\nconfig: prov.yaml\ncontents:\n- '[A Note](notes/note.md)'\n---\n# The Vault\n",
)
.unwrap();
std::fs::write(
dir.join("prov.yaml"),
"title: vault config\nfields:\n audience:\n vocabulary: audiences.yaml\n values: closed\n",
)
.unwrap();
std::fs::write(
dir.join("audiences.yaml"),
"title: Audiences\nvocabulary:\n field: audience\n values: closed\nterms:\n public:\n means: Anyone\n private: {}\n",
)
.unwrap();
std::fs::write(
dir.join("notes/note.md"),
"---\ntitle: A Note\npart_of: '[The Vault](/README.md)'\nlinks:\n- '[Missing](gone.md)'\n- 'https://example.com/'\naudience: public\n---\n# A Note\n",
)
.unwrap();
Self(dir)
}
fn path(&self, rel: &str) -> PathBuf {
self.0.join(rel)
}
}
impl Drop for Vault {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn link_at(view: &WorkspaceView, doc: &Path, path: &[Seg]) -> MetaLink {
let text = std::fs::read_to_string(doc).unwrap();
let parsed = prov::Document::parse(doc, &text).unwrap();
let meta = fig::Value::from(&parsed.meta);
links_in(&meta, view.facets())
.into_iter()
.find(|l| l.path == path)
.unwrap_or_else(|| panic!("no link at {path:?}"))
}
#[test]
fn discovers_the_workspace_a_document_sits_in() {
let vault = Vault::new("discover");
let view = WorkspaceView::discover(&vault.path("notes/note.md"))
.expect("discovery")
.expect("a workspace");
assert!(same_file(&view.root_document(), &vault.path("README.md")));
assert_eq!(
view.config_document()
.map(|p| p.file_name().unwrap().to_owned()),
Some("prov.yaml".into()),
"the root's config pointer resolved"
);
assert!(view.config().fields.contains_key("audience"));
let vocab = view.vocabularies().get("audience").expect("audiences.yaml");
assert!(vocab.terms.contains_key("public"));
}
#[test]
fn follows_a_link_to_a_document_and_says_so_when_it_is_broken() {
let vault = Vault::new("follow");
let note = vault.path("notes/note.md");
let view = WorkspaceView::discover(¬e).unwrap().unwrap();
let up = link_at(&view, ¬e, &[Seg::Key("part_of".into())]);
let landed = view.resolve(¬e, &up);
let opened = landed.openable().expect("the root is on disk");
assert!(same_file(opened, &vault.path("README.md")));
let broken = link_at(&view, ¬e, &[Seg::Key("links".into()), Seg::Index(0)]);
match view.resolve(¬e, &broken) {
Destination::Document { path, exists } => {
assert!(!exists, "gone.md is not there");
assert!(path.ends_with("notes/gone.md"), "resolved beside the note");
}
other => panic!("expected a broken document link, got {other:?}"),
}
let external = link_at(&view, ¬e, &[Seg::Key("links".into()), Seg::Index(1)]);
match view.resolve(¬e, &external) {
Destination::External(url) => assert_eq!(url, "https://example.com/"),
other => panic!("expected an external target, got {other:?}"),
}
}
#[test]
fn a_document_opens_under_the_schema_its_kind_calls_for() {
let vault = Vault::new("schema");
let view = WorkspaceView::discover(&vault.path("README.md"))
.unwrap()
.unwrap();
let note = view.open_document("notes/note.md").expect("open the note");
let schema = note.metadata().schema().expect("a content schema");
assert!(
schema
.rule_for(&[Seg::Key("audience".into())])
.is_some_and(|r| r.constraint.is_some()),
"the workspace's controlled field reached the editor"
);
let config = view.open_document("prov.yaml").expect("open the config");
let schema = config.metadata().schema().expect("a config schema");
assert!(
schema.rule_for(&[Seg::Key("fixity".into())]).is_some(),
"the config document is edited under the config schema"
);
}
#[test]
fn discovery_walks_up_from_a_subdirectory() {
let vault = Vault::new("walk_up");
let view = WorkspaceView::discover(&vault.path("notes"))
.expect("discovery")
.expect("a workspace");
assert!(same_file(&view.root_document(), &vault.path("README.md")));
}
#[test]
fn the_lexical_floor_resolves_what_it_can_and_names_what_it_cannot() {
let dir = std::env::temp_dir().join("provui_workspace_floor");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let doc = dir.join("note.md");
std::fs::write(
&doc,
"---\nlinks:\n- 'sibling.md'\n- '/root.md'\n- 'id:ajp7eq'\n---\n# n\n",
)
.unwrap();
std::fs::write(dir.join("sibling.md"), "# sibling\n").unwrap();
let text = std::fs::read_to_string(&doc).unwrap();
let parsed = prov::Document::parse(&doc, &text).unwrap();
let meta = fig::Value::from(&parsed.meta);
let links = links_in(&meta, &Facets::default());
match resolve_without_workspace(&doc, &links[0]) {
Destination::Document { exists, .. } => assert!(exists, "sibling.md is there"),
other => panic!("expected a document, got {other:?}"),
}
assert!(matches!(
resolve_without_workspace(&doc, &links[1]),
Destination::Unresolvable { .. }
));
assert!(matches!(
resolve_without_workspace(&doc, &links[2]),
Destination::Unresolvable { .. }
));
let _ = std::fs::remove_dir_all(&dir);
}
}