1use camino::{Utf8Path, Utf8PathBuf};
15
16use crate::domain::manifest::{DOCS_SCRATCH_VAR, MANIFEST_PATH};
17use crate::gates::{GateCtx, GateError};
18
19fn 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
30fn 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#[must_use]
45pub fn docs_scratch(ctx: &GateCtx) -> Option<Utf8PathBuf> {
46 docs_scratch_with(ctx, variable(DOCS_SCRATCH_VAR))
47}
48
49#[must_use]
54pub fn docs_scratch_variable() -> Option<Utf8PathBuf> {
55 variable(DOCS_SCRATCH_VAR)
56}
57
58#[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#[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#[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
108fn 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
123pub fn ki_records_judged(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
144 Ok(ctx.retained(ki_records(ctx, args)?))
145}
146
147pub 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}