use std::collections::HashMap;
use std::path::{Path, PathBuf};
use flower_core::{Choice, Schema};
use prov::index::FileIndex;
use prov::workspace::FieldScopes;
use prov::{
Backlink, Discovery, IdIndex, Settings, StdFs, Target, Workspace, WorkspaceConfig, block_on,
discover,
};
use crate::facets::Facets;
use crate::findings::Finding;
use crate::links::AnyLink;
use crate::schema::Vocabularies;
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,
scopes: FieldScopes,
vocabularies: Vocabularies,
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 scopes = block_on(ws.field_scopes_of(&root_doc, &config)).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,
scopes,
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) -> &Vocabularies {
&self.vocabularies
}
pub fn field_scopes(&self) -> &FieldScopes {
&self.scopes
}
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.unscoped(&self.config))
}
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),
_ => crate::schema::schema_for_document(
&self.config,
&self.scopes,
&self.vocabularies,
&self.relative(path),
),
}
}
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);
let mut session = DocumentSession::open_with_schema(&path, schema)?;
if let Ok(map) = self.candidates_map(&path) {
session.set_candidates(map);
}
Ok(session)
}
pub fn candidates_map(&self, doc: &Path) -> Result<HashMap<String, Vec<Choice>>, SessionError> {
let choices = self.candidates_for(doc, "")?;
Ok(self
.config
.relation_set()
.relations()
.iter()
.map(|rel| (rel.name.clone(), choices.clone()))
.collect())
}
pub fn candidates_for(&self, doc: &Path, relation: &str) -> Result<Vec<Choice>, SessionError> {
let _ = relation;
let doc_rel = self.relative(doc);
let reachable = block_on(self.ws.reachable_documents_from(&self.root_doc)).map_err(we)?;
let mut choices = Vec::new();
for target in reachable {
if target == doc_rel {
continue;
}
let reference = self.reference_to(&doc_rel, &target, None)?;
choices.push(
Choice::new(fig::Value::Str(reference), self.title_of(&target))
.detail(target.display().to_string()),
);
}
Ok(choices)
}
pub fn resolve(&self, doc: &Path, link: &impl AnyLink) -> Destination {
self.destination(self.ws.resolve_link(&self.relative(doc), link.link()), link)
}
pub fn resolve_nominal(
&self,
doc: &Path,
link: &impl AnyLink,
) -> 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 reference_to(
&self,
from: &Path,
to: &Path,
locator: Option<&str>,
) -> Result<String, SessionError> {
let from_rel = self.relative(from);
let to_rel = self.relative(to);
let style = self.ws.reference_style();
let id = style
.registers()
.then(|| self.ws.index().id_for_path(&to_rel))
.flatten();
let title = self.title_of(&to_rel);
let reference =
prov::link::format_reference(style, &from_rel, &to_rel, id.as_ref(), &title);
Ok(with_locator(&reference, locator))
}
fn title_of(&self, rel: &Path) -> String {
block_on(self.ws.read_text(rel))
.ok()
.and_then(|text| prov::Document::parse(rel, &text).ok())
.and_then(|doc| {
fig::Value::from(&doc.meta)
.get("title")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.unwrap_or_else(|| prov::link::path_to_title(rel))
}
pub fn findings_for(&self, doc: &Path) -> Result<Vec<Finding>, SessionError> {
let rel = self.relative(doc);
let found = block_on(self.ws.check(&rel)).map_err(we)?;
Ok(found
.iter()
.filter(|f| f.subject() == rel)
.map(|f| crate::findings::place(f, &rel))
.collect())
}
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: &impl AnyLink) -> 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: &impl AnyLink) -> 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 with_locator(reference: &str, locator: Option<&str>) -> String {
let Some(locator) = locator else {
return reference.to_string();
};
let link = prov::Link::parse(reference);
let target = prov::link::join_locator(link.target.clone(), Some(locator));
link.with_target(target).render()
}
pub fn reference_without_workspace(from: &Path, to: &Path, locator: Option<&str>) -> String {
let title = prov::link::path_to_title(to);
let reference = prov::format_link(prov::LinkStyle::MarkdownRelative, from, to, &title);
with_locator(&reference, locator)
}
pub fn reference_here(
view: Option<&WorkspaceView>,
session: &DocumentSession,
from: &Path,
) -> String {
let locator = session.locator_at_caret();
let here = session.path();
if let (Some(locator), true) = (locator.as_deref(), same_file(from, here)) {
let label = session
.heading_at_caret()
.map(|h| h.text)
.unwrap_or_else(|| locator.to_string());
return prov::Link {
label: Some(label),
target: prov::link::join_locator(String::new(), Some(locator)),
wikilink: false,
}
.render();
}
match view {
Some(view) => view
.reference_to(from, here, locator.as_deref())
.unwrap_or_else(|_| reference_without_workspace(from, here, locator.as_deref())),
None => reference_without_workspace(from, here, locator.as_deref()),
}
}
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,
) -> Vocabularies {
let mut loaded = Vocabularies::default();
for (field, declarations) in &config.fields {
for (index, spec) in declarations.iter().enumerate() {
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(), index, 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::findings::{Severity, Site};
use crate::links::{MetaLink, 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", 0)
.expect("audiences.yaml");
assert!(vocab.terms.contains_key("public"));
}
#[test]
fn a_reference_is_written_in_the_workspaces_own_style() {
let vault = Vault::new("reference");
let note = vault.path("notes/note.md");
let root = vault.path("README.md");
let view = WorkspaceView::discover(¬e).unwrap().unwrap();
assert_eq!(
view.reference_to(¬e, &root, None).unwrap(),
"[The Vault](/README.md)"
);
assert_eq!(
view.reference_to(&root, ¬e, None).unwrap(),
"[A Note](/notes/note.md)"
);
assert_eq!(
view.reference_to(&root, ¬e, Some("crash-safety"))
.unwrap(),
"[A Note](/notes/note.md#crash-safety)"
);
assert_eq!(
view.reference_to(&root, &vault.path("notes/not_here.md"), None)
.unwrap(),
"[Not Here](/notes/not_here.md)"
);
}
#[test]
fn a_link_to_here_names_the_heading_the_caret_is_under() {
let vault = Vault::new("link_to_here");
let note = vault.path("notes/note.md");
std::fs::write(
¬e,
"---\ntitle: A Note\npart_of: '[The Vault](/README.md)'\n---\nPreamble, above every heading.\n\n# A Note\n\n## Crash Safety\n\nWhy the journal is written first.\n",
)
.unwrap();
let view = WorkspaceView::discover(¬e).unwrap().unwrap();
let mut session = DocumentSession::open(¬e).unwrap();
let at = session.body().source.find("Why the journal").unwrap();
session.body_mut().caret = at;
assert_eq!(
reference_here(Some(&view), &session, ¬e),
"[Crash Safety](#crash-safety)"
);
assert_eq!(
reference_here(Some(&view), &session, &vault.path("README.md")),
"[A Note](/notes/note.md#crash-safety)"
);
assert_eq!(
reference_here(None, &session, &vault.path("README.md")),
"[Note](notes/note.md#crash-safety)"
);
session.body_mut().caret = 0;
assert_eq!(session.locator_at_caret(), None);
assert_eq!(
reference_here(Some(&view), &session, &vault.path("README.md")),
"[A Note](/notes/note.md)"
);
}
#[test]
fn a_findings_run_places_each_one_in_the_region_it_was_written_in() {
let vault = Vault::new("findings");
let note = vault.path("notes/note.md");
std::fs::write(
¬e,
"---\ntitle: A Note\npart_of: '[Nowhere](/nowhere.md)'\n---\n# A Note\n\nSee [the missing one](gone.md).\n",
)
.unwrap();
let view = WorkspaceView::discover(¬e).unwrap().unwrap();
let findings = view.findings_for(¬e).expect("check");
let body: Vec<&Finding> = findings
.iter()
.filter(|f| matches!(f.site, Site::Body(_)))
.collect();
assert_eq!(body.len(), 1, "one body finding: {findings:#?}");
assert_eq!(body[0].kind, "broken_link");
assert_eq!(body[0].severity, Severity::Error);
let Site::Body(span) = &body[0].site else {
unreachable!()
};
let session = DocumentSession::open(¬e).unwrap();
assert_eq!(
&session.body().source[span.clone()],
"[the missing one](gone.md)"
);
let meta: Vec<&Finding> = findings
.iter()
.filter(|f| matches!(f.site, Site::Meta(_)))
.collect();
assert_eq!(meta.len(), 1, "one metadata finding: {findings:#?}");
assert_eq!(meta[0].kind, "broken_link");
assert_eq!(
meta[0].site,
Site::Meta(vec![Seg::Key("part_of".into())]),
"on the row that declares it"
);
assert!(
meta[0].message.starts_with("broken part_of link:"),
"{:?}",
meta[0].message
);
}
#[test]
fn applying_findings_highlights_the_body_and_holds_the_rest() {
let vault = Vault::new("apply_findings");
let note = vault.path("notes/note.md");
std::fs::write(
¬e,
"---\ntitle: A Note\npart_of: '[Nowhere](/nowhere.md)'\n---\n# A Note\n\nSee [the missing one](gone.md).\n",
)
.unwrap();
let view = WorkspaceView::discover(¬e).unwrap().unwrap();
let findings = view.findings_for(¬e).unwrap();
let mut session = DocumentSession::open(¬e).unwrap();
assert!(session.body().highlights().is_empty());
session.apply_findings(&findings);
let highlights = session.body().highlights();
assert_eq!(highlights.len(), 1, "the body half, and only it");
assert_eq!(highlights[0].id, "broken_link", "the kind is the id");
assert_eq!(highlights[0].marker.as_deref(), Some("finding"));
assert_eq!(
&session.body().source[highlights[0].start..highlights[0].end],
"[the missing one](gone.md)"
);
assert!(
session
.meta_finding_at(&[Seg::Key("part_of".into())])
.is_some()
);
assert!(
session
.meta_finding_at(&[Seg::Key("title".into())])
.is_none()
);
session
.metadata_mut()
.set_view(flower_core::ViewMode::Pages);
let items = &session.metadata().page().items;
let part_of = items
.iter()
.find(|i| i.path == [Seg::Key("part_of".into())])
.expect("a row for part_of");
let annotation = part_of.annotation.as_ref().expect("marked");
assert_eq!(annotation.severity, flower_core::annotate::Severity::Error);
assert!(
annotation.message.starts_with("broken part_of link:"),
"{:?}",
annotation.message
);
let title = items
.iter()
.find(|i| i.path == [Seg::Key("title".into())])
.expect("a row for title");
assert!(title.annotation.is_none(), "only the row that is wrong");
session.apply_findings(&[]);
assert!(session.metadata().annotations().is_empty());
}
#[test]
fn a_reference_field_offers_the_workspaces_other_documents() {
let vault = Vault::new("candidates");
let note = vault.path("notes/note.md");
let view = WorkspaceView::discover(¬e).unwrap().unwrap();
let offered = view.candidates_for(¬e, "part_of").expect("walk");
let labels: Vec<&str> = offered.iter().map(|c| c.label.as_str()).collect();
assert_eq!(labels, ["The Vault", "vault config"], "{offered:#?}");
let root = offered
.iter()
.find(|c| c.label == "The Vault")
.expect("the root");
assert_eq!(
root.value,
fig::Value::Str("[The Vault](/README.md)".into()),
"exactly what `reference_to` would write"
);
assert_eq!(
root.detail.as_deref(),
Some("README.md"),
"the path, to tell two titles apart"
);
let mut session = view.open_document(¬e).unwrap();
let part_of = [Seg::Key("part_of".into())];
let choices = session
.metadata()
.choices_at(&part_of)
.expect("the workspace answered");
assert_eq!(
choices.iter().map(|c| &c.value).collect::<Vec<_>>(),
offered.iter().map(|c| &c.value).collect::<Vec<_>>()
);
session.metadata_mut().focus_on(&part_of);
session.metadata_mut().begin_choose();
while session
.metadata()
.choice_selected()
.is_some_and(|c| c.label != "The Vault")
{
session.metadata_mut().choose_next();
}
session.metadata_mut().choose_commit();
let out = session.reassemble().unwrap();
assert!(
out.contains("part_of: '[The Vault](/README.md)'")
|| out.contains("part_of: \"[The Vault](/README.md)\""),
"the chosen reference was written:\n{out}"
);
let audience = [Seg::Key("audience".into()), Seg::Index(0)];
let terms: Vec<String> = session
.metadata()
.choices_at(&audience)
.expect("the vocabulary answered")
.iter()
.map(|c| c.label.clone())
.collect();
assert_eq!(
terms,
["private", "public"],
"the vocabulary, not the documents"
);
assert!(
session
.metadata()
.choices_at(&[Seg::Key("title".into())])
.is_none()
);
}
#[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"
);
}
fn scoped_vault(name: &str) -> Vault {
let vault = Vault::new(name);
let dir = &vault.0;
std::fs::create_dir_all(dir.join("tasks")).unwrap();
std::fs::create_dir_all(dir.join("proposals")).unwrap();
std::fs::write(
dir.join("README.md"),
"---\ntitle: The Vault\nconfig: prov.yaml\ncontents:\n- '[A Note](notes/note.md)'\n- '[Tasks](tasks.md)'\n- '[Proposals](proposals.md)'\n---\n# The Vault\n",
)
.unwrap();
std::fs::write(
dir.join("prov.yaml"),
"title: vault config\nfields:\n status:\n - under: '[[Tasks]]'\n values: closed\n vocabulary: task-statuses.yaml\n - under: '[[Proposals]]'\n values: closed\n vocabulary: proposal-statuses.yaml\n",
)
.unwrap();
std::fs::write(
dir.join("task-statuses.yaml"),
"title: Task statuses\nvocabulary:\n field: status\n values: closed\nterms:\n open: {}\n done: {}\n",
)
.unwrap();
std::fs::write(
dir.join("proposal-statuses.yaml"),
"title: Proposal statuses\nvocabulary:\n field: status\n values: closed\nterms:\n draft: {}\n accepted: {}\n",
)
.unwrap();
std::fs::write(
dir.join("tasks.md"),
"---\ntitle: Tasks\npart_of: '[The Vault](/README.md)'\ncontents:\n- '[Fix the thing](tasks/fix.md)'\n---\n# Tasks\n",
)
.unwrap();
std::fs::write(
dir.join("tasks/fix.md"),
"---\ntitle: Fix the thing\npart_of: '[Tasks](/tasks.md)'\nstatus: open\n---\n# Fix the thing\n",
)
.unwrap();
std::fs::write(
dir.join("proposals.md"),
"---\ntitle: Proposals\npart_of: '[The Vault](/README.md)'\ncontents:\n- '[Do it differently](proposals/differently.md)'\n---\n# Proposals\n",
)
.unwrap();
std::fs::write(
dir.join("proposals/differently.md"),
"---\ntitle: Do it differently\npart_of: '[Proposals](/proposals.md)'\nstatus: draft\n---\n# Do it differently\n",
)
.unwrap();
vault
}
fn status_terms(view: &WorkspaceView, rel: &str) -> Option<Vec<String>> {
use flower_core::FieldRuleExt;
let schema = view.schema_for(&view.absolute(Path::new(rel)));
schema.rule_for(&[Seg::Key("status".into())]).map(|rule| {
let (terms, closed) = rule.enum_constraint().expect("a closed enum");
assert!(closed, "{rel}: the declaration says `values: closed`");
terms.iter().map(|t| t.value.clone()).collect()
})
}
#[test]
fn a_scoped_field_is_governed_by_the_declaration_that_reaches_the_document() {
let vault = scoped_vault("scoped");
let view = WorkspaceView::discover(&vault.path("README.md"))
.unwrap()
.unwrap();
assert!(view.vocabularies().get("status", 0).is_some());
assert!(view.vocabularies().get("status", 1).is_some());
assert!(view.field_scopes().unresolved().is_empty());
assert_eq!(
status_terms(&view, "tasks/fix.md").as_deref(),
Some(&["done".to_string(), "open".to_string()][..]),
"a task draws the task terms"
);
assert_eq!(
status_terms(&view, "proposals/differently.md").as_deref(),
Some(&["accepted".to_string(), "draft".to_string()][..]),
"a proposal draws the proposal terms"
);
assert!(
status_terms(&view, "notes/note.md").is_none(),
"a document under neither index has no status declaration"
);
assert!(
status_terms(&view, "tasks.md").is_none(),
"the index is not in its own scope"
);
assert!(
view.content_schema()
.rule_for(&[Seg::Key("status".into())])
.is_none()
);
assert!(view.vocabularies().unscoped(view.config()).is_empty());
let task = view.open_document("tasks/fix.md").unwrap();
let schema = task.metadata().schema().expect("a content schema");
assert!(
schema
.rule_for(&[Seg::Key("status".into())])
.is_some_and(|r| r.constraint.is_some())
);
}
#[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);
}
}