git_paw/mcp/query/
docs.rs1use std::path::Path;
15
16use rmcp::schemars;
17use serde::Serialize;
18
19use crate::config::GovernanceConfig;
20use crate::error::PawError;
21
22use super::resolve_under_root;
23
24pub fn read_readme(repo_root: &Path, gov: &GovernanceConfig) -> Result<Option<String>, PawError> {
32 let Some(rel) = gov.readme.as_deref() else {
33 return Ok(None);
34 };
35 let path = resolve_under_root(repo_root, rel);
36 match std::fs::read_to_string(&path) {
37 Ok(content) => Ok(Some(content)),
38 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
39 Err(e) => Err(PawError::McpError(format!(
40 "configured readme path {} could not be read: {e}",
41 path.display()
42 ))),
43 }
44}
45
46#[derive(Debug, Clone, Serialize, schemars::JsonSchema, PartialEq, Eq)]
48pub struct DocEntry {
49 pub path: String,
52}
53
54fn collect_md(dir: &Path, base: &Path, out: &mut Vec<DocEntry>) {
57 let Ok(entries) = std::fs::read_dir(dir) else {
58 return;
59 };
60 for entry in entries.flatten() {
61 let path = entry.path();
62 if path.is_dir() {
63 collect_md(&path, base, out);
64 continue;
65 }
66 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
67 continue;
68 };
69 if !name.to_ascii_lowercase().ends_with(".md") {
70 continue;
71 }
72 let Ok(rel) = path.strip_prefix(base) else {
73 continue;
74 };
75 let rel = rel
76 .components()
77 .map(|c| c.as_os_str().to_string_lossy())
78 .collect::<Vec<_>>()
79 .join("/");
80 out.push(DocEntry { path: rel });
81 }
82}
83
84#[must_use]
90pub fn list_docs(repo_root: &Path, gov: &GovernanceConfig) -> Vec<DocEntry> {
91 let Some(dir) = gov.docs.as_ref() else {
92 return Vec::new();
93 };
94 let dir = resolve_under_root(repo_root, dir);
95 let mut out = Vec::new();
96 collect_md(&dir, &dir, &mut out);
97 out.sort_by(|a, b| a.path.cmp(&b.path));
98 out
99}
100
101pub fn read_doc(
116 repo_root: &Path,
117 gov: &GovernanceConfig,
118 rel_path: &str,
119) -> Result<Option<String>, PawError> {
120 let Some(dir) = gov.docs.as_ref() else {
121 return Ok(None);
122 };
123 let dir = resolve_under_root(repo_root, dir);
124 let Ok(canonical_dir) = dir.canonicalize() else {
127 return Ok(None);
128 };
129
130 let requested = dir.join(rel_path);
131 let Ok(canonical) = requested.canonicalize() else {
134 return Ok(None);
135 };
136 if !canonical.starts_with(&canonical_dir) {
140 return Ok(None);
141 }
142
143 match std::fs::read_to_string(&canonical) {
144 Ok(content) => Ok(Some(content)),
145 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
146 Err(e) => Err(PawError::McpError(format!(
147 "configured doc {} could not be read: {e}",
148 canonical.display()
149 ))),
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use std::path::PathBuf;
157
158 #[test]
159 fn read_readme_returns_content_when_configured_and_present() {
160 let tmp = tempfile::tempdir().unwrap();
161 std::fs::write(tmp.path().join("README.md"), "# Hello\nbody").unwrap();
162 let gov = GovernanceConfig {
163 readme: Some(PathBuf::from("README.md")),
164 ..Default::default()
165 };
166 let content = read_readme(tmp.path(), &gov).unwrap();
167 assert_eq!(content.as_deref(), Some("# Hello\nbody"));
168 }
169
170 #[test]
171 fn read_readme_none_when_unconfigured() {
172 let tmp = tempfile::tempdir().unwrap();
173 assert!(
174 read_readme(tmp.path(), &GovernanceConfig::default())
175 .unwrap()
176 .is_none()
177 );
178 }
179
180 #[test]
181 fn read_readme_none_when_configured_but_absent() {
182 let tmp = tempfile::tempdir().unwrap();
183 let gov = GovernanceConfig {
184 readme: Some(PathBuf::from("README.md")),
185 ..Default::default()
186 };
187 assert!(read_readme(tmp.path(), &gov).unwrap().is_none());
189 }
190
191 #[test]
192 fn list_docs_empty_when_unconfigured() {
193 let tmp = tempfile::tempdir().unwrap();
194 assert!(list_docs(tmp.path(), &GovernanceConfig::default()).is_empty());
195 }
196
197 #[test]
198 fn list_docs_empty_when_dir_absent() {
199 let tmp = tempfile::tempdir().unwrap();
200 let gov = GovernanceConfig {
201 docs: Some(PathBuf::from("docs/src")),
202 ..Default::default()
203 };
204 assert!(list_docs(tmp.path(), &gov).is_empty());
205 }
206
207 #[test]
208 fn list_docs_enumerates_nested_markdown_relative_to_dir() {
209 let tmp = tempfile::tempdir().unwrap();
210 let docs = tmp.path().join("docs/src");
211 std::fs::create_dir_all(docs.join("user-guide")).unwrap();
212 std::fs::write(docs.join("intro.md"), "# Intro").unwrap();
213 std::fs::write(docs.join("user-guide/mcp.md"), "# MCP").unwrap();
214 std::fs::write(docs.join("not-a-doc.txt"), "ignored").unwrap();
215 let gov = GovernanceConfig {
216 docs: Some(PathBuf::from("docs/src")),
217 ..Default::default()
218 };
219 let list = list_docs(tmp.path(), &gov);
220 let paths: Vec<&str> = list.iter().map(|d| d.path.as_str()).collect();
221 assert_eq!(paths, vec!["intro.md", "user-guide/mcp.md"]);
222 }
223
224 #[test]
225 fn read_doc_happy_path() {
226 let tmp = tempfile::tempdir().unwrap();
227 let docs = tmp.path().join("docs/src");
228 std::fs::create_dir_all(docs.join("user-guide")).unwrap();
229 std::fs::write(docs.join("user-guide/mcp.md"), "# MCP guide").unwrap();
230 let gov = GovernanceConfig {
231 docs: Some(PathBuf::from("docs/src")),
232 ..Default::default()
233 };
234 let content = read_doc(tmp.path(), &gov, "user-guide/mcp.md").unwrap();
235 assert_eq!(content.as_deref(), Some("# MCP guide"));
236 }
237
238 #[test]
239 fn read_doc_none_when_unconfigured() {
240 let tmp = tempfile::tempdir().unwrap();
241 assert!(
242 read_doc(tmp.path(), &GovernanceConfig::default(), "x.md")
243 .unwrap()
244 .is_none()
245 );
246 }
247
248 #[test]
249 fn read_doc_rejects_dotdot_traversal() {
250 let tmp = tempfile::tempdir().unwrap();
251 let docs = tmp.path().join("docs/src");
252 std::fs::create_dir_all(&docs).unwrap();
253 std::fs::write(docs.join("ok.md"), "# ok").unwrap();
254 std::fs::write(tmp.path().join("secret.txt"), "TOPSECRET").unwrap();
256 let gov = GovernanceConfig {
257 docs: Some(PathBuf::from("docs/src")),
258 ..Default::default()
259 };
260 let escaped = read_doc(tmp.path(), &gov, "../../secret.txt").unwrap();
262 assert!(escaped.is_none(), "traversal must be refused");
263 }
264
265 #[test]
266 fn read_doc_rejects_absolute_path() {
267 let tmp = tempfile::tempdir().unwrap();
268 let docs = tmp.path().join("docs/src");
269 std::fs::create_dir_all(&docs).unwrap();
270 let secret = tmp.path().join("secret.txt");
271 std::fs::write(&secret, "TOPSECRET").unwrap();
272 let gov = GovernanceConfig {
273 docs: Some(PathBuf::from("docs/src")),
274 ..Default::default()
275 };
276 let abs = secret.to_string_lossy().into_owned();
277 let escaped = read_doc(tmp.path(), &gov, &abs).unwrap();
278 assert!(escaped.is_none(), "absolute escape must be refused");
279 }
280}