open_tool.rs

  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}