Skip to main content

rto_spec/
layer.rs

1//! Reading the **authored layer** out of a git tree: which files carry authored
2//! intent, and what parsing each of them yields.
3//!
4//! This is one function ([`authored_layer`]) rather than a rule each caller
5//! applies for itself, and that is the whole reason the module exists. The
6//! authored file set must match the tree the *derived* layer was built from, and
7//! the two disagreeing is issue #330 — a silent wrong answer, not a loud one.
8//! [`rto_graph::GraphSource`] names the tree for both halves; this function is
9//! the authored half of that pairing.
10//!
11//! It reads a git tree and parses text. It touches no [`Store`](rto_graph::Store)
12//! and writes nothing, so a read-only surface can call it — which is what
13//! [`crate::tool_check`] does.
14
15use rto_graph::{BlobRef, GitError, GraphSource, Repo};
16
17use crate::adr::AdrDoc;
18use crate::annotate::Annotation;
19use crate::blueprint::BlueprintDoc;
20use crate::check::{Violation, ViolationKind};
21use crate::site::SitePage;
22
23/// Yields a blob's authored bytes, or `None` when the tree has no such file (a
24/// worktree deletion, which the caller drops).
25///
26/// Generic over the error so a caller in a crate with its own error type — the
27/// `roteiro` binary's `anyhow`, this crate's [`GitError`] — passes its own
28/// closure without converting on the way in.
29pub type BlobReader<'a, E> = dyn Fn(&BlobRef) -> Result<Option<Vec<u8>>, E> + 'a;
30
31/// The authored documents found in one tree, ready for [`crate::check::run`] or
32/// [`crate::check::validate`].
33#[derive(Debug, Default)]
34pub struct AuthoredLayer {
35    /// ADRs under `docs/adr/` that parsed.
36    pub docs: Vec<AdrDoc>,
37    /// House-style blueprints (markdown, no frontmatter).
38    pub blueprints: Vec<BlueprintDoc>,
39    /// `@rto:` annotations scanned from every other file.
40    pub annotations: Vec<Annotation>,
41    /// ADRs under `docs/adr/` — and site pages anywhere — that did **not**
42    /// parse. Carried as violations rather than dropped: a malformed ADR is
43    /// drift, not a skippable warning — swallowing it lets the gate pass while
44    /// silently discarding authored intent. A site page that declared itself
45    /// published and then failed to parse is the same failure with a public
46    /// consequence: the page silently does not exist.
47    pub malformed: Vec<Violation>,
48}
49
50/// Which files in `source`'s tree carry the authored layer.
51///
52/// The staged files in `Index` mode (so a staged-new ADR is seen), the `HEAD`
53/// tree in `Committed` mode, and in `Worktree` mode `HEAD` **plus untracked
54/// files** — because that is precisely what [`rto_graph::sync_worktree`] overlaid
55/// into the derived layer.
56///
57/// Getting this wrong is issue #330's observed symptom. `sync_worktree` walks
58/// untracked files deliberately, "so the working-tree `sync`/`check`/`review` see
59/// new work that isn't staged yet" — but the authored set read only `HEAD`, so a
60/// brand-new ADR had its symbols extracted while the file was never parsed as an
61/// ADR. `check` then reported 17 ADRs with 18 on disk, `sync` said "up to date",
62/// and nothing indicated that the newest decision was missing. The two layers
63/// disagreed about which tree they were describing, in one worktree, with no
64/// second worktree involved.
65///
66/// # Errors
67/// Returns [`GitError`] if the tree or the index cannot be walked.
68pub fn authored_blobs(repo: &Repo, source: GraphSource) -> Result<Vec<BlobRef>, GitError> {
69    match source {
70        GraphSource::Index => repo.index_files(),
71        GraphSource::Committed => repo.walk_blobs(),
72        GraphSource::Worktree => {
73            let mut blobs = repo.walk_blobs()?;
74            // `untracked_files` is defined against the index, so it cannot return
75            // a path already in `blobs`. The synthesized oid is unused:
76            // `Repo::read_source` reads Worktree content from disk by path, and an
77            // untracked file has no git object to read anyway. (A bare repo has no
78            // working tree, and `untracked_files` returns nothing there, so the
79            // oid-reading fallback is never reached with one of these.)
80            blobs.extend(repo.untracked_files()?.into_iter().map(|path| BlobRef {
81                path,
82                oid: String::new(),
83            }));
84            Ok(blobs)
85        }
86    }
87}
88
89/// The authored layer **plus the site pages** — everything one tree's classify
90/// pass yields.
91///
92/// A wrapper rather than a fourth field on [`AuthoredLayer`], because that struct
93/// is destructured exhaustively by its callers and a new field is a breaking
94/// change for every one of them. Wrapping lets a caller adopt site pages when it
95/// is ready to render and gate them, and lets the rest keep compiling against
96/// exactly the layer they already handle — which matters here because the
97/// classification below must stay the *one* copy of the rule either way.
98#[derive(Debug, Default)]
99pub struct AuthoredDocs {
100    /// ADRs, blueprints, annotations, and anything malformed — including a site
101    /// page that declared itself published and then failed to parse.
102    pub layer: AuthoredLayer,
103    /// Documents that declared themselves published (`site-page:` frontmatter).
104    pub site: Vec<SitePage>,
105}
106
107/// Classify and parse the authored layer out of `blobs`, reading each blob's
108/// bytes with `read` — **discarding the site pages**.
109///
110/// Site pages are classified (they are not ADRs, blueprints or annotation
111/// carriers, and misfiling them would put website prose into the annotation
112/// scan) and then dropped, because this function's return type has nowhere to
113/// put them. A caller that publishes or gates the website wants
114/// [`authored_docs_from`], which is this function's whole body with the site
115/// pages kept.
116///
117/// # Errors
118/// Returns `E` if `read` fails.
119pub fn authored_layer_from<E>(
120    blobs: Vec<BlobRef>,
121    read: &BlobReader<'_, E>,
122) -> Result<AuthoredLayer, E> {
123    Ok(authored_docs_from(blobs, read)?.layer)
124}
125
126/// Read and parse the authored layer from `source`'s tree, **discarding the site
127/// pages** — see [`authored_layer_from`].
128///
129/// # Errors
130/// Returns [`GitError`] if the tree cannot be walked or a source file cannot be
131/// read.
132pub fn authored_layer(repo: &Repo, source: GraphSource) -> Result<AuthoredLayer, GitError> {
133    Ok(authored_docs(repo, source)?.layer)
134}
135
136/// Read and parse **everything** the authored classification yields from
137/// `source`'s tree: the file set from [`authored_blobs`], the bytes from
138/// [`Repo::read_source`], and the classification from [`authored_docs_from`].
139///
140/// # Errors
141/// Returns [`GitError`] if the tree cannot be walked or a source file cannot be
142/// read.
143pub fn authored_docs(repo: &Repo, source: GraphSource) -> Result<AuthoredDocs, GitError> {
144    authored_docs_from(authored_blobs(repo, source)?, &|blob| {
145        repo.read_source(blob, source)
146    })
147}
148
149/// Classify and parse the authored layer out of `blobs`, reading each blob's
150/// bytes with `read`.
151///
152/// # This is the one copy of the classification rule
153///
154/// Which path is an ADR, which markdown is a blueprint, which markdown declares
155/// itself a published site page, which file merely carries `@rto:` annotations,
156/// and that a malformed ADR is drift rather than a skippable warning — that is a
157/// rule with one correct answer, and it now has three callers
158/// that reach it by different routes:
159///
160/// - [`authored_layer`] below, from a [`GraphSource`] tree (`build_graph`);
161/// - `build_graph_at_rev` in the `roteiro` binary, from an arbitrary rev's blobs
162///   (the Stage 35b graph arm, which needs the ADRs *of the reviewed commit*);
163/// - [`crate::tool_check`], read-only, which cannot use either of the first two
164///   because both end in a write.
165///
166/// Copying the loop would leave them free to drift, which is the shape this
167/// repository has closed repeatedly — `[debt] ignore` honoured on three surfaces
168/// and not a fourth, `limit == 0` meaning two things across five endpoints. A
169/// graph arm whose ADRs were classified by a slightly different rule than
170/// `check`'s would be measuring its own reimplementation.
171///
172/// `read` yields a blob's authored bytes, or `None` when the tree has no such
173/// file (a worktree deletion); the caller supplies it because *where* the bytes
174/// come from is precisely what differs between a tree, a rev, and a read-only
175/// query. It is generic over its error so a caller in a crate with its own error
176/// type does not have to convert on the way in.
177///
178/// # Errors
179/// Returns `E` if `read` fails. A file that reads but does not *parse* is not an
180/// error: a malformed ADR lands in [`AuthoredLayer::malformed`] as a violation.
181pub fn authored_docs_from<E>(
182    blobs: Vec<BlobRef>,
183    read: &BlobReader<'_, E>,
184) -> Result<AuthoredDocs, E> {
185    let mut out = AuthoredDocs::default();
186    let layer = &mut out.layer;
187    for blob in blobs {
188        // Parse the authored source from the same tree the derived layer used.
189        let Some(bytes) = read(&blob)? else {
190            continue;
191        };
192        let text = String::from_utf8_lossy(&bytes);
193        let file = std::path::Path::new(&blob.path);
194        let is_md = file
195            .extension()
196            .and_then(|e| e.to_str())
197            .is_some_and(|e| e.eq_ignore_ascii_case("md"));
198        let name = file
199            .file_name()
200            .and_then(|n| n.to_str())
201            .unwrap_or_default();
202        let is_adr = blob.path.starts_with("docs/adr/") && is_md && name != "README.md";
203        if is_adr {
204            match crate::adr::parse_adr(&blob.path, &text) {
205                Ok(doc) => layer.docs.push(doc),
206                Err(e) => layer.malformed.push(Violation {
207                    kind: ViolationKind::MalformedAdr,
208                    message: format!("{}: cannot parse ADR: {e}", blob.path),
209                }),
210            }
211        } else if is_md && crate::site::is_site_page(&text) {
212            // A document that declares itself published (`site-page:`) authors
213            // `[[…]]` links like an ADR, and is checked the same way — which is
214            // the entire reason the class exists. Classified before the blueprint
215            // rule so a published document is never demoted by a coincidence of
216            // its path or its H1.
217            match crate::site::parse_site_page(&blob.path, &text) {
218                Ok(page) => out.site.push(page),
219                Err(e) => layer.malformed.push(Violation {
220                    kind: ViolationKind::MalformedSitePage,
221                    message: format!("{}: cannot parse site page: {e}", blob.path),
222                }),
223            }
224        } else if is_md && crate::blueprint::is_blueprint(&blob.path, &text) {
225            // House-style blueprints (no frontmatter) author `[[…]]` links like
226            // ADRs; their links are drift-checked against the derived graph too.
227            layer
228                .blueprints
229                .push(crate::blueprint::parse_blueprint(&blob.path, &text));
230        } else {
231            layer
232                .annotations
233                .extend(crate::annotate::scan_annotations(&blob.path, &text));
234        }
235    }
236    Ok(out)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::authored_docs_from;
242    use rto_graph::BlobRef;
243
244    /// Classify a set of `(path, text)` pairs through the one classification
245    /// rule, reading bytes straight from the fixture.
246    fn classify(files: &[(&str, &str)]) -> super::AuthoredDocs {
247        let blobs: Vec<BlobRef> = files
248            .iter()
249            .map(|(path, _)| BlobRef {
250                path: (*path).to_owned(),
251                oid: String::new(),
252            })
253            .collect();
254        authored_docs_from(blobs, &|blob: &BlobRef| -> Result<Option<Vec<u8>>, ()> {
255            Ok(files
256                .iter()
257                .find(|(p, _)| *p == blob.path)
258                .map(|(_, text)| text.as_bytes().to_vec()))
259        })
260        .expect("classify")
261    }
262
263    #[test]
264    fn publication_is_a_declaration_and_survives_living_outside_docs_site() {
265        // The rule that makes the class worth having: `docs/OFFLINE_SETUP.md`
266        // gains a public page *in place*, and the internal working documents
267        // beside it stay internal — neither outcome depends on a path.
268        let layer = classify(&[
269            (
270                "docs/OFFLINE_SETUP.md",
271                "---\nsite-page: offline-setup\n---\n\n# Offline setup\n",
272            ),
273            (
274                "docs/REVIEW_CHECKLIST.md",
275                "# Review checklist\n\nInternal.\n",
276            ),
277            ("docs/BUILD_PLAN_V2.md", "# Build Plan V2\n\nInternal.\n"),
278        ]);
279        let published: Vec<&str> = layer.site.iter().map(|p| p.path.as_str()).collect();
280        assert_eq!(published, ["docs/OFFLINE_SETUP.md"]);
281        assert_eq!(layer.site[0].slug, "offline-setup");
282        assert!(
283            layer.layer.malformed.is_empty(),
284            "{:?}",
285            layer.layer.malformed
286        );
287    }
288
289    #[test]
290    fn an_adr_is_still_an_adr_and_a_page_outranks_the_blueprint_rule() {
291        // ADRs are recognised first and are published by their own mechanism.
292        // A declared page under `docs/blueprint/` must not be demoted to a
293        // blueprint by the coincidence of its path.
294        let layer = classify(&[
295            (
296                "docs/adr/0001-x.md",
297                "---\nadr-id: \"0001\"\nstatus: Accepted\nsite-page: sneaky\n---\n\n# ADR-0001\n",
298            ),
299            (
300                "docs/blueprint/landing.md",
301                "---\nsite-page: index\n---\n\n# Roteiro\n",
302            ),
303            (
304                "docs/blueprint/roteiro.md",
305                "# Roteiro — Technical Implementation Plan\n",
306            ),
307        ]);
308        assert_eq!(layer.layer.docs.len(), 1, "the ADR is still an ADR");
309        let pages: Vec<&str> = layer.site.iter().map(|p| p.slug.as_str()).collect();
310        assert_eq!(pages, ["index"]);
311        assert_eq!(layer.layer.blueprints.len(), 1);
312        assert_eq!(layer.layer.blueprints[0].path, "docs/blueprint/roteiro.md");
313    }
314
315    #[test]
316    fn a_page_that_declares_itself_and_fails_to_parse_is_drift_not_silence() {
317        // It asked to be published. Dropping it would leave the gate green and
318        // the page silently absent from the site.
319        let layer = classify(&[("docs/site/x.md", "---\nsite-page: Not A Slug\n---\n\n# X\n")]);
320        assert!(layer.site.is_empty());
321        assert_eq!(layer.layer.malformed.len(), 1);
322        assert_eq!(
323            layer.layer.malformed[0].kind,
324            crate::check::ViolationKind::MalformedSitePage
325        );
326        assert!(
327            layer.layer.malformed[0].message.contains("docs/site/x.md"),
328            "names the file: {}",
329            layer.layer.malformed[0].message
330        );
331    }
332
333    #[test]
334    fn the_three_field_entry_point_drops_pages_rather_than_misfiling_them() {
335        // `authored_layer_from` has nowhere to put a site page. Dropping it is
336        // deliberate and documented; the failure to avoid is the *other* one —
337        // a published page falling through to the annotation scan, which would
338        // put website prose into the `@rto:` surface.
339        let files = [(
340            "docs/OFFLINE_SETUP.md",
341            "---\nsite-page: offline-setup\n---\n\n# Offline setup\n\n// @rto:0001\n",
342        )];
343        let blobs = vec![BlobRef {
344            path: files[0].0.to_owned(),
345            oid: String::new(),
346        }];
347        let layer =
348            super::authored_layer_from(blobs, &|_: &BlobRef| -> Result<Option<Vec<u8>>, ()> {
349                Ok(Some(files[0].1.as_bytes().to_vec()))
350            })
351            .expect("classify");
352        assert!(layer.docs.is_empty());
353        assert!(layer.blueprints.is_empty());
354        assert!(
355            layer.annotations.is_empty(),
356            "a published page is not an annotation carrier: {:?}",
357            layer.annotations
358        );
359        // The full form keeps it.
360        assert_eq!(classify(&files).site.len(), 1);
361    }
362
363    #[test]
364    fn a_non_page_still_contributes_its_annotations() {
365        // Adding a class must not steal files from the annotation scan.
366        let layer = classify(&[("src/store.rs", "//! @rto:0001\n")]);
367        assert!(layer.site.is_empty());
368        assert_eq!(layer.layer.annotations.len(), 1);
369    }
370}