1pub mod logging;
38pub mod query;
39pub mod server;
40pub mod tools;
41
42use std::path::{Path, PathBuf};
43
44use crate::error::PawError;
45use crate::session::{self, Session, SessionStatus};
46
47#[derive(Debug, Clone)]
50pub struct RepoContext {
51 pub root: PathBuf,
54 pub git_paw_dir: Option<PathBuf>,
57 pub broker_url: Option<String>,
62 pub server_name: String,
67}
68
69impl RepoContext {
70 #[must_use]
78 pub fn for_root(root: PathBuf) -> Self {
79 let git_paw_dir = {
80 let candidate = root.join(".git-paw");
81 candidate.is_dir().then_some(candidate)
82 };
83 let broker_url = session::find_session_for_repo(&root)
84 .ok()
85 .flatten()
86 .as_ref()
87 .and_then(broker_url_from_session);
88 let server_name = crate::config::load_config(&root, None)
89 .map_or_else(|_| "git-paw".to_string(), |cfg| cfg.mcp_server_name());
90 Self {
91 root,
92 git_paw_dir,
93 broker_url,
94 server_name,
95 }
96 }
97}
98
99fn broker_url_from_session(session: &Session) -> Option<String> {
102 if session.status != SessionStatus::Active {
105 return None;
106 }
107 let port = session.broker_port?;
108 let bind = session.broker_bind.as_deref().unwrap_or("127.0.0.1");
109 let host = if bind == "0.0.0.0" || bind.is_empty() {
111 "127.0.0.1"
112 } else {
113 bind
114 };
115 Some(format!("http://{host}:{port}"))
116}
117
118pub fn resolve_repo(repo_flag: Option<&Path>) -> Result<PathBuf, PawError> {
130 if let Some(flag) = repo_flag {
131 let canonical = flag.canonicalize().map_err(|e| {
132 PawError::McpError(format!(
133 "--repo path {} could not be opened: {e}. Pass an existing repository path.",
134 flag.display()
135 ))
136 })?;
137 crate::git::validate_repo(&canonical).map_err(|_| {
138 PawError::McpError(format!(
139 "--repo path {} is not a git repository. Point --repo at a directory inside a git repo.",
140 canonical.display()
141 ))
142 })
143 } else {
144 let cwd = std::env::current_dir()
145 .map_err(|e| PawError::McpError(format!("cannot read current directory: {e}")))?;
146 crate::git::validate_repo(&cwd).map_err(|_| {
147 PawError::McpError(
148 "no git repository found in the current directory or any parent. \
149 Run `git paw mcp` from inside a git repository, or pass \
150 `--repo <path>` (required for clients like Claude Desktop that \
151 spawn from a fixed directory)."
152 .to_string(),
153 )
154 })
155 }
156}
157
158pub fn cmd_mcp(repo_flag: Option<&Path>, log_file: Option<&Path>) -> Result<(), PawError> {
163 let root = resolve_repo(repo_flag)?;
164 let context = RepoContext::for_root(root);
165 server::run(context, log_file)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use std::process::Command;
172
173 fn git_init(dir: &Path) {
175 for args in [
176 vec!["init", "-q"],
177 vec!["config", "user.email", "t@example.com"],
178 vec!["config", "user.name", "Test"],
179 ] {
180 let ok = Command::new("git")
181 .current_dir(dir)
182 .args(&args)
183 .status()
184 .expect("git runs")
185 .success();
186 assert!(ok, "git {args:?} failed");
187 }
188 }
189
190 #[test]
191 fn resolve_repo_with_valid_repo_path_returns_root() {
192 let tmp = tempfile::tempdir().unwrap();
193 let repo = tmp.path().join("proj");
194 std::fs::create_dir(&repo).unwrap();
195 git_init(&repo);
196
197 let resolved = resolve_repo(Some(&repo)).expect("valid repo resolves");
198 assert_eq!(
199 resolved.canonicalize().unwrap(),
200 repo.canonicalize().unwrap()
201 );
202 }
203
204 #[test]
205 fn resolve_repo_with_non_git_path_errors_with_path() {
206 let tmp = tempfile::tempdir().unwrap();
207 let not_repo = tmp.path().join("plain");
208 std::fs::create_dir(¬_repo).unwrap();
209
210 let err = resolve_repo(Some(¬_repo)).expect_err("non-git path must error");
211 let msg = err.to_string();
212 assert!(msg.contains("not a git repository"), "got: {msg}");
213 }
214
215 #[test]
216 fn resolve_repo_with_nonexistent_path_errors() {
217 let err = resolve_repo(Some(Path::new("/no/such/path/at/all")))
218 .expect_err("nonexistent path must error");
219 assert!(
220 err.to_string().contains("could not be opened"),
221 "got: {err}"
222 );
223 }
224
225 #[test]
226 fn resolve_repo_from_subdir_finds_enclosing_repo() {
227 let tmp = tempfile::tempdir().unwrap();
228 let repo = tmp.path().join("proj");
229 std::fs::create_dir(&repo).unwrap();
230 git_init(&repo);
231 let sub = repo.join("a").join("b");
232 std::fs::create_dir_all(&sub).unwrap();
233
234 let resolved = resolve_repo(Some(&sub)).expect("subdir resolves to enclosing repo");
237 assert_eq!(
238 resolved.canonicalize().unwrap(),
239 repo.canonicalize().unwrap()
240 );
241 }
242
243 #[test]
244 fn resolve_repo_worktree_resolves_to_worktree_root() {
245 let tmp = tempfile::tempdir().unwrap();
246 let main = tmp.path().join("main");
247 std::fs::create_dir(&main).unwrap();
248 git_init(&main);
249 std::fs::write(main.join("README.md"), "hi").unwrap();
251 for args in [vec!["add", "."], vec!["commit", "-q", "-m", "init"]] {
252 assert!(
253 Command::new("git")
254 .current_dir(&main)
255 .args(&args)
256 .status()
257 .unwrap()
258 .success()
259 );
260 }
261 let wt = tmp.path().join("wt");
262 assert!(
263 Command::new("git")
264 .current_dir(&main)
265 .args([
266 "worktree",
267 "add",
268 "-q",
269 wt.to_str().unwrap(),
270 "-b",
271 "feat/x"
272 ])
273 .status()
274 .unwrap()
275 .success(),
276 "worktree add failed"
277 );
278
279 let resolved = resolve_repo(Some(&wt)).expect("worktree resolves");
280 assert_eq!(
281 resolved.canonicalize().unwrap(),
282 wt.canonicalize().unwrap(),
283 "worktree must resolve to its own root, not the main repo"
284 );
285 }
286
287 #[test]
288 fn for_root_without_git_paw_dir_yields_none() {
289 let tmp = tempfile::tempdir().unwrap();
290 let repo = tmp.path().join("proj");
291 std::fs::create_dir(&repo).unwrap();
292 git_init(&repo);
293
294 let ctx = RepoContext::for_root(repo.canonicalize().unwrap());
295 assert!(ctx.git_paw_dir.is_none());
296 assert!(ctx.broker_url.is_none());
297 }
298
299 #[test]
300 fn for_root_with_git_paw_dir_is_some() {
301 let tmp = tempfile::tempdir().unwrap();
302 let repo = tmp.path().join("proj");
303 std::fs::create_dir(&repo).unwrap();
304 git_init(&repo);
305 std::fs::create_dir(repo.join(".git-paw")).unwrap();
306
307 let ctx = RepoContext::for_root(repo.canonicalize().unwrap());
308 assert!(ctx.git_paw_dir.is_some());
309 }
310
311 #[test]
315 fn no_stdout_macros_under_src_mcp() {
316 let mcp_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
317 .join("src")
318 .join("mcp");
319 let mut offenders = Vec::new();
320 visit_rs_files(&mcp_dir, &mut |path, contents| {
321 for (lineno, line) in contents.lines().enumerate() {
322 let trimmed = line.trim_start();
324 if trimmed.starts_with("//") || trimmed.starts_with('*') {
325 continue;
326 }
327 if is_macro_call(line, "println!(") || is_macro_call(line, "print!(") {
331 offenders.push(format!("{}:{}", path.display(), lineno + 1));
332 }
333 }
334 });
335 assert!(
336 offenders.is_empty(),
337 "stdout macros found under src/mcp/ (stdout is reserved for JSON-RPC): {offenders:?}"
338 );
339 }
340
341 fn is_macro_call(line: &str, needle: &str) -> bool {
345 let mut from = 0;
346 while let Some(rel) = line[from..].find(needle) {
347 let idx = from + rel;
348 let prev = line[..idx].chars().next_back();
349 if prev != Some('"') {
350 return true;
351 }
352 from = idx + needle.len();
353 }
354 false
355 }
356
357 fn visit_rs_files(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
358 let Ok(entries) = std::fs::read_dir(dir) else {
359 return;
360 };
361 for entry in entries.flatten() {
362 let path = entry.path();
363 if path.is_dir() {
364 visit_rs_files(&path, f);
365 } else if path.extension().is_some_and(|e| e == "rs")
366 && let Ok(contents) = std::fs::read_to_string(&path)
367 {
368 f(&path, &contents);
369 }
370 }
371 }
372}