Skip to main content

thulp_query/
lib.rs

1//! # thulp-query
2//!
3//! Query engine for searching and filtering tools.
4//!
5//! This crate provides a DSL for querying tool definitions by various criteria.
6
7use serde::{Deserialize, Serialize};
8use thulp_core::ToolDefinition;
9
10/// Parse a natural language query into QueryCriteria
11pub fn parse_query(query: &str) -> Result<QueryCriteria> {
12    // Simple parser for demonstration
13    // In a real implementation, this would use a proper NLP library
14
15    let lower_query = query.to_lowercase();
16
17    if lower_query.contains("and") {
18        let parts: Vec<&str> = query.split(" and ").collect();
19        let criteria = parts
20            .into_iter()
21            .map(parse_single_criterion)
22            .collect::<std::result::Result<Vec<_>, _>>()?;
23        Ok(QueryCriteria::And(criteria))
24    } else if lower_query.contains("or") {
25        let parts: Vec<&str> = query.split(" or ").collect();
26        let criteria = parts
27            .into_iter()
28            .map(parse_single_criterion)
29            .collect::<std::result::Result<Vec<_>, _>>()?;
30        Ok(QueryCriteria::Or(criteria))
31    } else {
32        parse_single_criterion(query)
33    }
34}
35
36fn parse_single_criterion(criterion: &str) -> Result<QueryCriteria> {
37    let lower = criterion.to_lowercase();
38
39    if lower.starts_with("name:") {
40        let name = criterion[5..].trim();
41        Ok(QueryCriteria::Name(name.to_string()))
42    } else if lower.starts_with("has:") {
43        let param = criterion[4..].trim();
44        Ok(QueryCriteria::HasParameter(param.to_string()))
45    } else if lower.starts_with("min:") {
46        let count: usize = criterion[4..]
47            .trim()
48            .parse()
49            .map_err(|_| QueryError::Parse("Invalid number for min".to_string()))?;
50        Ok(QueryCriteria::MinParameters(count))
51    } else if lower.starts_with("max:") {
52        let count: usize = criterion[4..]
53            .trim()
54            .parse()
55            .map_err(|_| QueryError::Parse("Invalid number for max".to_string()))?;
56        Ok(QueryCriteria::MaxParameters(count))
57    } else if lower.starts_with("desc:") || lower.starts_with("description:") {
58        let desc = if lower.starts_with("desc:") {
59            criterion[5..].trim()
60        } else {
61            criterion[12..].trim()
62        };
63        Ok(QueryCriteria::Description(desc.to_string()))
64    } else {
65        // Default to name search
66        Ok(QueryCriteria::Name(criterion.to_string()))
67    }
68}
69
70/// Result type for query operations
71pub type Result<T> = std::result::Result<T, QueryError>;
72
73/// Errors that can occur in query operations
74#[derive(Debug, thiserror::Error)]
75pub enum QueryError {
76    #[error("Parse error: {0}")]
77    Parse(String),
78
79    #[error("Invalid query: {0}")]
80    Invalid(String),
81}
82
83/// Query criteria for filtering tools
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub enum QueryCriteria {
86    /// Match tools by name (supports wildcards)
87    Name(String),
88
89    /// Match tools by description keyword
90    Description(String),
91
92    /// Match tools with specific parameter
93    HasParameter(String),
94
95    /// Match tools with at least N parameters
96    MinParameters(usize),
97
98    /// Match tools with at most N parameters
99    MaxParameters(usize),
100
101    /// Combine criteria with AND
102    And(Vec<QueryCriteria>),
103
104    /// Combine criteria with OR
105    Or(Vec<QueryCriteria>),
106
107    /// Negate a criteria
108    Not(Box<QueryCriteria>),
109}
110
111impl QueryCriteria {
112    /// Check if a tool matches this criteria
113    pub fn matches(&self, tool: &ToolDefinition) -> bool {
114        match self {
115            QueryCriteria::Name(pattern) => {
116                if pattern.contains('*') {
117                    let regex = pattern.replace('*', ".*");
118                    regex::Regex::new(&regex)
119                        .map(|re| re.is_match(&tool.name))
120                        .unwrap_or(false)
121                } else {
122                    tool.name.contains(pattern)
123                }
124            }
125            QueryCriteria::Description(keyword) => tool
126                .description
127                .to_lowercase()
128                .contains(&keyword.to_lowercase()),
129            QueryCriteria::HasParameter(param_name) => {
130                tool.parameters.iter().any(|p| p.name == *param_name)
131            }
132            QueryCriteria::MinParameters(min) => tool.parameters.len() >= *min,
133            QueryCriteria::MaxParameters(max) => tool.parameters.len() <= *max,
134            QueryCriteria::And(criteria) => criteria.iter().all(|c| c.matches(tool)),
135            QueryCriteria::Or(criteria) => criteria.iter().any(|c| c.matches(tool)),
136            QueryCriteria::Not(criteria) => !criteria.matches(tool),
137        }
138    }
139}
140
141/// Query builder for constructing queries
142#[derive(Debug, Default)]
143pub struct QueryBuilder {
144    criteria: Vec<QueryCriteria>,
145}
146
147impl QueryBuilder {
148    /// Create a new query builder
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Match tools by name
154    pub fn name(mut self, pattern: impl Into<String>) -> Self {
155        self.criteria.push(QueryCriteria::Name(pattern.into()));
156        self
157    }
158
159    /// Match tools by description keyword
160    pub fn description(mut self, keyword: impl Into<String>) -> Self {
161        self.criteria
162            .push(QueryCriteria::Description(keyword.into()));
163        self
164    }
165
166    /// Match tools with specific parameter
167    pub fn has_parameter(mut self, param_name: impl Into<String>) -> Self {
168        self.criteria
169            .push(QueryCriteria::HasParameter(param_name.into()));
170        self
171    }
172
173    /// Match tools with at least N parameters
174    pub fn min_parameters(mut self, min: usize) -> Self {
175        self.criteria.push(QueryCriteria::MinParameters(min));
176        self
177    }
178
179    /// Match tools with at most N parameters
180    pub fn max_parameters(mut self, max: usize) -> Self {
181        self.criteria.push(QueryCriteria::MaxParameters(max));
182        self
183    }
184
185    /// Build the query
186    pub fn build(self) -> Query {
187        Query {
188            criteria: if self.criteria.len() == 1 {
189                self.criteria.into_iter().next().unwrap()
190            } else {
191                QueryCriteria::And(self.criteria)
192            },
193        }
194    }
195}
196
197/// A query for filtering tools
198#[derive(Debug, Clone)]
199pub struct Query {
200    criteria: QueryCriteria,
201}
202
203impl Query {
204    /// Create a new query from criteria
205    pub fn new(criteria: QueryCriteria) -> Self {
206        Self { criteria }
207    }
208
209    /// Execute the query on a collection of tools
210    pub fn execute(&self, tools: &[ToolDefinition]) -> Vec<ToolDefinition> {
211        tools
212            .iter()
213            .filter(|tool| self.criteria.matches(tool))
214            .cloned()
215            .collect()
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use thulp_core::Parameter;
223
224    fn create_test_tool(name: &str, description: &str, param_count: usize) -> ToolDefinition {
225        let mut builder = ToolDefinition::builder(name).description(description);
226
227        for i in 0..param_count {
228            builder = builder.parameter(Parameter::required_string(format!("param{}", i)));
229        }
230
231        builder.build()
232    }
233
234    #[test]
235    fn test_query_name() {
236        let tool = create_test_tool("file_read", "Read a file", 1);
237        let criteria = QueryCriteria::Name("file".to_string());
238        assert!(criteria.matches(&tool));
239    }
240
241    #[test]
242    fn test_query_name_wildcard() {
243        let tool = create_test_tool("file_read", "Read a file", 1);
244        let criteria = QueryCriteria::Name("file_*".to_string());
245        assert!(criteria.matches(&tool));
246    }
247
248    #[test]
249    fn test_query_description() {
250        let tool = create_test_tool("file_read", "Read a file from disk", 1);
251        let criteria = QueryCriteria::Description("disk".to_string());
252        assert!(criteria.matches(&tool));
253    }
254
255    #[test]
256    fn test_query_has_parameter() {
257        let tool = ToolDefinition::builder("test")
258            .parameter(Parameter::required_string("path"))
259            .build();
260
261        let criteria = QueryCriteria::HasParameter("path".to_string());
262        assert!(criteria.matches(&tool));
263    }
264
265    #[test]
266    fn test_query_min_parameters() {
267        let tool = create_test_tool("test", "Test", 3);
268        let criteria = QueryCriteria::MinParameters(2);
269        assert!(criteria.matches(&tool));
270    }
271
272    #[test]
273    fn test_query_max_parameters() {
274        let tool = create_test_tool("test", "Test", 2);
275        let criteria = QueryCriteria::MaxParameters(3);
276        assert!(criteria.matches(&tool));
277    }
278
279    #[test]
280    fn test_query_and() {
281        let tool = create_test_tool("file_read", "Read a file", 2);
282        let criteria = QueryCriteria::And(vec![
283            QueryCriteria::Name("file".to_string()),
284            QueryCriteria::MinParameters(2),
285        ]);
286        assert!(criteria.matches(&tool));
287    }
288
289    #[test]
290    fn test_query_or() {
291        let tool = create_test_tool("file_read", "Read a file", 1);
292        let criteria = QueryCriteria::Or(vec![
293            QueryCriteria::Name("network".to_string()),
294            QueryCriteria::Name("file".to_string()),
295        ]);
296        assert!(criteria.matches(&tool));
297    }
298
299    #[test]
300    fn test_query_not() {
301        let tool = create_test_tool("file_read", "Read a file", 1);
302        let criteria = QueryCriteria::Not(Box::new(QueryCriteria::Name("network".to_string())));
303        assert!(criteria.matches(&tool));
304    }
305
306    #[test]
307    fn test_query_builder() {
308        let query = QueryBuilder::new().name("file").min_parameters(1).build();
309
310        let tools = vec![
311            create_test_tool("file_read", "Read", 2),
312            create_test_tool("network_get", "Get", 1),
313        ];
314
315        let results = query.execute(&tools);
316        assert_eq!(results.len(), 1);
317        assert_eq!(results[0].name, "file_read");
318    }
319
320    #[test]
321    fn test_query_execute() {
322        let query = Query::new(QueryCriteria::MinParameters(2));
323
324        let tools = vec![
325            create_test_tool("tool1", "Test 1", 1),
326            create_test_tool("tool2", "Test 2", 2),
327            create_test_tool("tool3", "Test 3", 3),
328        ];
329
330        let results = query.execute(&tools);
331        assert_eq!(results.len(), 2);
332    }
333
334    #[test]
335    fn test_parse_query_name() {
336        let criteria = parse_query("search").unwrap();
337        assert!(matches!(criteria, QueryCriteria::Name(_)));
338    }
339
340    #[test]
341    fn test_parse_query_with_prefix() {
342        let criteria = parse_query("name:search").unwrap();
343        assert!(matches!(criteria, QueryCriteria::Name(_)));
344    }
345
346    #[test]
347    fn test_parse_query_has_parameter() {
348        let criteria = parse_query("has:path").unwrap();
349        assert!(matches!(criteria, QueryCriteria::HasParameter(_)));
350    }
351
352    #[test]
353    fn test_parse_query_min_parameters() {
354        let criteria = parse_query("min:2").unwrap();
355        assert!(matches!(criteria, QueryCriteria::MinParameters(2)));
356    }
357
358    #[test]
359    fn test_parse_query_max_parameters() {
360        let criteria = parse_query("max:5").unwrap();
361        assert!(matches!(criteria, QueryCriteria::MaxParameters(5)));
362    }
363
364    #[test]
365    fn test_parse_query_description() {
366        let criteria = parse_query("desc:file").unwrap();
367        assert!(matches!(criteria, QueryCriteria::Description(_)));
368    }
369
370    #[test]
371    fn test_parse_query_and() {
372        let criteria = parse_query("name:search and has:query").unwrap();
373        assert!(matches!(criteria, QueryCriteria::And(_)));
374    }
375
376    #[test]
377    fn test_parse_query_or() {
378        let criteria = parse_query("name:search or name:find").unwrap();
379        assert!(matches!(criteria, QueryCriteria::Or(_)));
380    }
381}