spec_driven_docs/gates/
paths.rs1use camino::{Utf8Path, Utf8PathBuf};
11
12use crate::domain::manifest::MANIFEST_PATH;
13use crate::gates::{GateCtx, GateError};
14
15#[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#[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
51fn 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
66pub 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}