1use std::collections::HashMap;
2use std::sync::Arc;
3
4use tokio::sync::RwLock;
5
6use crate::error::RuntimeError;
7use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
8use crate::value::Value;
9
10use crate::migration::MigratedRule;
11
12#[derive(Default, Clone)]
13pub struct RuleFetch {
14 entries: Arc<RwLock<HashMap<String, String>>>,
15 migrated: Arc<RwLock<Vec<MigratedRule>>>,
16}
17
18impl RuleFetch {
19 pub fn new() -> Self {
20 Self::default()
21 }
22
23 pub async fn insert(&self, name: impl Into<String>, content: impl Into<String>) {
24 self.entries
25 .write()
26 .await
27 .insert(name.into(), content.into());
28 }
29
30 pub async fn set_migrated(&self, rules: Vec<MigratedRule>) {
31 *self.migrated.write().await = rules;
32 }
33
34 pub async fn migrated_count(&self) -> usize {
35 self.migrated.read().await.len()
36 }
37}
38
39impl Tool for RuleFetch {
40 fn name(&self) -> &str {
41 "rule.fetch"
42 }
43
44 fn tier(&self) -> Tier {
45 Tier::Zero
46 }
47
48 fn description(&self) -> Option<&str> {
49 Some(
50 "Load a skill/rule's full content by name, or search the rule index by keyword. \
51 Sources: CLAUDE.md, AGENTS.md, project and user skills, .cursorrules, \
52 .kiro/steering/*.md, and aider conventions.",
53 )
54 }
55
56 fn input_schema(&self) -> serde_json::Value {
57 serde_json::json!({
58 "type": "object",
59 "properties": {
60 "name": {
61 "type": "string",
62 "description": "Exact rule name to load full content (e.g. 'skill:code-review::references/rules.md')."
63 },
64 "query": {
65 "type": "string",
66 "description": "Keyword to search across rule names and descriptions. Returns a list of {name, description, scope, source, source_path}. Omit both name and query to list the full index."
67 }
68 }
69 })
70 }
71
72 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
73 Box::pin(async move {
74 if let Some(Value::Str(query)) = args.named("query") {
76 let migrated = self.migrated.read().await;
77 let lower = query.to_lowercase();
78 let results: Vec<Value> = migrated
79 .iter()
80 .filter(|r| {
81 r.name.to_lowercase().contains(&lower)
82 || r.description
83 .as_deref()
84 .map(|d| d.to_lowercase().contains(&lower))
85 .unwrap_or(false)
86 })
87 .map(rule_to_index_value)
88 .collect();
89 return Ok(Value::List(results));
90 }
91
92 if args.named("name").is_none() && args.positional(0).is_err() {
94 let migrated = self.migrated.read().await;
95 let index: Vec<Value> = migrated.iter().map(rule_to_index_value).collect();
96 return Ok(Value::List(index));
97 }
98
99 let name = extract_string(&args, "name", 0)?;
100 let entries = self.entries.read().await;
101 if let Some(content) = entries.get(&name) {
102 return Ok(Value::Str(content.clone()));
103 }
104 drop(entries);
105 let migrated = self.migrated.read().await;
106 if let Some(rule) = crate::migration::resolve_by_name(&migrated, &name) {
107 return Ok(Value::Str(rule.content.clone()));
108 }
109 Ok(Value::Str(String::new()))
110 })
111 }
112}
113
114fn rule_to_index_value(rule: &MigratedRule) -> Value {
116 use crate::value::Value as V;
117 V::Struct(vec![
118 ("name".into(), V::Str(rule.name.clone())),
119 (
120 "description".into(),
121 V::Str(rule.description.clone().unwrap_or_default()),
122 ),
123 ("scope".into(), V::Str(rule.scope.as_str().to_string())),
124 ("source".into(), V::Str(rule.source_tool.clone())),
125 (
126 "source_path".into(),
127 V::Str(rule.source_path.display().to_string()),
128 ),
129 ])
130}
131
132fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
133 let value = match args.named(name) {
134 Some(v) => v,
135 None => args.positional(pos)?,
136 };
137 match value {
138 Value::Str(s) => Ok(s.clone()),
139 other => Err(RuntimeError::TypeMismatch {
140 expected: "string".into(),
141 actual: other.kind_name().into(),
142 }),
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[tokio::test]
151 async fn rule_fetch_returns_stored_content() {
152 let tool = RuleFetch::new();
153 tool.insert("code-review", "review carefully").await;
154 let out = tool
155 .call(
156 ToolArgs {
157 positional: vec![Value::Str("code-review".into())],
158 named: vec![],
159 },
160 &ToolCtx::new(),
161 )
162 .await
163 .unwrap();
164 assert!(matches!(out, Value::Str(s) if s == "review carefully"));
165 }
166
167 #[tokio::test]
168 async fn rule_fetch_missing_returns_empty_string() {
169 let tool = RuleFetch::new();
170 let out = tool
171 .call(
172 ToolArgs {
173 positional: vec![Value::Str("missing".into())],
174 named: vec![],
175 },
176 &ToolCtx::new(),
177 )
178 .await
179 .unwrap();
180 assert!(matches!(out, Value::Str(s) if s.is_empty()));
181 }
182
183 fn migrated_rule(name: &str, description: &str) -> MigratedRule {
184 MigratedRule {
185 name: name.to_string(),
186 source_tool: "skill".to_string(),
187 source_path: "/tmp/x".into(),
188 scope: crate::migration::RuleScope::Global,
189 content: "content".to_string(),
190 description: Some(description.to_string()),
191 }
192 }
193
194 #[tokio::test]
195 async fn rule_fetch_query_searches_name_and_description() {
196 let tool = RuleFetch::new();
197 tool.set_migrated(vec![
198 migrated_rule("skill:code-review::a.md", "structured code review"),
199 migrated_rule("skill:deploy::b.md", "deployment checklist"),
200 ])
201 .await;
202
203 let out = tool
204 .call(
205 ToolArgs {
206 positional: vec![],
207 named: vec![("query".into(), Value::Str("review".into()))],
208 },
209 &ToolCtx::new(),
210 )
211 .await
212 .unwrap();
213 let Value::List(items) = out else {
214 panic!("expected list, got {out:?}")
215 };
216 assert_eq!(items.len(), 1, "only the review rule should match");
217 let Value::Struct(fields) = &items[0] else {
218 panic!("expected struct entry")
219 };
220 assert_eq!(fields[0].0, "name");
221 assert!(matches!(&fields[0].1, Value::Str(s) if s == "skill:code-review::a.md"));
222 }
223
224 #[tokio::test]
225 async fn rule_fetch_no_args_returns_full_index() {
226 let tool = RuleFetch::new();
227 tool.set_migrated(vec![
228 migrated_rule("a", "first"),
229 migrated_rule("b", "second"),
230 ])
231 .await;
232
233 let out = tool
234 .call(ToolArgs::default(), &ToolCtx::new())
235 .await
236 .unwrap();
237 let Value::List(items) = out else {
238 panic!("expected list, got {out:?}")
239 };
240 assert_eq!(items.len(), 2);
241 }
242}