Skip to main content

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(&note).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, &note, &[Seg::Key("part_of".into())]);
596        let landed = view.resolve(&note, &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, &note, &[Seg::Key("links".into()), Seg::Index(0)]);
601        match view.resolve(&note, &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, &note, &[Seg::Key("links".into()), Seg::Index(1)]);
612        match view.resolve(&note, &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}