Skip to main content

spec_driven_docs/gates/
paths.rs

1//! Where an instance keeps the documents the gates read.
2//!
3//! The documentation root comes from the manifest when one exists, is
4//! discovered from the conventional layouts when none does, and defaults to
5//! `_docs`. Known-issue roots follow the same ladder, and explicit arguments
6//! win over all of it — a repository keeping records outside the root passes
7//! the directories holding them. Nothing here judges content; that is each
8//! gate's business.
9
10use camino::{Utf8Path, Utf8PathBuf};
11
12use crate::domain::manifest::MANIFEST_PATH;
13use crate::gates::GateCtx;
14
15/// The instance's documentation root, relative to the repository.
16#[must_use]
17pub fn docs_root(ctx: &GateCtx) -> Utf8PathBuf {
18    if let Ok(text) = std::fs::read_to_string(ctx.path(MANIFEST_PATH))
19        && let Ok(value) = serde_json::from_str::<serde_json::Value>(&text)
20        && let Some(root) = value.get("docs_root").and_then(serde_json::Value::as_str)
21        && !root.is_empty()
22    {
23        return Utf8PathBuf::from(root);
24    }
25    for candidate in ["_docs", "docs"] {
26        if ctx.path(candidate).join("specs").is_dir() {
27            return Utf8PathBuf::from(candidate);
28        }
29    }
30    Utf8PathBuf::from("_docs")
31}
32
33/// The directories that may hold known-issue records, relative to the
34/// repository. Arguments win; a manifest names one root; a bare consumer's
35/// roots are discovered.
36#[must_use]
37pub fn ki_record_roots(ctx: &GateCtx, args: &[String]) -> Vec<Utf8PathBuf> {
38    if !args.is_empty() {
39        return args.iter().map(Utf8PathBuf::from).collect();
40    }
41    if ctx.path(MANIFEST_PATH).is_file() {
42        return vec![docs_root(ctx).join("reference/known-issues")];
43    }
44    ["_docs", "docs"]
45        .into_iter()
46        .map(|candidate| Utf8Path::new(candidate).join("reference/known-issues"))
47        .filter(|root| ctx.path(root).is_dir())
48        .collect()
49}
50
51/// Every known-issue record under the resolved roots, repository-relative.
52#[must_use]
53pub fn ki_records(ctx: &GateCtx, args: &[String]) -> Vec<Utf8PathBuf> {
54    let mut records = Vec::new();
55    for root in ki_record_roots(ctx, args) {
56        let Ok(entries) = ctx.path(&root).read_dir_utf8() else {
57            continue;
58        };
59        let mut names: Vec<String> = entries
60            .filter_map(Result::ok)
61            .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
62            .map(|entry| entry.file_name().to_string())
63            .filter(|name| {
64                name.strip_prefix("KI-")
65                    .and_then(|rest| rest.strip_suffix(".md"))
66                    .is_some_and(|slug| !slug.is_empty())
67            })
68            .collect();
69        names.sort();
70        records.extend(names.into_iter().map(|name| root.join(name)));
71    }
72    records
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    fn ctx(dir: &tempfile::TempDir) -> GateCtx {
80        GateCtx::new(dir.path().to_str().unwrap())
81    }
82
83    fn write(dir: &tempfile::TempDir, path: &str, text: &str) {
84        let path = dir.path().join(path);
85        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
86        std::fs::write(path, text).unwrap();
87    }
88
89    #[test]
90    fn manifest_root_wins() {
91        let dir = tempfile::tempdir().unwrap();
92        write(
93            &dir,
94            ".spec-driven-docs/manifest.json",
95            "{\n  \"docs_root\": \"docs\"\n}\n",
96        );
97        assert_eq!(docs_root(&ctx(&dir)), "docs");
98    }
99
100    #[test]
101    fn roots_are_discovered_without_a_manifest() {
102        let dir = tempfile::tempdir().unwrap();
103        write(&dir, "docs/specs/SPEC-sample.md", "# S\n");
104        assert_eq!(docs_root(&ctx(&dir)), "docs");
105
106        let both = tempfile::tempdir().unwrap();
107        write(&both, "_docs/specs/SPEC-sample.md", "# S\n");
108        write(&both, "docs/specs/SPEC-sample.md", "# S\n");
109        assert_eq!(docs_root(&ctx(&both)), "_docs");
110
111        let neither = tempfile::tempdir().unwrap();
112        assert_eq!(docs_root(&ctx(&neither)), "_docs");
113    }
114
115    #[test]
116    fn record_arguments_win_over_discovery() {
117        let dir = tempfile::tempdir().unwrap();
118        write(&dir, "docs/reference/known-issues/KI-real.md", "# R\n");
119        let roots = ki_record_roots(&ctx(&dir), &["tests/fixtures".to_string()]);
120        assert_eq!(roots, vec![Utf8PathBuf::from("tests/fixtures")]);
121    }
122
123    #[test]
124    fn records_follow_the_manifest_root() {
125        let dir = tempfile::tempdir().unwrap();
126        write(
127            &dir,
128            ".spec-driven-docs/manifest.json",
129            "{\n  \"docs_root\": \"docs\"\n}\n",
130        );
131        write(&dir, "docs/reference/known-issues/KI-vendor.md", "# V\n");
132        write(&dir, "docs/reference/known-issues/KI-.md", "# empty slug\n");
133        write(
134            &dir,
135            "docs/reference/known-issues/notes.md",
136            "# not a record\n",
137        );
138        assert_eq!(
139            ki_records(&ctx(&dir), &[]),
140            vec![Utf8PathBuf::from(
141                "docs/reference/known-issues/KI-vendor.md"
142            )]
143        );
144    }
145
146    #[test]
147    fn bare_consumer_roots_are_discovered() {
148        let dir = tempfile::tempdir().unwrap();
149        write(&dir, "docs/reference/known-issues/KI-a.md", "# A\n");
150        write(&dir, "docs/reference/known-issues/KI-b.md", "# B\n");
151        assert_eq!(
152            ki_records(&ctx(&dir), &[]),
153            vec![
154                Utf8PathBuf::from("docs/reference/known-issues/KI-a.md"),
155                Utf8PathBuf::from("docs/reference/known-issues/KI-b.md"),
156            ]
157        );
158    }
159}