foundry_mcp/core/backends/
filesystem.rs1use anyhow::{Context, Result};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use std::fs;
10use std::path::PathBuf;
11use tracing::warn;
12
13use crate::core::backends::{BackendCapabilities, FoundryBackend, ResourceLocator};
14use crate::core::filesystem;
15use crate::types::{
16 project::{Project, ProjectConfig, ProjectMetadata},
17 spec::{Spec, SpecConfig, SpecContentData, SpecFileType, SpecMetadata},
18};
19use crate::utils::timestamp;
20
21pub struct FilesystemBackend;
26
27impl FilesystemBackend {
28 pub fn new() -> Self {
29 Self
30 }
31
32 fn get_project_path(&self, name: &str) -> Result<PathBuf> {
33 let foundry_dir = filesystem::foundry_dir()?;
34 Ok(foundry_dir.join(name))
35 }
36
37 fn get_spec_path(&self, project_name: &str, spec_name: &str) -> Result<PathBuf> {
38 let foundry_dir = filesystem::foundry_dir()?;
39 Ok(foundry_dir.join(project_name).join("specs").join(spec_name))
40 }
41
42 fn capabilities() -> BackendCapabilities {
43 BackendCapabilities {
44 supports_documents: true,
45 supports_subtasks: true,
46 url_deeplinks: false,
47 atomic_replace: true,
48 strong_consistency: true,
49 }
50 }
51}
52
53impl Default for FilesystemBackend {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59#[async_trait]
60impl FoundryBackend for FilesystemBackend {
61 async fn create_project(&self, config: ProjectConfig) -> Result<Project> {
62 let project_path = self.get_project_path(&config.name)?;
63 let created_at = Utc::now().to_rfc3339();
64
65 filesystem::create_dir_all(&project_path)?;
67 filesystem::create_dir_all(project_path.join("specs"))?;
68
69 filesystem::write_file_atomic(project_path.join("vision.md"), &config.vision)?;
71 filesystem::write_file_atomic(project_path.join("tech-stack.md"), &config.tech_stack)?;
72 filesystem::write_file_atomic(project_path.join("summary.md"), &config.summary)?;
73
74 let path_string = project_path.to_string_lossy().to_string();
75 Ok(Project {
76 name: config.name,
77 created_at,
78 path: project_path, location_hint: Some(path_string.clone()),
80 locator: Some(ResourceLocator::FilesystemPath(path_string)),
81 vision: Some(config.vision),
82 tech_stack: Some(config.tech_stack),
83 summary: Some(config.summary),
84 })
85 }
86
87 async fn project_exists(&self, name: &str) -> Result<bool> {
88 let project_path = self.get_project_path(name)?;
89 Ok(project_path.exists())
90 }
91
92 async fn list_projects(&self) -> Result<Vec<ProjectMetadata>> {
93 let foundry_dir = filesystem::foundry_dir()?;
94
95 if !foundry_dir.exists() {
96 return Ok(Vec::new());
97 }
98
99 let projects: Vec<ProjectMetadata> = fs::read_dir(foundry_dir)?
100 .filter_map(|entry| {
101 let entry = entry.ok()?;
102 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
103 Some(entry)
104 } else {
105 None
106 }
107 })
108 .map(|entry| {
109 let project_name = entry.file_name().to_string_lossy().to_string();
110 let project_path = entry.path();
111
112 let specs_dir = project_path.join("specs");
114 let spec_count = if specs_dir.exists() {
115 fs::read_dir(specs_dir)
116 .ok()
117 .map(|dir| {
118 dir.filter_map(|e| e.ok())
119 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
120 .fold(0, |acc, _| acc + 1)
121 })
122 .unwrap_or(0)
123 } else {
124 0
125 };
126
127 let created_at = entry
129 .metadata()
130 .ok()
131 .and_then(|m| m.created().ok())
132 .map(DateTime::<Utc>::from)
133 .map(|dt| dt.to_rfc3339())
134 .unwrap_or_else(|| Utc::now().to_rfc3339());
135
136 ProjectMetadata {
137 name: project_name,
138 created_at: created_at.clone(),
139 spec_count,
140 last_modified: created_at, }
142 })
143 .collect();
144
145 Ok(projects)
146 }
147
148 async fn load_project(&self, name: &str) -> Result<Project> {
149 let project_path = self.get_project_path(name)?;
150
151 if !project_path.exists() {
152 return Err(anyhow::anyhow!("Project '{}' not found", name));
153 }
154
155 let vision = filesystem::read_file(project_path.join("vision.md")).ok();
157 let tech_stack = filesystem::read_file(project_path.join("tech-stack.md")).ok();
158 let summary = filesystem::read_file(project_path.join("summary.md")).ok();
159
160 let created_at =
162 DateTime::<Utc>::from(fs::metadata(&project_path)?.created()?).to_rfc3339();
163
164 let path_string = project_path.to_string_lossy().to_string();
165 Ok(Project {
166 name: name.to_string(),
167 created_at,
168 path: project_path, location_hint: Some(path_string.clone()),
170 locator: Some(ResourceLocator::FilesystemPath(path_string)),
171 vision,
172 tech_stack,
173 summary,
174 })
175 }
176
177 async fn create_spec(&self, config: SpecConfig) -> Result<Spec> {
178 let foundry_dir = filesystem::foundry_dir()?;
179 let project_path = foundry_dir.join(&config.project_name);
180 let specs_dir = project_path.join("specs");
181 let spec_name =
182 crate::core::foundry::Foundry::<Self>::generate_spec_name(&config.feature_name);
183 let spec_path = specs_dir.join(&spec_name);
184 let created_at = Utc::now().to_rfc3339();
185
186 filesystem::create_dir_all(&spec_path)?;
188
189 filesystem::write_file_atomic(spec_path.join("spec.md"), &config.content.spec)?;
191 filesystem::write_file_atomic(spec_path.join("notes.md"), &config.content.notes)?;
192 filesystem::write_file_atomic(spec_path.join("task-list.md"), &config.content.tasks)?;
193
194 let path_string = spec_path.to_string_lossy().to_string();
195 Ok(Spec {
196 name: spec_name,
197 created_at,
198 path: spec_path, project_name: config.project_name,
200 location_hint: Some(path_string.clone()),
201 locator: Some(ResourceLocator::FilesystemPath(path_string)),
202 content: config.content,
203 })
204 }
205
206 async fn list_specs(&self, project_name: &str) -> Result<Vec<SpecMetadata>> {
207 let foundry_dir = filesystem::foundry_dir()?;
208 let specs_dir = foundry_dir.join(project_name).join("specs");
209
210 if !specs_dir.exists() {
211 return Ok(Vec::new());
212 }
213
214 let mut specs = Vec::new();
215 let mut malformed_count = 0;
216
217 for entry in fs::read_dir(specs_dir)? {
218 let entry = match entry {
219 Ok(entry) => entry,
220 Err(e) => {
221 warn!("Failed to read directory entry: {}", e);
222 continue;
223 }
224 };
225
226 if let Ok(file_type) = entry.file_type() {
227 if file_type.is_dir() {
228 let spec_name = entry.file_name().to_string_lossy().to_string();
229
230 match (
232 timestamp::parse_spec_timestamp(&spec_name),
233 timestamp::extract_feature_name(&spec_name),
234 ) {
235 (Some(timestamp_str), Some(feature_name)) => {
236 let created_at = timestamp::spec_timestamp_to_iso(×tamp_str)
238 .unwrap_or_else(|_| timestamp::iso_timestamp());
239
240 specs.push(SpecMetadata {
241 name: spec_name.clone(),
242 created_at,
243 feature_name,
244 project_name: project_name.to_string(),
245 });
246 }
247 _ => {
248 malformed_count += 1;
249 warn!("Skipping malformed spec directory: '{}'", spec_name);
250 }
251 }
252 }
253 } else {
254 warn!(
255 "Failed to determine file type for entry: {:?}",
256 entry.path()
257 );
258 }
259 }
260
261 if malformed_count > 0 {
263 warn!(
264 "Skipped {} malformed spec directories in project '{}'",
265 malformed_count, project_name
266 );
267 }
268
269 specs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
271
272 Ok(specs)
273 }
274
275 async fn load_spec(&self, project_name: &str, spec_name: &str) -> Result<Spec> {
276 crate::core::foundry::Foundry::<Self>::validate_spec_name(spec_name)
278 .with_context(|| format!("Invalid spec name: {}", spec_name))?;
279
280 let foundry_dir = filesystem::foundry_dir()?;
281 let spec_path = foundry_dir.join(project_name).join("specs").join(spec_name);
282
283 if !spec_path.exists() {
284 return Err(anyhow::anyhow!(
285 "Spec '{}' not found in project '{}'",
286 spec_name,
287 project_name
288 ));
289 }
290
291 let spec_content = filesystem::read_file(spec_path.join("spec.md"))?;
293 let notes = filesystem::read_file(spec_path.join("notes.md"))?;
294 let task_list = filesystem::read_file(spec_path.join("task-list.md"))?;
295
296 let created_at = timestamp::parse_spec_timestamp(spec_name).map_or_else(
298 || {
299 fs::metadata(&spec_path)
301 .and_then(|metadata| metadata.created())
302 .map_err(anyhow::Error::from)
303 .and_then(|time| {
304 time.duration_since(std::time::UNIX_EPOCH)
305 .map_err(anyhow::Error::from)
306 })
307 .map(|duration| {
308 chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0)
309 .unwrap_or_else(chrono::Utc::now)
310 .to_rfc3339()
311 })
312 .unwrap_or_else(|_| timestamp::iso_timestamp())
313 },
314 |timestamp_str| {
315 timestamp::spec_timestamp_to_iso(×tamp_str)
316 .unwrap_or_else(|_| timestamp::iso_timestamp())
317 },
318 );
319
320 let path_string = spec_path.to_string_lossy().to_string();
321 Ok(Spec {
322 name: spec_name.to_string(),
323 created_at,
324 path: spec_path, project_name: project_name.to_string(),
326 location_hint: Some(path_string.clone()),
327 locator: Some(ResourceLocator::FilesystemPath(path_string)),
328 content: SpecContentData {
329 spec: spec_content,
330 notes,
331 tasks: task_list,
332 },
333 })
334 }
335
336 async fn update_spec_content(
337 &self,
338 project_name: &str,
339 spec_name: &str,
340 file_type: SpecFileType,
341 new_content: &str,
342 ) -> Result<()> {
343 crate::core::foundry::Foundry::<Self>::validate_spec_name(spec_name)?;
345 if !self.spec_exists(project_name, spec_name).await? {
346 return Err(anyhow::anyhow!(
347 "Spec '{}' not found in project '{}'",
348 spec_name,
349 project_name
350 ));
351 }
352
353 let foundry_dir = filesystem::foundry_dir()?;
354 let spec_path = foundry_dir.join(project_name).join("specs").join(spec_name);
355
356 let file_path = match file_type {
357 SpecFileType::Spec => spec_path.join("spec.md"),
358 SpecFileType::Notes => spec_path.join("notes.md"),
359 SpecFileType::TaskList => spec_path.join("task-list.md"),
360 };
361
362 filesystem::write_file_atomic(&file_path, new_content).with_context(|| {
363 format!("Failed to update {:?} for spec '{}'", file_type, spec_name)
364 })?;
365
366 Ok(())
367 }
368
369 async fn delete_spec(&self, project_name: &str, spec_name: &str) -> Result<()> {
370 crate::core::foundry::Foundry::<Self>::validate_spec_name(spec_name)?;
371
372 let spec_path = self.get_spec_path(project_name, spec_name)?;
373
374 if !spec_path.exists() {
375 return Err(anyhow::anyhow!(
376 "Spec '{}' not found in project '{}'",
377 spec_name,
378 project_name
379 ));
380 }
381
382 std::fs::remove_dir_all(&spec_path).with_context(|| {
383 format!(
384 "Failed to delete spec '{}' from project '{}'",
385 spec_name, project_name
386 )
387 })?;
388
389 Ok(())
390 }
391
392 async fn get_latest_spec(&self, project_name: &str) -> Result<Option<SpecMetadata>> {
393 let specs = self.list_specs(project_name).await?;
394 Ok(specs.into_iter().next()) }
396
397 async fn count_specs(&self, project_name: &str) -> Result<usize> {
398 let specs = self.list_specs(project_name).await?;
399 Ok(specs.len())
400 }
401
402 fn capabilities(&self) -> BackendCapabilities {
403 Self::capabilities()
404 }
405}
406
407impl FilesystemBackend {
408 pub async fn spec_exists(&self, project_name: &str, spec_name: &str) -> Result<bool> {
410 let spec_path = self.get_spec_path(project_name, spec_name)?;
411 Ok(spec_path.exists() && spec_path.is_dir())
412 }
413}