use camino::{Utf8Path, Utf8PathBuf};
use crate::domain::manifest::{DOCS_SCRATCH_VAR, MANIFEST_PATH, PLAN_ZONE_VAR};
use crate::gates::{GateCtx, GateError};
fn manifest_field(ctx: &GateCtx, key: &str) -> Option<serde_json::Value> {
let text = std::fs::read_to_string(ctx.path(MANIFEST_PATH)).ok()?;
let value: serde_json::Value = serde_json::from_str(&text).ok()?;
value.get(key).cloned().filter(|found| !found.is_null())
}
fn variable(name: &str) -> Option<Utf8PathBuf> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(Utf8PathBuf::from)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanZoneTarget {
Variable(Utf8PathBuf),
Tracked(Utf8PathBuf),
Broken(String),
Unchecked,
}
#[must_use]
pub fn plan_zone(ctx: &GateCtx) -> PlanZoneTarget {
plan_zone_with(ctx, variable(PLAN_ZONE_VAR))
}
#[must_use]
pub fn plan_zone_with(ctx: &GateCtx, named: Option<Utf8PathBuf>) -> PlanZoneTarget {
if let Some(path) = named {
return PlanZoneTarget::Variable(path);
}
let Some(recorded) = manifest_field(ctx, "plan_zone") else {
return PlanZoneTarget::Unchecked;
};
if recorded.get("kind").and_then(serde_json::Value::as_str) != Some("tracked") {
return PlanZoneTarget::Unchecked;
}
recorded
.get("path")
.and_then(serde_json::Value::as_str)
.map_or_else(
|| {
PlanZoneTarget::Broken(
"the recorded plan zone is tracked and carries no path".to_string(),
)
},
|path| {
if path.trim().is_empty() {
PlanZoneTarget::Broken(
"the recorded plan zone is tracked and its path is empty".to_string(),
)
} else {
PlanZoneTarget::Tracked(Utf8PathBuf::from(path))
}
},
)
}
#[must_use]
pub fn docs_scratch(ctx: &GateCtx) -> Option<Utf8PathBuf> {
docs_scratch_with(ctx, variable(DOCS_SCRATCH_VAR))
}
#[must_use]
pub fn docs_scratch_variable() -> Option<Utf8PathBuf> {
variable(DOCS_SCRATCH_VAR)
}
#[must_use]
pub fn docs_scratch_with(ctx: &GateCtx, named: Option<Utf8PathBuf>) -> Option<Utf8PathBuf> {
named.or_else(|| {
manifest_field(ctx, "docs_scratch")
.and_then(|value| value.as_str().map(Utf8PathBuf::from))
.filter(|path| !path.as_str().is_empty())
})
}
#[must_use]
pub fn docs_root(ctx: &GateCtx) -> Utf8PathBuf {
if let Ok(text) = std::fs::read_to_string(ctx.path(MANIFEST_PATH))
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&text)
&& let Some(root) = value.get("docs_root").and_then(serde_json::Value::as_str)
&& !root.is_empty()
{
return Utf8PathBuf::from(root);
}
for candidate in ["_docs", "docs"] {
if discovered(ctx, &Utf8Path::new(candidate).join("specs")) {
return Utf8PathBuf::from(candidate);
}
}
Utf8PathBuf::from("_docs")
}
#[must_use]
pub fn ki_record_roots(ctx: &GateCtx, args: &[String]) -> Vec<Utf8PathBuf> {
if !args.is_empty() {
return args.iter().map(Utf8PathBuf::from).collect();
}
if ctx.path(MANIFEST_PATH).is_file() {
return vec![docs_root(ctx).join("reference/known-issues")];
}
["_docs", "docs"]
.into_iter()
.map(|candidate| Utf8Path::new(candidate).join("reference/known-issues"))
.filter(|root| discovered(ctx, root))
.collect()
}
fn discovered(ctx: &GateCtx, root: &Utf8Path) -> bool {
match std::fs::metadata(ctx.path(root)) {
Ok(metadata) => metadata.is_dir(),
Err(source) => source.kind() != std::io::ErrorKind::NotFound,
}
}
pub fn ki_records_judged(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
Ok(ctx.retained(ki_records(ctx, args)?))
}
pub fn ki_records(ctx: &GateCtx, args: &[String]) -> Result<Vec<Utf8PathBuf>, GateError> {
let mut records = Vec::new();
for root in ki_record_roots(ctx, args) {
let entries = match ctx.path(&root).read_dir_utf8() {
Ok(entries) => entries,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => return Err(GateError::io(&root, source)),
};
let mut names = Vec::new();
for entry in entries {
let entry = entry.map_err(|source| GateError::io(&root, source))?;
if !entry
.file_type()
.map_err(|source| GateError::io(&root, source))?
.is_file()
{
continue;
}
let name = entry.file_name().to_string();
if name
.strip_prefix("KI-")
.and_then(|rest| rest.strip_suffix(".md"))
.is_some_and(|slug| !slug.is_empty())
{
names.push(name);
}
}
names.sort();
records.extend(names.into_iter().map(|name| root.join(name)));
}
Ok(records)
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(dir: &tempfile::TempDir) -> GateCtx {
GateCtx::new(dir.path().to_str().unwrap())
}
fn write(dir: &tempfile::TempDir, path: &str, text: &str) {
let path = dir.path().join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
#[test]
fn the_plan_zone_resolves_only_what_a_command_may_check() {
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(&dir);
assert_eq!(plan_zone_with(&ctx, None), PlanZoneTarget::Unchecked);
for (recorded, expected) in [
(
"{\"kind\": \"tracked\", \"path\": \"docs/plan\"}",
PlanZoneTarget::Tracked(Utf8PathBuf::from("docs/plan")),
),
(
"{\"kind\": \"tracked\"}",
PlanZoneTarget::Broken(
"the recorded plan zone is tracked and carries no path".to_string(),
),
),
(
"{\"kind\": \"tracked\", \"path\": \" \"}",
PlanZoneTarget::Broken(
"the recorded plan zone is tracked and its path is empty".to_string(),
),
),
(
"{\"kind\": \"untracked\", \"path\": \"docs/plan\"}",
PlanZoneTarget::Unchecked,
),
("{\"kind\": \"env\"}", PlanZoneTarget::Unchecked),
("{\"kind\": \"none\"}", PlanZoneTarget::Unchecked),
] {
write(
&dir,
".spec-driven-docs/manifest.json",
&format!("{{\"plan_zone\": {recorded}}}\n"),
);
assert_eq!(plan_zone_with(&ctx, None), expected, "{recorded}");
assert_eq!(
plan_zone_with(&ctx, Some(Utf8PathBuf::from("elsewhere"))),
PlanZoneTarget::Variable(Utf8PathBuf::from("elsewhere")),
"{recorded}"
);
}
}
#[test]
fn the_docs_scratch_takes_the_variable_then_the_record() {
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(&dir);
assert_eq!(docs_scratch_with(&ctx, None), None);
write(
&dir,
".spec-driven-docs/manifest.json",
"{\"docs_scratch\": \"../beside\"}\n",
);
assert_eq!(
docs_scratch_with(&ctx, None),
Some(Utf8PathBuf::from("../beside"))
);
assert_eq!(
docs_scratch_with(&ctx, Some(Utf8PathBuf::from("inside"))),
Some(Utf8PathBuf::from("inside"))
);
}
#[test]
fn manifest_root_wins() {
let dir = tempfile::tempdir().unwrap();
write(
&dir,
".spec-driven-docs/manifest.json",
"{\n \"docs_root\": \"docs\"\n}\n",
);
assert_eq!(docs_root(&ctx(&dir)), "docs");
}
#[test]
fn roots_are_discovered_without_a_manifest() {
let dir = tempfile::tempdir().unwrap();
write(&dir, "docs/specs/SPEC-sample.md", "# S\n");
assert_eq!(docs_root(&ctx(&dir)), "docs");
let both = tempfile::tempdir().unwrap();
write(&both, "_docs/specs/SPEC-sample.md", "# S\n");
write(&both, "docs/specs/SPEC-sample.md", "# S\n");
assert_eq!(docs_root(&ctx(&both)), "_docs");
let neither = tempfile::tempdir().unwrap();
assert_eq!(docs_root(&ctx(&neither)), "_docs");
}
#[test]
fn record_arguments_win_over_discovery() {
let dir = tempfile::tempdir().unwrap();
write(&dir, "docs/reference/known-issues/KI-real.md", "# R\n");
let roots = ki_record_roots(&ctx(&dir), &["tests/fixtures".to_string()]);
assert_eq!(roots, vec![Utf8PathBuf::from("tests/fixtures")]);
}
#[test]
fn records_follow_the_manifest_root() {
let dir = tempfile::tempdir().unwrap();
write(
&dir,
".spec-driven-docs/manifest.json",
"{\n \"docs_root\": \"docs\"\n}\n",
);
write(&dir, "docs/reference/known-issues/KI-vendor.md", "# V\n");
write(&dir, "docs/reference/known-issues/KI-.md", "# empty slug\n");
write(
&dir,
"docs/reference/known-issues/notes.md",
"# not a record\n",
);
assert_eq!(
ki_records(&ctx(&dir), &[]).unwrap(),
vec![Utf8PathBuf::from(
"docs/reference/known-issues/KI-vendor.md"
)]
);
}
#[test]
fn bare_consumer_roots_are_discovered() {
let dir = tempfile::tempdir().unwrap();
write(&dir, "docs/reference/known-issues/KI-a.md", "# A\n");
write(&dir, "docs/reference/known-issues/KI-b.md", "# B\n");
assert_eq!(
ki_records(&ctx(&dir), &[]).unwrap(),
vec![
Utf8PathBuf::from("docs/reference/known-issues/KI-a.md"),
Utf8PathBuf::from("docs/reference/known-issues/KI-b.md"),
]
);
}
#[test]
fn an_unreadable_layout_is_not_read_as_an_absent_one() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("docs/specs")).unwrap();
assert_eq!(docs_root(&ctx(&dir)), "docs");
let specs = dir.path().join("docs/specs");
let mut mode = std::fs::metadata(&specs).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
std::fs::set_permissions(dir.path().join("docs"), mode.clone()).unwrap();
let resolved = docs_root(&ctx(&dir));
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(dir.path().join("docs"), mode).unwrap();
assert_eq!(
resolved, "docs",
"an unreadable layout fell through to the default root"
);
}
#[test]
fn an_unsearchable_ancestor_is_raised_rather_than_discovered_away() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("docs/reference/known-issues")).unwrap();
let ancestor = dir.path().join("docs/reference");
let mut mode = std::fs::metadata(&ancestor).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
std::fs::set_permissions(&ancestor, mode.clone()).unwrap();
let raised = ki_records(&ctx(&dir), &[]).is_err();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&ancestor, mode).unwrap();
assert!(raised, "an unsearchable ancestor listed as no zone");
}
#[test]
fn an_absent_zone_is_skipped_and_an_unreadable_one_is_raised() {
let dir = tempfile::tempdir().unwrap();
write(&dir, "docs/specs/SPEC-a.md", "# A\n");
assert!(ki_records(&ctx(&dir), &[]).unwrap().is_empty());
let zone = dir.path().join("docs/reference/known-issues");
std::fs::create_dir_all(&zone).unwrap();
let mut mode = std::fs::metadata(&zone).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
std::fs::set_permissions(&zone, mode.clone()).unwrap();
let raised = ki_records(&ctx(&dir), &[]).is_err();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&zone, mode).unwrap();
assert!(raised, "an unreadable zone listed as empty");
}
}