grep_tool.rs

   1use crate::{AgentTool, ToolCallEventStream};
   2use agent_client_protocol as acp;
   3use anyhow::{Result, anyhow};
   4use futures::{FutureExt as _, StreamExt};
   5use gpui::{App, Entity, SharedString, Task};
   6use language::{OffsetRangeExt, ParseStatus, Point};
   7use project::{
   8    Project, SearchResults, WorktreeSettings,
   9    search::{SearchQuery, SearchResult},
  10};
  11use schemars::JsonSchema;
  12use serde::{Deserialize, Serialize};
  13use settings::Settings;
  14use std::{cmp, fmt::Write, sync::Arc};
  15use util::RangeExt;
  16use util::markdown::MarkdownInlineCode;
  17use util::paths::PathMatcher;
  18
  19/// Searches the contents of files in the project with a regular expression
  20///
  21/// - Prefer this tool to path search when searching for symbols in the project, because you won't need to guess what path it's in.
  22/// - Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
  23/// - Pass an `include_pattern` if you know how to narrow your search on the files system
  24/// - Never use this tool to search for paths. Only search file contents with this tool.
  25/// - Use this tool when you need to find files containing specific patterns
  26/// - Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages.
  27/// - DO NOT use HTML entities solely to escape characters in the tool parameters.
  28#[derive(Debug, Serialize, Deserialize, JsonSchema)]
  29pub struct GrepToolInput {
  30    /// A regex pattern to search for in the entire project. Note that the regex will be parsed by the Rust `regex` crate.
  31    ///
  32    /// Do NOT specify a path here! This will only be matched against the code **content**.
  33    pub regex: String,
  34    /// A glob pattern for the paths of files to include in the search.
  35    /// Supports standard glob patterns like "**/*.rs" or "frontend/src/**/*.ts".
  36    /// If omitted, all files in the project will be searched.
  37    ///
  38    /// The glob pattern is matched against the full path including the project root directory.
  39    ///
  40    /// <example>
  41    /// If the project has the following root directories:
  42    ///
  43    /// - /a/b/backend
  44    /// - /c/d/frontend
  45    ///
  46    /// Use "backend/**/*.rs" to search only Rust files in the backend root directory.
  47    /// Use "frontend/src/**/*.ts" to search TypeScript files only in the frontend root directory (sub-directory "src").
  48    /// Use "**/*.rs" to search Rust files across all root directories.
  49    /// </example>
  50    pub include_pattern: Option<String>,
  51    /// Optional starting position for paginated results (0-based).
  52    /// When not provided, starts from the beginning.
  53    #[serde(default)]
  54    pub offset: u32,
  55    /// Whether the regex is case-sensitive. Defaults to false (case-insensitive).
  56    #[serde(default)]
  57    pub case_sensitive: bool,
  58}
  59
  60impl GrepToolInput {
  61    /// Which page of search results this is.
  62    pub fn page(&self) -> u32 {
  63        1 + (self.offset / RESULTS_PER_PAGE)
  64    }
  65}
  66
  67const RESULTS_PER_PAGE: u32 = 20;
  68
  69pub struct GrepTool {
  70    project: Entity<Project>,
  71}
  72
  73impl GrepTool {
  74    pub fn new(project: Entity<Project>) -> Self {
  75        Self { project }
  76    }
  77}
  78
  79impl AgentTool for GrepTool {
  80    type Input = GrepToolInput;
  81    type Output = String;
  82
  83    fn name() -> &'static str {
  84        "grep"
  85    }
  86
  87    fn kind() -> acp::ToolKind {
  88        acp::ToolKind::Search
  89    }
  90
  91    fn initial_title(
  92        &self,
  93        input: Result<Self::Input, serde_json::Value>,
  94        _cx: &mut App,
  95    ) -> SharedString {
  96        match input {
  97            Ok(input) => {
  98                let page = input.page();
  99                let regex_str = MarkdownInlineCode(&input.regex);
 100                let case_info = if input.case_sensitive {
 101                    " (case-sensitive)"
 102                } else {
 103                    ""
 104                };
 105
 106                if page > 1 {
 107                    format!("Get page {page} of search results for regex {regex_str}{case_info}")
 108                } else {
 109                    format!("Search files for regex {regex_str}{case_info}")
 110                }
 111            }
 112            Err(_) => "Search with regex".into(),
 113        }
 114        .into()
 115    }
 116
 117    fn run(
 118        self: Arc<Self>,
 119        input: Self::Input,
 120        event_stream: ToolCallEventStream,
 121        cx: &mut App,
 122    ) -> Task<Result<Self::Output>> {
 123        const CONTEXT_LINES: u32 = 2;
 124        const MAX_ANCESTOR_LINES: u32 = 10;
 125
 126        let path_style = self.project.read(cx).path_style(cx);
 127
 128        let include_matcher = match PathMatcher::new(
 129            input
 130                .include_pattern
 131                .as_ref()
 132                .into_iter()
 133                .collect::<Vec<_>>(),
 134            path_style,
 135        ) {
 136            Ok(matcher) => matcher,
 137            Err(error) => {
 138                return Task::ready(Err(anyhow!("invalid include glob pattern: {error}")));
 139            }
 140        };
 141
 142        // Exclude global file_scan_exclusions and private_files settings
 143        let exclude_matcher = {
 144            let global_settings = WorktreeSettings::get_global(cx);
 145            let exclude_patterns = global_settings
 146                .file_scan_exclusions
 147                .sources()
 148                .chain(global_settings.private_files.sources());
 149
 150            match PathMatcher::new(exclude_patterns, path_style) {
 151                Ok(matcher) => matcher,
 152                Err(error) => {
 153                    return Task::ready(Err(anyhow!("invalid exclude pattern: {error}")));
 154                }
 155            }
 156        };
 157
 158        let query = match SearchQuery::regex(
 159            &input.regex,
 160            false,
 161            input.case_sensitive,
 162            false,
 163            false,
 164            include_matcher,
 165            exclude_matcher,
 166            true, // Always match file include pattern against *full project paths* that start with a project root.
 167            None,
 168        ) {
 169            Ok(query) => query,
 170            Err(error) => return Task::ready(Err(error)),
 171        };
 172
 173        let results = self
 174            .project
 175            .update(cx, |project, cx| project.search(query, cx));
 176
 177        let project = self.project.downgrade();
 178        cx.spawn(async move |cx|  {
 179            // Keep the search alive for the duration of result iteration. Dropping this task is the
 180            // cancellation mechanism; we intentionally do not detach it.
 181            let SearchResults {rx, _task_handle}  = results;
 182            futures::pin_mut!(rx);
 183
 184            let mut output = String::new();
 185            let mut skips_remaining = input.offset;
 186            let mut matches_found = 0;
 187            let mut has_more_matches = false;
 188
 189            'outer: loop {
 190                let search_result = futures::select! {
 191                    result = rx.next().fuse() => result,
 192                    _ = event_stream.cancelled_by_user().fuse() => {
 193                        anyhow::bail!("Search cancelled by user");
 194                    }
 195                };
 196                let Some(SearchResult::Buffer { buffer, ranges }) = search_result else {
 197                    break;
 198                };
 199                if ranges.is_empty() {
 200                    continue;
 201                }
 202
 203                let (Some(path), mut parse_status) = buffer.read_with(cx, |buffer, cx| {
 204                    (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status())
 205                }) else {
 206                    continue;
 207                };
 208
 209                // Check if this file should be excluded based on its worktree settings
 210                if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| {
 211                    project.find_project_path(&path, cx)
 212                }) {
 213                    if cx.update(|cx| {
 214                        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
 215                        worktree_settings.is_path_excluded(&project_path.path)
 216                            || worktree_settings.is_path_private(&project_path.path)
 217                    }) {
 218                        continue;
 219                    }
 220                }
 221
 222                while *parse_status.borrow() != ParseStatus::Idle {
 223                    parse_status.changed().await?;
 224                }
 225
 226                let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
 227
 228                let mut ranges = ranges
 229                    .into_iter()
 230                    .map(|range| {
 231                        let matched = range.to_point(&snapshot);
 232                        let matched_end_line_len = snapshot.line_len(matched.end.row);
 233                        let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len);
 234                        let symbols = snapshot.symbols_containing(matched.start, None);
 235
 236                        if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) {
 237                            let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot);
 238                            let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES);
 239                            let end_col = snapshot.line_len(end_row);
 240                            let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col);
 241
 242                            if capped_ancestor_range.contains_inclusive(&full_lines) {
 243                                return (capped_ancestor_range, Some(full_ancestor_range), symbols)
 244                            }
 245                        }
 246
 247                        let mut matched = matched;
 248                        matched.start.column = 0;
 249                        matched.start.row =
 250                            matched.start.row.saturating_sub(CONTEXT_LINES);
 251                        matched.end.row = cmp::min(
 252                            snapshot.max_point().row,
 253                            matched.end.row + CONTEXT_LINES,
 254                        );
 255                        matched.end.column = snapshot.line_len(matched.end.row);
 256
 257                        (matched, None, symbols)
 258                    })
 259                    .peekable();
 260
 261                let mut file_header_written = false;
 262
 263                while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){
 264                    if skips_remaining > 0 {
 265                        skips_remaining -= 1;
 266                        continue;
 267                    }
 268
 269                    // We'd already found a full page of matches, and we just found one more.
 270                    if matches_found >= RESULTS_PER_PAGE {
 271                        has_more_matches = true;
 272                        break 'outer;
 273                    }
 274
 275                    while let Some((next_range, _, _)) = ranges.peek() {
 276                        if range.end.row >= next_range.start.row {
 277                            range.end = next_range.end;
 278                            ranges.next();
 279                        } else {
 280                            break;
 281                        }
 282                    }
 283
 284                    if !file_header_written {
 285                        writeln!(output, "\n## Matches in {}", path.display())?;
 286                        file_header_written = true;
 287                    }
 288
 289                    let end_row = range.end.row;
 290                    output.push_str("\n### ");
 291
 292                    for symbol in parent_symbols {
 293                        write!(output, "{} › ", symbol.text)?;
 294                    }
 295
 296                    if range.start.row == end_row {
 297                        writeln!(output, "L{}", range.start.row + 1)?;
 298                    } else {
 299                        writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?;
 300                    }
 301
 302                    output.push_str("```\n");
 303                    output.extend(snapshot.text_for_range(range));
 304                    output.push_str("\n```\n");
 305
 306                    if let Some(ancestor_range) = ancestor_range
 307                        && end_row < ancestor_range.end.row {
 308                            let remaining_lines = ancestor_range.end.row - end_row;
 309                            writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?;
 310                        }
 311
 312                    matches_found += 1;
 313                }
 314            }
 315
 316            if matches_found == 0 {
 317                Ok("No matches found".into())
 318            } else if has_more_matches {
 319                Ok(format!(
 320                    "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}",
 321                    input.offset + 1,
 322                    input.offset + matches_found,
 323                    input.offset + RESULTS_PER_PAGE,
 324                ))
 325            } else {
 326                Ok(format!("Found {matches_found} matches:\n{output}"))
 327            }
 328        })
 329    }
 330}
 331
 332#[cfg(test)]
 333mod tests {
 334    use crate::ToolCallEventStream;
 335
 336    use super::*;
 337    use gpui::{TestAppContext, UpdateGlobal};
 338    use project::{FakeFs, Project};
 339    use serde_json::json;
 340    use settings::SettingsStore;
 341    use unindent::Unindent;
 342    use util::path;
 343
 344    #[gpui::test]
 345    async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) {
 346        init_test(cx);
 347        cx.executor().allow_parking();
 348
 349        let fs = FakeFs::new(cx.executor());
 350        fs.insert_tree(
 351            path!("/root"),
 352            serde_json::json!({
 353                "src": {
 354                    "main.rs": "fn main() {\n    println!(\"Hello, world!\");\n}",
 355                    "utils": {
 356                        "helper.rs": "fn helper() {\n    println!(\"I'm a helper!\");\n}",
 357                    },
 358                },
 359                "tests": {
 360                    "test_main.rs": "fn test_main() {\n    assert!(true);\n}",
 361                }
 362            }),
 363        )
 364        .await;
 365
 366        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 367
 368        // Test with include pattern for Rust files inside the root of the project
 369        let input = GrepToolInput {
 370            regex: "println".to_string(),
 371            include_pattern: Some("root/**/*.rs".to_string()),
 372            offset: 0,
 373            case_sensitive: false,
 374        };
 375
 376        let result = run_grep_tool(input, project.clone(), cx).await;
 377        assert!(result.contains("main.rs"), "Should find matches in main.rs");
 378        assert!(
 379            result.contains("helper.rs"),
 380            "Should find matches in helper.rs"
 381        );
 382        assert!(
 383            !result.contains("test_main.rs"),
 384            "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)"
 385        );
 386
 387        // Test with include pattern for src directory only
 388        let input = GrepToolInput {
 389            regex: "fn".to_string(),
 390            include_pattern: Some("root/**/src/**".to_string()),
 391            offset: 0,
 392            case_sensitive: false,
 393        };
 394
 395        let result = run_grep_tool(input, project.clone(), cx).await;
 396        assert!(
 397            result.contains("main.rs"),
 398            "Should find matches in src/main.rs"
 399        );
 400        assert!(
 401            result.contains("helper.rs"),
 402            "Should find matches in src/utils/helper.rs"
 403        );
 404        assert!(
 405            !result.contains("test_main.rs"),
 406            "Should not include test_main.rs as it's not in src directory"
 407        );
 408
 409        // Test with empty include pattern (should default to all files)
 410        let input = GrepToolInput {
 411            regex: "fn".to_string(),
 412            include_pattern: None,
 413            offset: 0,
 414            case_sensitive: false,
 415        };
 416
 417        let result = run_grep_tool(input, project.clone(), cx).await;
 418        assert!(result.contains("main.rs"), "Should find matches in main.rs");
 419        assert!(
 420            result.contains("helper.rs"),
 421            "Should find matches in helper.rs"
 422        );
 423        assert!(
 424            result.contains("test_main.rs"),
 425            "Should include test_main.rs"
 426        );
 427    }
 428
 429    #[gpui::test]
 430    async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) {
 431        init_test(cx);
 432        cx.executor().allow_parking();
 433
 434        let fs = FakeFs::new(cx.executor());
 435        fs.insert_tree(
 436            path!("/root"),
 437            serde_json::json!({
 438                "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
 439            }),
 440        )
 441        .await;
 442
 443        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 444
 445        // Test case-insensitive search (default)
 446        let input = GrepToolInput {
 447            regex: "uppercase".to_string(),
 448            include_pattern: Some("**/*.txt".to_string()),
 449            offset: 0,
 450            case_sensitive: false,
 451        };
 452
 453        let result = run_grep_tool(input, project.clone(), cx).await;
 454        assert!(
 455            result.contains("UPPERCASE"),
 456            "Case-insensitive search should match uppercase"
 457        );
 458
 459        // Test case-sensitive search
 460        let input = GrepToolInput {
 461            regex: "uppercase".to_string(),
 462            include_pattern: Some("**/*.txt".to_string()),
 463            offset: 0,
 464            case_sensitive: true,
 465        };
 466
 467        let result = run_grep_tool(input, project.clone(), cx).await;
 468        assert!(
 469            !result.contains("UPPERCASE"),
 470            "Case-sensitive search should not match uppercase"
 471        );
 472
 473        // Test case-sensitive search
 474        let input = GrepToolInput {
 475            regex: "LOWERCASE".to_string(),
 476            include_pattern: Some("**/*.txt".to_string()),
 477            offset: 0,
 478            case_sensitive: true,
 479        };
 480
 481        let result = run_grep_tool(input, project.clone(), cx).await;
 482
 483        assert!(
 484            !result.contains("lowercase"),
 485            "Case-sensitive search should match lowercase"
 486        );
 487
 488        // Test case-sensitive search for lowercase pattern
 489        let input = GrepToolInput {
 490            regex: "lowercase".to_string(),
 491            include_pattern: Some("**/*.txt".to_string()),
 492            offset: 0,
 493            case_sensitive: true,
 494        };
 495
 496        let result = run_grep_tool(input, project.clone(), cx).await;
 497        assert!(
 498            result.contains("lowercase"),
 499            "Case-sensitive search should match lowercase text"
 500        );
 501    }
 502
 503    /// Helper function to set up a syntax test environment
 504    async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity<Project> {
 505        use unindent::Unindent;
 506        init_test(cx);
 507        cx.executor().allow_parking();
 508
 509        let fs = FakeFs::new(cx.executor());
 510
 511        // Create test file with syntax structures
 512        fs.insert_tree(
 513            path!("/root"),
 514            serde_json::json!({
 515                "test_syntax.rs": r#"
 516                    fn top_level_function() {
 517                        println!("This is at the top level");
 518                    }
 519
 520                    mod feature_module {
 521                        pub mod nested_module {
 522                            pub fn nested_function(
 523                                first_arg: String,
 524                                second_arg: i32,
 525                            ) {
 526                                println!("Function in nested module");
 527                                println!("{first_arg}");
 528                                println!("{second_arg}");
 529                            }
 530                        }
 531                    }
 532
 533                    struct MyStruct {
 534                        field1: String,
 535                        field2: i32,
 536                    }
 537
 538                    impl MyStruct {
 539                        fn method_with_block() {
 540                            let condition = true;
 541                            if condition {
 542                                println!("Inside if block");
 543                            }
 544                        }
 545
 546                        fn long_function() {
 547                            println!("Line 1");
 548                            println!("Line 2");
 549                            println!("Line 3");
 550                            println!("Line 4");
 551                            println!("Line 5");
 552                            println!("Line 6");
 553                            println!("Line 7");
 554                            println!("Line 8");
 555                            println!("Line 9");
 556                            println!("Line 10");
 557                            println!("Line 11");
 558                            println!("Line 12");
 559                        }
 560                    }
 561
 562                    trait Processor {
 563                        fn process(&self, input: &str) -> String;
 564                    }
 565
 566                    impl Processor for MyStruct {
 567                        fn process(&self, input: &str) -> String {
 568                            format!("Processed: {}", input)
 569                        }
 570                    }
 571                "#.unindent().trim(),
 572            }),
 573        )
 574        .await;
 575
 576        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 577
 578        project.update(cx, |project, _cx| {
 579            project.languages().add(language::rust_lang())
 580        });
 581
 582        project
 583    }
 584
 585    #[gpui::test]
 586    async fn test_grep_top_level_function(cx: &mut TestAppContext) {
 587        let project = setup_syntax_test(cx).await;
 588
 589        // Test: Line at the top level of the file
 590        let input = GrepToolInput {
 591            regex: "This is at the top level".to_string(),
 592            include_pattern: Some("**/*.rs".to_string()),
 593            offset: 0,
 594            case_sensitive: false,
 595        };
 596
 597        let result = run_grep_tool(input, project.clone(), cx).await;
 598        let expected = r#"
 599            Found 1 matches:
 600
 601            ## Matches in root/test_syntax.rs
 602
 603            ### fn top_level_function › L1-3
 604            ```
 605            fn top_level_function() {
 606                println!("This is at the top level");
 607            }
 608            ```
 609            "#
 610        .unindent();
 611        assert_eq!(result, expected);
 612    }
 613
 614    #[gpui::test]
 615    async fn test_grep_function_body(cx: &mut TestAppContext) {
 616        let project = setup_syntax_test(cx).await;
 617
 618        // Test: Line inside a function body
 619        let input = GrepToolInput {
 620            regex: "Function in nested module".to_string(),
 621            include_pattern: Some("**/*.rs".to_string()),
 622            offset: 0,
 623            case_sensitive: false,
 624        };
 625
 626        let result = run_grep_tool(input, project.clone(), cx).await;
 627        let expected = r#"
 628            Found 1 matches:
 629
 630            ## Matches in root/test_syntax.rs
 631
 632            ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14
 633            ```
 634                    ) {
 635                        println!("Function in nested module");
 636                        println!("{first_arg}");
 637                        println!("{second_arg}");
 638                    }
 639            ```
 640            "#
 641        .unindent();
 642        assert_eq!(result, expected);
 643    }
 644
 645    #[gpui::test]
 646    async fn test_grep_function_args_and_body(cx: &mut TestAppContext) {
 647        let project = setup_syntax_test(cx).await;
 648
 649        // Test: Line with a function argument
 650        let input = GrepToolInput {
 651            regex: "second_arg".to_string(),
 652            include_pattern: Some("**/*.rs".to_string()),
 653            offset: 0,
 654            case_sensitive: false,
 655        };
 656
 657        let result = run_grep_tool(input, project.clone(), cx).await;
 658        let expected = r#"
 659            Found 1 matches:
 660
 661            ## Matches in root/test_syntax.rs
 662
 663            ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14
 664            ```
 665                    pub fn nested_function(
 666                        first_arg: String,
 667                        second_arg: i32,
 668                    ) {
 669                        println!("Function in nested module");
 670                        println!("{first_arg}");
 671                        println!("{second_arg}");
 672                    }
 673            ```
 674            "#
 675        .unindent();
 676        assert_eq!(result, expected);
 677    }
 678
 679    #[gpui::test]
 680    async fn test_grep_if_block(cx: &mut TestAppContext) {
 681        use unindent::Unindent;
 682        let project = setup_syntax_test(cx).await;
 683
 684        // Test: Line inside an if block
 685        let input = GrepToolInput {
 686            regex: "Inside if block".to_string(),
 687            include_pattern: Some("**/*.rs".to_string()),
 688            offset: 0,
 689            case_sensitive: false,
 690        };
 691
 692        let result = run_grep_tool(input, project.clone(), cx).await;
 693        let expected = r#"
 694            Found 1 matches:
 695
 696            ## Matches in root/test_syntax.rs
 697
 698            ### impl MyStruct › fn method_with_block › L26-28
 699            ```
 700                    if condition {
 701                        println!("Inside if block");
 702                    }
 703            ```
 704            "#
 705        .unindent();
 706        assert_eq!(result, expected);
 707    }
 708
 709    #[gpui::test]
 710    async fn test_grep_long_function_top(cx: &mut TestAppContext) {
 711        use unindent::Unindent;
 712        let project = setup_syntax_test(cx).await;
 713
 714        // Test: Line in the middle of a long function - should show message about remaining lines
 715        let input = GrepToolInput {
 716            regex: "Line 5".to_string(),
 717            include_pattern: Some("**/*.rs".to_string()),
 718            offset: 0,
 719            case_sensitive: false,
 720        };
 721
 722        let result = run_grep_tool(input, project.clone(), cx).await;
 723        let expected = r#"
 724            Found 1 matches:
 725
 726            ## Matches in root/test_syntax.rs
 727
 728            ### impl MyStruct › fn long_function › L31-41
 729            ```
 730                fn long_function() {
 731                    println!("Line 1");
 732                    println!("Line 2");
 733                    println!("Line 3");
 734                    println!("Line 4");
 735                    println!("Line 5");
 736                    println!("Line 6");
 737                    println!("Line 7");
 738                    println!("Line 8");
 739                    println!("Line 9");
 740                    println!("Line 10");
 741            ```
 742
 743            3 lines remaining in ancestor node. Read the file to see all.
 744            "#
 745        .unindent();
 746        assert_eq!(result, expected);
 747    }
 748
 749    #[gpui::test]
 750    async fn test_grep_long_function_bottom(cx: &mut TestAppContext) {
 751        use unindent::Unindent;
 752        let project = setup_syntax_test(cx).await;
 753
 754        // Test: Line in the long function
 755        let input = GrepToolInput {
 756            regex: "Line 12".to_string(),
 757            include_pattern: Some("**/*.rs".to_string()),
 758            offset: 0,
 759            case_sensitive: false,
 760        };
 761
 762        let result = run_grep_tool(input, project.clone(), cx).await;
 763        let expected = r#"
 764            Found 1 matches:
 765
 766            ## Matches in root/test_syntax.rs
 767
 768            ### impl MyStruct › fn long_function › L41-45
 769            ```
 770                    println!("Line 10");
 771                    println!("Line 11");
 772                    println!("Line 12");
 773                }
 774            }
 775            ```
 776            "#
 777        .unindent();
 778        assert_eq!(result, expected);
 779    }
 780
 781    async fn run_grep_tool(
 782        input: GrepToolInput,
 783        project: Entity<Project>,
 784        cx: &mut TestAppContext,
 785    ) -> String {
 786        let tool = Arc::new(GrepTool { project });
 787        let task = cx.update(|cx| tool.run(input, ToolCallEventStream::test().0, cx));
 788
 789        match task.await {
 790            Ok(result) => {
 791                if cfg!(windows) {
 792                    result.replace("root\\", "root/")
 793                } else {
 794                    result
 795                }
 796            }
 797            Err(e) => panic!("Failed to run grep tool: {}", e),
 798        }
 799    }
 800
 801    fn init_test(cx: &mut TestAppContext) {
 802        cx.update(|cx| {
 803            let settings_store = SettingsStore::test(cx);
 804            cx.set_global(settings_store);
 805        });
 806    }
 807
 808    #[gpui::test]
 809    async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
 810        init_test(cx);
 811
 812        let fs = FakeFs::new(cx.executor());
 813
 814        fs.insert_tree(
 815            path!("/"),
 816            json!({
 817                "project_root": {
 818                    "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
 819                    ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
 820                    ".secretdir": {
 821                        "config": "fn special_configuration() { /* excluded */ }"
 822                    },
 823                    ".mymetadata": "fn custom_metadata() { /* excluded */ }",
 824                    "subdir": {
 825                        "normal_file.rs": "fn normal_file_content() { /* Normal */ }",
 826                        "special.privatekey": "fn private_key_content() { /* private */ }",
 827                        "data.mysensitive": "fn sensitive_data() { /* private */ }"
 828                    }
 829                },
 830                "outside_project": {
 831                    "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }"
 832                }
 833            }),
 834        )
 835        .await;
 836
 837        cx.update(|cx| {
 838            use gpui::UpdateGlobal;
 839            use settings::SettingsStore;
 840            SettingsStore::update_global(cx, |store, cx| {
 841                store.update_user_settings(cx, |settings| {
 842                    settings.project.worktree.file_scan_exclusions = Some(vec![
 843                        "**/.secretdir".to_string(),
 844                        "**/.mymetadata".to_string(),
 845                    ]);
 846                    settings.project.worktree.private_files = Some(
 847                        vec![
 848                            "**/.mysecrets".to_string(),
 849                            "**/*.privatekey".to_string(),
 850                            "**/*.mysensitive".to_string(),
 851                        ]
 852                        .into(),
 853                    );
 854                });
 855            });
 856        });
 857
 858        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 859
 860        // Searching for files outside the project worktree should return no results
 861        let result = run_grep_tool(
 862            GrepToolInput {
 863                regex: "outside_function".to_string(),
 864                include_pattern: None,
 865                offset: 0,
 866                case_sensitive: false,
 867            },
 868            project.clone(),
 869            cx,
 870        )
 871        .await;
 872        let paths = extract_paths_from_results(&result);
 873        assert!(
 874            paths.is_empty(),
 875            "grep_tool should not find files outside the project worktree"
 876        );
 877
 878        // Searching within the project should succeed
 879        let result = run_grep_tool(
 880            GrepToolInput {
 881                regex: "main".to_string(),
 882                include_pattern: None,
 883                offset: 0,
 884                case_sensitive: false,
 885            },
 886            project.clone(),
 887            cx,
 888        )
 889        .await;
 890        let paths = extract_paths_from_results(&result);
 891        assert!(
 892            paths.iter().any(|p| p.contains("allowed_file.rs")),
 893            "grep_tool should be able to search files inside worktrees"
 894        );
 895
 896        // Searching files that match file_scan_exclusions should return no results
 897        let result = run_grep_tool(
 898            GrepToolInput {
 899                regex: "special_configuration".to_string(),
 900                include_pattern: None,
 901                offset: 0,
 902                case_sensitive: false,
 903            },
 904            project.clone(),
 905            cx,
 906        )
 907        .await;
 908        let paths = extract_paths_from_results(&result);
 909        assert!(
 910            paths.is_empty(),
 911            "grep_tool should not search files in .secretdir (file_scan_exclusions)"
 912        );
 913
 914        let result = run_grep_tool(
 915            GrepToolInput {
 916                regex: "custom_metadata".to_string(),
 917                include_pattern: None,
 918                offset: 0,
 919                case_sensitive: false,
 920            },
 921            project.clone(),
 922            cx,
 923        )
 924        .await;
 925        let paths = extract_paths_from_results(&result);
 926        assert!(
 927            paths.is_empty(),
 928            "grep_tool should not search .mymetadata files (file_scan_exclusions)"
 929        );
 930
 931        // Searching private files should return no results
 932        let result = run_grep_tool(
 933            GrepToolInput {
 934                regex: "SECRET_KEY".to_string(),
 935                include_pattern: None,
 936                offset: 0,
 937                case_sensitive: false,
 938            },
 939            project.clone(),
 940            cx,
 941        )
 942        .await;
 943        let paths = extract_paths_from_results(&result);
 944        assert!(
 945            paths.is_empty(),
 946            "grep_tool should not search .mysecrets (private_files)"
 947        );
 948
 949        let result = run_grep_tool(
 950            GrepToolInput {
 951                regex: "private_key_content".to_string(),
 952                include_pattern: None,
 953                offset: 0,
 954                case_sensitive: false,
 955            },
 956            project.clone(),
 957            cx,
 958        )
 959        .await;
 960        let paths = extract_paths_from_results(&result);
 961
 962        assert!(
 963            paths.is_empty(),
 964            "grep_tool should not search .privatekey files (private_files)"
 965        );
 966
 967        let result = run_grep_tool(
 968            GrepToolInput {
 969                regex: "sensitive_data".to_string(),
 970                include_pattern: None,
 971                offset: 0,
 972                case_sensitive: false,
 973            },
 974            project.clone(),
 975            cx,
 976        )
 977        .await;
 978        let paths = extract_paths_from_results(&result);
 979        assert!(
 980            paths.is_empty(),
 981            "grep_tool should not search .mysensitive files (private_files)"
 982        );
 983
 984        // Searching a normal file should still work, even with private_files configured
 985        let result = run_grep_tool(
 986            GrepToolInput {
 987                regex: "normal_file_content".to_string(),
 988                include_pattern: None,
 989                offset: 0,
 990                case_sensitive: false,
 991            },
 992            project.clone(),
 993            cx,
 994        )
 995        .await;
 996        let paths = extract_paths_from_results(&result);
 997        assert!(
 998            paths.iter().any(|p| p.contains("normal_file.rs")),
 999            "Should be able to search normal files"
1000        );
1001
1002        // Path traversal attempts with .. in include_pattern should not escape project
1003        let result = run_grep_tool(
1004            GrepToolInput {
1005                regex: "outside_function".to_string(),
1006                include_pattern: Some("../outside_project/**/*.rs".to_string()),
1007                offset: 0,
1008                case_sensitive: false,
1009            },
1010            project.clone(),
1011            cx,
1012        )
1013        .await;
1014        let paths = extract_paths_from_results(&result);
1015        assert!(
1016            paths.is_empty(),
1017            "grep_tool should not allow escaping project boundaries with relative paths"
1018        );
1019    }
1020
1021    #[gpui::test]
1022    async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
1023        init_test(cx);
1024
1025        let fs = FakeFs::new(cx.executor());
1026
1027        // Create first worktree with its own private files
1028        fs.insert_tree(
1029            path!("/worktree1"),
1030            json!({
1031                ".zed": {
1032                    "settings.json": r#"{
1033                        "file_scan_exclusions": ["**/fixture.*"],
1034                        "private_files": ["**/secret.rs"]
1035                    }"#
1036                },
1037                "src": {
1038                    "main.rs": "fn main() { let secret_key = \"hidden\"; }",
1039                    "secret.rs": "const API_KEY: &str = \"secret_value\";",
1040                    "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
1041                },
1042                "tests": {
1043                    "test.rs": "fn test_secret() { assert!(true); }",
1044                    "fixture.sql": "SELECT * FROM secret_table;"
1045                }
1046            }),
1047        )
1048        .await;
1049
1050        // Create second worktree with different private files
1051        fs.insert_tree(
1052            path!("/worktree2"),
1053            json!({
1054                ".zed": {
1055                    "settings.json": r#"{
1056                        "file_scan_exclusions": ["**/internal.*"],
1057                        "private_files": ["**/private.js", "**/data.json"]
1058                    }"#
1059                },
1060                "lib": {
1061                    "public.js": "export function getSecret() { return 'public'; }",
1062                    "private.js": "const SECRET_KEY = \"private_value\";",
1063                    "data.json": "{\"secret_data\": \"hidden\"}"
1064                },
1065                "docs": {
1066                    "README.md": "# Documentation with secret info",
1067                    "internal.md": "Internal secret documentation"
1068                }
1069            }),
1070        )
1071        .await;
1072
1073        // Set global settings
1074        cx.update(|cx| {
1075            SettingsStore::update_global(cx, |store, cx| {
1076                store.update_user_settings(cx, |settings| {
1077                    settings.project.worktree.file_scan_exclusions =
1078                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1079                    settings.project.worktree.private_files =
1080                        Some(vec!["**/.env".to_string()].into());
1081                });
1082            });
1083        });
1084
1085        let project = Project::test(
1086            fs.clone(),
1087            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1088            cx,
1089        )
1090        .await;
1091
1092        // Wait for worktrees to be fully scanned
1093        cx.executor().run_until_parked();
1094
1095        // Search for "secret" - should exclude files based on worktree-specific settings
1096        let result = run_grep_tool(
1097            GrepToolInput {
1098                regex: "secret".to_string(),
1099                include_pattern: None,
1100                offset: 0,
1101                case_sensitive: false,
1102            },
1103            project.clone(),
1104            cx,
1105        )
1106        .await;
1107        let paths = extract_paths_from_results(&result);
1108
1109        // Should find matches in non-private files
1110        assert!(
1111            paths.iter().any(|p| p.contains("main.rs")),
1112            "Should find 'secret' in worktree1/src/main.rs"
1113        );
1114        assert!(
1115            paths.iter().any(|p| p.contains("test.rs")),
1116            "Should find 'secret' in worktree1/tests/test.rs"
1117        );
1118        assert!(
1119            paths.iter().any(|p| p.contains("public.js")),
1120            "Should find 'secret' in worktree2/lib/public.js"
1121        );
1122        assert!(
1123            paths.iter().any(|p| p.contains("README.md")),
1124            "Should find 'secret' in worktree2/docs/README.md"
1125        );
1126
1127        // Should NOT find matches in private/excluded files based on worktree settings
1128        assert!(
1129            !paths.iter().any(|p| p.contains("secret.rs")),
1130            "Should not search in worktree1/src/secret.rs (local private_files)"
1131        );
1132        assert!(
1133            !paths.iter().any(|p| p.contains("fixture.sql")),
1134            "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
1135        );
1136        assert!(
1137            !paths.iter().any(|p| p.contains("private.js")),
1138            "Should not search in worktree2/lib/private.js (local private_files)"
1139        );
1140        assert!(
1141            !paths.iter().any(|p| p.contains("data.json")),
1142            "Should not search in worktree2/lib/data.json (local private_files)"
1143        );
1144        assert!(
1145            !paths.iter().any(|p| p.contains("internal.md")),
1146            "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
1147        );
1148
1149        // Test with `include_pattern` specific to one worktree
1150        let result = run_grep_tool(
1151            GrepToolInput {
1152                regex: "secret".to_string(),
1153                include_pattern: Some("worktree1/**/*.rs".to_string()),
1154                offset: 0,
1155                case_sensitive: false,
1156            },
1157            project.clone(),
1158            cx,
1159        )
1160        .await;
1161
1162        let paths = extract_paths_from_results(&result);
1163
1164        // Should only find matches in worktree1 *.rs files (excluding private ones)
1165        assert!(
1166            paths.iter().any(|p| p.contains("main.rs")),
1167            "Should find match in worktree1/src/main.rs"
1168        );
1169        assert!(
1170            paths.iter().any(|p| p.contains("test.rs")),
1171            "Should find match in worktree1/tests/test.rs"
1172        );
1173        assert!(
1174            !paths.iter().any(|p| p.contains("secret.rs")),
1175            "Should not find match in excluded worktree1/src/secret.rs"
1176        );
1177        assert!(
1178            paths.iter().all(|p| !p.contains("worktree2")),
1179            "Should not find any matches in worktree2"
1180        );
1181    }
1182
1183    // Helper function to extract file paths from grep results
1184    fn extract_paths_from_results(results: &str) -> Vec<String> {
1185        results
1186            .lines()
1187            .filter(|line| line.starts_with("## Matches in "))
1188            .map(|line| {
1189                line.strip_prefix("## Matches in ")
1190                    .unwrap()
1191                    .trim()
1192                    .to_string()
1193            })
1194            .collect()
1195    }
1196}