provui_core/workspace.rs
1//! Following a link — the one step that needs a workspace to take it in.
2//!
3//! [`crate::links`] says a metadata row *is* a link and what it says; this
4//! module says where that link lands. The two are separate because the second
5//! costs something the first does not: a root to resolve `/`-absolute targets
6//! from, a registry to turn `id:ajp7eq` into a path, and the filesystem to say
7//! whether the answer is actually there. A frontend that only draws links
8//! differently should not pay for any of it.
9//!
10//! [`WorkspaceView`] is prov's read surface with the pieces an editor needs
11//! resolved once and kept: the effective config, the vocabularies its controlled
12//! fields point at, the [`Facets`] its vocabulary implies, and the
13//! [`Schema`](flower_core::Schema) a content document is edited under. It is a
14//! *view* — the name is the promise. Nothing here writes.
15//!
16//! ## Read-only, and why that is not a temporary state
17//!
18//! Following a link reads. **Editing** one does not: a relation field is half of
19//! a pair prov maintains bidirectionally, so writing `contents` in one document
20//! means writing `part_of` in another, and that is prov's `mutate` layer rather
21//! than its `edit` layer. This crate's metadata backend edits one document's
22//! bytes and has no way to touch a second, which is exactly why the scope line
23//! is where it is. So a frontend may follow a link with what is here and must
24//! not conclude it can retarget one.
25//!
26//! prov's read surface is async over a filesystem port. Nothing here is, because
27//! an editor's "open the document under the cursor" is a foreground action with
28//! nothing to overlap: each entry point blocks with prov's own
29//! [`block_on`](prov::block_on), which is the same executor prov's CLI uses.
30
31use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33
34use flower_core::Schema;
35use prov::index::FileIndex;
36use prov::{
37 Backlink, Discovery, Settings, StdFs, Target, Vocabulary, Workspace, WorkspaceConfig, block_on,
38 discover,
39};
40
41use crate::facets::Facets;
42use crate::links::MetaLink;
43use crate::session::{DocumentSession, SessionError};
44
45fn we(e: impl std::fmt::Display) -> SessionError {
46 SessionError(e.to_string())
47}
48
49/// Where a link lands.
50///
51/// prov's own [`Target`] is the resolution; this adds the two things a frontend
52/// about to open a file needs and `Target` deliberately does not carry — an
53/// absolute path rather than a workspace-relative one, and whether the file is
54/// there.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Destination {
57 /// A document in this workspace, at an absolute path.
58 ///
59 /// `exists` is `false` for a broken link. It is still a `Document` and not
60 /// an error case: the target is well-formed and names a place in this
61 /// workspace, and an editor's right answer to a broken link is usually to
62 /// say so and offer to create it, not to refuse to describe it.
63 Document {
64 /// Absolute, ready to open.
65 path: PathBuf,
66 /// Whether a file is actually there.
67 exists: bool,
68 },
69 /// A `#locator` alone — a place inside the document the link is written in.
70 /// prov does not read a document's internal address space, so where that
71 /// place is, is the frontend's question to answer.
72 SameDocument,
73 /// A URL or mail address. Off-workspace by construction: prov recognizes it
74 /// by syntax and never resolves it.
75 External(String),
76 /// `id:<workspace>/<id>` — a document named in another workspace. prov holds
77 /// no map from a workspace name to a location (that map is a property of the
78 /// device, not of the archive), so this is as far as resolution goes.
79 Foreign {
80 /// The workspace qualifier, as written.
81 workspace: String,
82 /// The id within it, as written — never check-verified, since that
83 /// workspace owns its id space.
84 id: String,
85 },
86 /// An `id:` target with no live registry entry: unknown, tombstoned, or a
87 /// workspace with no registry at all.
88 UnresolvedId(String),
89 /// A nominal (`[[My File]]`) target several documents claim, so it names no
90 /// one of them.
91 AmbiguousAlias(String),
92 /// The target is well-formed but cannot be resolved from here, with the
93 /// reason — a `/`-absolute path asked about outside any workspace, say.
94 Unresolvable {
95 /// The target as written.
96 target: String,
97 /// Why, in a sentence a status line can show.
98 why: String,
99 },
100}
101
102impl Destination {
103 /// The file to open, when there is one that exists.
104 pub fn openable(&self) -> Option<&Path> {
105 match self {
106 Destination::Document { path, exists: true } => Some(path),
107 _ => None,
108 }
109 }
110
111 /// A one-line description, for a status line that has to say what happened
112 /// when nothing opened.
113 pub fn describe(&self) -> String {
114 match self {
115 Destination::Document { path, exists: true } => path.display().to_string(),
116 Destination::Document {
117 path,
118 exists: false,
119 } => format!("{} — not on disk", path.display()),
120 Destination::SameDocument => "a place inside this document".to_string(),
121 Destination::External(url) => format!("{url} — outside the workspace"),
122 Destination::Foreign { workspace, id } => {
123 format!("{id} in workspace `{workspace}` — not locatable from here")
124 }
125 Destination::UnresolvedId(id) => format!("id {id} — no registry entry"),
126 Destination::AmbiguousAlias(name) => {
127 format!("`{name}` — several documents claim that name")
128 }
129 Destination::Unresolvable { target, why } => format!("{target} — {why}"),
130 }
131 }
132}
133
134/// One workspace, opened for reading, with everything an editor resolves once.
135pub struct WorkspaceView {
136 ws: Workspace<StdFs, prov::identity::NoIdentity, FileIndex>,
137 /// The root document, workspace-relative — what every pointer resolves from.
138 root_doc: PathBuf,
139 /// The config document the root points at, workspace-relative, when it has
140 /// one. Kept because a document that *is* the config is edited under a
141 /// different schema than the content around it.
142 config_doc: Option<PathBuf>,
143 config: WorkspaceConfig,
144 vocabularies: BTreeMap<String, Vocabulary>,
145 facets: Facets,
146}
147
148impl WorkspaceView {
149 /// Find the workspace `from` belongs to and open it for reading.
150 ///
151 /// `from` may be a file or a directory; a file's directory is where the walk
152 /// up starts. `Ok(None)` when no ancestor holds a root document, which is
153 /// the ordinary state of a markdown file that is simply a markdown file —
154 /// not an error, and a frontend should carry on without a workspace rather
155 /// than refuse to open it.
156 ///
157 /// A directory holding two root candidates and no `index`/`readme` to break
158 /// the tie *is* an error: prov will not guess which is the root, and neither
159 /// should this.
160 pub fn discover(from: &Path) -> Result<Option<Self>, SessionError> {
161 let start = starting_dir(from)?;
162 match block_on(discover(&StdFs, &start)).map_err(we)? {
163 Discovery::Found(found) => Ok(Some(Self::open(
164 found.root_dir,
165 found.root_doc,
166 found.config,
167 )?)),
168 Discovery::NotFound => Ok(None),
169 Discovery::Ambiguous { dir, candidates } => Err(SessionError(format!(
170 "{} holds {} root candidates and no index/readme to choose between them: {}",
171 dir.display(),
172 candidates.len(),
173 candidates.join(", ")
174 ))),
175 }
176 }
177
178 /// Open a workspace whose root and config are already known — the path a
179 /// caller that did its own discovery takes, and what
180 /// [`discover`](Self::discover) calls.
181 pub fn open(
182 root_dir: impl Into<PathBuf>,
183 root_doc: impl Into<PathBuf>,
184 config: WorkspaceConfig,
185 ) -> Result<Self, SessionError> {
186 let root_dir = root_dir.into();
187 let root_doc = prov::link::normalize(root_doc.into());
188
189 // Every policy knob at once: the relation vocabulary, the reference
190 // style, the embedding pair, fixity, id storage, what the workspace calls
191 // itself. Threading them one at a time is what `Settings` exists to stop.
192 let probe: Workspace<StdFs> = Workspace::builder(StdFs).root(&root_dir).build();
193 let index = load_registry(&probe, &root_doc, &config)?;
194 let ws = Workspace::builder(StdFs)
195 .root(&root_dir)
196 .settings(Settings::from(&config))
197 .index(index)
198 .build();
199
200 let config_doc = block_on(ws.config_path(&root_doc)).map_err(we)?;
201 let vocabularies = load_vocabularies(&ws, &root_doc, &config);
202 let facets = Facets::from_config(&config);
203 Ok(Self {
204 ws,
205 root_doc,
206 config_doc,
207 config,
208 vocabularies,
209 facets,
210 })
211 }
212
213 /// The workspace root directory — the absolute path every relative one here
214 /// is joined to.
215 pub fn root_dir(&self) -> &Path {
216 self.ws.root()
217 }
218
219 /// The root document, absolute.
220 pub fn root_document(&self) -> PathBuf {
221 self.ws.fs_path(&self.root_doc)
222 }
223
224 /// The config document the root points at, absolute, when there is one. A
225 /// workspace that keeps all its policy in the root's `prov:` block has none.
226 pub fn config_document(&self) -> Option<PathBuf> {
227 self.config_doc.as_ref().map(|rel| self.ws.fs_path(rel))
228 }
229
230 /// The effective config — defaults, overlaid by the root's `prov:` block,
231 /// overlaid by the config document.
232 pub fn config(&self) -> &WorkspaceConfig {
233 &self.config
234 }
235
236 /// The classification this workspace's vocabulary implies. See
237 /// [`crate::facets`] for why nothing acts on it.
238 pub fn facets(&self) -> &Facets {
239 &self.facets
240 }
241
242 /// The vocabularies the controlled fields point at, keyed by field name. A
243 /// vocabulary that failed to load is simply absent.
244 pub fn vocabularies(&self) -> &BTreeMap<String, Vocabulary> {
245 &self.vocabularies
246 }
247
248 /// prov's read surface, for a frontend that wants more of it than this view
249 /// exposes — a tree, a census, a title index.
250 pub fn prov(&self) -> &Workspace<StdFs, prov::identity::NoIdentity, FileIndex> {
251 &self.ws
252 }
253
254 /// The schema a **content** document in this workspace is edited under.
255 pub fn content_schema(&self) -> Schema {
256 crate::schema_from_config(&self.config, &self.vocabularies)
257 }
258
259 /// The schema `path` is edited under: the config-document schema for the
260 /// document that *is* this workspace's config, and the content schema for
261 /// everything else.
262 ///
263 /// A fact about which document this is, not a preference. `prov.yaml` is a
264 /// document whose keys are policy, and editing it under the content schema
265 /// would offer term pickers for fields it does not have and none for the
266 /// ones it does.
267 pub fn schema_for(&self, path: &Path) -> Schema {
268 match self.config_document() {
269 Some(config_doc) if same_file(&config_doc, path) => crate::config_schema(&self.config),
270 _ => self.content_schema(),
271 }
272 }
273
274 /// Open a document in this workspace as a [`DocumentSession`], under the
275 /// schema [`schema_for`](Self::schema_for) picks.
276 ///
277 /// The schema and **nothing else** — no keys are made read-only and no rows
278 /// are sunk, because both of those are the frontend's to decide (see
279 /// [`crate::facets`]). Most editors want at least the first, which is three
280 /// lines rather than one:
281 ///
282 /// ```ignore
283 /// let facets = view.facets();
284 /// let schema = Some(view.schema_for(&path));
285 /// let mut session = DocumentSession::open_managed(&path, schema, facets.managed_key_names())?;
286 /// session.metadata_mut().set_demoted(facets.structural_keys(session.meta()));
287 /// ```
288 pub fn open_document(&self, path: impl AsRef<Path>) -> Result<DocumentSession, SessionError> {
289 let path = self.absolute(path.as_ref());
290 let schema = self.schema_for(&path);
291 DocumentSession::open_with_schema(path, schema)
292 }
293
294 /// Resolve a link written in the document at `doc` (absolute or
295 /// workspace-relative).
296 ///
297 /// Path targets and `id:` handles resolve; a nominal (`[[My File]]`) target
298 /// does not, because resolving one needs a title index over the whole
299 /// workspace and that is a scan an editor should not do behind a keystroke.
300 /// Use [`resolve_nominal`](Self::resolve_nominal) to pay for it deliberately.
301 pub fn resolve(&self, doc: &Path, link: &MetaLink) -> Destination {
302 self.destination(self.ws.resolve_link(&self.relative(doc), &link.link), link)
303 }
304
305 /// [`resolve`](Self::resolve), also resolving nominal targets against a
306 /// title index built by walking the workspace.
307 ///
308 /// Separate because of what it costs: the index is a scan from the root, and
309 /// a `[[My File]]` link is the only kind that needs one. A frontend that
310 /// follows links from the keyboard should call [`resolve`](Self::resolve)
311 /// first and fall back to this only when it comes back
312 /// [`Unresolvable`](Destination::Unresolvable).
313 pub fn resolve_nominal(
314 &self,
315 doc: &Path,
316 link: &MetaLink,
317 ) -> Result<Destination, SessionError> {
318 let index = block_on(self.ws.title_index()).map_err(we)?;
319 let target = self
320 .ws
321 .resolve_link_with(&self.relative(doc), &link.link, Some(&index));
322 Ok(self.destination(target, link))
323 }
324
325 /// Every inbound reference to `target`, walked from the workspace root.
326 ///
327 /// prov keeps no stored backlink index — this is the census inverted, so it
328 /// is always fresh and always a walk. Worth it on demand ("what points at
329 /// this?"), not on every frame.
330 pub fn backlinks_to(&self, target: impl AsRef<Path>) -> Result<Vec<Backlink>, SessionError> {
331 let target = self.relative(target.as_ref());
332 block_on(self.ws.backlinks_to(&self.root_doc, &target)).map_err(we)
333 }
334
335 /// Turn prov's resolution into a destination: absolute, checked against the
336 /// filesystem, and — for the cases prov answers by *kind* rather than by
337 /// value — carrying the target the link was written with.
338 ///
339 /// `Target::External` is the one that needs the link: prov recognizes a URL
340 /// by syntax and has nothing further to say about it, so its answer is the
341 /// bare fact of externality. A status line that then has to tell a reader
342 /// their link went nowhere has nothing to name.
343 fn destination(&self, target: Target, link: &MetaLink) -> Destination {
344 match target {
345 Target::Path(rel) => {
346 let path = self.ws.fs_path(&rel);
347 let exists = path.is_file();
348 Destination::Document { path, exists }
349 }
350 Target::SameDocument => Destination::SameDocument,
351 Target::External => Destination::External(link.target().to_string()),
352 Target::Foreign { workspace, id } => Destination::Foreign {
353 workspace,
354 id: id.to_string(),
355 },
356 Target::UnresolvedId(id) => Destination::UnresolvedId(id.to_string()),
357 Target::AmbiguousAlias(name) => Destination::AmbiguousAlias(name),
358 }
359 }
360
361 /// `path` as this workspace sees it: relative to the root, normalized.
362 fn relative(&self, path: &Path) -> PathBuf {
363 let rel = path.strip_prefix(self.ws.root()).unwrap_or(path);
364 prov::link::normalize(rel)
365 }
366
367 fn absolute(&self, path: &Path) -> PathBuf {
368 if path.is_absolute() {
369 path.to_path_buf()
370 } else {
371 self.ws.fs_path(path)
372 }
373 }
374}
375
376/// Where a link points with **no workspace to resolve it in** — the lexical
377/// floor, for a document opened on its own.
378///
379/// A relative target resolves against the document's own directory, which needs
380/// nothing but the two strings. A `/`-absolute target does not: it is relative
381/// to a workspace root, and there is no root, so this says so rather than
382/// guessing at the filesystem root or at the document's directory — both of
383/// which would sometimes open the wrong file, silently. An `id:` target is the
384/// same story with a registry in place of a root.
385pub fn resolve_without_workspace(doc: &Path, link: &MetaLink) -> Destination {
386 use crate::links::TargetKind;
387
388 match &link.kind {
389 TargetKind::SameDocument => Destination::SameDocument,
390 TargetKind::External => Destination::External(link.target().to_string()),
391 TargetKind::Foreign { workspace } => Destination::Foreign {
392 workspace: workspace.clone(),
393 id: link.target().to_string(),
394 },
395 TargetKind::Id => Destination::Unresolvable {
396 target: link.target().to_string(),
397 why: "an id needs the workspace's registry to resolve".to_string(),
398 },
399 TargetKind::MalformedId => Destination::Unresolvable {
400 target: link.target().to_string(),
401 why: "an `id:` target with no id in it".to_string(),
402 },
403 TargetKind::Path if link.target().starts_with('/') => Destination::Unresolvable {
404 target: link.target().to_string(),
405 why: "a workspace-absolute path needs a workspace root".to_string(),
406 },
407 TargetKind::Path => {
408 let path = prov::link::resolve(doc, link.target());
409 let exists = path.is_file();
410 Destination::Document { path, exists }
411 }
412 }
413}
414
415/// The directory a discovery walk starts from: the file's own for a file, the
416/// directory itself for a directory, and absolute either way — the walk goes up
417/// through `ancestors()`, which a relative path exhausts in one step.
418fn starting_dir(from: &Path) -> Result<PathBuf, SessionError> {
419 let absolute = if from.is_absolute() {
420 from.to_path_buf()
421 } else {
422 std::env::current_dir()
423 .map_err(|e| SessionError(format!("resolving {}: {e}", from.display())))?
424 .join(from)
425 };
426 Ok(if absolute.is_dir() {
427 absolute
428 } else {
429 absolute.parent().map(Path::to_path_buf).unwrap_or(absolute)
430 })
431}
432
433/// The registry the root declares, parsed, or an empty one in the workspace's
434/// metadata format.
435///
436/// An empty index is not a failure: a workspace that stores ids in frontmatter
437/// alone keeps no registry document at all, and one that has not bootstrapped
438/// its registry yet is an ordinary new workspace. What it costs is that `id:`
439/// targets come back [`UnresolvedId`](Destination::UnresolvedId), which is the
440/// truth about them.
441fn load_registry(
442 probe: &Workspace<StdFs>,
443 root_doc: &Path,
444 config: &WorkspaceConfig,
445) -> Result<FileIndex, SessionError> {
446 let empty = || FileIndex::new(config.default_embed_format);
447 let Some(rel) = block_on(probe.registry_path(root_doc)).map_err(we)? else {
448 return Ok(empty());
449 };
450 match block_on(probe.read_text(&rel)) {
451 Ok(text) => FileIndex::parse(&rel, &text).map_err(we),
452 // A declared registry that is not there yet is a workspace mid-setup,
453 // not a workspace that cannot be opened. Nothing an editor does depends
454 // on it beyond `id:` resolution, which then honestly reports nothing.
455 Err(_) => Ok(empty()),
456 }
457}
458
459/// Load every controlled field's vocabulary, keyed by field name.
460///
461/// A vocabulary that does not load is left out rather than raised: it means the
462/// pointer is broken or the store is malformed, which is `prov check`'s finding
463/// to report and not a reason an editor cannot open a file. The field then
464/// reaches the schema as an enum with no offered terms — which, for a closed
465/// field, rejects everything, and that is the honest signal that its vocabulary
466/// is missing.
467fn load_vocabularies(
468 ws: &Workspace<StdFs, prov::identity::NoIdentity, FileIndex>,
469 root_doc: &Path,
470 config: &WorkspaceConfig,
471) -> BTreeMap<String, Vocabulary> {
472 let mut loaded = BTreeMap::new();
473 for (field, spec) in &config.fields {
474 let Some(pointer) = spec.vocabulary.as_deref() else {
475 continue;
476 };
477 // A reified vocabulary's terms are documents down the spanning tree, not
478 // rows in a flat store, so it is read a different way. What makes it
479 // reified is the declaration, not anything the target says about itself.
480 let vocabulary = if spec.reify {
481 block_on(ws.load_reified_vocabulary(root_doc, field, spec))
482 } else {
483 block_on(ws.load_vocabulary(root_doc, pointer))
484 };
485 if let Ok(Some(vocabulary)) = vocabulary {
486 loaded.insert(field.clone(), vocabulary);
487 }
488 }
489 loaded
490}
491
492/// Whether two absolute paths name the same file, comparing normalized paths and
493/// falling back to the filesystem's own answer where it can give one.
494fn same_file(a: &Path, b: &Path) -> bool {
495 if a == b {
496 return true;
497 }
498 match (a.canonicalize(), b.canonicalize()) {
499 (Ok(a), Ok(b)) => a == b,
500 _ => false,
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::links::links_in;
508 use flower_core::Seg;
509
510 /// A three-document workspace on disk: a root, a child it contains, and a
511 /// config document it points at.
512 struct Vault(PathBuf);
513
514 impl Vault {
515 fn new(name: &str) -> Self {
516 let dir = std::env::temp_dir().join(format!("provui_workspace_{name}"));
517 let _ = std::fs::remove_dir_all(&dir);
518 std::fs::create_dir_all(dir.join("notes")).unwrap();
519 std::fs::write(
520 dir.join("README.md"),
521 "---\ntitle: The Vault\nconfig: prov.yaml\ncontents:\n- '[A Note](notes/note.md)'\n---\n# The Vault\n",
522 )
523 .unwrap();
524 std::fs::write(
525 dir.join("prov.yaml"),
526 "title: vault config\nfields:\n audience:\n vocabulary: audiences.yaml\n values: closed\n",
527 )
528 .unwrap();
529 std::fs::write(
530 dir.join("audiences.yaml"),
531 "title: Audiences\nvocabulary:\n field: audience\n values: closed\nterms:\n public:\n means: Anyone\n private: {}\n",
532 )
533 .unwrap();
534 std::fs::write(
535 dir.join("notes/note.md"),
536 "---\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",
537 )
538 .unwrap();
539 Self(dir)
540 }
541
542 fn path(&self, rel: &str) -> PathBuf {
543 self.0.join(rel)
544 }
545 }
546
547 impl Drop for Vault {
548 fn drop(&mut self) {
549 let _ = std::fs::remove_dir_all(&self.0);
550 }
551 }
552
553 fn link_at(view: &WorkspaceView, doc: &Path, path: &[Seg]) -> MetaLink {
554 let text = std::fs::read_to_string(doc).unwrap();
555 let parsed = prov::Document::parse(doc, &text).unwrap();
556 let meta = fig::Value::from(&parsed.meta);
557 links_in(&meta, view.facets())
558 .into_iter()
559 .find(|l| l.path == path)
560 .unwrap_or_else(|| panic!("no link at {path:?}"))
561 }
562
563 #[test]
564 fn discovers_the_workspace_a_document_sits_in() {
565 let vault = Vault::new("discover");
566 let view = WorkspaceView::discover(&vault.path("notes/note.md"))
567 .expect("discovery")
568 .expect("a workspace");
569
570 assert!(same_file(&view.root_document(), &vault.path("README.md")));
571 assert_eq!(
572 view.config_document()
573 .map(|p| p.file_name().unwrap().to_owned()),
574 Some("prov.yaml".into()),
575 "the root's config pointer resolved"
576 );
577 // The config document's `fields` reached the effective config, so the
578 // vocabulary it points at was loaded.
579 assert!(view.config().fields.contains_key("audience"));
580 let vocab = view.vocabularies().get("audience").expect("audiences.yaml");
581 assert!(vocab.terms.contains_key("public"));
582 }
583
584 /// The three answers a follow actually has: a document that is there, a
585 /// well-formed link to one that is not, and a target that was never going to
586 /// be a file.
587 #[test]
588 fn follows_a_link_to_a_document_and_says_so_when_it_is_broken() {
589 let vault = Vault::new("follow");
590 let note = vault.path("notes/note.md");
591 let view = WorkspaceView::discover(¬e).unwrap().unwrap();
592
593 // `/README.md` is workspace-absolute — resolved from the root, which is
594 // the thing a bare document cannot do.
595 let up = link_at(&view, ¬e, &[Seg::Key("part_of".into())]);
596 let landed = view.resolve(¬e, &up);
597 let opened = landed.openable().expect("the root is on disk");
598 assert!(same_file(opened, &vault.path("README.md")));
599
600 let broken = link_at(&view, ¬e, &[Seg::Key("links".into()), Seg::Index(0)]);
601 match view.resolve(¬e, &broken) {
602 Destination::Document { path, exists } => {
603 assert!(!exists, "gone.md is not there");
604 assert!(path.ends_with("notes/gone.md"), "resolved beside the note");
605 }
606 other => panic!("expected a broken document link, got {other:?}"),
607 }
608
609 // An external target keeps the URL: prov's own answer is the bare fact
610 // of externality, and a status line has to be able to name what it was.
611 let external = link_at(&view, ¬e, &[Seg::Key("links".into()), Seg::Index(1)]);
612 match view.resolve(¬e, &external) {
613 Destination::External(url) => assert_eq!(url, "https://example.com/"),
614 other => panic!("expected an external target, got {other:?}"),
615 }
616 }
617
618 /// Opening through the workspace is what gets a document its schema — and
619 /// the config document gets the *other* one.
620 #[test]
621 fn a_document_opens_under_the_schema_its_kind_calls_for() {
622 let vault = Vault::new("schema");
623 let view = WorkspaceView::discover(&vault.path("README.md"))
624 .unwrap()
625 .unwrap();
626
627 let note = view.open_document("notes/note.md").expect("open the note");
628 let schema = note.metadata().schema().expect("a content schema");
629 assert!(
630 schema
631 .rule_for(&[Seg::Key("audience".into())])
632 .is_some_and(|r| r.constraint.is_some()),
633 "the workspace's controlled field reached the editor"
634 );
635
636 let config = view.open_document("prov.yaml").expect("open the config");
637 let schema = config.metadata().schema().expect("a config schema");
638 assert!(
639 schema.rule_for(&[Seg::Key("fixity".into())]).is_some(),
640 "the config document is edited under the config schema"
641 );
642 }
643
644 /// The walk goes **up**: a document in a subdirectory finds the root above
645 /// it, which is what makes "open any file in the vault" work.
646 #[test]
647 fn discovery_walks_up_from_a_subdirectory() {
648 let vault = Vault::new("walk_up");
649 let view = WorkspaceView::discover(&vault.path("notes"))
650 .expect("discovery")
651 .expect("a workspace");
652 assert!(same_file(&view.root_document(), &vault.path("README.md")));
653 }
654
655 /// Without a workspace a relative target still resolves; the two that need a
656 /// root or a registry say what is missing instead of guessing.
657 #[test]
658 fn the_lexical_floor_resolves_what_it_can_and_names_what_it_cannot() {
659 let dir = std::env::temp_dir().join("provui_workspace_floor");
660 let _ = std::fs::remove_dir_all(&dir);
661 std::fs::create_dir_all(&dir).unwrap();
662 let doc = dir.join("note.md");
663 std::fs::write(
664 &doc,
665 "---\nlinks:\n- 'sibling.md'\n- '/root.md'\n- 'id:ajp7eq'\n---\n# n\n",
666 )
667 .unwrap();
668 std::fs::write(dir.join("sibling.md"), "# sibling\n").unwrap();
669
670 let text = std::fs::read_to_string(&doc).unwrap();
671 let parsed = prov::Document::parse(&doc, &text).unwrap();
672 let meta = fig::Value::from(&parsed.meta);
673 let links = links_in(&meta, &Facets::default());
674
675 match resolve_without_workspace(&doc, &links[0]) {
676 Destination::Document { exists, .. } => assert!(exists, "sibling.md is there"),
677 other => panic!("expected a document, got {other:?}"),
678 }
679 assert!(matches!(
680 resolve_without_workspace(&doc, &links[1]),
681 Destination::Unresolvable { .. }
682 ));
683 assert!(matches!(
684 resolve_without_workspace(&doc, &links[2]),
685 Destination::Unresolvable { .. }
686 ));
687
688 let _ = std::fs::remove_dir_all(&dir);
689 }
690}