Skip to main content

foundry_mcp/core/
foundry.rs

1//! Foundry façade providing storage-agnostic domain logic
2
3use crate::core::backends::{FoundryBackend, SpecContentStore};
4use crate::core::edit_engine::{EditCommandsResult, EditEngine};
5use crate::core::spec::SpecMatchStrategy;
6use crate::types::edit_commands::EditCommand;
7use crate::types::{
8    project::{Project, ProjectConfig, ProjectMetadata},
9    spec::{Spec, SpecConfig, SpecFileType, SpecMetadata},
10};
11use anyhow::Result;
12
13/// Foundry façade providing storage-agnostic domain logic
14pub struct Foundry<B: FoundryBackend> {
15    backend: B,
16}
17
18impl<B: FoundryBackend> Foundry<B> {
19    pub fn new(backend: B) -> Self {
20        Self { backend }
21    }
22
23    // Project operations - thin delegation
24    pub async fn create_project(&self, config: ProjectConfig) -> Result<Project> {
25        self.backend.create_project(config).await
26    }
27
28    pub async fn project_exists(&self, name: &str) -> Result<bool> {
29        self.backend.project_exists(name).await
30    }
31
32    pub async fn list_projects(&self) -> Result<Vec<ProjectMetadata>> {
33        self.backend.list_projects().await
34    }
35
36    pub async fn load_project(&self, name: &str) -> Result<Project> {
37        self.backend.load_project(name).await
38    }
39
40    // Spec operations - thin delegation
41    pub async fn create_spec(&self, config: SpecConfig) -> Result<Spec> {
42        self.backend.create_spec(config).await
43    }
44
45    pub async fn list_specs(&self, project_name: &str) -> Result<Vec<SpecMetadata>> {
46        self.backend.list_specs(project_name).await
47    }
48
49    pub async fn load_spec(&self, project_name: &str, spec_name: &str) -> Result<Spec> {
50        self.backend.load_spec(project_name, spec_name).await
51    }
52
53    pub async fn update_spec_content(
54        &self,
55        project_name: &str,
56        spec_name: &str,
57        file_type: SpecFileType,
58        content: &str,
59    ) -> Result<()> {
60        self.backend
61            .update_spec_content(project_name, spec_name, file_type, content)
62            .await
63    }
64
65    pub async fn delete_spec(&self, project_name: &str, spec_name: &str) -> Result<()> {
66        self.backend.delete_spec(project_name, spec_name).await
67    }
68
69    // Helper operations - thin delegation
70    pub async fn get_latest_spec(&self, project_name: &str) -> Result<Option<SpecMetadata>> {
71        self.backend.get_latest_spec(project_name).await
72    }
73
74    pub async fn count_specs(&self, project_name: &str) -> Result<usize> {
75        self.backend.count_specs(project_name).await
76    }
77
78    // Domain logic - centralized here
79    pub fn generate_spec_name(feature_name: &str) -> String {
80        // This will be moved from spec.rs in Phase 1
81        use chrono::{Datelike, Timelike, Utc};
82        let now = Utc::now();
83        format!(
84            "{:04}{:02}{:02}_{:02}{:02}{:02}_{}",
85            now.year(),
86            now.month(),
87            now.day(),
88            now.hour(),
89            now.minute(),
90            now.second(),
91            feature_name
92        )
93    }
94
95    pub fn validate_spec_name(spec_name: &str) -> Result<()> {
96        // This will be moved from spec.rs in Phase 1
97        use crate::utils::timestamp;
98
99        if timestamp::parse_spec_timestamp(spec_name).is_none() {
100            return Err(anyhow::anyhow!(
101                "Invalid spec name format. Expected: YYYYMMDD_HHMMSS_feature_name, got: {}",
102                spec_name
103            ));
104        }
105
106        // Validate feature name part
107        if let Some(feature_name) = timestamp::extract_feature_name(spec_name) {
108            if feature_name.is_empty() {
109                return Err(anyhow::anyhow!(
110                    "Spec name must include a feature name after the timestamp"
111                ));
112            }
113
114            // Validate feature name follows snake_case convention
115            if !feature_name
116                .chars()
117                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
118                || feature_name.starts_with('_')
119                || feature_name.ends_with('_')
120                || feature_name.contains("__")
121            {
122                return Err(anyhow::anyhow!(
123                    "Feature name must be in snake_case format: {}",
124                    feature_name
125                ));
126            }
127        } else {
128            return Err(anyhow::anyhow!(
129                "Could not extract feature name from spec name: {}",
130                spec_name
131            ));
132        }
133
134        Ok(())
135    }
136
137    pub async fn find_spec_match(
138        &self,
139        project_name: &str,
140        query: &str,
141    ) -> Result<SpecMatchStrategy> {
142        // This will be moved from spec.rs in Phase 1
143        use strsim;
144
145        // Validate inputs
146        if query.trim().is_empty() {
147            return Err(anyhow::anyhow!("Query cannot be empty"));
148        }
149
150        if project_name.trim().is_empty() {
151            return Err(anyhow::anyhow!("Project name cannot be empty"));
152        }
153
154        let available_specs = self.list_specs(project_name).await?;
155
156        if available_specs.is_empty() {
157            return Ok(SpecMatchStrategy::None);
158        }
159
160        // Try exact spec name match first (highest priority)
161        if let Some(exact_match) = available_specs.iter().find(|s| s.name == query) {
162            return Ok(SpecMatchStrategy::Exact(exact_match.name.clone()));
163        }
164
165        // Try exact feature name match
166        if let Some(feature_match) = available_specs.iter().find(|s| s.feature_name == query) {
167            return Ok(SpecMatchStrategy::FeatureExact(feature_match.name.clone()));
168        }
169
170        // Try feature name substring match (case-insensitive)
171        let query_lower = query.to_lowercase();
172        let substring_matches: Vec<&SpecMetadata> = available_specs
173            .iter()
174            .filter(|s| s.feature_name.to_lowercase().contains(&query_lower))
175            .collect();
176
177        if substring_matches.len() == 1 {
178            return Ok(SpecMatchStrategy::FeatureFuzzy(
179                substring_matches[0].name.clone(),
180            ));
181        } else if substring_matches.len() > 1 {
182            // Multiple substring matches - return for disambiguation
183            let mut names: Vec<String> = substring_matches
184                .into_iter()
185                .map(|s| s.name.clone())
186                .collect();
187            names.sort();
188            return Ok(SpecMatchStrategy::Multiple(names));
189        }
190
191        // Try fuzzy matching on feature names
192        let feature_matches: Vec<(String, f32)> = available_specs
193            .iter()
194            .map(|s| {
195                let similarity = strsim::normalized_levenshtein(query, &s.feature_name) as f32;
196                (s.name.clone(), similarity)
197            })
198            .filter(|(_, confidence)| *confidence > 0.8) // High confidence threshold
199            .collect();
200
201        if feature_matches.len() == 1 {
202            return Ok(SpecMatchStrategy::FeatureFuzzy(
203                feature_matches[0].0.clone(),
204            ));
205        } else if feature_matches.len() > 1 {
206            // Multiple feature matches - return for disambiguation
207            let mut names: Vec<String> =
208                feature_matches.into_iter().map(|(name, _)| name).collect();
209            names.sort();
210            return Ok(SpecMatchStrategy::Multiple(names));
211        }
212
213        // Try fuzzy matching on spec names
214        let name_matches: Vec<(String, f32)> = available_specs
215            .iter()
216            .map(|s| {
217                let similarity = strsim::normalized_levenshtein(query, &s.name) as f32;
218                (s.name.clone(), similarity)
219            })
220            .filter(|(_, confidence)| *confidence > 0.8) // High confidence threshold
221            .collect();
222
223        if name_matches.len() == 1 {
224            return Ok(SpecMatchStrategy::NameFuzzy(name_matches[0].0.clone()));
225        } else if name_matches.len() > 1 {
226            // Multiple name matches - return for disambiguation
227            let mut names: Vec<String> = name_matches.into_iter().map(|(name, _)| name).collect();
228            names.sort();
229            return Ok(SpecMatchStrategy::Multiple(names));
230        }
231
232        Ok(SpecMatchStrategy::None)
233    }
234
235    // Edit commands integration
236    pub async fn apply_edit_commands(
237        &self,
238        project_name: &str,
239        spec_name: &str,
240        commands: &[EditCommand],
241    ) -> Result<EditCommandsResult> {
242        EditEngine::apply_edit_commands_with_store(project_name, spec_name, commands, self).await
243    }
244}
245
246/// SpecContentStore implementation for the Foundry façade
247#[async_trait::async_trait]
248impl<B: FoundryBackend> SpecContentStore for Foundry<B> {
249    async fn read_spec_file(
250        &self,
251        project_name: &str,
252        spec_name: &str,
253        file_type: SpecFileType,
254    ) -> Result<String> {
255        // Try to load the spec to get the file content
256        let spec = self.load_spec(project_name, spec_name).await?;
257
258        let content = match file_type {
259            SpecFileType::Spec => spec.content.spec,
260            SpecFileType::Notes => spec.content.notes,
261            SpecFileType::TaskList => spec.content.tasks,
262        };
263
264        Ok(content)
265    }
266
267    async fn write_spec_file(
268        &self,
269        project_name: &str,
270        spec_name: &str,
271        file_type: SpecFileType,
272        content: &str,
273    ) -> Result<()> {
274        self.update_spec_content(project_name, spec_name, file_type, content)
275            .await
276    }
277
278    async fn is_file_modified(
279        &self,
280        project_name: &str,
281        spec_name: &str,
282        file_type: SpecFileType,
283        new_content: &str,
284    ) -> Result<bool> {
285        let current_content = self
286            .read_spec_file(project_name, spec_name, file_type)
287            .await?;
288        Ok(current_content != new_content)
289    }
290}
291
292/// Get the default Foundry instance with filesystem backend
293///
294/// Returns a Foundry instance using the FilesystemBackend as the default storage backend.
295pub fn get_default_foundry() -> Result<Foundry<crate::core::backends::filesystem::FilesystemBackend>>
296{
297    let backend = crate::core::backends::filesystem::FilesystemBackend::new();
298    Ok(Foundry::new(backend))
299}