Skip to main content

synaptic_deep/backend/
state.rs

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