use std::path::Path;
use rmcp::schemars;
use serde::Serialize;
use crate::config::GovernanceConfig;
use crate::error::PawError;
use super::resolve_under_root;
pub fn read_readme(repo_root: &Path, gov: &GovernanceConfig) -> Result<Option<String>, PawError> {
let Some(rel) = gov.readme.as_deref() else {
return Ok(None);
};
let path = resolve_under_root(repo_root, rel);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(PawError::McpError(format!(
"configured readme path {} could not be read: {e}",
path.display()
))),
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct DocEntry {
pub path: String,
}
fn collect_md(dir: &Path, base: &Path, out: &mut Vec<DocEntry>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_md(&path, base, out);
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.to_ascii_lowercase().ends_with(".md") {
continue;
}
let Ok(rel) = path.strip_prefix(base) else {
continue;
};
let rel = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
out.push(DocEntry { path: rel });
}
}
#[must_use]
pub fn list_docs(repo_root: &Path, gov: &GovernanceConfig) -> Vec<DocEntry> {
let Some(dir) = gov.docs.as_ref() else {
return Vec::new();
};
let dir = resolve_under_root(repo_root, dir);
let mut out = Vec::new();
collect_md(&dir, &dir, &mut out);
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
pub fn read_doc(
repo_root: &Path,
gov: &GovernanceConfig,
rel_path: &str,
) -> Result<Option<String>, PawError> {
let Some(dir) = gov.docs.as_ref() else {
return Ok(None);
};
let dir = resolve_under_root(repo_root, dir);
let Ok(canonical_dir) = dir.canonicalize() else {
return Ok(None);
};
let requested = dir.join(rel_path);
let Ok(canonical) = requested.canonicalize() else {
return Ok(None);
};
if !canonical.starts_with(&canonical_dir) {
return Ok(None);
}
match std::fs::read_to_string(&canonical) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(PawError::McpError(format!(
"configured doc {} could not be read: {e}",
canonical.display()
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn read_readme_returns_content_when_configured_and_present() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("README.md"), "# Hello\nbody").unwrap();
let gov = GovernanceConfig {
readme: Some(PathBuf::from("README.md")),
..Default::default()
};
let content = read_readme(tmp.path(), &gov).unwrap();
assert_eq!(content.as_deref(), Some("# Hello\nbody"));
}
#[test]
fn read_readme_none_when_unconfigured() {
let tmp = tempfile::tempdir().unwrap();
assert!(
read_readme(tmp.path(), &GovernanceConfig::default())
.unwrap()
.is_none()
);
}
#[test]
fn read_readme_none_when_configured_but_absent() {
let tmp = tempfile::tempdir().unwrap();
let gov = GovernanceConfig {
readme: Some(PathBuf::from("README.md")),
..Default::default()
};
assert!(read_readme(tmp.path(), &gov).unwrap().is_none());
}
#[test]
fn list_docs_empty_when_unconfigured() {
let tmp = tempfile::tempdir().unwrap();
assert!(list_docs(tmp.path(), &GovernanceConfig::default()).is_empty());
}
#[test]
fn list_docs_empty_when_dir_absent() {
let tmp = tempfile::tempdir().unwrap();
let gov = GovernanceConfig {
docs: Some(PathBuf::from("docs/src")),
..Default::default()
};
assert!(list_docs(tmp.path(), &gov).is_empty());
}
#[test]
fn list_docs_enumerates_nested_markdown_relative_to_dir() {
let tmp = tempfile::tempdir().unwrap();
let docs = tmp.path().join("docs/src");
std::fs::create_dir_all(docs.join("user-guide")).unwrap();
std::fs::write(docs.join("intro.md"), "# Intro").unwrap();
std::fs::write(docs.join("user-guide/mcp.md"), "# MCP").unwrap();
std::fs::write(docs.join("not-a-doc.txt"), "ignored").unwrap();
let gov = GovernanceConfig {
docs: Some(PathBuf::from("docs/src")),
..Default::default()
};
let list = list_docs(tmp.path(), &gov);
let paths: Vec<&str> = list.iter().map(|d| d.path.as_str()).collect();
assert_eq!(paths, vec!["intro.md", "user-guide/mcp.md"]);
}
#[test]
fn read_doc_happy_path() {
let tmp = tempfile::tempdir().unwrap();
let docs = tmp.path().join("docs/src");
std::fs::create_dir_all(docs.join("user-guide")).unwrap();
std::fs::write(docs.join("user-guide/mcp.md"), "# MCP guide").unwrap();
let gov = GovernanceConfig {
docs: Some(PathBuf::from("docs/src")),
..Default::default()
};
let content = read_doc(tmp.path(), &gov, "user-guide/mcp.md").unwrap();
assert_eq!(content.as_deref(), Some("# MCP guide"));
}
#[test]
fn read_doc_none_when_unconfigured() {
let tmp = tempfile::tempdir().unwrap();
assert!(
read_doc(tmp.path(), &GovernanceConfig::default(), "x.md")
.unwrap()
.is_none()
);
}
#[test]
fn read_doc_rejects_dotdot_traversal() {
let tmp = tempfile::tempdir().unwrap();
let docs = tmp.path().join("docs/src");
std::fs::create_dir_all(&docs).unwrap();
std::fs::write(docs.join("ok.md"), "# ok").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "TOPSECRET").unwrap();
let gov = GovernanceConfig {
docs: Some(PathBuf::from("docs/src")),
..Default::default()
};
let escaped = read_doc(tmp.path(), &gov, "../../secret.txt").unwrap();
assert!(escaped.is_none(), "traversal must be refused");
}
#[test]
fn read_doc_rejects_absolute_path() {
let tmp = tempfile::tempdir().unwrap();
let docs = tmp.path().join("docs/src");
std::fs::create_dir_all(&docs).unwrap();
let secret = tmp.path().join("secret.txt");
std::fs::write(&secret, "TOPSECRET").unwrap();
let gov = GovernanceConfig {
docs: Some(PathBuf::from("docs/src")),
..Default::default()
};
let abs = secret.to_string_lossy().into_owned();
let escaped = read_doc(tmp.path(), &gov, &abs).unwrap();
assert!(escaped.is_none(), "absolute escape must be refused");
}
}