Skip to main content

spec_driven_docs/gates/
paths.rs

1//! Where an instance keeps the documents the gates read.
2//!
3//! One of these locations is declared rather than fixed: the docs scratch.
4//! It is recorded in the manifest and named by one environment variable,
5//! and the variable wins where it is set.
6//!
7//! The documentation root comes from the manifest when one exists, is
8//! discovered from the conventional layouts when none does, and defaults to
9//! `_docs`. Known-issue roots follow the same ladder, and explicit arguments
10//! win over all of it — a repository keeping records outside the root passes
11//! the directories holding them. Nothing here judges content; that is each
12//! gate's business.
13
14use camino::{Utf8Path, Utf8PathBuf};
15
16use crate::domain::manifest::{DOCS_SCRATCH_VAR, MANIFEST_PATH};
17use crate::gates::{GateCtx, GateError};
18
19/// One field of the instance manifest, read permissively.
20///
21/// A gate reads the record as free JSON rather than through `Manifest::parse`
22/// on purpose: a record of another schema version is a reason to upgrade, and
23/// a gate that went blind there would report a clean tree it never read.
24fn manifest_field(ctx: &GateCtx, key: &str) -> Option<serde_json::Value> {
25    let text = std::fs::read_to_string(ctx.path(MANIFEST_PATH)).ok()?;
26    let value: serde_json::Value = serde_json::from_str(&text).ok()?;
27    value.get(key).cloned().filter(|found| !found.is_null())
28}
29
30/// What a variable carries here, trimmed, or `None` when it is unset or blank.
31fn variable(name: &str) -> Option<Utf8PathBuf> {
32    std::env::var(name)
33        .ok()
34        .map(|value| value.trim().to_string())
35        .filter(|value| !value.is_empty())
36        .map(Utf8PathBuf::from)
37}
38
39/// Resolve the docs scratch: the variable first, then the recorded path.
40///
41/// `None` means the project declared none. A caller with a discovery
42/// candidate of its own supplies it; there is none here, because a gate that
43/// guessed the location would judge a directory nobody declared.
44#[must_use]
45pub fn docs_scratch(ctx: &GateCtx) -> Option<Utf8PathBuf> {
46    docs_scratch_with(ctx, variable(DOCS_SCRATCH_VAR))
47}
48
49/// What [`DOCS_SCRATCH_VAR`] carries here, or `None` when it is unset.
50///
51/// The one place the environment is read for this value, so a caller that
52/// resolves it can be tested by supplying the answer instead.
53#[must_use]
54pub fn docs_scratch_variable() -> Option<Utf8PathBuf> {
55    variable(DOCS_SCRATCH_VAR)
56}
57
58/// The resolution, with the variable's value supplied.
59///
60/// The environment is read at one boundary and passed in, so every case is
61/// reachable from a test. This crate forbids unsafe code, and setting a
62/// variable is unsafe from the 2024 edition on.
63#[must_use]
64pub fn docs_scratch_with(ctx: &GateCtx, named: Option<Utf8PathBuf>) -> Option<Utf8PathBuf> {
65    named.or_else(|| {
66        manifest_field(ctx, "docs_scratch")
67            .and_then(|value| value.as_str().map(Utf8PathBuf::from))
68            .filter(|path| !path.as_str().is_empty())
69    })
70}
71
72/// The instance's documentation root, relative to the repository.
73#[must_use]
74pub fn docs_root(ctx: &GateCtx) -> Utf8PathBuf {
75    if let Ok(text) = std::fs::read_to_string(ctx.path(MANIFEST_PATH))
76        && let Ok(value) = serde_json::from_str::<serde_json::Value>(&text)
77        && let Some(root) = value.get("docs_root").and_then(serde_json::Value::as_str)
78        && !root.is_empty()
79    {
80        return Utf8PathBuf::from(root);
81    }
82    for candidate in ["_docs", "docs"] {
83        if discovered(ctx, &Utf8Path::new(candidate).join("specs")) {
84            return Utf8PathBuf::from(candidate);
85        }
86    }
87    Utf8PathBuf::from("_docs")
88}
89
90/// The directories that may hold known-issue records, relative to the
91/// repository. Arguments win; a manifest names one root; a bare consumer's
92/// roots are discovered.
93#[must_use]
94pub fn ki_record_roots(ctx: &GateCtx, args: &[String]) -> Vec<Utf8PathBuf> {
95    if !args.is_empty() {
96        return args.iter().map(Utf8PathBuf::from).collect();
97    }
98    if ctx.path(MANIFEST_PATH).is_file() {
99        return vec![docs_root(ctx).join("reference/known-issues")];
100    }
101    ["_docs", "docs"]
102        .into_iter()
103        .map(|candidate| Utf8Path::new(candidate).join("reference/known-issues"))
104        .filter(|root| discovered(ctx, root))
105        .collect()
106}
107
108/// Whether a discovered candidate is a directory the caller must read.
109///
110/// A candidate whose metadata cannot be read is kept rather than dropped.
111/// `is_dir` answers false for a directory the process cannot stat, so
112/// dropping it there would report an unreadable layout as a layout the
113/// repository does not keep. Kept, it reaches the reader, which raises the
114/// failure or reports the layout as moved rather than judging a tree it
115/// never opened.
116fn discovered(ctx: &GateCtx, root: &Utf8Path) -> bool {
117    match std::fs::metadata(ctx.path(root)) {
118        Ok(metadata) => metadata.is_dir(),
119        Err(source) => source.kind() != std::io::ErrorKind::NotFound,
120    }
121}
122
123/// Every known-issue record under the resolved roots, repository-relative.
124///
125/// A root that is not there is a zone the repository does not keep, and it
126/// is skipped. Every other failure is raised: a directory the process
127/// cannot read holds records this returns none of, and reporting that as an
128/// empty zone would read as a clean review.
129///
130/// # Errors
131///
132/// [`crate::gates::GateError::Io`] when a present root cannot be listed.
133/// The records this gate judges, filtered.
134///
135/// [`ki_records`] stays unfiltered because the same records are support for
136/// `suppression-names-its-case`, which resolves a case id against them
137/// rather than judging their contents. A gate that judges a record's own
138/// text calls this one.
139///
140/// # Errors
141///
142/// See [`ki_records`].
143pub fn ki_records_judged(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
144    Ok(ctx.retained(ki_records(ctx, args)?))
145}
146
147/// Every known-issue record the instance carries, unfiltered.
148///
149/// Unfiltered because the same records are support for
150/// `suppression-names-its-case`. A gate judging a record's own text calls
151/// [`ki_records_judged`].
152///
153/// # Errors
154///
155/// [`GateError::Io`] naming the root that could not be read.
156pub fn ki_records(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
157    let mut records = Vec::new();
158    for root in ki_record_roots(ctx, args) {
159        let entries = match ctx.path(&root).read_dir_utf8() {
160            Ok(entries) => entries,
161            Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue,
162            Err(source) => return Err(GateError::io(&root, source)),
163        };
164        let mut names = Vec::new();
165        for entry in entries {
166            let entry = entry.map_err(|source| GateError::io(&root, source))?;
167            if !entry
168                .file_type()
169                .map_err(|source| GateError::io(&root, source))?
170                .is_file()
171            {
172                continue;
173            }
174            let name = entry.file_name().to_string();
175            if name
176                .strip_prefix("KI-")
177                .and_then(|rest| rest.strip_suffix(".md"))
178                .is_some_and(|slug| !slug.is_empty())
179            {
180                names.push(name);
181            }
182        }
183        names.sort();
184        records.extend(names.into_iter().map(|name| root.join(name)));
185    }
186    Ok(records)
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn ctx(dir: &tempfile::TempDir) -> GateCtx {
194        GateCtx::new(dir.path().to_str().unwrap())
195    }
196
197    fn write(dir: &tempfile::TempDir, path: &str, text: &str) {
198        let path = dir.path().join(path);
199        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
200        std::fs::write(path, text).unwrap();
201    }
202
203    #[test]
204    fn the_docs_scratch_takes_the_variable_then_the_record() {
205        let dir = tempfile::tempdir().unwrap();
206        let ctx = ctx(&dir);
207        assert_eq!(docs_scratch_with(&ctx, None), None);
208
209        write(
210            &dir,
211            ".spec-driven-docs/manifest.json",
212            "{\"docs_scratch\": \"../beside\"}\n",
213        );
214        assert_eq!(
215            docs_scratch_with(&ctx, None),
216            Some(Utf8PathBuf::from("../beside"))
217        );
218        assert_eq!(
219            docs_scratch_with(&ctx, Some(Utf8PathBuf::from("inside"))),
220            Some(Utf8PathBuf::from("inside"))
221        );
222    }
223
224    #[test]
225    fn manifest_root_wins() {
226        let dir = tempfile::tempdir().unwrap();
227        write(
228            &dir,
229            ".spec-driven-docs/manifest.json",
230            "{\n  \"docs_root\": \"docs\"\n}\n",
231        );
232        assert_eq!(docs_root(&ctx(&dir)), "docs");
233    }
234
235    #[test]
236    fn roots_are_discovered_without_a_manifest() {
237        let dir = tempfile::tempdir().unwrap();
238        write(&dir, "docs/specs/SPEC-sample.md", "# S\n");
239        assert_eq!(docs_root(&ctx(&dir)), "docs");
240
241        let both = tempfile::tempdir().unwrap();
242        write(&both, "_docs/specs/SPEC-sample.md", "# S\n");
243        write(&both, "docs/specs/SPEC-sample.md", "# S\n");
244        assert_eq!(docs_root(&ctx(&both)), "_docs");
245
246        let neither = tempfile::tempdir().unwrap();
247        assert_eq!(docs_root(&ctx(&neither)), "_docs");
248    }
249
250    #[test]
251    fn record_arguments_win_over_discovery() {
252        let dir = tempfile::tempdir().unwrap();
253        write(&dir, "docs/reference/known-issues/KI-real.md", "# R\n");
254        let roots = ki_record_roots(&ctx(&dir), &["tests/fixtures".to_string()]);
255        assert_eq!(roots, vec![Utf8PathBuf::from("tests/fixtures")]);
256    }
257
258    #[test]
259    fn records_follow_the_manifest_root() {
260        let dir = tempfile::tempdir().unwrap();
261        write(
262            &dir,
263            ".spec-driven-docs/manifest.json",
264            "{\n  \"docs_root\": \"docs\"\n}\n",
265        );
266        write(&dir, "docs/reference/known-issues/KI-vendor.md", "# V\n");
267        write(&dir, "docs/reference/known-issues/KI-.md", "# empty slug\n");
268        write(
269            &dir,
270            "docs/reference/known-issues/notes.md",
271            "# not a record\n",
272        );
273        assert_eq!(
274            ki_records(&ctx(&dir), &[]).unwrap(),
275            vec![Utf8PathBuf::from(
276                "docs/reference/known-issues/KI-vendor.md"
277            )]
278        );
279    }
280
281    #[test]
282    fn bare_consumer_roots_are_discovered() {
283        let dir = tempfile::tempdir().unwrap();
284        write(&dir, "docs/reference/known-issues/KI-a.md", "# A\n");
285        write(&dir, "docs/reference/known-issues/KI-b.md", "# B\n");
286        assert_eq!(
287            ki_records(&ctx(&dir), &[]).unwrap(),
288            vec![
289                Utf8PathBuf::from("docs/reference/known-issues/KI-a.md"),
290                Utf8PathBuf::from("docs/reference/known-issues/KI-b.md"),
291            ]
292        );
293    }
294
295    #[test]
296    fn an_unreadable_layout_is_not_read_as_an_absent_one() {
297        let dir = tempfile::tempdir().unwrap();
298        std::fs::create_dir_all(dir.path().join("docs/specs")).unwrap();
299        assert_eq!(docs_root(&ctx(&dir)), "docs");
300
301        let specs = dir.path().join("docs/specs");
302        let mut mode = std::fs::metadata(&specs).unwrap().permissions();
303        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
304        std::fs::set_permissions(dir.path().join("docs"), mode.clone()).unwrap();
305        let resolved = docs_root(&ctx(&dir));
306        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
307        std::fs::set_permissions(dir.path().join("docs"), mode).unwrap();
308        assert_eq!(
309            resolved, "docs",
310            "an unreadable layout fell through to the default root"
311        );
312    }
313
314    #[test]
315    fn an_unsearchable_ancestor_is_raised_rather_than_discovered_away() {
316        let dir = tempfile::tempdir().unwrap();
317        std::fs::create_dir_all(dir.path().join("docs/reference/known-issues")).unwrap();
318        let ancestor = dir.path().join("docs/reference");
319        let mut mode = std::fs::metadata(&ancestor).unwrap().permissions();
320        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
321        std::fs::set_permissions(&ancestor, mode.clone()).unwrap();
322        let raised = ki_records(&ctx(&dir), &[]).is_err();
323        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
324        std::fs::set_permissions(&ancestor, mode).unwrap();
325        assert!(raised, "an unsearchable ancestor listed as no zone");
326    }
327
328    #[test]
329    fn an_absent_zone_is_skipped_and_an_unreadable_one_is_raised() {
330        let dir = tempfile::tempdir().unwrap();
331        write(&dir, "docs/specs/SPEC-a.md", "# A\n");
332        assert!(ki_records(&ctx(&dir), &[]).unwrap().is_empty());
333
334        let zone = dir.path().join("docs/reference/known-issues");
335        std::fs::create_dir_all(&zone).unwrap();
336        let mut mode = std::fs::metadata(&zone).unwrap().permissions();
337        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
338        std::fs::set_permissions(&zone, mode.clone()).unwrap();
339        let raised = ki_records(&ctx(&dir), &[]).is_err();
340        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
341        std::fs::set_permissions(&zone, mode).unwrap();
342        assert!(raised, "an unreadable zone listed as empty");
343    }
344}