turbovault_tools/
metadata_tools.rs

1//! Metadata query tools for finding and extracting file metadata
2
3use serde_json::{Value, json};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7use turbovault_core::prelude::*;
8use turbovault_vault::VaultManager;
9
10/// Metadata query filter
11#[derive(Debug, Clone)]
12pub enum QueryFilter {
13    Equals(String, Value),
14    GreaterThan(String, f64),
15    LessThan(String, f64),
16    Contains(String, String),
17    And(Vec<QueryFilter>),
18    Or(Vec<QueryFilter>),
19}
20
21impl QueryFilter {
22    /// Check if metadata matches this filter
23    fn matches(&self, metadata: &HashMap<String, Value>) -> bool {
24        match self {
25            QueryFilter::Equals(key, expected) => metadata.get(key) == Some(expected),
26            QueryFilter::GreaterThan(key, threshold) => {
27                if let Some(Value::Number(num)) = metadata.get(key)
28                    && let Some(n) = num.as_f64()
29                {
30                    return n > *threshold;
31                }
32                false
33            }
34            QueryFilter::LessThan(key, threshold) => {
35                if let Some(Value::Number(num)) = metadata.get(key)
36                    && let Some(n) = num.as_f64()
37                {
38                    return n < *threshold;
39                }
40                false
41            }
42            QueryFilter::Contains(key, substring) => metadata
43                .get(key)
44                .and_then(|v| v.as_str())
45                .map(|s| s.contains(substring))
46                .unwrap_or(false),
47            QueryFilter::And(filters) => filters.iter().all(|f| f.matches(metadata)),
48            QueryFilter::Or(filters) => filters.iter().any(|f| f.matches(metadata)),
49        }
50    }
51}
52
53/// Parse simple query patterns
54/// Examples:
55/// - 'status: "draft"' → Equals("status", String("draft"))
56/// - 'priority > 3' → GreaterThan("priority", 3.0)
57/// - 'priority < 5' → LessThan("priority", 5.0)
58/// - 'tags: contains("important")' → Contains("tags", "important")
59fn parse_query(pattern: &str) -> Result<QueryFilter> {
60    let pattern = pattern.trim();
61
62    // Try: key: "value" (equals string)
63    if let Some(colon_pos) = pattern.find(':') {
64        let key = pattern[..colon_pos].trim();
65        let rest = pattern[colon_pos + 1..].trim();
66
67        // Check for string literal
68        if rest.starts_with('"') && rest.ends_with('"') {
69            let value = rest[1..rest.len() - 1].to_string();
70            return Ok(QueryFilter::Equals(key.to_string(), Value::String(value)));
71        }
72
73        // Check for contains()
74        if rest.starts_with("contains(") && rest.ends_with(")") {
75            let inner = &rest[9..rest.len() - 1];
76            if inner.starts_with('"') && inner.ends_with('"') {
77                let substring = inner[1..inner.len() - 1].to_string();
78                return Ok(QueryFilter::Contains(key.to_string(), substring));
79            }
80        }
81    }
82
83    // Try: key > number
84    if let Some(gt_pos) = pattern.find(" > ") {
85        let key = pattern[..gt_pos].trim();
86        let rest = pattern[gt_pos + 3..].trim();
87        if let Ok(num) = rest.parse::<f64>() {
88            return Ok(QueryFilter::GreaterThan(key.to_string(), num));
89        }
90    }
91
92    // Try: key < number
93    if let Some(lt_pos) = pattern.find(" < ") {
94        let key = pattern[..lt_pos].trim();
95        let rest = pattern[lt_pos + 3..].trim();
96        if let Ok(num) = rest.parse::<f64>() {
97            return Ok(QueryFilter::LessThan(key.to_string(), num));
98        }
99    }
100
101    Err(Error::config_error(format!(
102        "Unable to parse query pattern: {}",
103        pattern
104    )))
105}
106
107/// Metadata tools for querying and extracting file metadata
108pub struct MetadataTools {
109    pub manager: Arc<VaultManager>,
110}
111
112impl MetadataTools {
113    /// Create new metadata tools
114    pub fn new(manager: Arc<VaultManager>) -> Self {
115        Self { manager }
116    }
117
118    /// Query files by metadata pattern
119    pub async fn query_metadata(&self, pattern: &str) -> Result<Value> {
120        let filter = parse_query(pattern)?;
121
122        // Get all markdown files
123        let files = self.manager.scan_vault().await?;
124        let mut matches = Vec::new();
125
126        for file_path in files {
127            if !file_path.ends_with(".md") {
128                continue;
129            }
130
131            // Parse file to extract frontmatter
132            match self.manager.parse_file(&file_path).await {
133                Ok(vault_file) => {
134                    if let Some(frontmatter) = vault_file.frontmatter
135                        && filter.matches(&frontmatter.data)
136                    {
137                        let display_path = file_path
138                            .strip_prefix(self.manager.vault_path())
139                            .map(|p| p.to_string_lossy().to_string())
140                            .unwrap_or_else(|_| file_path.to_string_lossy().to_string());
141
142                        matches.push(json!({
143                            "path": display_path,
144                            "metadata": frontmatter.data
145                        }));
146                    }
147                }
148                Err(_) => {
149                    // Skip files that can't be parsed
150                    continue;
151                }
152            }
153        }
154
155        Ok(json!({
156            "query": pattern,
157            "matched": matches.len(),
158            "files": matches
159        }))
160    }
161
162    /// Get metadata value from a file by key (supports dot notation for nested keys)
163    pub async fn get_metadata_value(&self, file: &str, key: &str) -> Result<Value> {
164        // Resolve file path
165        let file_path = PathBuf::from(file);
166
167        // Parse file
168        let vault_file = self.manager.parse_file(&file_path).await?;
169
170        // Extract frontmatter
171        let frontmatter = vault_file
172            .frontmatter
173            .ok_or_else(|| Error::not_found("No frontmatter in file".to_string()))?;
174
175        // Handle nested keys: "a.b.c" → drill down
176        let mut current: &Value = &Value::Object(serde_json::Map::from_iter(
177            frontmatter.data.iter().map(|(k, v)| (k.clone(), v.clone())),
178        ));
179
180        for part in key.split('.') {
181            current = current
182                .get(part)
183                .ok_or_else(|| Error::not_found(format!("Key not found: {}", key)))?;
184        }
185
186        Ok(json!({
187            "file": file,
188            "key": key,
189            "value": current
190        }))
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_parse_query_equals_string() {
200        let filter = parse_query(r#"status: "draft""#).unwrap();
201        matches!(filter, QueryFilter::Equals(_, _));
202    }
203
204    #[test]
205    fn test_parse_query_greater_than() {
206        let filter = parse_query("priority > 3").unwrap();
207        matches!(filter, QueryFilter::GreaterThan(_, _));
208    }
209
210    #[test]
211    fn test_parse_query_less_than() {
212        let filter = parse_query("priority < 5").unwrap();
213        matches!(filter, QueryFilter::LessThan(_, _));
214    }
215
216    #[test]
217    fn test_parse_query_contains() {
218        let filter = parse_query(r#"tags: contains("important")"#).unwrap();
219        matches!(filter, QueryFilter::Contains(_, _));
220    }
221
222    #[test]
223    fn test_filter_matches_equals() {
224        let mut metadata = HashMap::new();
225        metadata.insert("status".to_string(), Value::String("draft".to_string()));
226
227        let filter = QueryFilter::Equals("status".to_string(), Value::String("draft".to_string()));
228        assert!(filter.matches(&metadata));
229
230        let filter_no_match =
231            QueryFilter::Equals("status".to_string(), Value::String("active".to_string()));
232        assert!(!filter_no_match.matches(&metadata));
233    }
234
235    #[test]
236    fn test_filter_matches_greater_than() {
237        let mut metadata = HashMap::new();
238        metadata.insert(
239            "priority".to_string(),
240            Value::Number(serde_json::Number::from(5)),
241        );
242
243        let filter = QueryFilter::GreaterThan("priority".to_string(), 3.0);
244        assert!(filter.matches(&metadata));
245
246        let filter_no_match = QueryFilter::GreaterThan("priority".to_string(), 5.0);
247        assert!(!filter_no_match.matches(&metadata));
248    }
249
250    #[test]
251    fn test_filter_matches_contains() {
252        let mut metadata = HashMap::new();
253        metadata.insert(
254            "tags".to_string(),
255            Value::String("important task".to_string()),
256        );
257
258        let filter = QueryFilter::Contains("tags".to_string(), "important".to_string());
259        assert!(filter.matches(&metadata));
260
261        let filter_no_match = QueryFilter::Contains("tags".to_string(), "urgent".to_string());
262        assert!(!filter_no_match.matches(&metadata));
263    }
264
265    #[test]
266    fn test_filter_matches_and() {
267        let mut metadata = HashMap::new();
268        metadata.insert("status".to_string(), Value::String("draft".to_string()));
269        metadata.insert(
270            "priority".to_string(),
271            Value::Number(serde_json::Number::from(5)),
272        );
273
274        let filter = QueryFilter::And(vec![
275            QueryFilter::Equals("status".to_string(), Value::String("draft".to_string())),
276            QueryFilter::GreaterThan("priority".to_string(), 3.0),
277        ]);
278        assert!(filter.matches(&metadata));
279
280        let filter_no_match = QueryFilter::And(vec![
281            QueryFilter::Equals("status".to_string(), Value::String("draft".to_string())),
282            QueryFilter::GreaterThan("priority".to_string(), 5.0),
283        ]);
284        assert!(!filter_no_match.matches(&metadata));
285    }
286
287    #[test]
288    fn test_filter_matches_or() {
289        let mut metadata = HashMap::new();
290        metadata.insert("status".to_string(), Value::String("draft".to_string()));
291
292        let filter = QueryFilter::Or(vec![
293            QueryFilter::Equals("status".to_string(), Value::String("active".to_string())),
294            QueryFilter::Equals("status".to_string(), Value::String("draft".to_string())),
295        ]);
296        assert!(filter.matches(&metadata));
297
298        let filter_no_match = QueryFilter::Or(vec![
299            QueryFilter::Equals("status".to_string(), Value::String("archived".to_string())),
300            QueryFilter::Equals("status".to_string(), Value::String("active".to_string())),
301        ]);
302        assert!(!filter_no_match.matches(&metadata));
303    }
304}