use std::path::PathBuf;
use anyhow::Result;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
pub struct CdTool;
#[derive(Deserialize)]
struct CdArgs {
path: String,
}
#[async_trait]
impl Tool for CdTool {
fn definition(&self) -> ToolDef {
ToolDef {
name: "change_dir",
description: "Change the working directory. All subsequent file operations and bash commands will execute in the new directory.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The directory path to change to. Can be absolute or relative to current working directory."
}
},
"required": ["path"]
}),
}
}
fn approval(&self, _args: &str) -> ApprovalRequirement {
ApprovalRequirement::AutoApprove
}
fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
let parsed = match serde_json::from_str::<CdArgs>(args) {
Ok(parsed) => parsed,
Err(_) => return self.approval(args),
};
let working_dir = match ctx.working_dir.try_read() {
Ok(wd) => wd.clone(),
Err(_) => return self.approval(args),
};
match super::approval_for_path(
&parsed.path,
&working_dir,
super::ExternalPathAction::Enumerate,
) {
Ok(approval) => approval,
Err(_) => self.approval(args),
}
}
async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
let parsed: CdArgs = serde_json::from_str(args)?;
let path = parsed.path.as_str();
let current_wd = ctx.working_dir.read().await.clone();
let target = if path == "~" {
super::real_home_dir().unwrap_or_else(|| PathBuf::from(path))
} else if let Some(rest) = path.strip_prefix("~/") {
super::real_home_dir()
.map(|h| h.join(rest))
.unwrap_or_else(|| PathBuf::from(path))
} else if path.starts_with('/') {
PathBuf::from(path)
} else {
current_wd.join(path)
};
if target.is_dir() {
let resolved = std::fs::canonicalize(&target).unwrap_or(target);
let mut wd = ctx.working_dir.write().await;
*wd = resolved.clone();
Ok(ToolResult {
call_id: String::new(),
output: format!("Changed working directory to {}", resolved.display()),
success: true,
})
} else if target.exists() {
Ok(ToolResult {
call_id: String::new(),
output: format!("Not a directory: {}", target.display()),
success: false,
})
} else {
Ok(ToolResult {
call_id: String::new(),
output: format!("Path does not exist: {}", target.display()),
success: false,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn tilde_prefixed_relative_dir_is_not_expanded_to_home() {
let workspace = TempDir::new().unwrap();
let target = workspace.path().join("~cache");
std::fs::create_dir(&target).unwrap();
let ctx = ToolContext::new(workspace.path().to_path_buf());
let tool = CdTool;
let result = tool.execute(r#"{"path":"~cache"}"#, &ctx).await.unwrap();
assert!(result.success, "unexpected output: {}", result.output);
assert_eq!(
*ctx.working_dir.read().await,
std::fs::canonicalize(target).unwrap()
);
}
#[tokio::test]
async fn slash_after_tilde_still_expands_to_home() {
let Some(home) = super::super::real_home_dir() else {
return;
};
let ctx = ToolContext::new(PathBuf::from("/tmp"));
let tool = CdTool;
let result = tool.execute(r#"{"path":"~/"}"#, &ctx).await.unwrap();
assert!(result.success, "unexpected output: {}", result.output);
assert_eq!(
*ctx.working_dir.read().await,
std::fs::canonicalize(home).unwrap()
);
}
}