foundry_mcp/core/backends/
filesystem.rs

1//! Filesystem backend implementation
2//!
3//! Implements the FoundryBackend trait using direct filesystem operations.
4//! This backend preserves the existing directory structure and atomic write semantics.
5
6use 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
21/// Filesystem backend implementation
22///
23/// Implements the FoundryBackend trait using direct filesystem operations.
24/// Preserves existing directory structure, atomic writes, and timestamp formats.
25pub 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        // Create project directory structure
66        filesystem::create_dir_all(&project_path)?;
67        filesystem::create_dir_all(project_path.join("specs"))?;
68
69        // Write project files
70        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, // Keep for backward compatibility
79            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                // Count specs using fold
113                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                // Get creation time (use directory creation time as fallback)
128                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, // TODO: Use actual last modified time
141                }
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        // Read project files
156        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        // Get creation time from directory metadata
161        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, // Keep for backward compatibility
169            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        // Ensure specs directory exists
187        filesystem::create_dir_all(&spec_path)?;
188
189        // Write spec files
190        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, // Keep for backward compatibility
199            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                    // Use enhanced timestamp parsing with better error handling
231                    match (
232                        timestamp::parse_spec_timestamp(&spec_name),
233                        timestamp::extract_feature_name(&spec_name),
234                    ) {
235                        (Some(timestamp_str), Some(feature_name)) => {
236                            // Convert timestamp to ISO format for consistent storage
237                            let created_at = timestamp::spec_timestamp_to_iso(&timestamp_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        // Log summary of malformed specs if any were found
262        if malformed_count > 0 {
263            warn!(
264                "Skipped {} malformed spec directories in project '{}'",
265                malformed_count, project_name
266            );
267        }
268
269        // Sort by creation time (newest first)
270        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        // Validate spec name format first
277        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        // Read spec files
292        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        // Get creation time from spec name timestamp (more reliable than filesystem metadata)
297        let created_at = timestamp::parse_spec_timestamp(spec_name).map_or_else(
298            || {
299                // Fallback to filesystem metadata if timestamp parsing fails
300                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(&timestamp_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, // Keep for backward compatibility
325            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        // Validate spec exists
344        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()) // Already sorted by creation time (newest first)
395    }
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    /// Check if a spec exists
409    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}