Skip to main content

spec_driven_docs/gates/
paths.rs

1//! Where an instance keeps the documents the gates read.
2//!
3//! Two of these locations are declared rather than fixed: the plan zone and
4//! the docs scratch. Each is recorded in the manifest and named by one
5//! environment variable, 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, PLAN_ZONE_VAR};
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/// Where a gate may look for entry documents, and what named the place.
40///
41/// The distinction is what keeps the check honest. A path a command may read
42/// carries the name of whoever declared it, so an absent directory is
43/// reported against that declaration. Every other case reads as no zone: an
44/// untracked zone and an unset variable are absent on a fresh clone, and
45/// failing there would judge a layout the project never promised.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum PlanZoneTarget {
48    /// The variable named this path.
49    Variable(Utf8PathBuf),
50    /// The manifest recorded this tracked path.
51    Tracked(Utf8PathBuf),
52    /// The record declares a gated zone the reader cannot resolve.
53    Broken(String),
54    /// Nothing a command may check.
55    Unchecked,
56}
57
58/// Resolve the plan zone: the variable first, then the recorded kind.
59#[must_use]
60pub fn plan_zone(ctx: &GateCtx) -> PlanZoneTarget {
61    plan_zone_with(ctx, variable(PLAN_ZONE_VAR))
62}
63
64/// The resolution, with the variable's value supplied.
65///
66/// The environment is read at one boundary and passed in, so every case is
67/// reachable from a test. This crate forbids unsafe code, and setting a
68/// variable is unsafe from the 2024 edition on.
69#[must_use]
70pub fn plan_zone_with(ctx: &GateCtx, named: Option<Utf8PathBuf>) -> PlanZoneTarget {
71    if let Some(path) = named {
72        return PlanZoneTarget::Variable(path);
73    }
74    let Some(recorded) = manifest_field(ctx, "plan_zone") else {
75        return PlanZoneTarget::Unchecked;
76    };
77    if recorded.get("kind").and_then(serde_json::Value::as_str) != Some("tracked") {
78        return PlanZoneTarget::Unchecked;
79    }
80    // A tracked kind whose path is missing, empty, or not a string is a
81    // broken declaration, never an absent one. Reading it as `Unchecked`
82    // would skip a zone the project declared gated, which is the state
83    // `lifecycle.md` forbids a gate from reaching.
84    recorded
85        .get("path")
86        .and_then(serde_json::Value::as_str)
87        .map_or_else(
88            || {
89                PlanZoneTarget::Broken(
90                    "the recorded plan zone is tracked and carries no path".to_string(),
91                )
92            },
93            |path| {
94                if path.trim().is_empty() {
95                    PlanZoneTarget::Broken(
96                        "the recorded plan zone is tracked and its path is empty".to_string(),
97                    )
98                } else {
99                    PlanZoneTarget::Tracked(Utf8PathBuf::from(path))
100                }
101            },
102        )
103}
104
105/// Resolve the docs scratch: the variable first, then the recorded path.
106///
107/// `None` means the project declared none. A caller with a discovery
108/// candidate of its own supplies it; there is none here, because a gate that
109/// guessed the location would judge a directory nobody declared.
110#[must_use]
111pub fn docs_scratch(ctx: &GateCtx) -> Option<Utf8PathBuf> {
112    docs_scratch_with(ctx, variable(DOCS_SCRATCH_VAR))
113}
114
115/// What [`DOCS_SCRATCH_VAR`] carries here, or `None` when it is unset.
116///
117/// The one place the environment is read for this value, so a caller that
118/// resolves it can be tested by supplying the answer instead.
119#[must_use]
120pub fn docs_scratch_variable() -> Option<Utf8PathBuf> {
121    variable(DOCS_SCRATCH_VAR)
122}
123
124/// The resolution, with the variable's value supplied.
125#[must_use]
126pub fn docs_scratch_with(ctx: &GateCtx, named: Option<Utf8PathBuf>) -> Option<Utf8PathBuf> {
127    named.or_else(|| {
128        manifest_field(ctx, "docs_scratch")
129            .and_then(|value| value.as_str().map(Utf8PathBuf::from))
130            .filter(|path| !path.as_str().is_empty())
131    })
132}
133
134/// The instance's documentation root, relative to the repository.
135#[must_use]
136pub fn docs_root(ctx: &GateCtx) -> Utf8PathBuf {
137    if let Ok(text) = std::fs::read_to_string(ctx.path(MANIFEST_PATH))
138        && let Ok(value) = serde_json::from_str::<serde_json::Value>(&text)
139        && let Some(root) = value.get("docs_root").and_then(serde_json::Value::as_str)
140        && !root.is_empty()
141    {
142        return Utf8PathBuf::from(root);
143    }
144    for candidate in ["_docs", "docs"] {
145        if discovered(ctx, &Utf8Path::new(candidate).join("specs")) {
146            return Utf8PathBuf::from(candidate);
147        }
148    }
149    Utf8PathBuf::from("_docs")
150}
151
152/// The directories that may hold known-issue records, relative to the
153/// repository. Arguments win; a manifest names one root; a bare consumer's
154/// roots are discovered.
155#[must_use]
156pub fn ki_record_roots(ctx: &GateCtx, args: &[String]) -> Vec<Utf8PathBuf> {
157    if !args.is_empty() {
158        return args.iter().map(Utf8PathBuf::from).collect();
159    }
160    if ctx.path(MANIFEST_PATH).is_file() {
161        return vec![docs_root(ctx).join("reference/known-issues")];
162    }
163    ["_docs", "docs"]
164        .into_iter()
165        .map(|candidate| Utf8Path::new(candidate).join("reference/known-issues"))
166        .filter(|root| discovered(ctx, root))
167        .collect()
168}
169
170/// Whether a discovered candidate is a directory the caller must read.
171///
172/// A candidate whose metadata cannot be read is kept rather than dropped.
173/// `is_dir` answers false for a directory the process cannot stat, so
174/// dropping it there would report an unreadable layout as a layout the
175/// repository does not keep. Kept, it reaches the reader, which raises the
176/// failure or reports the layout as moved rather than judging a tree it
177/// never opened.
178fn discovered(ctx: &GateCtx, root: &Utf8Path) -> bool {
179    match std::fs::metadata(ctx.path(root)) {
180        Ok(metadata) => metadata.is_dir(),
181        Err(source) => source.kind() != std::io::ErrorKind::NotFound,
182    }
183}
184
185/// Every known-issue record under the resolved roots, repository-relative.
186///
187/// A root that is not there is a zone the repository does not keep, and it
188/// is skipped. Every other failure is raised: a directory the process
189/// cannot read holds records this returns none of, and reporting that as an
190/// empty zone would read as a clean review.
191///
192/// # Errors
193///
194/// [`crate::gates::GateError::Io`] when a present root cannot be listed.
195/// The records this gate judges, filtered.
196///
197/// [`ki_records`] stays unfiltered because the same records are support for
198/// `suppression-names-its-case`, which resolves a case id against them
199/// rather than judging their contents. A gate that judges a record's own
200/// text calls this one.
201///
202/// # Errors
203///
204/// See [`ki_records`].
205pub fn ki_records_judged(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
206    Ok(ctx.retained(ki_records(ctx, args)?))
207}
208
209/// Every known-issue record the instance carries, unfiltered.
210///
211/// Unfiltered because the same records are support for
212/// `suppression-names-its-case`. A gate judging a record's own text calls
213/// [`ki_records_judged`].
214///
215/// # Errors
216///
217/// [`GateError::Io`] naming the root that could not be read.
218pub fn ki_records(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
219    let mut records = Vec::new();
220    for root in ki_record_roots(ctx, args) {
221        let entries = match ctx.path(&root).read_dir_utf8() {
222            Ok(entries) => entries,
223            Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue,
224            Err(source) => return Err(GateError::io(&root, source)),
225        };
226        let mut names = Vec::new();
227        for entry in entries {
228            let entry = entry.map_err(|source| GateError::io(&root, source))?;
229            if !entry
230                .file_type()
231                .map_err(|source| GateError::io(&root, source))?
232                .is_file()
233            {
234                continue;
235            }
236            let name = entry.file_name().to_string();
237            if name
238                .strip_prefix("KI-")
239                .and_then(|rest| rest.strip_suffix(".md"))
240                .is_some_and(|slug| !slug.is_empty())
241            {
242                names.push(name);
243            }
244        }
245        names.sort();
246        records.extend(names.into_iter().map(|name| root.join(name)));
247    }
248    Ok(records)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn ctx(dir: &tempfile::TempDir) -> GateCtx {
256        GateCtx::new(dir.path().to_str().unwrap())
257    }
258
259    fn write(dir: &tempfile::TempDir, path: &str, text: &str) {
260        let path = dir.path().join(path);
261        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
262        std::fs::write(path, text).unwrap();
263    }
264
265    #[test]
266    fn the_plan_zone_resolves_only_what_a_command_may_check() {
267        let dir = tempfile::tempdir().unwrap();
268        let ctx = ctx(&dir);
269        // No record at all.
270        assert_eq!(plan_zone_with(&ctx, None), PlanZoneTarget::Unchecked);
271
272        for (recorded, expected) in [
273            (
274                "{\"kind\": \"tracked\", \"path\": \"docs/plan\"}",
275                PlanZoneTarget::Tracked(Utf8PathBuf::from("docs/plan")),
276            ),
277            // A tracked kind whose path is missing or empty is a broken
278            // declaration, never an absent one: reading it as `Unchecked`
279            // would skip a zone the project declared gated.
280            (
281                "{\"kind\": \"tracked\"}",
282                PlanZoneTarget::Broken(
283                    "the recorded plan zone is tracked and carries no path".to_string(),
284                ),
285            ),
286            (
287                "{\"kind\": \"tracked\", \"path\": \"  \"}",
288                PlanZoneTarget::Broken(
289                    "the recorded plan zone is tracked and its path is empty".to_string(),
290                ),
291            ),
292            (
293                "{\"kind\": \"untracked\", \"path\": \"docs/plan\"}",
294                PlanZoneTarget::Unchecked,
295            ),
296            ("{\"kind\": \"env\"}", PlanZoneTarget::Unchecked),
297            ("{\"kind\": \"none\"}", PlanZoneTarget::Unchecked),
298        ] {
299            write(
300                &dir,
301                ".spec-driven-docs/manifest.json",
302                &format!("{{\"plan_zone\": {recorded}}}\n"),
303            );
304            assert_eq!(plan_zone_with(&ctx, None), expected, "{recorded}");
305            // The variable wins over every recorded kind.
306            assert_eq!(
307                plan_zone_with(&ctx, Some(Utf8PathBuf::from("elsewhere"))),
308                PlanZoneTarget::Variable(Utf8PathBuf::from("elsewhere")),
309                "{recorded}"
310            );
311        }
312    }
313
314    #[test]
315    fn the_docs_scratch_takes_the_variable_then_the_record() {
316        let dir = tempfile::tempdir().unwrap();
317        let ctx = ctx(&dir);
318        assert_eq!(docs_scratch_with(&ctx, None), None);
319
320        write(
321            &dir,
322            ".spec-driven-docs/manifest.json",
323            "{\"docs_scratch\": \"../beside\"}\n",
324        );
325        assert_eq!(
326            docs_scratch_with(&ctx, None),
327            Some(Utf8PathBuf::from("../beside"))
328        );
329        assert_eq!(
330            docs_scratch_with(&ctx, Some(Utf8PathBuf::from("inside"))),
331            Some(Utf8PathBuf::from("inside"))
332        );
333    }
334
335    #[test]
336    fn manifest_root_wins() {
337        let dir = tempfile::tempdir().unwrap();
338        write(
339            &dir,
340            ".spec-driven-docs/manifest.json",
341            "{\n  \"docs_root\": \"docs\"\n}\n",
342        );
343        assert_eq!(docs_root(&ctx(&dir)), "docs");
344    }
345
346    #[test]
347    fn roots_are_discovered_without_a_manifest() {
348        let dir = tempfile::tempdir().unwrap();
349        write(&dir, "docs/specs/SPEC-sample.md", "# S\n");
350        assert_eq!(docs_root(&ctx(&dir)), "docs");
351
352        let both = tempfile::tempdir().unwrap();
353        write(&both, "_docs/specs/SPEC-sample.md", "# S\n");
354        write(&both, "docs/specs/SPEC-sample.md", "# S\n");
355        assert_eq!(docs_root(&ctx(&both)), "_docs");
356
357        let neither = tempfile::tempdir().unwrap();
358        assert_eq!(docs_root(&ctx(&neither)), "_docs");
359    }
360
361    #[test]
362    fn record_arguments_win_over_discovery() {
363        let dir = tempfile::tempdir().unwrap();
364        write(&dir, "docs/reference/known-issues/KI-real.md", "# R\n");
365        let roots = ki_record_roots(&ctx(&dir), &["tests/fixtures".to_string()]);
366        assert_eq!(roots, vec![Utf8PathBuf::from("tests/fixtures")]);
367    }
368
369    #[test]
370    fn records_follow_the_manifest_root() {
371        let dir = tempfile::tempdir().unwrap();
372        write(
373            &dir,
374            ".spec-driven-docs/manifest.json",
375            "{\n  \"docs_root\": \"docs\"\n}\n",
376        );
377        write(&dir, "docs/reference/known-issues/KI-vendor.md", "# V\n");
378        write(&dir, "docs/reference/known-issues/KI-.md", "# empty slug\n");
379        write(
380            &dir,
381            "docs/reference/known-issues/notes.md",
382            "# not a record\n",
383        );
384        assert_eq!(
385            ki_records(&ctx(&dir), &[]).unwrap(),
386            vec![Utf8PathBuf::from(
387                "docs/reference/known-issues/KI-vendor.md"
388            )]
389        );
390    }
391
392    #[test]
393    fn bare_consumer_roots_are_discovered() {
394        let dir = tempfile::tempdir().unwrap();
395        write(&dir, "docs/reference/known-issues/KI-a.md", "# A\n");
396        write(&dir, "docs/reference/known-issues/KI-b.md", "# B\n");
397        assert_eq!(
398            ki_records(&ctx(&dir), &[]).unwrap(),
399            vec![
400                Utf8PathBuf::from("docs/reference/known-issues/KI-a.md"),
401                Utf8PathBuf::from("docs/reference/known-issues/KI-b.md"),
402            ]
403        );
404    }
405
406    #[test]
407    fn an_unreadable_layout_is_not_read_as_an_absent_one() {
408        let dir = tempfile::tempdir().unwrap();
409        std::fs::create_dir_all(dir.path().join("docs/specs")).unwrap();
410        assert_eq!(docs_root(&ctx(&dir)), "docs");
411
412        let specs = dir.path().join("docs/specs");
413        let mut mode = std::fs::metadata(&specs).unwrap().permissions();
414        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
415        std::fs::set_permissions(dir.path().join("docs"), mode.clone()).unwrap();
416        let resolved = docs_root(&ctx(&dir));
417        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
418        std::fs::set_permissions(dir.path().join("docs"), mode).unwrap();
419        assert_eq!(
420            resolved, "docs",
421            "an unreadable layout fell through to the default root"
422        );
423    }
424
425    #[test]
426    fn an_unsearchable_ancestor_is_raised_rather_than_discovered_away() {
427        let dir = tempfile::tempdir().unwrap();
428        std::fs::create_dir_all(dir.path().join("docs/reference/known-issues")).unwrap();
429        let ancestor = dir.path().join("docs/reference");
430        let mut mode = std::fs::metadata(&ancestor).unwrap().permissions();
431        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
432        std::fs::set_permissions(&ancestor, mode.clone()).unwrap();
433        let raised = ki_records(&ctx(&dir), &[]).is_err();
434        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
435        std::fs::set_permissions(&ancestor, mode).unwrap();
436        assert!(raised, "an unsearchable ancestor listed as no zone");
437    }
438
439    #[test]
440    fn an_absent_zone_is_skipped_and_an_unreadable_one_is_raised() {
441        let dir = tempfile::tempdir().unwrap();
442        write(&dir, "docs/specs/SPEC-a.md", "# A\n");
443        assert!(ki_records(&ctx(&dir), &[]).unwrap().is_empty());
444
445        let zone = dir.path().join("docs/reference/known-issues");
446        std::fs::create_dir_all(&zone).unwrap();
447        let mut mode = std::fs::metadata(&zone).unwrap().permissions();
448        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
449        std::fs::set_permissions(&zone, mode.clone()).unwrap();
450        let raised = ki_records(&ctx(&dir), &[]).is_err();
451        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
452        std::fs::set_permissions(&zone, mode).unwrap();
453        assert!(raised, "an unreadable zone listed as empty");
454    }
455}