Skip to main content

synaptic_deep/backend/
store.rs

1use async_trait::async_trait;
2use regex::Regex;
3use std::collections::HashMap;
4use std::sync::Arc;
5use synaptic_core::{Store, SynapticError};
6
7use super::{Backend, DirEntry, GrepMatch, GrepOutputMode};
8
9/// Backend that persists files through a [`Store`] implementation.
10///
11/// Each file is stored as an item with key=path and value=`{"content": "..."}`.
12/// All items share a configurable namespace prefix.
13pub struct StoreBackend {
14    store: Arc<dyn Store>,
15    namespace: Vec<String>,
16}
17
18impl StoreBackend {
19    pub fn new(store: Arc<dyn Store>, namespace: Vec<String>) -> Self {
20        Self { store, namespace }
21    }
22
23    fn ns_refs(&self) -> Vec<&str> {
24        self.namespace.iter().map(|s| s.as_str()).collect()
25    }
26}
27
28fn normalize_path(path: &str) -> String {
29    let trimmed = path.trim_matches('/');
30    if trimmed == "." {
31        String::new()
32    } else {
33        trimmed.to_string()
34    }
35}
36
37fn glob_to_regex(pattern: &str) -> String {
38    let mut regex = String::from("^");
39    let mut chars = pattern.chars().peekable();
40    while let Some(c) = chars.next() {
41        match c {
42            '*' => {
43                if chars.peek() == Some(&'*') {
44                    chars.next();
45                    if chars.peek() == Some(&'/') {
46                        chars.next();
47                        regex.push_str("(.*/)?");
48                    } else {
49                        regex.push_str(".*");
50                    }
51                } else {
52                    regex.push_str("[^/]*");
53                }
54            }
55            '?' => regex.push_str("[^/]"),
56            '.' => regex.push_str("\\."),
57            '{' => regex.push('('),
58            '}' => regex.push(')'),
59            ',' => regex.push('|'),
60            c => regex.push(c),
61        }
62    }
63    regex.push('$');
64    regex
65}
66
67#[async_trait]
68impl Backend for StoreBackend {
69    async fn ls(&self, path: &str) -> Result<Vec<DirEntry>, SynapticError> {
70        let ns = self.ns_refs();
71        let items = self.store.search(&ns, None, 10000).await?;
72        let prefix = normalize_path(path);
73        let prefix_with_slash = if prefix.is_empty() {
74            String::new()
75        } else {
76            format!("{}/", prefix)
77        };
78
79        let mut entries: HashMap<String, bool> = HashMap::new();
80        for item in items {
81            let rel = if prefix_with_slash.is_empty() {
82                item.key.clone()
83            } else if let Some(rel) = item.key.strip_prefix(&prefix_with_slash) {
84                rel.to_string()
85            } else {
86                continue;
87            };
88
89            if let Some(slash_pos) = rel.find('/') {
90                entries.insert(rel[..slash_pos].to_string(), true);
91            } else if !rel.is_empty() {
92                entries.entry(rel).or_insert(false);
93            }
94        }
95
96        let mut result: Vec<DirEntry> = entries
97            .into_iter()
98            .map(|(name, is_dir)| DirEntry {
99                name,
100                is_dir,
101                size: None,
102            })
103            .collect();
104        result.sort_by(|a, b| a.name.cmp(&b.name));
105        Ok(result)
106    }
107
108    async fn read_file(
109        &self,
110        path: &str,
111        offset: usize,
112        limit: usize,
113    ) -> Result<String, SynapticError> {
114        let ns = self.ns_refs();
115        let normalized = normalize_path(path);
116        let item = self
117            .store
118            .get(&ns, &normalized)
119            .await?
120            .ok_or_else(|| SynapticError::Tool(format!("file not found: {}", path)))?;
121
122        let content = item
123            .value
124            .get("content")
125            .and_then(|v| v.as_str())
126            .unwrap_or("");
127
128        let lines: Vec<&str> = content.lines().collect();
129        let total = lines.len();
130        if offset >= total {
131            return Ok(String::new());
132        }
133        let end = (offset + limit).min(total);
134        Ok(lines[offset..end].join("\n"))
135    }
136
137    async fn write_file(&self, path: &str, content: &str) -> Result<(), SynapticError> {
138        let ns = self.ns_refs();
139        let normalized = normalize_path(path);
140        self.store
141            .put(&ns, &normalized, serde_json::json!({ "content": content }))
142            .await
143    }
144
145    async fn edit_file(
146        &self,
147        path: &str,
148        old_text: &str,
149        new_text: &str,
150        replace_all: bool,
151    ) -> Result<(), SynapticError> {
152        let ns = self.ns_refs();
153        let normalized = normalize_path(path);
154        let item = self
155            .store
156            .get(&ns, &normalized)
157            .await?
158            .ok_or_else(|| SynapticError::Tool(format!("file not found: {}", path)))?;
159
160        let content = item
161            .value
162            .get("content")
163            .and_then(|v| v.as_str())
164            .unwrap_or("");
165
166        if !content.contains(old_text) {
167            return Err(SynapticError::Tool(format!(
168                "old_string not found in {}",
169                path
170            )));
171        }
172
173        let new_content = if replace_all {
174            content.replace(old_text, new_text)
175        } else {
176            content.replacen(old_text, new_text, 1)
177        };
178
179        self.store
180            .put(
181                &ns,
182                &normalized,
183                serde_json::json!({ "content": new_content }),
184            )
185            .await
186    }
187
188    async fn glob(&self, pattern: &str, base: &str) -> Result<Vec<String>, SynapticError> {
189        let ns = self.ns_refs();
190        let items = self.store.search(&ns, None, 10000).await?;
191        let base_normalized = normalize_path(base);
192
193        let regex_str = glob_to_regex(pattern);
194        let re = Regex::new(&regex_str)
195            .map_err(|e| SynapticError::Tool(format!("invalid glob pattern: {}", e)))?;
196
197        let mut matches = Vec::new();
198        for item in items {
199            let rel = if base_normalized.is_empty() {
200                item.key.clone()
201            } else if let Some(rel) = item.key.strip_prefix(&format!("{}/", base_normalized)) {
202                rel.to_string()
203            } else {
204                continue;
205            };
206            if re.is_match(&rel) {
207                matches.push(item.key);
208            }
209        }
210        matches.sort();
211        Ok(matches)
212    }
213
214    async fn grep(
215        &self,
216        pattern: &str,
217        path: Option<&str>,
218        file_glob: Option<&str>,
219        output_mode: GrepOutputMode,
220    ) -> Result<String, SynapticError> {
221        let ns = self.ns_refs();
222        let items = self.store.search(&ns, None, 10000).await?;
223        let re = Regex::new(pattern)
224            .map_err(|e| SynapticError::Tool(format!("invalid regex: {}", e)))?;
225        let glob_re = file_glob.and_then(|g| Regex::new(&glob_to_regex(g)).ok());
226        let base = path.map(normalize_path).unwrap_or_default();
227
228        let mut file_matches: Vec<GrepMatch> = Vec::new();
229        let mut match_files: Vec<String> = Vec::new();
230        let mut match_counts: HashMap<String, usize> = HashMap::new();
231
232        for item in items {
233            if !base.is_empty() && !item.key.starts_with(&base) {
234                continue;
235            }
236            if let Some(ref gre) = glob_re {
237                let rel = if base.is_empty() {
238                    item.key.clone()
239                } else {
240                    item.key
241                        .strip_prefix(&format!("{}/", base))
242                        .unwrap_or(&item.key)
243                        .to_string()
244                };
245                if !gre.is_match(&rel) {
246                    continue;
247                }
248            }
249
250            let content = item
251                .value
252                .get("content")
253                .and_then(|v| v.as_str())
254                .unwrap_or("");
255            let mut found = false;
256            for (line_num, line) in content.lines().enumerate() {
257                if re.is_match(line) {
258                    found = true;
259                    file_matches.push(GrepMatch {
260                        file: item.key.clone(),
261                        line_number: line_num + 1,
262                        line: line.to_string(),
263                    });
264                    *match_counts.entry(item.key.clone()).or_insert(0) += 1;
265                }
266            }
267            if found {
268                match_files.push(item.key.clone());
269            }
270        }
271
272        match output_mode {
273            GrepOutputMode::FilesWithMatches => {
274                match_files.sort();
275                Ok(match_files.join("\n"))
276            }
277            GrepOutputMode::Content => {
278                file_matches
279                    .sort_by(|a, b| a.file.cmp(&b.file).then(a.line_number.cmp(&b.line_number)));
280                Ok(file_matches
281                    .iter()
282                    .map(|m| format!("{}:{}:{}", m.file, m.line_number, m.line))
283                    .collect::<Vec<_>>()
284                    .join("\n"))
285            }
286            GrepOutputMode::Count => {
287                let mut counts: Vec<_> = match_counts.into_iter().collect();
288                counts.sort_by(|a, b| a.0.cmp(&b.0));
289                Ok(counts
290                    .iter()
291                    .map(|(f, c)| format!("{}:{}", f, c))
292                    .collect::<Vec<_>>()
293                    .join("\n"))
294            }
295        }
296    }
297}