返回 CodeWhale
git_history.rs
根目录 / crates / tui / src / tools / git_history.rs
1 //! Git history tools: `git_log`, `git_show`, and `git_blame`.
2 //!
3 //! These tools provide read-only access to commit history and attribution
4 //! without exposing arbitrary shell execution.
5
6 use std::fs;
7 use std::path::{Path, PathBuf};
8 use std::process::Output;
9
10 use async_trait::async_trait;
11 use serde_json::{Value, json};
12
13 use super::spec::{
14 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
15 optional_bool, optional_str, optional_u64, required_str,
16 };
17 use crate::dependencies::ExternalTool;
18
19 const MAX_OUTPUT_CHARS: usize = 40_000;
20 const DEFAULT_LOG_MAX_COUNT: u64 = 20;
21 const MAX_LOG_MAX_COUNT: u64 = 200;
22 const DEFAULT_UNIFIED: u64 = 3;
23 const MAX_UNIFIED: u64 = 50;
24 const DEFAULT_BLAME_START_LINE: u64 = 1;
25 const DEFAULT_BLAME_MAX_LINES: u64 = 200;
26 const MAX_BLAME_MAX_LINES: u64 = 2_000;
27
28 /// Tool for reading recent commit history.
29 pub struct GitLogTool;
30
31 #[async_trait]
32 impl ToolSpec for GitLogTool {
33 fn name(&self) -> &'static str {
34 "git_log"
35 }
36
37 fn model_visible(&self) -> bool {
38 false
39 }
40
41 fn description(&self) -> &'static str {
42 "Run `git log` in the workspace with optional path and author/date filters."
43 }
44
45 fn input_schema(&self) -> Value {
46 json!({
47 "type": "object",
48 "properties": {
49 "path": {
50 "type": "string",
51 "description": "Optional subdirectory or file path to scope history to."
52 },
53 "max_count": {
54 "type": "integer",
55 "minimum": 1,
56 "maximum": MAX_LOG_MAX_COUNT,
57 "default": DEFAULT_LOG_MAX_COUNT,
58 "description": "Maximum number of commits to return."
59 },
60 "author": {
61 "type": "string",
62 "description": "Optional git author filter (same semantics as `git log --author`)."
63 },
64 "since": {
65 "type": "string",
66 "description": "Optional lower date bound, e.g. '2 weeks ago' or ISO date."
67 },
68 "until": {
69 "type": "string",
70 "description": "Optional upper date bound, e.g. 'yesterday' or ISO date."
71 }
72 },
73 "additionalProperties": false
74 })
75 }
76
77 fn capabilities(&self) -> Vec<ToolCapability> {
78 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
79 }
80
81 fn approval_requirement(&self) -> ApprovalRequirement {
82 ApprovalRequirement::Auto
83 }
84
85 fn supports_parallel(&self) -> bool {
86 true
87 }
88
89 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
90 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
91 let max_count =
92 optional_u64(&input, "max_count", DEFAULT_LOG_MAX_COUNT)?.clamp(1, MAX_LOG_MAX_COUNT);
93 let author = optional_str(&input, "author")?.map(ToOwned::to_owned);
94 let since = optional_str(&input, "since")?.map(ToOwned::to_owned);
95 let until = optional_str(&input, "until")?.map(ToOwned::to_owned);
96
97 let mut args = vec![
98 "log".to_string(),
99 "--no-color".to_string(),
100 format!("--max-count={max_count}"),
101 "--date=iso-strict".to_string(),
102 "--pretty=format:%H%nAuthor: %an <%ae>%nDate: %ad%nSubject: %s%n".to_string(),
103 ];
104 if let Some(author) = &author {
105 args.push(format!("--author={author}"));
106 }
107 if let Some(since) = &since {
108 args.push(format!("--since={since}"));
109 }
110 if let Some(until) = &until {
111 args.push(format!("--until={until}"));
112 }
113 if let Some(pathspec) = &git_ctx.pathspec {
114 args.push("--".to_string());
115 args.push(pathspec.display().to_string());
116 }
117
118 let command_str = format_command(&git_ctx.working_dir, &args);
119 let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
120 if !output.status.success() {
121 let stderr = String::from_utf8_lossy(&output.stderr);
122 return Ok(
123 ToolResult::error(format!("git log failed: {}", stderr.trim())).with_metadata(
124 json!({
125 "command": command_str,
126 "exit_code": output.status.code(),
127 "stderr": stderr.trim(),
128 }),
129 ),
130 );
131 }
132
133 let stdout = String::from_utf8_lossy(&output.stdout);
134 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
135 Ok(ToolResult::success(content).with_metadata(json!({
136 "command": command_str,
137 "working_dir": git_ctx.working_dir,
138 "pathspec": git_ctx.pathspec,
139 "max_count": max_count,
140 "author": author,
141 "since": since,
142 "until": until,
143 "truncated": truncated,
144 "omitted_chars": omitted_chars,
145 })))
146 }
147 }
148
149 /// Tool for showing a specific commit with optional patch/stat output.
150 pub struct GitShowTool;
151
152 #[async_trait]
153 impl ToolSpec for GitShowTool {
154 fn name(&self) -> &'static str {
155 "git_show"
156 }
157
158 fn model_visible(&self) -> bool {
159 false
160 }
161
162 fn description(&self) -> &'static str {
163 "Run `git show` for a specific revision with optional patch and stats."
164 }
165
166 fn input_schema(&self) -> Value {
167 json!({
168 "type": "object",
169 "properties": {
170 "rev": {
171 "type": "string",
172 "description": "Revision to show (commit SHA, tag, branch, or ref expression)."
173 },
174 "path": {
175 "type": "string",
176 "description": "Optional subdirectory or file path to scope output."
177 },
178 "patch": {
179 "type": "boolean",
180 "default": true,
181 "description": "Include patch hunks (default true)."
182 },
183 "stat": {
184 "type": "boolean",
185 "default": true,
186 "description": "Include --stat summary (default true)."
187 },
188 "unified": {
189 "type": "integer",
190 "minimum": 0,
191 "maximum": MAX_UNIFIED,
192 "default": DEFAULT_UNIFIED,
193 "description": "Context lines for patch output when patch=true."
194 }
195 },
196 "required": ["rev"],
197 "additionalProperties": false
198 })
199 }
200
201 fn capabilities(&self) -> Vec<ToolCapability> {
202 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
203 }
204
205 fn approval_requirement(&self) -> ApprovalRequirement {
206 ApprovalRequirement::Auto
207 }
208
209 fn supports_parallel(&self) -> bool {
210 true
211 }
212
213 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
214 let rev = required_str(&input, "rev")?;
215 validate_git_rev(rev)?;
216 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
217 let patch = optional_bool(&input, "patch", true)?;
218 let stat = optional_bool(&input, "stat", true)?;
219 let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED)?.min(MAX_UNIFIED);
220
221 let mut args = vec![
222 "show".to_string(),
223 "--no-color".to_string(),
224 "--no-ext-diff".to_string(),
225 ];
226 if patch {
227 args.push(format!("--unified={unified}"));
228 } else {
229 args.push("--no-patch".to_string());
230 }
231 if stat {
232 args.push("--stat".to_string());
233 }
234 args.push(rev.to_string());
235 if let Some(pathspec) = &git_ctx.pathspec {
236 args.push("--".to_string());
237 args.push(pathspec.display().to_string());
238 }
239
240 let command_str = format_command(&git_ctx.working_dir, &args);
241 let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
242 if !output.status.success() {
243 let stderr = String::from_utf8_lossy(&output.stderr);
244 return Ok(ToolResult::error(format!(
245 "git show failed for '{rev}': {}",
246 stderr.trim()
247 ))
248 .with_metadata(json!({
249 "command": command_str,
250 "exit_code": output.status.code(),
251 "stderr": stderr.trim(),
252 })));
253 }
254
255 let stdout = String::from_utf8_lossy(&output.stdout);
256 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
257 Ok(ToolResult::success(content).with_metadata(json!({
258 "command": command_str,
259 "working_dir": git_ctx.working_dir,
260 "pathspec": git_ctx.pathspec,
261 "rev": rev,
262 "patch": patch,
263 "stat": stat,
264 "unified": if patch { Some(unified) } else { None },
265 "truncated": truncated,
266 "omitted_chars": omitted_chars,
267 })))
268 }
269 }
270
271 /// Tool for attributing lines in a file to commits and authors.
272 pub struct GitBlameTool;
273
274 #[async_trait]
275 impl ToolSpec for GitBlameTool {
276 fn name(&self) -> &'static str {
277 "git_blame"
278 }
279
280 fn model_visible(&self) -> bool {
281 false
282 }
283
284 fn description(&self) -> &'static str {
285 "Run `git blame` on a file with optional revision and line-range controls."
286 }
287
288 fn input_schema(&self) -> Value {
289 json!({
290 "type": "object",
291 "properties": {
292 "path": {
293 "type": "string",
294 "description": "Path to a tracked file within the workspace."
295 },
296 "rev": {
297 "type": "string",
298 "description": "Optional revision to blame against (default: HEAD)."
299 },
300 "start_line": {
301 "type": "integer",
302 "minimum": 1,
303 "default": DEFAULT_BLAME_START_LINE,
304 "description": "First line to include in blame output."
305 },
306 "max_lines": {
307 "type": "integer",
308 "minimum": 1,
309 "maximum": MAX_BLAME_MAX_LINES,
310 "default": DEFAULT_BLAME_MAX_LINES,
311 "description": "Maximum number of lines to include."
312 },
313 "porcelain": {
314 "type": "boolean",
315 "default": false,
316 "description": "When true, emit `--line-porcelain` output."
317 }
318 },
319 "required": ["path"],
320 "additionalProperties": false
321 })
322 }
323
324 fn capabilities(&self) -> Vec<ToolCapability> {
325 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
326 }
327
328 fn approval_requirement(&self) -> ApprovalRequirement {
329 ApprovalRequirement::Auto
330 }
331
332 fn supports_parallel(&self) -> bool {
333 true
334 }
335
336 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
337 let path_str = required_str(&input, "path")?;
338 let resolved_path = context.resolve_path(path_str)?;
339 let metadata = fs::metadata(&resolved_path).map_err(|e| {
340 ToolError::invalid_input(format!(
341 "Path does not exist or is not accessible: {path_str} ({e})"
342 ))
343 })?;
344 if !metadata.is_file() {
345 return Err(ToolError::invalid_input(format!(
346 "Path must point to a file: {path_str}"
347 )));
348 }
349
350 let working_dir = resolved_path.parent().ok_or_else(|| {
351 ToolError::invalid_input(format!("Path has no parent directory: {path_str}"))
352 })?;
353 let pathspec = pathspec_from(working_dir, &resolved_path);
354 let rev = optional_str(&input, "rev")?.unwrap_or("HEAD");
355 validate_git_rev(rev)?;
356 let start_line = optional_u64(&input, "start_line", DEFAULT_BLAME_START_LINE)?.max(1);
357 let max_lines = optional_u64(&input, "max_lines", DEFAULT_BLAME_MAX_LINES)?
358 .clamp(1, MAX_BLAME_MAX_LINES);
359 let end_line = start_line.saturating_add(max_lines.saturating_sub(1));
360 let porcelain = optional_bool(&input, "porcelain", false)?;
361
362 let mut args = vec![
363 "blame".to_string(),
364 "--date=iso".to_string(),
365 format!("-L{start_line},{end_line}"),
366 ];
367 if porcelain {
368 args.push("--line-porcelain".to_string());
369 }
370 args.push(rev.to_string());
371 args.push("--".to_string());
372 args.push(pathspec.display().to_string());
373
374 let command_str = format_command(working_dir, &args);
375 let output = run_git_command_async(working_dir.to_path_buf(), args).await?;
376 if !output.status.success() {
377 let stderr = String::from_utf8_lossy(&output.stderr);
378 return Ok(ToolResult::error(format!(
379 "git blame failed for '{path_str}' at '{rev}': {}",
380 stderr.trim()
381 ))
382 .with_metadata(json!({
383 "command": command_str,
384 "exit_code": output.status.code(),
385 "stderr": stderr.trim(),
386 })));
387 }
388
389 let stdout = String::from_utf8_lossy(&output.stdout);
390 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
391 Ok(ToolResult::success(content).with_metadata(json!({
392 "command": command_str,
393 "working_dir": working_dir,
394 "pathspec": pathspec,
395 "rev": rev,
396 "start_line": start_line,
397 "max_lines": max_lines,
398 "porcelain": porcelain,
399 "truncated": truncated,
400 "omitted_chars": omitted_chars,
401 })))
402 }
403 }
404
405 struct GitContext {
406 working_dir: PathBuf,
407 pathspec: Option<PathBuf>,
408 }
409
410 fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> {
411 let workspace = canonical_or_workspace(&context.workspace);
412 let mut working_dir = workspace.clone();
413 let mut pathspec = None;
414
415 if let Some(raw) = path {
416 let resolved = context.resolve_path(raw)?;
417 let metadata = fs::metadata(&resolved).map_err(|e| {
418 ToolError::invalid_input(format!(
419 "Path does not exist or is not accessible: {raw} ({e})"
420 ))
421 })?;
422
423 if metadata.is_dir() {
424 working_dir = resolved;
425 pathspec = Some(PathBuf::from("."));
426 } else {
427 let parent = resolved.parent().ok_or_else(|| {
428 ToolError::invalid_input(format!("Path has no parent directory: {raw}"))
429 })?;
430 working_dir = parent.to_path_buf();
431 pathspec = Some(pathspec_from(&working_dir, &resolved));
432 }
433 }
434
435 if !working_dir.exists() {
436 return Err(ToolError::invalid_input(format!(
437 "Working directory does not exist: {}",
438 working_dir.display()
439 )));
440 }
441
442 Ok(GitContext {
443 working_dir,
444 pathspec,
445 })
446 }
447
448 fn validate_git_rev(rev: &str) -> Result<(), ToolError> {
449 let trimmed = rev.trim();
450 if trimmed.is_empty() {
451 return Err(ToolError::invalid_input(
452 "git revision must not be empty".to_string(),
453 ));
454 }
455 if trimmed.starts_with('-') {
456 return Err(ToolError::invalid_input(
457 "git revision must not start with '-'".to_string(),
458 ));
459 }
460 if trimmed.chars().any(char::is_whitespace) {
461 return Err(ToolError::invalid_input(
462 "git revision must not contain whitespace".to_string(),
463 ));
464 }
465 if trimmed
466 .chars()
467 .any(|ch| ch == '\0' || ch.is_ascii_control())
468 {
469 return Err(ToolError::invalid_input(
470 "git revision must not contain control characters".to_string(),
471 ));
472 }
473 Ok(())
474 }
475
476 fn canonical_or_workspace(workspace: &Path) -> PathBuf {
477 workspace
478 .canonicalize()
479 .unwrap_or_else(|_| workspace.to_path_buf())
480 }
481
482 fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf {
483 match resolved.strip_prefix(working_dir) {
484 Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
485 Ok(rel) => rel.to_path_buf(),
486 Err(_) => PathBuf::from("."),
487 }
488 }
489
490 fn run_git_command(working_dir: &Path, args: &[String]) -> Result<Output, ToolError> {
491 let Some(mut cmd) = crate::dependencies::Git::command() else {
492 return Err(ToolError::not_available(
493 "git is not installed or not in PATH",
494 ));
495 };
496 cmd.args(args).current_dir(working_dir);
497 cmd.output().map_err(|e| {
498 if e.kind() == std::io::ErrorKind::NotFound {
499 ToolError::not_available("git is not installed or not in PATH")
500 } else {
501 ToolError::execution_failed(format!("Failed to run git: {e}"))
502 }
503 })
504 }
505
506 /// Async wrapper that offloads the blocking `git` invocation onto a
507 /// blocking-capable thread so the tokio worker is not stalled.
508 async fn run_git_command_async(
509 working_dir: PathBuf,
510 args: Vec<String>,
511 ) -> Result<Output, ToolError> {
512 tokio::task::spawn_blocking(move || run_git_command(&working_dir, &args))
513 .await
514 .map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))?
515 }
516
517 fn format_command(working_dir: &Path, args: &[String]) -> String {
518 format!(
519 "git -C {} {}",
520 working_dir.display(),
521 args.iter()
522 .map(String::as_str)
523 .collect::<Vec<_>>()
524 .join(" ")
525 )
526 }
527
528 fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool, usize) {
529 if text.chars().count() <= max_chars {
530 return (text.to_string(), false, 0);
531 }
532 let end = char_boundary_index(text, max_chars);
533 let truncated = &text[..end];
534 let omitted_chars = text
535 .chars()
536 .count()
537 .saturating_sub(truncated.chars().count());
538 let note = format!(
539 "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
540 );
541 (format!("{truncated}{note}"), true, omitted_chars)
542 }
543
544 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
545 if max_chars == 0 {
546 return 0;
547 }
548 for (count, (idx, _)) in text.char_indices().enumerate() {
549 if count == max_chars {
550 return idx;
551 }
552 }
553 text.len()
554 }
555
556 #[cfg(test)]
557 mod tests {
558 use super::*;
559 use std::fs;
560 use std::path::Path;
561 use tempfile::tempdir;
562
563 fn git_available() -> bool {
564 crate::dependencies::Git::available()
565 }
566
567 fn run_git(root: &Path, args: &[&str]) {
568 let status = crate::dependencies::Git::status(args, root).expect("git should spawn");
569 assert!(status.success(), "git {args:?} failed");
570 }
571
572 fn init_git_repo(root: &Path) {
573 run_git(root, &["init", "-q"]);
574 run_git(root, &["config", "core.autocrlf", "false"]);
575 run_git(root, &["config", "user.email", "test@example.com"]);
576 run_git(root, &["config", "user.name", "Test User"]);
577 }
578
579 fn commit_all(root: &Path, message: &str) {
580 run_git(root, &["add", "."]);
581 run_git(root, &["commit", "-q", "-m", message]);
582 }
583
584 #[tokio::test]
585 async fn git_log_lists_recent_commits() {
586 if !git_available() {
587 return;
588 }
589
590 let tmp = tempdir().expect("tempdir");
591 init_git_repo(tmp.path());
592 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
593 commit_all(tmp.path(), "first");
594 fs::write(tmp.path().join("file.txt"), "two\n").expect("write");
595 commit_all(tmp.path(), "second");
596
597 let ctx = ToolContext::new(tmp.path());
598 let result = GitLogTool
599 .execute(json!({ "max_count": 1 }), &ctx)
600 .await
601 .expect("execute");
602 assert!(result.success);
603 assert!(result.content.contains("Subject: second"));
604 }
605
606 #[tokio::test]
607 async fn git_show_returns_patch_for_revision() {
608 if !git_available() {
609 return;
610 }
611
612 let tmp = tempdir().expect("tempdir");
613 init_git_repo(tmp.path());
614 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
615 commit_all(tmp.path(), "first");
616 fs::write(tmp.path().join("file.txt"), "one\ntwo\n").expect("write");
617 commit_all(tmp.path(), "second");
618
619 let ctx = ToolContext::new(tmp.path());
620 let result = GitShowTool
621 .execute(json!({ "rev": "HEAD", "stat": false }), &ctx)
622 .await
623 .expect("execute");
624 assert!(result.success);
625 assert!(result.content.contains("diff --git"));
626 assert!(result.content.contains("+two"));
627 }
628
629 #[tokio::test]
630 async fn git_show_rejects_option_like_revision() {
631 let tmp = tempdir().expect("tempdir");
632 let ctx = ToolContext::new(tmp.path());
633 let err = GitShowTool
634 .execute(json!({ "rev": "--stat" }), &ctx)
635 .await
636 .expect_err("option-shaped rev should fail before git runs");
637 assert!(matches!(err, ToolError::InvalidInput { .. }));
638 assert!(err.to_string().contains("must not start with '-'"));
639 }
640
641 #[tokio::test]
642 async fn git_show_rejects_whitespace_revision_payload() {
643 let tmp = tempdir().expect("tempdir");
644 let ctx = ToolContext::new(tmp.path());
645 let err = GitShowTool
646 .execute(
647 json!({ "rev": "HEAD --output=/tmp/codewhale-git-show" }),
648 &ctx,
649 )
650 .await
651 .expect_err("whitespace rev payload should fail before git runs");
652 assert!(matches!(err, ToolError::InvalidInput { .. }));
653 assert!(err.to_string().contains("must not contain whitespace"));
654 }
655
656 #[tokio::test]
657 async fn git_blame_reports_author_for_range() {
658 if !git_available() {
659 return;
660 }
661
662 let tmp = tempdir().expect("tempdir");
663 init_git_repo(tmp.path());
664 let src = tmp.path().join("src");
665 fs::create_dir_all(&src).expect("mkdir");
666 let file = src.join("lib.rs");
667 fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write");
668 commit_all(tmp.path(), "first");
669 fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("write");
670 commit_all(tmp.path(), "second");
671
672 let ctx = ToolContext::new(tmp.path());
673 let result = GitBlameTool
674 .execute(
675 json!({
676 "path": "src/lib.rs",
677 "start_line": 1,
678 "max_lines": 1
679 }),
680 &ctx,
681 )
682 .await
683 .expect("execute");
684 assert!(result.success);
685 assert!(result.content.contains("Test User"));
686 }
687
688 #[tokio::test]
689 async fn git_blame_rejects_option_like_revision() {
690 let tmp = tempdir().expect("tempdir");
691 let file = tmp.path().join("file.txt");
692 fs::write(&file, "one\n").expect("write");
693 let ctx = ToolContext::new(tmp.path());
694 let err = GitBlameTool
695 .execute(
696 json!({ "path": "file.txt", "rev": "--contents=/tmp/x" }),
697 &ctx,
698 )
699 .await
700 .expect_err("option-shaped rev should fail before git runs");
701 assert!(matches!(err, ToolError::InvalidInput { .. }));
702 assert!(err.to_string().contains("must not start with '-'"));
703 }
704
705 #[tokio::test]
706 async fn git_blame_rejects_whitespace_revision_payload() {
707 let tmp = tempdir().expect("tempdir");
708 let file = tmp.path().join("file.txt");
709 fs::write(&file, "one\n").expect("write");
710 let ctx = ToolContext::new(tmp.path());
711 let err = GitBlameTool
712 .execute(
713 json!({ "path": "file.txt", "rev": "HEAD --contents=/tmp/codewhale-git-blame" }),
714 &ctx,
715 )
716 .await
717 .expect_err("whitespace rev payload should fail before git runs");
718 assert!(matches!(err, ToolError::InvalidInput { .. }));
719 assert!(err.to_string().contains("must not contain whitespace"));
720 }
721
722 #[tokio::test]
723 async fn git_blame_errors_for_non_file_path() {
724 if !git_available() {
725 return;
726 }
727
728 let tmp = tempdir().expect("tempdir");
729 init_git_repo(tmp.path());
730
731 let ctx = ToolContext::new(tmp.path());
732 let result = GitBlameTool
733 .execute(json!({ "path": "." }), &ctx)
734 .await
735 .expect_err("directory path should fail");
736 assert!(matches!(result, ToolError::InvalidInput { .. }));
737 }
738 }
739
739 lines RUST