1use crate::schema::json_schema_for;
2use action_log::ActionLog;
3use anyhow::{Context as _, Result, anyhow};
4use assistant_tool::{Tool, ToolResult};
5use gpui::{AnyWindowHandle, App, AppContext, Entity, Task};
6use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
7use project::Project;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::{path::PathBuf, sync::Arc};
11use ui::IconName;
12use util::markdown::MarkdownEscaped;
13
14#[derive(Debug, Serialize, Deserialize, JsonSchema)]
15pub struct OpenToolInput {
16 /// The path or URL to open with the default application.
17 path_or_url: String,
18}
19
20pub struct OpenTool;
21
22impl Tool for OpenTool {
23 fn name(&self) -> String {
24 "open".to_string()
25 }
26
27 fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
28 true
29 }
30 fn may_perform_edits(&self) -> bool {
31 false
32 }
33 fn description(&self) -> String {
34 include_str!("./open_tool/description.md").to_string()
35 }
36
37 fn icon(&self) -> IconName {
38 IconName::ArrowUpRight
39 }
40
41 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
42 json_schema_for::<OpenToolInput>(format)
43 }
44
45 fn ui_text(&self, input: &serde_json::Value) -> String {
46 match serde_json::from_value::<OpenToolInput>(input.clone()) {
47 Ok(input) => format!("Open `{}`", MarkdownEscaped(&input.path_or_url)),
48 Err(_) => "Open file or URL".to_string(),
49 }
50 }
51
52 fn run(
53 self: Arc<Self>,
54 input: serde_json::Value,
55 _request: Arc<LanguageModelRequest>,
56 project: Entity<Project>,
57 _action_log: Entity<ActionLog>,
58 _model: Arc<dyn LanguageModel>,
59 _window: Option<AnyWindowHandle>,
60 cx: &mut App,
61 ) -> ToolResult {
62 let input: OpenToolInput = match serde_json::from_value(input) {
63 Ok(input) => input,
64 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
65 };
66
67 // If path_or_url turns out to be a path in the project, make it absolute.
68 let abs_path = to_absolute_path(&input.path_or_url, project, cx);
69
70 cx.background_spawn(async move {
71 match abs_path {
72 Some(path) => open::that(path),
73 None => open::that(&input.path_or_url),
74 }
75 .context("Failed to open URL or file path")?;
76
77 Ok(format!("Successfully opened {}", input.path_or_url).into())
78 })
79 .into()
80 }
81}
82
83fn to_absolute_path(
84 potential_path: &str,
85 project: Entity<Project>,
86 cx: &mut App,
87) -> Option<PathBuf> {
88 let project = project.read(cx);
89 project
90 .find_project_path(PathBuf::from(potential_path), cx)
91 .and_then(|project_path| project.absolute_path(&project_path, cx))
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use gpui::TestAppContext;
98 use project::{FakeFs, Project};
99 use settings::SettingsStore;
100 use std::path::Path;
101 use tempfile::TempDir;
102
103 #[gpui::test]
104 async fn test_to_absolute_path(cx: &mut TestAppContext) {
105 init_test(cx);
106 let temp_dir = TempDir::new().expect("Failed to create temp directory");
107 let temp_path = temp_dir.path().to_string_lossy().into_owned();
108
109 let fs = FakeFs::new(cx.executor());
110 fs.insert_tree(
111 &temp_path,
112 serde_json::json!({
113 "src": {
114 "main.rs": "fn main() {}",
115 "lib.rs": "pub fn lib_fn() {}"
116 },
117 "docs": {
118 "readme.md": "# Project Documentation"
119 }
120 }),
121 )
122 .await;
123
124 // Use the temp_path as the root directory, not just its filename
125 let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
126
127 // Test cases where the function should return Some
128 cx.update(|cx| {
129 // Project-relative paths should return Some
130 // Create paths using the last segment of the temp path to simulate a project-relative path
131 let root_dir_name = Path::new(&temp_path)
132 .file_name()
133 .unwrap_or_else(|| std::ffi::OsStr::new("temp"))
134 .to_string_lossy();
135
136 assert!(
137 to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
138 .is_some(),
139 "Failed to resolve main.rs path"
140 );
141
142 assert!(
143 to_absolute_path(
144 &format!("{root_dir_name}/docs/readme.md",),
145 project.clone(),
146 cx,
147 )
148 .is_some(),
149 "Failed to resolve readme.md path"
150 );
151
152 // External URL should return None
153 let result = to_absolute_path("https://example.com", project.clone(), cx);
154 assert_eq!(result, None, "External URLs should return None");
155
156 // Path outside project
157 let result = to_absolute_path("../invalid/path", project.clone(), cx);
158 assert_eq!(result, None, "Paths outside the project should return None");
159 });
160 }
161
162 fn init_test(cx: &mut TestAppContext) {
163 cx.update(|cx| {
164 let settings_store = SettingsStore::test(cx);
165 cx.set_global(settings_store);
166 language::init(cx);
167 Project::init_settings(cx);
168 });
169 }
170}