use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::frontmatter;
#[derive(Debug)]
pub struct Boundary {
pub files: BTreeSet<PathBuf>,
pub dirs: BTreeSet<PathBuf>,
pub state: BTreeSet<String>,
}
impl Boundary {
pub fn file_allowed(&self, path: &Path) -> bool {
self.files.contains(path) || self.dirs.iter().any(|d| path.starts_with(d))
}
pub fn state_allowed(&self, name: &str) -> bool {
self.state.contains(name)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Entry {
File(PathBuf),
State(String),
Type(String),
}
impl Entry {
pub fn parse(raw: &str) -> Entry {
let t = raw.trim();
if let Some(name) = t.strip_prefix("state:") {
Entry::State(name.trim().to_string())
} else if let Some(path) = t.strip_prefix("file:") {
Entry::File(PathBuf::from(path.trim()))
} else if t.contains('/') || t.contains('\\') || t.contains('.') {
Entry::File(PathBuf::from(t))
} else {
Entry::Type(t.to_string())
}
}
pub fn allowed(&self, b: &Boundary) -> bool {
match self {
Entry::File(p) => b.file_allowed(p),
Entry::State(s) => b.state_allowed(s),
Entry::Type(_) => true,
}
}
}
pub fn build(docs_root: &Path) -> Result<Boundary, String> {
let mut b = Boundary {
files: BTreeSet::new(),
dirs: BTreeSet::new(),
state: BTreeSet::new(),
};
b.dirs.insert(PathBuf::from("research/plans"));
for tier in ["tier1", "tier2"] {
let dir = docs_root.join("agent").join(tier);
let read_dir =
std::fs::read_dir(&dir).map_err(|e| format!("cannot read {:?}: {e}", dir))?;
for de in read_dir {
let de = de.map_err(|e| e.to_string())?;
let path = de.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read {:?}: {e}", path))?;
let (meta, _) = frontmatter::split_frontmatter(&text)
.map_err(|e| format!("{:?}: frontmatter: {e}", path))?;
if tier == "tier1" {
if let Some(writes) = meta.get("writes").and_then(|v| v.as_list()) {
for w in writes {
if let Some(name) = w.strip_prefix("state:") {
b.state.insert(name.to_string());
} else if let Some(path) = w.strip_prefix("file:") {
if path.ends_with('/') {
b.dirs.insert(PathBuf::from(path));
} else {
b.files.insert(PathBuf::from(path));
}
}
}
}
} else if let Some(file) = meta.get("target_file").and_then(|v| v.as_scalar()) {
b.files.insert(PathBuf::from(file));
}
}
}
Ok(b)
}
#[cfg(test)]
mod tests {
use super::*;
fn write(p: impl AsRef<Path>, content: &str) {
let p = p.as_ref();
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, content).unwrap();
}
#[test]
fn boundary_from_docs_and_fence() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root.join("crates/kova-x/src/lib.rs"), "pub struct X;\n");
write(
root.join("docs/agent/tier1/a.md"),
"---\ndoc_tier: \"1\"\nmodule: kova-x\nstructs:\n - X\nreads: []\nwrites:\n - X\n - state:state-a\n - file:out/\n---\nbody\n",
);
write(
root.join("docs/agent/tier2/b.md"),
"---\ndoc_tier: \"2\"\ntarget_file: crates/kova-x/src/lib.rs\nprocs: []\ndrivers: []\nenv: []\n---\nbody\n",
);
let b = build(root.join("docs").as_path()).unwrap();
assert!(b.file_allowed(Path::new("research/plans/whatever.md")));
assert!(!b.file_allowed(Path::new("research/other/plan.md")));
assert!(b.file_allowed(Path::new("out/nested/deep.txt")));
assert!(b.file_allowed(Path::new("crates/kova-x/src/lib.rs")));
assert!(!b.file_allowed(Path::new("crates/kova-x/src/other.rs")));
assert!(b.state_allowed("state-a"));
assert!(!b.state_allowed("state-b"));
assert_eq!(
Entry::parse("file:out/x.txt"),
Entry::File(PathBuf::from("out/x.txt"))
);
assert_eq!(Entry::parse("state:s"), Entry::State("s".into()));
assert_eq!(
Entry::parse("crates/kova-x/src/lib.rs"),
Entry::File(PathBuf::from("crates/kova-x/src/lib.rs"))
);
assert!(Entry::parse("research/plans/p.md").allowed(&b));
assert!(!Entry::parse("crates/kova-x/src/other.rs").allowed(&b));
assert_eq!(
Entry::parse("PipelineState"),
Entry::Type("PipelineState".into())
);
assert_eq!(
Entry::parse("tool result ({content, isError})"),
Entry::Type("tool result ({content, isError})".into())
);
assert!(Entry::parse("PipelineState").allowed(&b));
}
#[test]
fn build_fails_on_bad_frontmatter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root.join("docs/agent/tier1/bad.md"),
"---\nno frontmatter delimiter\n",
);
let err = build(root.join("docs").as_path()).unwrap_err();
assert!(err.contains("bad.md"), "error should name the doc: {err}");
}
}