1use camino::{Utf8Path, Utf8PathBuf};
15
16use crate::domain::manifest::{DOCS_SCRATCH_VAR, MANIFEST_PATH, PLAN_ZONE_VAR};
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#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum PlanZoneTarget {
48 Variable(Utf8PathBuf),
50 Tracked(Utf8PathBuf),
52 Broken(String),
54 Unchecked,
56}
57
58#[must_use]
60pub fn plan_zone(ctx: &GateCtx) -> PlanZoneTarget {
61 plan_zone_with(ctx, variable(PLAN_ZONE_VAR))
62}
63
64#[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 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#[must_use]
111pub fn docs_scratch(ctx: &GateCtx) -> Option<Utf8PathBuf> {
112 docs_scratch_with(ctx, variable(DOCS_SCRATCH_VAR))
113}
114
115#[must_use]
120pub fn docs_scratch_variable() -> Option<Utf8PathBuf> {
121 variable(DOCS_SCRATCH_VAR)
122}
123
124#[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#[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#[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
170fn 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
185pub fn ki_records_judged(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
206 Ok(ctx.retained(ki_records(ctx, args)?))
207}
208
209pub 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 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 (
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 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}