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, GateError};
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 discovered(ctx, &Utf8Path::new(candidate).join("specs")) {
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| discovered(ctx, root))
48        .collect()
49}
50
51/// Whether a discovered candidate is a directory the caller must read.
52///
53/// A candidate whose metadata cannot be read is kept rather than dropped.
54/// `is_dir` answers false for a directory the process cannot stat, so
55/// dropping it there would report an unreadable layout as a layout the
56/// repository does not keep. Kept, it reaches the reader, which raises the
57/// failure or reports the layout as moved rather than judging a tree it
58/// never opened.
59fn discovered(ctx: &GateCtx, root: &Utf8Path) -> bool {
60    match std::fs::metadata(ctx.path(root)) {
61        Ok(metadata) => metadata.is_dir(),
62        Err(source) => source.kind() != std::io::ErrorKind::NotFound,
63    }
64}
65
66/// Every known-issue record under the resolved roots, repository-relative.
67///
68/// A root that is not there is a zone the repository does not keep, and it
69/// is skipped. Every other failure is raised: a directory the process
70/// cannot read holds records this returns none of, and reporting that as an
71/// empty zone would read as a clean review.
72///
73/// # Errors
74///
75/// [`crate::gates::GateError::Io`] when a present root cannot be listed.
76pub fn ki_records(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
77    let mut records = Vec::new();
78    for root in ki_record_roots(ctx, args) {
79        let entries = match ctx.path(&root).read_dir_utf8() {
80            Ok(entries) => entries,
81            Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue,
82            Err(source) => return Err(GateError::io(&root, source)),
83        };
84        let mut names = Vec::new();
85        for entry in entries {
86            let entry = entry.map_err(|source| GateError::io(&root, source))?;
87            if !entry
88                .file_type()
89                .map_err(|source| GateError::io(&root, source))?
90                .is_file()
91            {
92                continue;
93            }
94            let name = entry.file_name().to_string();
95            if name
96                .strip_prefix("KI-")
97                .and_then(|rest| rest.strip_suffix(".md"))
98                .is_some_and(|slug| !slug.is_empty())
99            {
100                names.push(name);
101            }
102        }
103        names.sort();
104        records.extend(names.into_iter().map(|name| root.join(name)));
105    }
106    Ok(records)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn ctx(dir: &tempfile::TempDir) -> GateCtx {
114        GateCtx::new(dir.path().to_str().unwrap())
115    }
116
117    fn write(dir: &tempfile::TempDir, path: &str, text: &str) {
118        let path = dir.path().join(path);
119        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
120        std::fs::write(path, text).unwrap();
121    }
122
123    #[test]
124    fn manifest_root_wins() {
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        assert_eq!(docs_root(&ctx(&dir)), "docs");
132    }
133
134    #[test]
135    fn roots_are_discovered_without_a_manifest() {
136        let dir = tempfile::tempdir().unwrap();
137        write(&dir, "docs/specs/SPEC-sample.md", "# S\n");
138        assert_eq!(docs_root(&ctx(&dir)), "docs");
139
140        let both = tempfile::tempdir().unwrap();
141        write(&both, "_docs/specs/SPEC-sample.md", "# S\n");
142        write(&both, "docs/specs/SPEC-sample.md", "# S\n");
143        assert_eq!(docs_root(&ctx(&both)), "_docs");
144
145        let neither = tempfile::tempdir().unwrap();
146        assert_eq!(docs_root(&ctx(&neither)), "_docs");
147    }
148
149    #[test]
150    fn record_arguments_win_over_discovery() {
151        let dir = tempfile::tempdir().unwrap();
152        write(&dir, "docs/reference/known-issues/KI-real.md", "# R\n");
153        let roots = ki_record_roots(&ctx(&dir), &["tests/fixtures".to_string()]);
154        assert_eq!(roots, vec![Utf8PathBuf::from("tests/fixtures")]);
155    }
156
157    #[test]
158    fn records_follow_the_manifest_root() {
159        let dir = tempfile::tempdir().unwrap();
160        write(
161            &dir,
162            ".spec-driven-docs/manifest.json",
163            "{\n  \"docs_root\": \"docs\"\n}\n",
164        );
165        write(&dir, "docs/reference/known-issues/KI-vendor.md", "# V\n");
166        write(&dir, "docs/reference/known-issues/KI-.md", "# empty slug\n");
167        write(
168            &dir,
169            "docs/reference/known-issues/notes.md",
170            "# not a record\n",
171        );
172        assert_eq!(
173            ki_records(&ctx(&dir), &[]).unwrap(),
174            vec![Utf8PathBuf::from(
175                "docs/reference/known-issues/KI-vendor.md"
176            )]
177        );
178    }
179
180    #[test]
181    fn bare_consumer_roots_are_discovered() {
182        let dir = tempfile::tempdir().unwrap();
183        write(&dir, "docs/reference/known-issues/KI-a.md", "# A\n");
184        write(&dir, "docs/reference/known-issues/KI-b.md", "# B\n");
185        assert_eq!(
186            ki_records(&ctx(&dir), &[]).unwrap(),
187            vec![
188                Utf8PathBuf::from("docs/reference/known-issues/KI-a.md"),
189                Utf8PathBuf::from("docs/reference/known-issues/KI-b.md"),
190            ]
191        );
192    }
193
194    #[test]
195    fn an_unreadable_layout_is_not_read_as_an_absent_one() {
196        let dir = tempfile::tempdir().unwrap();
197        std::fs::create_dir_all(dir.path().join("docs/specs")).unwrap();
198        assert_eq!(docs_root(&ctx(&dir)), "docs");
199
200        let specs = dir.path().join("docs/specs");
201        let mut mode = std::fs::metadata(&specs).unwrap().permissions();
202        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
203        std::fs::set_permissions(dir.path().join("docs"), mode.clone()).unwrap();
204        let resolved = docs_root(&ctx(&dir));
205        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
206        std::fs::set_permissions(dir.path().join("docs"), mode).unwrap();
207        assert_eq!(
208            resolved, "docs",
209            "an unreadable layout fell through to the default root"
210        );
211    }
212
213    #[test]
214    fn an_unsearchable_ancestor_is_raised_rather_than_discovered_away() {
215        let dir = tempfile::tempdir().unwrap();
216        std::fs::create_dir_all(dir.path().join("docs/reference/known-issues")).unwrap();
217        let ancestor = dir.path().join("docs/reference");
218        let mut mode = std::fs::metadata(&ancestor).unwrap().permissions();
219        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
220        std::fs::set_permissions(&ancestor, mode.clone()).unwrap();
221        let raised = ki_records(&ctx(&dir), &[]).is_err();
222        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
223        std::fs::set_permissions(&ancestor, mode).unwrap();
224        assert!(raised, "an unsearchable ancestor listed as no zone");
225    }
226
227    #[test]
228    fn an_absent_zone_is_skipped_and_an_unreadable_one_is_raised() {
229        let dir = tempfile::tempdir().unwrap();
230        write(&dir, "docs/specs/SPEC-a.md", "# A\n");
231        assert!(ki_records(&ctx(&dir), &[]).unwrap().is_empty());
232
233        let zone = dir.path().join("docs/reference/known-issues");
234        std::fs::create_dir_all(&zone).unwrap();
235        let mut mode = std::fs::metadata(&zone).unwrap().permissions();
236        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
237        std::fs::set_permissions(&zone, mode.clone()).unwrap();
238        let raised = ki_records(&ctx(&dir), &[]).is_err();
239        std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
240        std::fs::set_permissions(&zone, mode).unwrap();
241        assert!(raised, "an unreadable zone listed as empty");
242    }
243}