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