foundry_mcp/core/backends/
memory.rs

1//! In-memory backend implementation for testing
2//!
3//! This backend provides a lightweight, fast implementation of FoundryBackend
4//! that stores all data in memory for contract testing and development.
5
6use anyhow::{Result, anyhow};
7use chrono::{Datelike, Timelike, Utc};
8use std::collections::HashMap;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12use crate::core::backends::{BackendCapabilities, FoundryBackend, ResourceLocator};
13use crate::types::{
14    project::{Project, ProjectConfig, ProjectMetadata},
15    spec::{Spec, SpecConfig, SpecFileType, SpecMetadata},
16};
17
18/// In-memory storage for projects and specs
19#[derive(Debug, Clone, Default)]
20struct MemoryStore {
21    projects: HashMap<String, Project>,
22    specs: HashMap<String, HashMap<String, Spec>>, // project_name -> spec_name -> spec
23}
24
25/// In-memory backend implementation for testing
26#[derive(Debug, Clone)]
27pub struct InMemoryBackend {
28    store: Arc<RwLock<MemoryStore>>,
29}
30
31impl InMemoryBackend {
32    /// Create a new in-memory backend
33    pub fn new() -> Self {
34        Self {
35            store: Arc::new(RwLock::new(MemoryStore::default())),
36        }
37    }
38
39    /// Clear all data (useful for test cleanup)
40    pub async fn clear(&self) {
41        let mut store = self.store.write().await;
42        store.projects.clear();
43        store.specs.clear();
44    }
45
46    /// Get project count (useful for testing)
47    pub async fn project_count(&self) -> usize {
48        let store = self.store.read().await;
49        store.projects.len()
50    }
51
52    /// Get spec count for a project (useful for testing)
53    pub async fn spec_count_for_project(&self, project_name: &str) -> usize {
54        let store = self.store.read().await;
55        store
56            .specs
57            .get(project_name)
58            .map(|specs| specs.len())
59            .unwrap_or(0)
60    }
61}
62
63impl Default for InMemoryBackend {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69#[async_trait::async_trait]
70impl FoundryBackend for InMemoryBackend {
71    // Project operations
72    async fn create_project(&self, config: ProjectConfig) -> Result<Project> {
73        let mut store = self.store.write().await;
74
75        // Check if project already exists
76        if store.projects.contains_key(&config.name) {
77            return Err(anyhow!("Project '{}' already exists", config.name));
78        }
79
80        let created_at = Utc::now().to_rfc3339();
81        let project = Project {
82            name: config.name.clone(),
83            created_at,
84            path: std::path::PathBuf::from(format!("/memory/{}", config.name)), // Fake path for compatibility
85            location_hint: Some(format!("memory://{}", config.name)),
86            locator: Some(ResourceLocator::FilesystemPath(format!(
87                "memory://{}",
88                config.name
89            ))),
90            vision: Some(config.vision),
91            tech_stack: Some(config.tech_stack),
92            summary: Some(config.summary),
93        };
94
95        store.projects.insert(config.name.clone(), project.clone());
96        store.specs.insert(config.name, HashMap::new());
97
98        Ok(project)
99    }
100
101    async fn project_exists(&self, name: &str) -> Result<bool> {
102        let store = self.store.read().await;
103        Ok(store.projects.contains_key(name))
104    }
105
106    async fn list_projects(&self) -> Result<Vec<ProjectMetadata>> {
107        let store = self.store.read().await;
108        let mut projects: Vec<ProjectMetadata> = store
109            .projects
110            .values()
111            .map(|project| ProjectMetadata {
112                name: project.name.clone(),
113                created_at: project.created_at.clone(),
114                last_modified: project.created_at.clone(), // Use created_at as last_modified for simplicity
115                spec_count: store
116                    .specs
117                    .get(&project.name)
118                    .map(|specs| specs.len())
119                    .unwrap_or(0),
120            })
121            .collect();
122
123        // Sort by created_at descending (newest first)
124        projects.sort_by(|a, b| b.created_at.cmp(&a.created_at));
125        Ok(projects)
126    }
127
128    async fn load_project(&self, name: &str) -> Result<Project> {
129        let store = self.store.read().await;
130        store
131            .projects
132            .get(name)
133            .cloned()
134            .ok_or_else(|| anyhow!("Project '{}' not found", name))
135    }
136
137    // Spec operations
138    async fn create_spec(&self, config: SpecConfig) -> Result<Spec> {
139        let mut store = self.store.write().await;
140
141        // Check if project exists
142        if !store.projects.contains_key(&config.project_name) {
143            return Err(anyhow!("Project '{}' not found", config.project_name));
144        }
145
146        // Generate spec name
147        let now = Utc::now();
148        let spec_name = format!(
149            "{:04}{:02}{:02}_{:02}{:02}{:02}_{}",
150            now.year(),
151            now.month(),
152            now.day(),
153            now.hour(),
154            now.minute(),
155            now.second(),
156            config.feature_name
157        );
158
159        let created_at = now.to_rfc3339();
160        let spec = Spec {
161            name: spec_name.clone(),
162            created_at,
163            path: std::path::PathBuf::from(format!(
164                "/memory/{}/specs/{}",
165                config.project_name, spec_name
166            )),
167            project_name: config.project_name.clone(),
168            location_hint: Some(format!(
169                "memory://{}/specs/{}",
170                config.project_name, spec_name
171            )),
172            locator: Some(ResourceLocator::FilesystemPath(format!(
173                "memory://{}/specs/{}",
174                config.project_name, spec_name
175            ))),
176            content: config.content,
177        };
178
179        store
180            .specs
181            .get_mut(&config.project_name)
182            .unwrap()
183            .insert(spec_name, spec.clone());
184
185        Ok(spec)
186    }
187
188    async fn list_specs(&self, project_name: &str) -> Result<Vec<SpecMetadata>> {
189        let store = self.store.read().await;
190
191        // Check if project exists
192        if !store.projects.contains_key(project_name) {
193            return Err(anyhow!("Project '{}' not found", project_name));
194        }
195
196        let specs = store.specs.get(project_name).unwrap();
197        let mut spec_list: Vec<SpecMetadata> = specs
198            .values()
199            .map(|spec| {
200                // Extract feature name from spec name (format: YYYYMMDD_HHMMSS_feature_name)
201                let feature_name = spec.name.split('_').skip(2).collect::<Vec<_>>().join("_");
202
203                SpecMetadata {
204                    name: spec.name.clone(),
205                    created_at: spec.created_at.clone(),
206                    feature_name,
207                    project_name: spec.project_name.clone(),
208                }
209            })
210            .collect();
211
212        // Sort by created_at descending (newest first)
213        spec_list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
214        Ok(spec_list)
215    }
216
217    async fn load_spec(&self, project_name: &str, spec_name: &str) -> Result<Spec> {
218        let store = self.store.read().await;
219
220        let specs = store
221            .specs
222            .get(project_name)
223            .ok_or_else(|| anyhow!("Project '{}' not found", project_name))?;
224
225        specs.get(spec_name).cloned().ok_or_else(|| {
226            anyhow!(
227                "Spec '{}' not found in project '{}'",
228                spec_name,
229                project_name
230            )
231        })
232    }
233
234    async fn update_spec_content(
235        &self,
236        project_name: &str,
237        spec_name: &str,
238        file_type: SpecFileType,
239        content: &str,
240    ) -> Result<()> {
241        let mut store = self.store.write().await;
242
243        let specs = store
244            .specs
245            .get_mut(project_name)
246            .ok_or_else(|| anyhow!("Project '{}' not found", project_name))?;
247
248        let spec = specs.get_mut(spec_name).ok_or_else(|| {
249            anyhow!(
250                "Spec '{}' not found in project '{}'",
251                spec_name,
252                project_name
253            )
254        })?;
255
256        match file_type {
257            SpecFileType::Spec => spec.content.spec = content.to_string(),
258            SpecFileType::Notes => spec.content.notes = content.to_string(),
259            SpecFileType::TaskList => spec.content.tasks = content.to_string(),
260        }
261
262        Ok(())
263    }
264
265    async fn delete_spec(&self, project_name: &str, spec_name: &str) -> Result<()> {
266        let mut store = self.store.write().await;
267
268        let specs = store
269            .specs
270            .get_mut(project_name)
271            .ok_or_else(|| anyhow!("Project '{}' not found", project_name))?;
272
273        specs.remove(spec_name).ok_or_else(|| {
274            anyhow!(
275                "Spec '{}' not found in project '{}'",
276                spec_name,
277                project_name
278            )
279        })?;
280
281        Ok(())
282    }
283
284    // Helper operations
285    async fn get_latest_spec(&self, project_name: &str) -> Result<Option<SpecMetadata>> {
286        let specs = self.list_specs(project_name).await?;
287        Ok(specs.into_iter().next()) // Already sorted by created_at desc
288    }
289
290    async fn count_specs(&self, project_name: &str) -> Result<usize> {
291        let store = self.store.read().await;
292
293        // Check if project exists
294        if !store.projects.contains_key(project_name) {
295            return Err(anyhow!("Project '{}' not found", project_name));
296        }
297
298        let count = store
299            .specs
300            .get(project_name)
301            .map(|specs| specs.len())
302            .unwrap_or(0);
303
304        Ok(count)
305    }
306
307    // Capabilities introspection
308    fn capabilities(&self) -> BackendCapabilities {
309        BackendCapabilities {
310            supports_documents: true,
311            supports_subtasks: true,
312            url_deeplinks: false,
313            atomic_replace: true,
314            strong_consistency: true,
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_memory_backend_creation() {
325        let backend = InMemoryBackend::new();
326        let capabilities = backend.capabilities();
327        assert!(capabilities.supports_documents);
328        assert!(capabilities.supports_subtasks);
329        assert!(capabilities.atomic_replace);
330        assert!(capabilities.strong_consistency);
331    }
332
333    #[test]
334    fn test_memory_backend_basic_operations() {
335        let rt = tokio::runtime::Builder::new_current_thread()
336            .enable_all()
337            .build()
338            .expect("Failed to create tokio runtime for test");
339
340        rt.block_on(async {
341            let backend = InMemoryBackend::new();
342
343            // Test project creation
344            let config = ProjectConfig {
345                name: "test-project".to_string(),
346                vision: "Test vision".to_string(),
347                tech_stack: "Test tech stack".to_string(),
348                summary: "Test summary".to_string(),
349            };
350
351            let project = backend.create_project(config).await.unwrap();
352            assert_eq!(project.name, "test-project");
353
354            // Test project exists
355            assert!(backend.project_exists("test-project").await.unwrap());
356            assert!(!backend.project_exists("nonexistent").await.unwrap());
357
358            // Test clear
359            backend.clear().await;
360            assert!(!backend.project_exists("test-project").await.unwrap());
361        });
362    }
363}