use prov_graph::document::{Body, Document};
use prov_graph::fs::{DirEntry, Metadata};
use prov_graph::graph::{Backlink, CensusEntry, Graph, Node, ReadSettings, TreeOptions, Walk};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::change::{ChangeSet, FileOp};
use crate::config::{Fixity, IdStorage};
use crate::identity::{IdentityPolicy, NoIdentity, Trigger};
use prov_graph::document::EmbedStyle;
use prov_graph::error::{Error, Result};
use prov_graph::fs::ReadStorage;
use prov_graph::graph::Target;
use prov_graph::index::{Collision, IdIndex, NoIndex};
use prov_graph::link::{self, Addressing, Link, LinkStyle, ReferenceStyle, Wrapper};
use prov_graph::memo::ReadScope;
use prov_graph::meta::Value;
use prov_graph::relation::RelationSet;
use prov_graph::title::TitleIndex;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
mod fields;
mod ignore;
pub(crate) mod inbound;
pub use fields::{FieldScopes, Unresolved};
pub use ignore::{Ignore, IgnoreList, Reason};
fn store_dir(store_index: &Path) -> PathBuf {
store_index.parent().unwrap_or(Path::new("")).to_path_buf()
}
#[derive(Debug, Clone)]
pub struct Settings {
pub relations: RelationSet,
pub link_style: LinkStyle,
pub id_links: bool,
pub reference_style: Option<ReferenceStyle>,
pub default_embed_format: fig::Format,
pub embed_style: EmbedStyle,
pub fixity: Fixity,
pub record_deletions: bool,
pub id_storage: IdStorage,
pub workspace_id: String,
pub out_of_scope: Vec<PathBuf>,
pub root: Option<PathBuf>,
pub updated: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
relations: RelationSet::diaryx(),
link_style: LinkStyle::default(),
id_links: false,
reference_style: None,
default_embed_format: fig::Format::Yaml,
embed_style: EmbedStyle::Delimited,
fixity: Fixity::On,
record_deletions: true,
id_storage: IdStorage::Registry,
workspace_id: String::new(),
out_of_scope: Vec::new(),
root: None,
updated: String::new(),
}
}
}
impl From<&crate::config::WorkspaceConfig> for Settings {
fn from(config: &crate::config::WorkspaceConfig) -> Self {
Self {
relations: config.relation_set(),
link_style: config.link_format(),
reference_style: Some(config.reference_style()),
default_embed_format: config.default_embed_format,
embed_style: config.embed_style,
fixity: config.fixity,
record_deletions: config.record_deletions,
id_storage: config.id_storage,
workspace_id: config.workspace_id.clone(),
out_of_scope: config.out_of_scope.iter().map(PathBuf::from).collect(),
root: config.root.as_deref().map(PathBuf::from),
updated: config.updated.clone(),
..Self::default()
}
}
}
#[derive(Debug)]
pub struct Workspace<FS, Id = NoIdentity, Ix = NoIndex> {
graph: Graph<FS, Ix>,
identity: Id,
settings: Settings,
pending_stamps: Vec<(PathBuf, prov_graph::identity::Id)>,
inbound: std::sync::Mutex<Option<inbound::InboundIndex>>,
}
impl<FS: Clone, Id: Clone, Ix: Clone> Clone for Workspace<FS, Id, Ix> {
fn clone(&self) -> Self {
Self {
graph: self.graph.clone(),
identity: self.identity.clone(),
settings: self.settings.clone(),
pending_stamps: self.pending_stamps.clone(),
inbound: inbound::empty(),
}
}
}
impl<FS> Workspace<FS, NoIdentity, NoIndex> {
pub fn builder(fs: FS) -> WorkspaceBuilder<FS, NoIdentity, NoIndex> {
WorkspaceBuilder {
fs,
root: PathBuf::from("."),
identity: NoIdentity,
index: NoIndex,
settings: Settings::default(),
}
}
}
impl<FS, Id, Ix> Workspace<FS, Id, Ix> {
pub fn graph(&self) -> &Graph<FS, Ix> {
&self.graph
}
pub fn root(&self) -> &Path {
self.graph.root()
}
pub fn fs_path(&self, rel: impl AsRef<Path>) -> PathBuf {
self.graph.fs_path(rel)
}
pub fn relations(&self) -> &RelationSet {
&self.settings.relations
}
#[must_use = "the scope ends the moment its guard is dropped"]
pub fn read_scope(&self) -> ReadScope {
self.graph.read_scope()
}
fn forget_written(&self, cs: &ChangeSet) {
let mut memo = self.graph.memo_lock();
let mut forget = |path: &Path| {
memo.forget(path);
};
for op in cs.ops() {
match op {
FileOp::Write { path, .. }
| FileOp::Remove { path }
| FileOp::CopyFrom { path, .. }
| FileOp::SetExecutable { path, .. }
| FileOp::SetLink { path, .. } => forget(path),
FileOp::Rename { from, to } => {
forget(from);
forget(to);
}
}
}
}
pub fn identity(&self) -> &Id {
&self.identity
}
pub fn index(&self) -> &Ix {
self.graph.index()
}
pub fn link_style(&self) -> LinkStyle {
self.settings.link_style
}
pub fn id_links(&self) -> bool {
self.reference_style().registers()
}
pub fn fixity(&self) -> Fixity {
self.settings.fixity
}
pub fn record_deletions(&self) -> bool {
self.settings.record_deletions
}
pub fn embed_style(&self) -> EmbedStyle {
self.settings.embed_style
}
pub fn id_storage(&self) -> IdStorage {
self.settings.id_storage
}
pub fn named_root(&self) -> Option<&Path> {
self.settings.root.as_deref()
}
pub fn workspace_id(&self) -> &str {
&self.settings.workspace_id
}
pub fn updated_field(&self) -> Option<&str> {
(!self.settings.updated.is_empty()).then_some(self.settings.updated.as_str())
}
pub fn out_of_scope(&self) -> &[PathBuf] {
&self.settings.out_of_scope
}
pub fn reference_style(&self) -> ReferenceStyle {
self.settings.reference_style.unwrap_or(ReferenceStyle {
wrapper: Wrapper::Markdown,
addressing: if self.settings.id_links {
Addressing::Id
} else {
Addressing::Path
},
label: false,
path_style: self.settings.link_style,
})
}
pub fn reference_style_for(&self, relation: &str) -> ReferenceStyle {
self.settings
.relations
.style_for(relation)
.unwrap_or_else(|| self.reference_style())
}
pub fn default_embed_format(&self) -> fig::Format {
self.settings.default_embed_format
}
pub fn index_mut(&mut self) -> &mut Ix {
self.graph.index_mut()
}
}
impl<FS, Id, Ix: IndexStore> Workspace<FS, Id, Ix> {
pub fn registration_conflict(
&self,
id: &prov_graph::identity::Id,
path: &Path,
) -> Option<Collision> {
if let Some(held_by) = self.graph.index().resolve(id)
&& held_by != path
{
return Some(Collision::Id {
id: id.clone(),
held_by,
});
}
if let Some(held) = self.graph.index().id_for_path(path)
&& held != *id
{
return Some(Collision::Path {
path: path.to_path_buf(),
held,
});
}
None
}
pub(crate) fn move_conflict(
&self,
id: &prov_graph::identity::Id,
dest: &Path,
) -> Option<Collision> {
let held = self.graph.index().id_for_path(dest)?;
(held != *id).then(|| Collision::Path {
path: dest.to_path_buf(),
held,
})
}
}
impl<FS: ReadStorage, Id, Ix: IdIndex> Workspace<FS, Id, Ix> {
pub async fn registry_path(&self, root_doc: &Path) -> Result<Option<PathBuf>> {
match self.relations().registry_relation() {
Some(relation) => self.pointer_target(root_doc, relation).await,
None => Ok(None),
}
}
pub async fn config_path(&self, root_doc: &Path) -> Result<Option<PathBuf>> {
match self.relations().config_relation() {
Some(relation) => self.pointer_target(root_doc, relation).await,
None => Ok(None),
}
}
pub async fn deletions_path(&self, root_doc: &Path) -> Result<Option<PathBuf>> {
Ok(self
.deletions_pointer(root_doc)
.await?
.map(|(path, _)| path))
}
pub async fn deletions_pointer(&self, root_doc: &Path) -> Result<Option<(PathBuf, String)>> {
for relation in [
self.relations().deletions_relation(),
self.relations().recycle_relation(),
]
.into_iter()
.flatten()
{
if let Some(path) = self.pointer_target(root_doc, relation).await? {
return Ok(Some((path, relation.to_string())));
}
}
Ok(None)
}
pub async fn history_path(&self, root_doc: &Path) -> Result<Option<PathBuf>> {
match self.relations().history_relation() {
Some(relation) => self.pointer_target(root_doc, relation).await,
None => Ok(None),
}
}
pub(crate) async fn parked_dirs(&self, root_doc: &Path) -> Result<Vec<PathBuf>> {
let mut dirs: Vec<PathBuf> = self.settings.out_of_scope.clone();
if let Some(index) = self.history_path(root_doc).await? {
dirs.push(store_dir(&index).join("events"));
dirs.push(store_dir(&index).join("blobs"));
}
if let Some((index, relation)) = self.deletions_pointer(root_doc).await?
&& Some(relation.as_str()) == self.relations().recycle_relation()
{
dirs.push(store_dir(&index).join("items"));
}
Ok(dirs)
}
pub async fn about_path(&self, root_doc: &Path) -> Result<Option<PathBuf>> {
match self.relations().about_relation() {
Some(relation) => self.pointer_target(root_doc, relation).await,
None => Ok(None),
}
}
pub async fn config_get(
&self,
root_doc: &Path,
key: &str,
) -> Result<Option<prov_graph::meta::Value>> {
let Some(config_doc) = self.config_path(root_doc).await? else {
return Ok(None);
};
let (_, doc) = self.load(&config_doc).await?;
Ok(doc.meta.get(key).cloned())
}
pub async fn effective_config(
&self,
root_doc: &Path,
) -> Result<crate::config::WorkspaceConfig> {
let mut config = crate::config::WorkspaceConfig::default();
if let Ok((_, root)) = self.load(root_doc).await
&& let Some(block) = root.meta.get(crate::config::ROOT_CONFIG_KEY)
{
config.apply(block);
}
if let Some(config_doc) = self.config_path(root_doc).await? {
let (_, doc) = self.load(&config_doc).await?;
config.apply(&doc.meta);
}
Ok(config)
}
pub fn vocabulary_path(&self, root_doc: &Path, pointer: &str) -> Option<PathBuf> {
match self.resolve_link(&link::normalize(root_doc), &Link::parse(pointer)) {
Target::Path(path) => Some(path),
_ => None,
}
}
pub async fn load_vocabulary(
&self,
root_doc: &Path,
pointer: &str,
) -> Result<Option<crate::vocabulary::Vocabulary>> {
let Some(path) = self.vocabulary_path(root_doc, pointer) else {
return Ok(None);
};
let (_, doc) = self.load(&path).await?;
if let Some(carrier) = doc.carrier {
prov_graph::document::require_whole_file(&path, carrier)?;
}
Ok(crate::vocabulary::Vocabulary::from_meta(&doc.meta))
}
pub async fn load_reified_vocabulary(
&self,
root_doc: &Path,
field: &str,
spec: &crate::config::FieldSpec,
) -> Result<Option<crate::vocabulary::Vocabulary>> {
let Some(terms) = self
.reified_terms(root_doc, spec.vocabulary.as_deref())
.await?
else {
return Ok(None);
};
Ok(Some(crate::vocabulary::Vocabulary {
field: field.to_string(),
values: spec.values,
terms: terms
.into_iter()
.map(|(key, (_, term))| (key, term))
.collect(),
}))
}
pub async fn reified_term_path(
&self,
root_doc: &Path,
pointer: &str,
term: &str,
) -> Result<Option<PathBuf>> {
Ok(self
.reified_terms(root_doc, Some(pointer))
.await?
.and_then(|mut terms| terms.remove(term))
.map(|(path, _)| path))
}
async fn reified_terms(
&self,
root_doc: &Path,
pointer: Option<&str>,
) -> Result<Option<BTreeMap<String, (PathBuf, crate::vocabulary::Term)>>> {
let Some(pointer) = pointer else {
return Ok(None);
};
let Some(index_path) = self.vocabulary_path(root_doc, pointer) else {
return Ok(None);
};
let Some(spanning) = self.relations().spanning_relation() else {
return Ok(Some(BTreeMap::new()));
};
let (_, index) = self.load(&index_path).await?;
let children = index
.meta
.get(spanning)
.map(Value::link_strings)
.unwrap_or_default();
let mut terms = BTreeMap::new();
for raw in children {
let Target::Path(path) = self.resolve_link(&index_path, &Link::parse(&raw)) else {
continue;
};
let Ok((_, child)) = self.load(&path).await else {
continue;
};
let Some(key) = child
.meta
.get("term")
.and_then(Value::as_str)
.or_else(|| child.meta.get("title").and_then(Value::as_str))
else {
continue;
};
let term = crate::vocabulary::Term {
id: child
.meta
.get("id")
.and_then(Value::as_str)
.map(|s| prov_graph::identity::Id(s.to_string()))
.or_else(|| self.index().id_for_path(&path)),
means: child
.meta
.get("means")
.and_then(Value::as_str)
.map(str::to_owned),
retired: child
.meta
.get("retired")
.and_then(Value::as_bool)
.unwrap_or(false),
};
terms.insert(key.to_string(), (path, term));
}
Ok(Some(terms))
}
pub(crate) async fn load_field_vocabulary(
&self,
root_doc: &Path,
field: &str,
spec: &crate::config::FieldSpec,
) -> Result<Option<crate::vocabulary::Vocabulary>> {
let Some(pointer) = spec.vocabulary.as_deref() else {
return Ok(None);
};
if spec.reify {
self.load_reified_vocabulary(root_doc, field, spec).await
} else {
self.load_vocabulary(root_doc, pointer).await
}
}
async fn pointer_target(&self, root_doc: &Path, relation: &str) -> Result<Option<PathBuf>> {
let root_doc = link::normalize(root_doc);
let (_, doc) = self.load(&root_doc).await?;
let Some(raw) = doc
.meta
.get(relation)
.map(prov_graph::meta::Value::link_strings)
.and_then(|targets| targets.into_iter().next())
else {
return Ok(None);
};
match self.resolve_link(&root_doc, &Link::parse(&raw)) {
Target::Path(path) => Ok(Some(path)),
_ => Ok(None),
}
}
}
impl<FS: Storage, Id: IdentityPolicy, Ix: IndexStore> Workspace<FS, Id, Ix> {
pub async fn register(
&mut self,
path: &Path,
event: Trigger,
) -> Result<prov_graph::identity::Id> {
let path = link::normalize(path);
if let Some(id) = self.graph.index().id_for_path(&path) {
return Ok(id);
}
if !self.identity.registration().fires_on(event) {
return Err(Error::Structure(format!(
"identity policy does not register on {event:?}"
)));
}
if !self.exists(&path).await? {
return Err(Error::NotFound(path.to_path_buf()));
}
let id = self.mint_unique(&path);
self.graph.index_mut().register(&id, &path);
self.queue_stamp(&path, &id);
Ok(id)
}
pub(crate) fn mint_unique(&mut self, path: &Path) -> prov_graph::identity::Id {
loop {
let id = self.identity.mint(path);
if !self.graph.index().is_known(&id) {
return id;
}
}
}
pub(crate) async fn authored_target(
&mut self,
relation: &str,
from: &Path,
to: &Path,
title: &str,
target_exists: bool,
) -> Result<String> {
let style = self.reference_style_for(relation);
let id = if style.registers() && self.identity.registration().fires_on(Trigger::Link) {
Some(if target_exists {
self.register(to, Trigger::Link).await?
} else {
self.register_for_authoring(to)
})
} else {
None
};
Ok(link::format_reference(style, from, to, id.as_ref(), title))
}
pub(crate) fn register_for_authoring(&mut self, path: &Path) -> prov_graph::identity::Id {
let path = link::normalize(path);
if let Some(id) = self.graph.index().id_for_path(&path) {
return id;
}
let id = self.mint_unique(&path);
self.graph.index_mut().register(&id, &path);
self.queue_stamp(&path, &id);
id
}
fn queue_stamp(&mut self, path: &Path, id: &prov_graph::identity::Id) {
if self.settings.id_storage.stamps_frontmatter() {
self.pending_stamps.push((path.to_path_buf(), id.clone()));
}
}
}
impl<FS, Id, Ix> Workspace<FS, Id, Ix> {
pub fn fs(&self) -> &FS {
self.graph.fs()
}
}
impl<FS: Storage, IdP, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub(crate) fn change(&mut self) -> ChangeSet {
self.graph.index_mut().rollback();
self.graph.index_mut().checkpoint();
ChangeSet::new()
}
pub(crate) async fn load_staged(
&self,
cs: &ChangeSet,
path: &Path,
) -> Result<(String, prov_graph::document::Document)> {
let Some(bytes) = cs.staged(path) else {
return self.load(path).await;
};
let text = String::from_utf8(bytes.to_vec())
.map_err(|e| Error::Structure(format!("{} is not valid UTF-8: {e}", path.display())))?;
let doc = prov_graph::document::Document::parse(path, &text)?;
Ok((text, doc))
}
pub(crate) async fn commit(&mut self, mut cs: ChangeSet) -> Result<()> {
if let Err(e) = self.stage_pending_stamps(&mut cs).await {
self.pending_stamps.clear();
self.graph.index_mut().rollback();
return Err(e);
}
if let Err(e) = self.graph.index_mut().rebase(&cs) {
self.graph.index_mut().rollback();
return Err(e);
}
let staged_index = match self.graph.index_mut().pending_write() {
Ok(Some((path, text))) => {
cs.write(path, text);
true
}
Ok(None) => false,
Err(e) => {
self.graph.index_mut().rollback();
return Err(e);
}
};
self.forget_written(&cs);
if staged_index {
self.forget_inbound();
}
match self.apply_set(&cs).await {
Ok(()) => {
self.graph.index_mut().committed(staged_index);
Ok(())
}
Err(e) => {
self.graph.index_mut().rollback();
Err(e)
}
}
}
pub async fn apply_set(&self, cs: &ChangeSet) -> Result<()> {
let plan = self.plan_inbound(cs);
match crate::journal::workspace_journal()
.apply(cs, self.fs(), self.root())
.await
{
Ok(()) => {
self.settle_inbound(plan).await;
Ok(())
}
Err(e) => {
self.forget_inbound();
Err(e.into())
}
}
}
async fn stage_pending_stamps(&mut self, cs: &mut ChangeSet) -> Result<()> {
for (path, id) in std::mem::take(&mut self.pending_stamps) {
if cs.renamed_to(&path).is_some() {
continue;
}
let text = match cs.staged(&path) {
Some(bytes) => match std::str::from_utf8(bytes) {
Ok(text) => text.to_string(),
Err(_) => continue,
},
None => match self.read_text(&path).await {
Ok(text) => text,
Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
},
};
let doc = prov_graph::document::Document::parse(&path, &text)?;
if doc.meta.get("id").and_then(Value::as_str) == Some(id.0.as_str()) {
continue;
}
let updated = prov_store::edit::set_in_text(
&text,
doc.carrier,
"id",
fig::Value::Str(id.0.clone()),
)?;
cs.write(&path, updated);
}
Ok(())
}
pub async fn link_sidecar(
&self,
root_doc: &Path,
pointer: &str,
sidecar: &Path,
seed: &prov_graph::meta::Mapping,
format: fig::Format,
) -> Result<bool> {
let mut cs = ChangeSet::new();
let created = !self.exists(sidecar).await?;
if created {
cs.write(sidecar, prov_graph::meta::serialize_mapping(seed, format)?);
}
let (text, doc) = self.load(root_doc).await?;
let updated = prov_store::edit::set_in_text(
&text,
doc.carrier,
pointer,
prov_store::edit::infer_scalar(&sidecar.to_string_lossy()),
)?;
cs.write(root_doc, updated);
self.apply_set(&cs).await?;
Ok(created)
}
}
impl<FS: ReadStorage, Id, Ix: IdIndex> Workspace<FS, Id, Ix> {
pub(crate) async fn load(&self, path: &Path) -> Result<(String, Document)> {
self.graph.load(path).await
}
pub async fn document(&self, path: impl AsRef<Path>) -> Result<Document> {
self.graph.document(path).await
}
pub async fn body(&self, path: impl AsRef<Path>) -> Result<Body> {
self.graph.body(path).await
}
pub async fn exists(&self, path: &Path) -> Result<bool> {
self.graph.exists(path).await
}
pub async fn read_bytes(&self, path: &Path) -> Result<Vec<u8>> {
self.graph.read_bytes(path).await
}
pub async fn read_text(&self, path: &Path) -> Result<String> {
self.graph.read_text(path).await
}
pub async fn listing(&self, path: &Path) -> Result<Vec<DirEntry>> {
self.graph.listing(path).await
}
pub async fn stat(&self, path: &Path) -> Result<Metadata> {
self.graph.stat(path).await
}
pub fn resolve_link(&self, doc: &Path, link: &Link) -> Target {
self.graph.resolve_link(doc, link)
}
pub fn resolve_link_with(
&self,
doc: &Path,
link: &Link,
titles: Option<&TitleIndex>,
) -> Target {
self.graph.resolve_link_with(doc, link, titles)
}
pub async fn census(&self, start: impl AsRef<Path>) -> Result<Vec<CensusEntry>> {
let _scope = self.read_scope();
let start = start.as_ref();
let parked = self.parked_dirs(start).await?;
self.graph.census_within(start, &parked).await
}
pub async fn reachable_documents_from(
&self,
start: impl AsRef<Path>,
) -> Result<BTreeSet<PathBuf>> {
let start = start.as_ref();
let walk = self.walk(start).await?;
self.reachable_documents(start, &walk.census, &walk.content_bodies)
.await
}
pub(crate) async fn walk(&self, start: &Path) -> Result<Walk> {
let _scope = self.read_scope();
let parked = self.parked_dirs(start).await?;
self.graph.walk(start, &parked).await
}
pub async fn backlinks(
&self,
start: impl AsRef<Path>,
) -> Result<BTreeMap<PathBuf, Vec<Backlink>>> {
Ok(prov_graph::graph::invert(self.census(start).await?))
}
pub async fn backlinks_to(
&self,
start: impl AsRef<Path>,
target: impl AsRef<Path>,
) -> Result<Vec<Backlink>> {
Ok(prov_graph::graph::inbound(
self.census(start).await?,
target.as_ref(),
))
}
pub async fn reachable_files(&self, start: impl AsRef<Path>) -> Result<BTreeSet<PathBuf>> {
let _scope = self.read_scope();
let start = start.as_ref();
let parked = self.parked_dirs(start).await?;
self.graph.reachable_files_within(start, &parked).await
}
pub async fn reachable_documents(
&self,
start: &Path,
census: &[CensusEntry],
content_bodies: &[PathBuf],
) -> Result<BTreeSet<PathBuf>> {
self.graph
.reachable_documents(start, census, content_bodies)
.await
}
pub async fn tree(&self, start: impl AsRef<Path>) -> Result<Node> {
self.tree_with(start, TreeOptions::default()).await
}
pub async fn tree_with(&self, start: impl AsRef<Path>, options: TreeOptions) -> Result<Node> {
let start = start.as_ref();
let parked = self.parked_dirs(start).await?;
self.graph.tree_within(start, options, &parked).await
}
pub async fn spanning_children(
&self,
parent: impl AsRef<Path>,
) -> Result<Vec<(PathBuf, Document)>> {
let parent = parent.as_ref();
let (_, doc) = self.load(parent).await?;
let mut out = Vec::new();
for raw in self.relations().children(&fig::Value::from(&doc.meta)) {
let Target::Path(path) = self.resolve_link(parent, &Link::parse(&raw)) else {
continue;
};
let Ok((_, child)) = self.load(&path).await else {
continue;
};
out.push((path, child));
}
Ok(out)
}
pub async fn title_index(&self) -> Result<TitleIndex> {
self.graph.title_index().await
}
pub async fn title_index_scoped(&self, start: &Path) -> Result<TitleIndex> {
let parked = self.parked_dirs(start).await?;
self.graph.title_index_scoped(start, &parked).await
}
pub async fn select_view(
&self,
root_doc: &Path,
spec: &prov_views::ViewSpec,
) -> std::result::Result<prov_views::Selection, prov_views::Error> {
let nominal = spec.under.as_deref().is_some_and(|under| {
let link = Link::parse(under);
!link.is_external()
&& !link.is_same_document()
&& link.id_ref().is_none()
&& prov_graph::title::is_alias_shaped(link.addressed_target())
});
let titles = if nominal {
Some(self.title_index_scoped(root_doc).await?)
} else {
None
};
prov_views::select_with(&self.graph, spec, root_doc, titles.as_ref()).await
}
pub async fn scan_ids(&self) -> Result<Vec<(prov_graph::identity::Id, PathBuf)>> {
self.graph.scan_ids().await
}
pub async fn content_documents(&self) -> Result<Vec<PathBuf>> {
self.graph.content_documents().await
}
pub(crate) async fn direct_child_files(
&self,
dirs: &BTreeSet<PathBuf>,
) -> Result<Vec<PathBuf>> {
self.graph.direct_child_files(dirs).await
}
pub(crate) fn reached_dirs(reachable: &BTreeSet<PathBuf>) -> BTreeSet<PathBuf> {
Graph::<FS, Ix>::reached_dirs(reachable)
}
}
#[derive(Debug, Clone)]
pub struct WorkspaceBuilder<FS, Id, Ix> {
fs: FS,
root: PathBuf,
identity: Id,
index: Ix,
settings: Settings,
}
impl<FS, Id, Ix> WorkspaceBuilder<FS, Id, Ix> {
pub fn root(mut self, root: impl Into<PathBuf>) -> Self {
self.root = root.into();
self
}
pub fn relations(mut self, relations: RelationSet) -> Self {
self.settings.relations = relations;
self
}
pub fn link_style(mut self, link_style: LinkStyle) -> Self {
self.settings.link_style = link_style;
self
}
pub fn id_links(mut self, id_links: bool) -> Self {
self.settings.id_links = id_links;
self
}
pub fn fixity(mut self, fixity: Fixity) -> Self {
self.settings.fixity = fixity;
self
}
pub fn record_deletions(mut self, record_deletions: bool) -> Self {
self.settings.record_deletions = record_deletions;
self
}
pub fn embed_style(mut self, embed_style: EmbedStyle) -> Self {
self.settings.embed_style = embed_style;
self
}
pub fn id_storage(mut self, id_storage: IdStorage) -> Self {
self.settings.id_storage = id_storage;
self
}
pub fn workspace_id(mut self, name: impl Into<String>) -> Self {
self.settings.workspace_id = name.into();
self
}
pub fn out_of_scope(mut self, dirs: impl IntoIterator<Item = PathBuf>) -> Self {
self.settings.out_of_scope = dirs.into_iter().collect();
self
}
pub fn reference_style(mut self, style: ReferenceStyle) -> Self {
self.settings.reference_style = Some(style);
self
}
pub fn default_embed_format(mut self, format: fig::Format) -> Self {
self.settings.default_embed_format = format;
self
}
pub fn settings(mut self, settings: Settings) -> Self {
self.settings = settings;
self
}
pub fn identity<Id2>(self, identity: Id2) -> WorkspaceBuilder<FS, Id2, Ix> {
WorkspaceBuilder {
fs: self.fs,
root: self.root,
identity,
index: self.index,
settings: self.settings,
}
}
pub fn index<Ix2>(self, index: Ix2) -> WorkspaceBuilder<FS, Id, Ix2> {
WorkspaceBuilder {
fs: self.fs,
root: self.root,
identity: self.identity,
index,
settings: self.settings,
}
}
pub fn build(self) -> Workspace<FS, Id, Ix> {
let read = ReadSettings {
relations: self.settings.relations.clone(),
workspace_id: self.settings.workspace_id.clone(),
id_storage: self.settings.id_storage,
};
Workspace {
graph: Graph::new(self.fs, self.root, self.index, read),
identity: self.identity,
settings: self.settings,
pending_stamps: Vec::new(),
inbound: inbound::empty(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{IdentityPolicy, Minter};
use prov_store::index::InMemoryIndex;
#[derive(Clone)]
struct DummyFs;
#[test]
fn paths_only_by_default() {
let ws = Workspace::builder(DummyFs).root("vault").build();
assert_eq!(ws.root(), Path::new("vault"));
assert_eq!(ws.relations().spanning_relation(), Some("contents"));
assert!(!ws.identity().registration().is_active());
}
#[test]
fn fs_path_joins_a_workspace_relative_path_onto_the_root() {
let ws = Workspace::builder(DummyFs).root("vault").build();
assert_eq!(
ws.fs_path(Path::new("notes/a.md")),
Path::new("vault/notes/a.md")
);
}
#[test]
fn identity_opts_in_via_one_builder_line() {
let ws = Workspace::builder(DummyFs)
.root("vault")
.identity(Minter::lazy(1))
.index(InMemoryIndex::new())
.build();
assert!(ws.identity().registration().on_link);
assert!(ws.index().is_empty());
}
#[test]
fn every_setting_survives_the_builder_type_flips() {
let settings = Settings {
relations: RelationSet::diaryx(),
link_style: LinkStyle::PlainRelative,
id_links: true,
reference_style: None,
default_embed_format: fig::Format::Json,
embed_style: EmbedStyle::CodeBlock,
fixity: Fixity::Off,
record_deletions: false,
id_storage: IdStorage::Frontmatter,
workspace_id: "notes".into(),
out_of_scope: vec![PathBuf::from("history")],
root: Some(PathBuf::from("home.md")),
updated: "updated".into(),
};
let ws = Workspace::builder(DummyFs)
.root("vault")
.settings(settings)
.identity(Minter::lazy(1))
.index(InMemoryIndex::new())
.build();
assert_eq!(ws.link_style(), LinkStyle::PlainRelative);
assert_eq!(ws.default_embed_format(), fig::Format::Json);
assert_eq!(ws.embed_style(), EmbedStyle::CodeBlock);
assert_eq!(ws.fixity(), Fixity::Off);
assert!(!ws.record_deletions());
assert_eq!(ws.id_storage(), IdStorage::Frontmatter);
assert_eq!(ws.workspace_id(), "notes");
assert_eq!(ws.out_of_scope(), [PathBuf::from("history")]);
assert_eq!(ws.relations().spanning_relation(), Some("contents"));
assert!(ws.id_links());
assert_eq!(ws.reference_style().addressing, Addressing::Id);
let copy = ws.clone();
assert_eq!(copy.id_storage(), IdStorage::Frontmatter);
assert_eq!(copy.workspace_id(), "notes");
assert_eq!(copy.out_of_scope(), [PathBuf::from("history")]);
}
#[test]
fn a_config_becomes_the_workspaces_settings() {
let config = crate::config::WorkspaceConfig {
id_storage: IdStorage::Frontmatter,
fixity: Fixity::Off,
record_deletions: false,
embed_style: EmbedStyle::CodeBlock,
default_embed_format: fig::Format::Json,
workspace_id: "notes".into(),
..Default::default()
};
let ws = Workspace::builder(DummyFs)
.root("vault")
.settings(Settings::from(&config))
.build();
assert_eq!(ws.id_storage(), IdStorage::Frontmatter);
assert_eq!(ws.fixity(), Fixity::Off);
assert!(!ws.record_deletions());
assert_eq!(ws.embed_style(), EmbedStyle::CodeBlock);
assert_eq!(ws.default_embed_format(), fig::Format::Json);
assert_eq!(ws.workspace_id(), "notes");
assert_eq!(ws.reference_style(), config.reference_style());
}
}
#[cfg(test)]
mod spanning_children_tests {
use super::*;
use crate::fs_faults::CountingFs;
use prov_graph::exec::block_on;
use prov_testkit::write;
fn tempdir(tag: &str) -> PathBuf {
prov_testkit::scratch("children", tag)
}
#[test]
fn reads_one_generation_and_hands_back_what_it_read() {
let dir = tempdir("bounded");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- a.md\n- b.md\n- deep/index.md\n---\n",
);
write(&dir, "a.md", "---\ntitle: A\npart_of: index.md\n---\n");
write(&dir, "b.md", "---\ntitle: B\npart_of: index.md\n---\n");
write(
&dir,
"deep/index.md",
"---\ntitle: Deep\npart_of: /index.md\ncontents:\n- child.md\n---\n",
);
write(
&dir,
"deep/child.md",
"---\ntitle: Grandchild\npart_of: /deep/index.md\n---\n",
);
let fs = CountingFs::default();
let ws = Workspace::builder(fs.clone()).root(&dir).build();
let children = block_on(ws.spanning_children("index.md")).expect("children");
let named: Vec<(String, String)> = children
.iter()
.map(|(path, doc)| {
(
path.display().to_string(),
doc.meta
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
)
})
.collect();
assert_eq!(
named,
vec![
("a.md".to_string(), "A".to_string()),
("b.md".to_string(), "B".to_string()),
("deep/index.md".to_string(), "Deep".to_string()),
],
"the parent's declared children, in the order it declared them, each parsed"
);
assert_eq!(
fs.doc_reads(&dir, "deep/child.md"),
0,
"a generation below the one asked for was read"
);
for rel in ["index.md", "a.md", "b.md", "deep/index.md"] {
assert_eq!(fs.doc_reads(&dir, rel), 1, "{rel} was read more than once");
}
}
#[test]
fn a_child_that_cannot_be_resolved_or_read_is_left_out_rather_than_raised() {
let dir = tempdir("broken");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- gone.md\n- '[Elsewhere](https://example.com/)'\n- ok.md\n---\n",
);
write(&dir, "ok.md", "---\ntitle: OK\npart_of: index.md\n---\n");
let ws = Workspace::builder(CountingFs::default()).root(&dir).build();
let children =
block_on(ws.spanning_children("index.md")).expect("a broken sibling is not an error");
assert_eq!(children.len(), 1, "{children:?}");
assert_eq!(children[0].0, Path::new("ok.md"));
}
#[test]
fn a_node_declaring_no_containment_has_no_children() {
let dir = tempdir("leaf");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
let fs = CountingFs::default();
let ws = Workspace::builder(fs.clone()).root(&dir).build();
assert!(
block_on(ws.spanning_children("index.md"))
.expect("children")
.is_empty()
);
assert_eq!(fs.doc_reads(&dir, "index.md"), 1);
}
}
#[cfg(all(test, feature = "yaml"))]
mod reified_vocabulary_tests {
use super::*;
use crate::config::{FieldSpec, OpenClosed};
use crate::identity::Minter;
use prov_graph::exec::block_on;
use prov_graph::fs::StdFs;
use prov_graph::identity::Id as DocId;
use prov_store::index::FileIndex;
use prov_testkit::write;
fn tempdir(tag: &str) -> PathBuf {
prov_testkit::scratch("reified-vocab", tag)
}
fn spec(values: OpenClosed) -> FieldSpec {
FieldSpec {
ty: None,
values,
vocabulary: Some("vocab/index.md".into()),
reify: true,
default: None,
under: None,
}
}
fn a_vocabulary_of_audiences(tag: &str) -> PathBuf {
let dir = tempdir(tag);
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- vocab/index.md\n---\n",
);
write(
&dir,
"vocab/index.md",
"---\ntitle: Audiences\npart_of: /index.md\ncontents:\n\
- public.md\n- friends.md\n- colleagues.md\n- readme.md\n- gone.md\n---\nWho may read what.\n",
);
write(
&dir,
"vocab/public.md",
"---\ntitle: public\npart_of: index.md\nmeans: Anyone; safe to publish\n---\nThe gloss lives here.\n",
);
write(
&dir,
"vocab/friends.md",
"---\ntitle: Friends and family\nterm: friends\nid: aud_k9fp\npart_of: index.md\n---\n",
);
write(
&dir,
"vocab/colleagues.md",
"---\ntitle: colleagues\npart_of: index.md\nretired: true\n---\n",
);
write(
&dir,
"vocab/readme.md",
"---\npart_of: index.md\n---\nHow to add a term.\n",
);
dir
}
fn load(dir: &Path, values: OpenClosed) -> crate::vocabulary::Vocabulary {
let ws = Workspace::builder(StdFs).root(dir).build();
block_on(ws.load_reified_vocabulary(Path::new("index.md"), "audience", &spec(values)))
.unwrap()
.expect("a reified vocabulary")
}
#[test]
fn the_terms_are_the_index_nodes_spanning_children() {
let dir = a_vocabulary_of_audiences("terms");
let vocab = load(&dir, OpenClosed::Closed);
assert_eq!(vocab.field, "audience");
assert_eq!(vocab.values, OpenClosed::Closed);
assert_eq!(
vocab.terms.keys().cloned().collect::<Vec<_>>(),
vec![
"colleagues".to_string(),
"friends".to_string(),
"public".to_string()
],
"a broken child link and a child that is no term are somebody else's finding"
);
}
#[test]
fn an_explicit_term_key_wins_over_the_title_it_is_read_under() {
let dir = a_vocabulary_of_audiences("term-key");
let vocab = load(&dir, OpenClosed::Closed);
assert!(vocab.accepts("friends"));
assert!(
!vocab.terms.contains_key("Friends and family"),
"retitling a term must not rename the value: {:?}",
vocab.terms.keys().collect::<Vec<_>>()
);
}
#[test]
fn a_term_node_without_an_explicit_key_is_read_under_its_title() {
let dir = a_vocabulary_of_audiences("title-fallback");
assert!(load(&dir, OpenClosed::Closed).accepts("public"));
}
#[test]
fn retired_means_and_the_nodes_own_id_come_off_the_term_node() {
let dir = a_vocabulary_of_audiences("fields");
let vocab = load(&dir, OpenClosed::Closed);
assert!(vocab.is_retired("colleagues"), "{:?}", vocab.terms);
assert!(!vocab.accepts("colleagues"));
assert_eq!(
vocab.terms["public"].means.as_deref(),
Some("Anyone; safe to publish"),
"the prose body is the gloss prov does not read; `means:` is the one it does"
);
assert_eq!(vocab.terms["friends"].id, Some(DocId("aud_k9fp".into())));
assert_eq!(vocab.terms["public"].id, None);
assert_eq!(vocab.terms["friends"].means, None);
}
#[test]
fn a_term_with_no_frontmatter_id_takes_the_one_the_registry_holds() {
let dir = a_vocabulary_of_audiences("registry-id");
let mut ws = Workspace::builder(StdFs)
.root(&dir)
.identity(Minter::lazy(9))
.index(FileIndex::new(fig::Format::Yaml))
.build();
ws.index_mut()
.register(&DocId("bcdfghj".into()), Path::new("vocab/public.md"));
let vocab = block_on(ws.load_reified_vocabulary(
Path::new("index.md"),
"audience",
&spec(OpenClosed::Closed),
))
.unwrap()
.expect("a reified vocabulary");
assert_eq!(vocab.terms["public"].id, Some(DocId("bcdfghj".into())));
assert_eq!(vocab.terms["friends"].id, Some(DocId("aud_k9fp".into())));
}
#[test]
fn a_term_value_resolves_to_the_node_that_declares_it() {
let dir = a_vocabulary_of_audiences("term-path");
let ws = Workspace::builder(StdFs).root(&dir).build();
let path = |term: &str| {
block_on(ws.reified_term_path(Path::new("index.md"), "vocab/index.md", term)).unwrap()
};
assert_eq!(path("public"), Some(PathBuf::from("vocab/public.md")));
assert_eq!(path("friends"), Some(PathBuf::from("vocab/friends.md")));
assert_eq!(
path("colleagues"),
Some(PathBuf::from("vocab/colleagues.md"))
);
assert_eq!(
path("Friends and family"),
None,
"the key is the term, not the title"
);
assert_eq!(path("nobody"), None);
}
#[test]
fn a_field_with_no_pointer_and_a_pointer_at_nothing_both_load_nothing() {
let dir = a_vocabulary_of_audiences("absent");
let ws = Workspace::builder(StdFs).root(&dir).build();
let none = FieldSpec {
ty: None,
values: OpenClosed::Closed,
vocabulary: None,
reify: true,
default: None,
under: None,
};
assert!(
block_on(ws.load_reified_vocabulary(Path::new("index.md"), "audience", &none))
.unwrap()
.is_none()
);
assert!(
block_on(ws.reified_term_path(
Path::new("index.md"),
"https://example.com/terms",
"public"
))
.unwrap()
.is_none()
);
}
}