Pull out plain rules file loading code into a new `agent_rules` crate (#28383)

Michael Sloan created

Also renames for rules file templated into the system prompt

Release Notes:

- N/A

Change summary

Cargo.lock                                 | 14 +++
Cargo.toml                                 |  2 
assets/prompts/assistant_system_prompt.hbs |  2 
crates/agent/Cargo.toml                    |  1 
crates/agent/src/active_thread.rs          |  2 
crates/agent/src/thread.rs                 | 86 +++++++----------------
crates/agent_rules/Cargo.toml              | 24 ++++++
crates/agent_rules/LICENSE-GPL             |  1 
crates/agent_rules/src/agent_rules.rs      | 51 ++++++++++++++
crates/prompt_store/src/prompts.rs         |  6 
10 files changed, 125 insertions(+), 64 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -52,6 +52,7 @@ dependencies = [
 name = "agent"
 version = "0.1.0"
 dependencies = [
+ "agent_rules",
  "anyhow",
  "assistant_context_editor",
  "assistant_settings",
@@ -161,6 +162,19 @@ dependencies = [
  "workspace-hack",
 ]
 
+[[package]]
+name = "agent_rules"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "fs",
+ "gpui",
+ "indoc",
+ "prompt_store",
+ "util",
+ "worktree",
+]
+
 [[package]]
 name = "ahash"
 version = "0.7.8"

Cargo.toml 🔗

@@ -3,6 +3,7 @@ resolver = "2"
 members = [
     "crates/activity_indicator",
     "crates/agent",
+    "crates/agent_rules",
     "crates/anthropic",
     "crates/askpass",
     "crates/assets",
@@ -209,6 +210,7 @@ edition = "2024"
 
 activity_indicator = { path = "crates/activity_indicator" }
 agent = { path = "crates/agent" }
+agent_rules = { path = "crates/agent_rules" }
 ai = { path = "crates/ai" }
 anthropic = { path = "crates/anthropic" }
 askpass = { path = "crates/askpass" }

assets/prompts/assistant_system_prompt.hbs 🔗

@@ -155,7 +155,7 @@ There are rules that apply to these root directories:
 {{#each worktrees}}
 {{#if rules_file}}
 
-`{{root_name}}/{{rules_file.rel_path}}`:
+`{{root_name}}/{{rules_file.path_in_worktree}}`:
 
 ``````
 {{{rules_file.text}}}

crates/agent/Cargo.toml 🔗

@@ -19,6 +19,7 @@ test-support = [
 ]
 
 [dependencies]
+agent_rules.workspace = true
 anyhow.workspace = true
 assistant_context_editor.workspace = true
 assistant_settings.workspace = true

crates/agent/src/active_thread.rs 🔗

@@ -2581,7 +2581,7 @@ impl ActiveThread {
         let label_text = match rules_files.as_slice() {
             &[] => return div().into_any(),
             &[rules_file] => {
-                format!("Using {:?} file", rules_file.rel_path)
+                format!("Using {:?} file", rules_file.path_in_worktree)
             }
             rules_files => {
                 format!("Using {} rules files", rules_files.len())

crates/agent/src/thread.rs 🔗

@@ -3,6 +3,7 @@ use std::io::Write;
 use std::ops::Range;
 use std::sync::Arc;
 
+use agent_rules::load_worktree_rules_file;
 use anyhow::{Context as _, Result, anyhow};
 use assistant_settings::AssistantSettings;
 use assistant_tool::{ActionLog, Tool, ToolWorkingSet};
@@ -21,13 +22,11 @@ use language_model::{
 };
 use project::git_store::{GitStore, GitStoreCheckpoint, RepositoryState};
 use project::{Project, Worktree};
-use prompt_store::{
-    AssistantSystemPromptContext, PromptBuilder, RulesFile, WorktreeInfoForSystemPrompt,
-};
+use prompt_store::{AssistantSystemPromptContext, PromptBuilder, WorktreeInfoForSystemPrompt};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::Settings;
-use util::{ResultExt as _, TryFutureExt as _, maybe, post_inc};
+use util::{ResultExt as _, TryFutureExt as _, post_inc};
 use uuid::Uuid;
 
 use crate::context::{AssistantContext, ContextId, format_context_as_string};
@@ -854,67 +853,36 @@ impl Thread {
         let root_name = worktree.root_name().into();
         let abs_path = worktree.abs_path();
 
-        // Note that Cline supports `.clinerules` being a directory, but that is not currently
-        // supported. This doesn't seem to occur often in GitHub repositories.
-        const RULES_FILE_NAMES: [&'static str; 6] = [
-            ".rules",
-            ".cursorrules",
-            ".windsurfrules",
-            ".clinerules",
-            ".github/copilot-instructions.md",
-            "CLAUDE.md",
-        ];
-        let selected_rules_file = RULES_FILE_NAMES
-            .into_iter()
-            .filter_map(|name| {
-                worktree
-                    .entry_for_path(name)
-                    .filter(|entry| entry.is_file())
-                    .map(|entry| (entry.path.clone(), worktree.absolutize(&entry.path)))
-            })
-            .next();
-
-        if let Some((rel_rules_path, abs_rules_path)) = selected_rules_file {
-            cx.spawn(async move |_| {
-                let rules_file_result = maybe!(async move {
-                    let abs_rules_path = abs_rules_path?;
-                    let text = fs.load(&abs_rules_path).await.with_context(|| {
-                        format!("Failed to load assistant rules file {:?}", abs_rules_path)
-                    })?;
-                    anyhow::Ok(RulesFile {
-                        rel_path: rel_rules_path,
-                        abs_path: abs_rules_path.into(),
-                        text: text.trim().to_string(),
-                    })
-                })
-                .await;
-                let (rules_file, rules_file_error) = match rules_file_result {
-                    Ok(rules_file) => (Some(rules_file), None),
-                    Err(err) => (
-                        None,
-                        Some(ThreadError::Message {
-                            header: "Error loading rules file".into(),
-                            message: format!("{err}").into(),
-                        }),
-                    ),
-                };
-                let worktree_info = WorktreeInfoForSystemPrompt {
-                    root_name,
-                    abs_path,
-                    rules_file,
-                };
-                (worktree_info, rules_file_error)
-            })
-        } else {
-            Task::ready((
+        let rules_task = load_worktree_rules_file(fs, worktree, cx);
+        let Some(rules_task) = rules_task else {
+            return Task::ready((
                 WorktreeInfoForSystemPrompt {
                     root_name,
                     abs_path,
                     rules_file: None,
                 },
                 None,
-            ))
-        }
+            ));
+        };
+
+        cx.spawn(async move |_| {
+            let (rules_file, rules_file_error) = match rules_task.await {
+                Ok(rules_file) => (Some(rules_file), None),
+                Err(err) => (
+                    None,
+                    Some(ThreadError::Message {
+                        header: "Error loading rules file".into(),
+                        message: format!("{err}").into(),
+                    }),
+                ),
+            };
+            let worktree_info = WorktreeInfoForSystemPrompt {
+                root_name,
+                abs_path,
+                rules_file,
+            };
+            (worktree_info, rules_file_error)
+        })
     }
 
     pub fn send_to_model(

crates/agent_rules/Cargo.toml 🔗

@@ -0,0 +1,24 @@
+[package]
+name = "agent_rules"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/agent_rules.rs"
+doctest = false
+
+[dependencies]
+anyhow.workspace = true
+fs.workspace = true
+gpui.workspace = true
+prompt_store.workspace = true
+util.workspace = true
+worktree.workspace = true
+
+[dev-dependencies]
+indoc.workspace = true

crates/agent_rules/src/agent_rules.rs 🔗

@@ -0,0 +1,51 @@
+use std::sync::Arc;
+
+use anyhow::{Context as _, Result};
+use fs::Fs;
+use gpui::{App, AppContext, Task};
+use prompt_store::SystemPromptRulesFile;
+use util::maybe;
+use worktree::Worktree;
+
+const RULES_FILE_NAMES: [&'static str; 6] = [
+    ".rules",
+    ".cursorrules",
+    ".windsurfrules",
+    ".clinerules",
+    ".github/copilot-instructions.md",
+    "CLAUDE.md",
+];
+
+pub fn load_worktree_rules_file(
+    fs: Arc<dyn Fs>,
+    worktree: &Worktree,
+    cx: &App,
+) -> Option<Task<Result<SystemPromptRulesFile>>> {
+    let selected_rules_file = RULES_FILE_NAMES
+        .into_iter()
+        .filter_map(|name| {
+            worktree
+                .entry_for_path(name)
+                .filter(|entry| entry.is_file())
+                .map(|entry| (entry.path.clone(), worktree.absolutize(&entry.path)))
+        })
+        .next();
+
+    // Note that Cline supports `.clinerules` being a directory, but that is not currently
+    // supported. This doesn't seem to occur often in GitHub repositories.
+    selected_rules_file.map(|(path_in_worktree, abs_path)| {
+        let fs = fs.clone();
+        cx.background_spawn(maybe!(async move {
+            let abs_path = abs_path?;
+            let text = fs
+                .load(&abs_path)
+                .await
+                .with_context(|| format!("Failed to load assistant rules file {:?}", abs_path))?;
+            anyhow::Ok(SystemPromptRulesFile {
+                path_in_worktree,
+                abs_path: abs_path.into(),
+                text: text.trim().to_string(),
+            })
+        }))
+    })
+}

crates/prompt_store/src/prompts.rs 🔗

@@ -38,12 +38,12 @@ impl AssistantSystemPromptContext {
 pub struct WorktreeInfoForSystemPrompt {
     pub root_name: String,
     pub abs_path: Arc<Path>,
-    pub rules_file: Option<RulesFile>,
+    pub rules_file: Option<SystemPromptRulesFile>,
 }
 
 #[derive(Serialize)]
-pub struct RulesFile {
-    pub rel_path: Arc<Path>,
+pub struct SystemPromptRulesFile {
+    pub path_in_worktree: Arc<Path>,
     pub abs_path: Arc<Path>,
     pub text: String,
 }