1use std::path::PathBuf;
2
3use anyhow::Result;
4use async_trait::async_trait;
5use serde::Deserialize;
6use serde_json::json;
7
8use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
9
10pub struct CdTool;
11
12#[derive(Deserialize)]
13struct CdArgs {
14 path: String,
15}
16
17#[async_trait]
18impl Tool for CdTool {
19 fn definition(&self) -> ToolDef {
20 ToolDef {
21 name: "change_dir",
22 description: "Change the working directory. All subsequent file operations and bash commands will execute in the new directory.".to_string(),
23 parameters: json!({
24 "type": "object",
25 "properties": {
26 "path": {
27 "type": "string",
28 "description": "The directory path to change to. Can be absolute or relative to current working directory."
29 }
30 },
31 "required": ["path"]
32 }),
33 }
34 }
35
36 fn approval(&self, _args: &str) -> ApprovalRequirement {
37 ApprovalRequirement::AutoApprove
38 }
39
40 fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
41 let parsed = match serde_json::from_str::<CdArgs>(args) {
42 Ok(parsed) => parsed,
43 Err(_) => return self.approval(args),
44 };
45 let working_dir = match ctx.working_dir.try_read() {
46 Ok(wd) => wd.clone(),
47 Err(_) => return self.approval(args),
48 };
49 match super::approval_for_path(
50 &parsed.path,
51 &working_dir,
52 super::ExternalPathAction::Enumerate,
53 ) {
54 Ok(approval) => approval,
55 Err(_) => self.approval(args),
56 }
57 }
58
59 async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
60 let parsed: CdArgs = serde_json::from_str(args)?;
61 let path = parsed.path.as_str();
62
63 let current_wd = ctx.working_dir.read().await.clone();
65 let target = if path == "~" {
66 super::real_home_dir().unwrap_or_else(|| PathBuf::from(path))
67 } else if let Some(rest) = path.strip_prefix("~/") {
68 super::real_home_dir()
69 .map(|h| h.join(rest))
70 .unwrap_or_else(|| PathBuf::from(path))
71 } else if path.starts_with('/') {
72 PathBuf::from(path)
73 } else {
74 current_wd.join(path)
75 };
76
77 if target.is_dir() {
79 let resolved = std::fs::canonicalize(&target).unwrap_or(target);
80 let mut wd = ctx.working_dir.write().await;
82 *wd = resolved.clone();
83 Ok(ToolResult {
84 call_id: String::new(),
85 output: format!("Changed working directory to {}", resolved.display()),
86 success: true,
87 })
88 } else if target.exists() {
89 Ok(ToolResult {
90 call_id: String::new(),
91 output: format!("Not a directory: {}", target.display()),
92 success: false,
93 })
94 } else {
95 Ok(ToolResult {
96 call_id: String::new(),
97 output: format!("Path does not exist: {}", target.display()),
98 success: false,
99 })
100 }
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use tempfile::TempDir;
108
109 #[tokio::test]
110 async fn tilde_prefixed_relative_dir_is_not_expanded_to_home() {
111 let workspace = TempDir::new().unwrap();
112 let target = workspace.path().join("~cache");
113 std::fs::create_dir(&target).unwrap();
114
115 let ctx = ToolContext::new(workspace.path().to_path_buf());
116 let tool = CdTool;
117
118 let result = tool.execute(r#"{"path":"~cache"}"#, &ctx).await.unwrap();
119 assert!(result.success, "unexpected output: {}", result.output);
120 assert_eq!(
121 *ctx.working_dir.read().await,
122 std::fs::canonicalize(target).unwrap()
123 );
124 }
125
126 #[tokio::test]
127 async fn slash_after_tilde_still_expands_to_home() {
128 let Some(home) = super::super::real_home_dir() else {
129 return;
130 };
131
132 let ctx = ToolContext::new(PathBuf::from("/tmp"));
133 let tool = CdTool;
134
135 let result = tool.execute(r#"{"path":"~/"}"#, &ctx).await.unwrap();
136 assert!(result.success, "unexpected output: {}", result.output);
137 assert_eq!(
138 *ctx.working_dir.read().await,
139 std::fs::canonicalize(home).unwrap()
140 );
141 }
142}