prov 0.6.4

A self-describing plaintext workspace: structure lives in documents' own embedded metadata.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! Root discovery — finding the workspace a directory belongs to.
//!
//! A prov workspace is *self-describing*, so where it begins is a fact to be
//! found, not configured: walk up from a starting directory and the first
//! directory holding a **root document** — one with metadata and no `part_of`
//! (nothing contains it) — is the workspace root. This is the counterpart to the
//! traversal in [`prov_graph::graph::tree`]: that walk goes *down* the spanning tree from a
//! known root; this one goes *up* the filesystem to locate the root in the first
//! place.
//!
//! It lives in the library, over the [`Storage`] seam, rather than in the CLI:
//! discovery is workspace semantics (which file is the root, ties broken by
//! `index`/`readme`, an ambiguous directory refused), not presentation, and a
//! programmatic embedder needs it exactly as the CLI does. The CLI keeps only the
//! thin shell around it — reading the real current directory, and phrasing the
//! "no workspace here" advice — while the judgment lives here and is testable
//! against a fake filesystem.

use std::path::{Path, PathBuf};

use crate::config::{ROOT_CONFIG_KEY, WorkspaceConfig};
use crate::workspace::Workspace;
use prov_graph::content::ContentFormat;
use prov_graph::document::{self, Document};
use prov_graph::error::Result;
use prov_store::fs::Storage;

/// A located workspace: where the root directory is, which document in it is the
/// root, the registry that root declares (if any), and the effective config
/// (defaults, overlaid by the root's `prov:` block, overlaid by the linked
/// config document).
#[derive(Debug, Clone)]
pub struct Discovered {
    /// The workspace root directory (as reached by walking up from the start).
    pub root_dir: PathBuf,
    /// The root document, relative to `root_dir`.
    pub root_doc: PathBuf,
    /// The registry document the root declares, relative to `root_dir`, if any.
    pub registry: Option<PathBuf>,
    /// The effective workspace configuration.
    pub config: WorkspaceConfig,
}

/// The outcome of a [`discover`] walk — one of the three answers "which workspace
/// is this directory in?" genuinely has.
// `Found` carries a whole `WorkspaceConfig` and so dwarfs the other two
// variants — which is the shape this type is *for*: `discover` returns exactly
// one of these, once, and every caller destructures it immediately. Boxing to
// even the variants out would put an allocation in the signature of the
// function every consumer starts with, to save a stack copy on a path taken
// once per process.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum Discovery {
    /// A single unambiguous root was found.
    Found(Discovered),
    /// A directory held two or more root candidates and no `index`/`readme` to
    /// break the tie — prov will not guess which is the root. Carries the
    /// directory and the candidate filenames so a caller can name them.
    Ambiguous {
        /// The directory that held the competing candidates.
        dir: PathBuf,
        /// The candidate filenames, in directory order.
        candidates: Vec<String>,
    },
    /// No ancestor directory held a root document at all.
    NotFound,
}

/// Whether a file `stem` is the conventional root name that wins ties.
fn stem_is(name: &Path, want: &str) -> bool {
    name.file_stem()
        .and_then(|s| s.to_str())
        .is_some_and(|s| s.eq_ignore_ascii_case(want))
}

/// Walk up from `from` (an absolute directory) and locate the workspace root.
///
/// In each directory a **root candidate** is a document — a content document
/// (Markdown/Djot/HTML), or a whole-file metadata document stemmed `index`/
/// `readme` (a *separated* root's node) — with metadata, no `part_of`, and no
/// prov byline marking it as generated (see [`is_root_candidate`]). A file
/// stemmed `index` wins, then `readme`, then a lone candidate; two or more
/// unnamed candidates are [`Discovery::Ambiguous`]. The first ancestor with a
/// winner is the root; a walk that reaches the filesystem top with none is
/// [`Discovery::NotFound`].
///
/// `FS: Clone` because the effective config is read through a throwaway probe
/// [`Workspace`] rooted at the found directory (its `registry_path`/`config_path`
/// resolve the pointer relations) — the same machinery every command uses, so
/// discovery and operation agree on where the registry and config live.
pub async fn discover<FS: Storage + Clone>(fs: &FS, from: &Path) -> Result<Discovery> {
    for dir in from.ancestors() {
        let Ok(entries) = fs.read_dir(dir).await else {
            continue;
        };
        let mut candidates: Vec<String> = Vec::new();
        for entry in entries {
            let path = entry.path();
            if !can_be_root(path) {
                continue;
            }
            let Ok(text) = fs.read_to_string(path).await else {
                continue;
            };
            let Ok(doc) = Document::parse(path, &text) else {
                continue;
            };
            if is_root_candidate(&doc)
                && let Some(name) = path.file_name().and_then(|n| n.to_str())
            {
                candidates.push(name.to_string());
            }
        }
        match choose_root(&candidates) {
            Some(root_doc) => {
                let discovered = build(fs, dir.to_path_buf(), PathBuf::from(root_doc)).await?;
                return Ok(Discovery::Found(discovered));
            }
            None if candidates.len() > 1 => {
                return Ok(Discovery::Ambiguous {
                    dir: dir.to_path_buf(),
                    candidates,
                });
            }
            None => continue,
        }
    }
    Ok(Discovery::NotFound)
}

/// Whether `path` is *shaped* like a root document — the cheap half of the test,
/// applied before anything is read.
///
/// A content document (Markdown/Djot/HTML) qualifies. So does a whole-file
/// metadata document (a *separated* root's node, `index.yaml` and friends), but
/// only under the conventional `index`/`readme` stem — otherwise a stray
/// `.json`/`.yaml` config file, which is a mapping at its root and declares no
/// `part_of`, would masquerade as a root.
fn can_be_root(path: &Path) -> bool {
    let is_content_ext = ContentFormat::from_extension(path).is_some();
    let is_meta_ext = document::whole_file_format(path).is_some();
    if is_content_ext {
        return true;
    }
    is_meta_ext && (stem_is(path, "index") || stem_is(path, "readme"))
}

/// The other half: a root document has metadata, says nothing contains it, and
/// is not a page prov itself derived.
///
/// The third clause is what keeps a workspace from being bricked by its own
/// `about` page. Generated prose is machinery-shaped — reached one way from the
/// root, carrying no `part_of` and no id (spec §4) — and that is *precisely* the
/// shape of a root candidate. In a workspace whose root is stemmed `index` or
/// `readme` the tie breaks by name and nothing is noticed; in one whose root is
/// named anything else, `prov about` writes a second candidate into the root
/// directory and every later command refuses to guess which is the root. The
/// page cannot say `part_of` — the spec forbids the back-link, and prov would
/// have to census the page as a tree member — so what settles it is the byline
/// it already carries: a file prov generated is derived *from* the root and so
/// can never be the root.
fn is_root_candidate(doc: &Document) -> bool {
    doc.has_meta() && doc.meta.get("part_of").is_none() && !crate::about::is_generated(&doc.meta)
}

/// Pick the root from a directory's candidates: an `index` stem wins, then
/// `readme`, then a lone candidate. Two or more unnamed candidates are a tie this
/// will not break — prov refuses to guess which is the root.
fn choose_root(candidates: &[String]) -> Option<String> {
    candidates
        .iter()
        .find(|n| stem_is(Path::new(n), "index"))
        .or_else(|| candidates.iter().find(|n| stem_is(Path::new(n), "readme")))
        .cloned()
        .or_else(|| (candidates.len() == 1).then(|| candidates[0].clone()))
}

impl<FS: prov_graph::fs::ReadStorage, Id, Ix: prov_graph::index::IdIndex> Workspace<FS, Id, Ix> {
    /// This workspace's own root document — the same judgment [`discover`] makes,
    /// asked of a workspace already located rather than of a directory being
    /// searched for one. `None` when the root directory holds no candidate, or
    /// holds several with no `index`/`readme` to break the tie.
    ///
    /// Where [`discover`] walks *up* the filesystem to find which workspace a
    /// directory belongs to, this reads one directory — the root this workspace is
    /// already rooted at — so it needs neither `Clone` nor a second config layering
    /// pass. It exists because "walk the spanning relation up to the root" is not a
    /// complete answer to "which document roots this workspace": a document that
    /// declares no `part_of` roots that walk at *itself*, whether it is the root or
    /// merely outside the tree. See
    /// [`spanning_root`](Workspace::spanning_root), which uses this to tell those
    /// two apart.
    pub async fn root_document(&self) -> Result<Option<PathBuf>> {
        let mut candidates = Vec::new();
        for entry in self.listing(Path::new("")).await? {
            if entry.file_type().is_dir() {
                continue;
            }
            // `listing` yields *absolute* paths; every document verb below takes
            // workspace-relative ones, and for entries of the root directory the
            // relative path is exactly the file name.
            let Some(name) = entry.file_name().and_then(|n| n.to_str()) else {
                continue;
            };
            let path = PathBuf::from(name);
            if !can_be_root(&path) {
                continue;
            }
            let Ok((_, doc)) = self.load(&path).await else {
                continue;
            };
            if is_root_candidate(&doc) {
                candidates.push(name.to_string());
            }
        }
        Ok(choose_root(&candidates).map(PathBuf::from))
    }
}

/// Assemble the [`Discovered`] for a chosen root: resolve the registry pointer
/// and layer the effective config (defaults → root `prov:` block → linked
/// config document), through a probe workspace rooted at `root_dir`.
async fn build<FS: Storage + Clone>(
    fs: &FS,
    root_dir: PathBuf,
    root_doc: PathBuf,
) -> Result<Discovered> {
    let probe: Workspace<FS> = Workspace::builder(fs.clone()).root(&root_dir).build();
    let registry = probe.registry_path(&root_doc).await?;

    let mut config = WorkspaceConfig::default();
    // The root's `prov:` frontmatter block (config's description home).
    if let Ok(text) = fs.read_to_string(&root_dir.join(&root_doc)).await
        && let Ok(doc) = Document::parse(&root_doc, &text)
        && let Some(block) = doc.meta.get(ROOT_CONFIG_KEY)
    {
        config.apply(block);
    }
    // The linked config document (the policy home) wins over the root block.
    if let Ok(Some(config_doc)) = probe.config_path(&root_doc).await
        && let Ok(text) = fs.read_to_string(&root_dir.join(&config_doc)).await
        && let Ok(doc) = Document::parse(&config_doc, &text)
    {
        config.apply(&doc.meta);
    }

    Ok(Discovered {
        root_dir,
        root_doc,
        registry,
        config,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use prov_graph::exec::block_on;
    use prov_graph::fs::StdFs;

    fn tmp(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("prov-discover-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn finds_the_root_by_walking_up_from_a_nested_dir() {
        let root = tmp("walk-up");
        std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n# Home\n").unwrap();
        std::fs::create_dir_all(root.join("a/b")).unwrap();
        std::fs::write(
            root.join("a/child.md"),
            "---\ntitle: Child\npart_of: '[Home](/index.md)'\n---\n",
        )
        .unwrap();

        let outcome = block_on(discover(&StdFs, &root.join("a/b"))).unwrap();
        match outcome {
            Discovery::Found(d) => {
                assert_eq!(d.root_dir, root);
                assert_eq!(d.root_doc, Path::new("index.md"));
            }
            other => panic!("expected Found, got {other:?}"),
        }
    }

    #[test]
    fn two_unnamed_candidates_are_ambiguous() {
        let root = tmp("ambiguous");
        std::fs::write(root.join("one.md"), "---\ntitle: One\n---\n").unwrap();
        std::fs::write(root.join("two.md"), "---\ntitle: Two\n---\n").unwrap();
        match block_on(discover(&StdFs, &root)).unwrap() {
            Discovery::Ambiguous { candidates, .. } => assert_eq!(candidates.len(), 2),
            other => panic!("expected Ambiguous, got {other:?}"),
        }
    }

    #[test]
    fn index_stem_breaks_a_tie() {
        let root = tmp("index-wins");
        std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n").unwrap();
        std::fs::write(root.join("other.md"), "---\ntitle: Other\n---\n").unwrap();
        match block_on(discover(&StdFs, &root)).unwrap() {
            Discovery::Found(d) => assert_eq!(d.root_doc, Path::new("index.md")),
            other => panic!("expected Found, got {other:?}"),
        }
    }

    #[test]
    fn a_directory_holding_no_document_yields_no_candidate_there() {
        // A directory with only non-documents contributes no root candidate, so
        // discovery keeps walking up rather than rooting here. (A full "reaches the
        // filesystem top with nothing" NotFound is covered end-to-end by the CLI's
        // `a_route_outside_a_workspace_says_so` test, which can control the whole
        // ancestor chain; a unit test cannot, since the walk climbs to `/`.)
        let root = tmp("no-doc-here");
        std::fs::write(root.join("plain.txt"), "not a document").unwrap();
        // Rooting *would* happen if this dir had a candidate; assert it does not by
        // giving it a child that IS a root and confirming discovery picks the
        // child's dir, never this one.
        std::fs::create_dir_all(root.join("sub")).unwrap();
        std::fs::write(root.join("sub/index.md"), "---\ntitle: Sub\n---\n").unwrap();
        match block_on(discover(&StdFs, &root.join("sub"))).unwrap() {
            Discovery::Found(d) => assert_eq!(d.root_dir, root.join("sub")),
            other => panic!("expected Found at sub, got {other:?}"),
        }
    }

    fn probe(dir: &Path) -> Workspace<StdFs> {
        Workspace::builder(StdFs).root(dir).build()
    }

    #[test]
    fn root_document_names_the_root_of_a_located_workspace() {
        // The same judgment `discover` makes, asked of a workspace already rooted:
        // `index` wins over another parentless document in the same directory, and
        // a child (which declares `part_of`) is not a candidate at all.
        let root = tmp("root-doc");
        std::fs::write(root.join("index.md"), "---\ntitle: Home\n---\n").unwrap();
        // The about page: parentless, so a *candidate*, but `index` outranks it.
        std::fs::write(root.join("about.md"), "---\ntitle: About\n---\n").unwrap();
        std::fs::write(
            root.join("child.md"),
            "---\ntitle: Child\npart_of: index.md\n---\n",
        )
        .unwrap();
        assert_eq!(
            block_on(probe(&root).root_document()).unwrap(),
            Some(PathBuf::from("index.md"))
        );
    }

    #[test]
    fn the_generated_page_is_never_a_root_candidate() {
        // The bug this pins: a workspace whose root is named anything but
        // `index`/`readme` used to brick itself the first time it wrote its own
        // `about` page. The page carries metadata and — as spec §4 requires —
        // no `part_of`, so it tied with the real root and every later command
        // refused to guess between them.
        let root = tmp("generated-page");
        std::fs::write(
            root.join("root.md"),
            "---\ntitle: Home\nabout: about.md\n---\n",
        )
        .unwrap();
        std::fs::write(
            root.join("about.md"),
            "---\ntitle: How this workspace is organized\ngenerated_by: prov 0.5.0\n---\n",
        )
        .unwrap();

        assert_eq!(
            block_on(probe(&root).root_document()).unwrap(),
            Some(PathBuf::from("root.md"))
        );
        match block_on(discover(&StdFs, &root)).unwrap() {
            Discovery::Found(d) => assert_eq!(d.root_doc, PathBuf::from("root.md")),
            other => panic!("expected root.md, got {other:?}"),
        }
    }

    #[test]
    fn another_tool_s_byline_does_not_disqualify_a_root() {
        // Only *prov's* byline is read as "derived from the root". A README a
        // site generator stamped is still an ordinary document, and excluding it
        // would be a second way to lose a workspace's root.
        let root = tmp("foreign-byline");
        std::fs::write(
            root.join("readme.md"),
            "---\ntitle: Home\ngenerated_by: some-site-generator 2.0\n---\n",
        )
        .unwrap();
        assert_eq!(
            block_on(probe(&root).root_document()).unwrap(),
            Some(PathBuf::from("readme.md"))
        );
    }

    #[test]
    fn root_document_declines_to_guess() {
        // Two unnamed candidates and no `index`/`readme` to break the tie is the
        // one case `discover` refuses; asked this way it answers `None` rather
        // than picking, so a caller falls back instead of acting on a guess.
        let root = tmp("root-doc-tie");
        std::fs::write(root.join("one.md"), "---\ntitle: One\n---\n").unwrap();
        std::fs::write(root.join("two.md"), "---\ntitle: Two\n---\n").unwrap();
        assert_eq!(block_on(probe(&root).root_document()).unwrap(), None);

        // And a directory with no document at all has no root to name.
        let bare = tmp("root-doc-bare");
        std::fs::write(bare.join("plain.txt"), "not a document").unwrap();
        assert_eq!(block_on(probe(&bare).root_document()).unwrap(), None);
    }
}