foundry_mcp/core/backends/
memory.rs1use 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#[derive(Debug, Clone, Default)]
20struct MemoryStore {
21 projects: HashMap<String, Project>,
22 specs: HashMap<String, HashMap<String, Spec>>, }
24
25#[derive(Debug, Clone)]
27pub struct InMemoryBackend {
28 store: Arc<RwLock<MemoryStore>>,
29}
30
31impl InMemoryBackend {
32 pub fn new() -> Self {
34 Self {
35 store: Arc::new(RwLock::new(MemoryStore::default())),
36 }
37 }
38
39 pub async fn clear(&self) {
41 let mut store = self.store.write().await;
42 store.projects.clear();
43 store.specs.clear();
44 }
45
46 pub async fn project_count(&self) -> usize {
48 let store = self.store.read().await;
49 store.projects.len()
50 }
51
52 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 async fn create_project(&self, config: ProjectConfig) -> Result<Project> {
73 let mut store = self.store.write().await;
74
75 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)), 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(), spec_count: store
116 .specs
117 .get(&project.name)
118 .map(|specs| specs.len())
119 .unwrap_or(0),
120 })
121 .collect();
122
123 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 async fn create_spec(&self, config: SpecConfig) -> Result<Spec> {
139 let mut store = self.store.write().await;
140
141 if !store.projects.contains_key(&config.project_name) {
143 return Err(anyhow!("Project '{}' not found", config.project_name));
144 }
145
146 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 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 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 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 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()) }
289
290 async fn count_specs(&self, project_name: &str) -> Result<usize> {
291 let store = self.store.read().await;
292
293 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 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 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 assert!(backend.project_exists("test-project").await.unwrap());
356 assert!(!backend.project_exists("nonexistent").await.unwrap());
357
358 backend.clear().await;
360 assert!(!backend.project_exists("test-project").await.unwrap());
361 });
362 }
363}