Skip to main content

foundry_mcp/core/
spec.rs

1//! Spec management core logic
2//!
3//! This module now delegates to the backend abstraction instead of direct I/O.
4//! The functions here maintain backward compatibility while using the Foundry façade.
5
6use anyhow::{Context, Result};
7use std::path::PathBuf;
8
9use crate::core::foundry::get_default_foundry;
10use crate::types::spec::{
11    ContentValidationStatus, Spec, SpecConfig, SpecFileType, SpecFilter, SpecMetadata,
12    SpecValidationResult,
13};
14
15// Import SpecContentData only for tests
16#[cfg(test)]
17use crate::types::spec::SpecContentData;
18
19/// Helper function to run async operations in sync context
20fn run_async<F, R>(f: F) -> Result<R>
21where
22    F: std::future::Future<Output = Result<R>>,
23{
24    // Use tokio runtime to block on futures
25    let rt = tokio::runtime::Builder::new_current_thread()
26        .enable_all()
27        .build()
28        .context("Failed to create tokio runtime")?;
29    rt.block_on(f)
30}
31
32/// Generate timestamped spec name
33pub fn generate_spec_name(feature_name: &str) -> String {
34    crate::core::foundry::Foundry::<crate::core::backends::filesystem::FilesystemBackend>::generate_spec_name(feature_name)
35}
36
37/// Create a new spec
38pub fn create_spec(config: SpecConfig) -> Result<Spec> {
39    let foundry = get_default_foundry()?;
40    run_async(foundry.create_spec(config))
41}
42
43/// Validate spec directory name format
44pub fn validate_spec_name(spec_name: &str) -> Result<()> {
45    crate::core::foundry::Foundry::<crate::core::backends::filesystem::FilesystemBackend>::validate_spec_name(spec_name)
46}
47/// List specs for a project with enhanced validation
48pub fn list_specs(project_name: &str) -> Result<Vec<SpecMetadata>> {
49    let foundry = get_default_foundry()?;
50    run_async(foundry.list_specs(project_name))
51}
52
53/// List specs with filtering capabilities
54pub fn list_specs_filtered(project_name: &str, filter: SpecFilter) -> Result<Vec<SpecMetadata>> {
55    let specs = list_specs(project_name)?;
56
57    let mut filtered_specs: Vec<SpecMetadata> = specs
58        .into_iter()
59        .filter(|spec| {
60            // Apply feature name filter
61            if let Some(name_filter) = &filter.feature_name_contains
62                && !spec
63                    .feature_name
64                    .to_lowercase()
65                    .contains(&name_filter.to_lowercase())
66            {
67                return false;
68            }
69
70            // Apply date range filters
71            if let Some(after) = &filter.created_after
72                && spec.created_at < *after
73            {
74                return false;
75            }
76
77            if let Some(before) = &filter.created_before
78                && spec.created_at > *before
79            {
80                return false;
81            }
82
83            true
84        })
85        .collect();
86
87    // Apply limit
88    if let Some(limit) = filter.limit {
89        filtered_specs.truncate(limit);
90    }
91
92    Ok(filtered_specs)
93}
94
95/// Get the most recent spec for a project
96pub fn get_latest_spec(project_name: &str) -> Result<Option<SpecMetadata>> {
97    let foundry = get_default_foundry()?;
98    run_async(foundry.get_latest_spec(project_name))
99}
100
101/// Count total specs for a project
102pub fn count_specs(project_name: &str) -> Result<usize> {
103    let foundry = get_default_foundry()?;
104    run_async(foundry.count_specs(project_name))
105}
106
107/// Check if a spec exists
108pub fn spec_exists(project_name: &str, spec_name: &str) -> Result<bool> {
109    run_async(async {
110        // Access the backend through the foundry instance
111        let backend = crate::core::backends::filesystem::FilesystemBackend::new();
112        backend.spec_exists(project_name, spec_name).await
113    })
114}
115
116/// Update spec content (for task list updates)
117pub fn update_spec_content(
118    project_name: &str,
119    spec_name: &str,
120    file_type: SpecFileType,
121    new_content: &str,
122) -> Result<()> {
123    let foundry = get_default_foundry()?;
124    run_async(foundry.update_spec_content(project_name, spec_name, file_type, new_content))
125}
126
127/// Get spec directory path
128pub fn get_spec_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
129    let foundry_dir = crate::core::filesystem::foundry_dir()?;
130    Ok(foundry_dir.join(project_name).join("specs").join(spec_name))
131}
132
133/// Get specs directory path for a project
134pub fn get_specs_directory(project_name: &str) -> Result<PathBuf> {
135    let foundry_dir = crate::core::filesystem::foundry_dir()?;
136    Ok(foundry_dir.join(project_name).join("specs"))
137}
138
139/// Ensure specs directory exists for a project
140pub fn ensure_specs_directory(project_name: &str) -> Result<PathBuf> {
141    let specs_dir = get_specs_directory(project_name)?;
142    crate::core::filesystem::create_dir_all(&specs_dir).with_context(|| {
143        format!(
144            "Failed to create specs directory for project '{}'",
145            project_name
146        )
147    })?;
148    Ok(specs_dir)
149}
150
151/// Delete a spec (with confirmation)
152pub fn delete_spec(project_name: &str, spec_name: &str) -> Result<()> {
153    let foundry = get_default_foundry()?;
154    run_async(foundry.delete_spec(project_name, spec_name))
155}
156
157/// Validate spec content files exist and are readable
158pub fn validate_spec_files(project_name: &str, spec_name: &str) -> Result<SpecValidationResult> {
159    let spec_path = get_spec_path(project_name, spec_name)?;
160
161    if !spec_path.exists() {
162        return Err(anyhow::anyhow!(
163            "Spec '{}' not found in project '{}'",
164            spec_name,
165            project_name
166        ));
167    }
168
169    let spec_file = spec_path.join("spec.md");
170    let notes_file = spec_path.join("notes.md");
171    let task_list_file = spec_path.join("task-list.md");
172
173    let mut result = SpecValidationResult {
174        spec_name: spec_name.to_string(),
175        project_name: project_name.to_string(),
176        spec_file_exists: spec_file.exists(),
177        notes_file_exists: notes_file.exists(),
178        task_list_file_exists: task_list_file.exists(),
179        content_validation: ContentValidationStatus {
180            spec_valid: false,
181            notes_valid: false,
182            task_list_valid: false,
183        },
184        validation_errors: Vec::new(),
185    };
186
187    // Validate file contents if they exist
188    if result.spec_file_exists {
189        match crate::core::filesystem::read_file(&spec_file) {
190            Ok(content) => {
191                result.content_validation.spec_valid = !content.trim().is_empty();
192                if !result.content_validation.spec_valid {
193                    result
194                        .validation_errors
195                        .push("Spec file is empty".to_string());
196                }
197            }
198            Err(e) => {
199                result
200                    .validation_errors
201                    .push(format!("Cannot read spec file: {}", e));
202            }
203        }
204    } else {
205        result
206            .validation_errors
207            .push("Spec file missing".to_string());
208    }
209
210    if result.notes_file_exists {
211        match crate::core::filesystem::read_file(&notes_file) {
212            Ok(content) => {
213                result.content_validation.notes_valid = !content.trim().is_empty();
214            }
215            Err(e) => {
216                result
217                    .validation_errors
218                    .push(format!("Cannot read notes file: {}", e));
219            }
220        }
221    }
222
223    if result.task_list_file_exists {
224        match crate::core::filesystem::read_file(&task_list_file) {
225            Ok(content) => {
226                result.content_validation.task_list_valid = !content.trim().is_empty();
227            }
228            Err(e) => {
229                result
230                    .validation_errors
231                    .push(format!("Cannot read task list file: {}", e));
232            }
233        }
234    }
235
236    Ok(result)
237}
238
239/// Fuzzy matching strategy for spec discovery
240#[derive(Debug, Clone, PartialEq)]
241pub enum SpecMatchStrategy {
242    /// Direct exact match found
243    Exact(String),
244    /// Matched by feature name (exact)
245    FeatureExact(String),
246    /// Matched by feature name (fuzzy)
247    FeatureFuzzy(String),
248    /// Matched by spec name similarity
249    NameFuzzy(String),
250    /// Multiple candidates found
251    Multiple(Vec<String>),
252    /// No reasonable matches
253    None,
254}
255
256/// Find the best matching spec using fuzzy matching
257pub fn find_spec_match(project_name: &str, query: &str) -> Result<SpecMatchStrategy> {
258    let foundry = get_default_foundry()?;
259    run_async(foundry.find_spec_match(project_name, query))
260}
261
262/// Load a spec with fuzzy matching support and comprehensive error handling
263pub fn load_spec_with_fuzzy(project_name: &str, query: &str) -> Result<(Spec, SpecMatchStrategy)> {
264    // Validate inputs with detailed error messages
265    if query.trim().is_empty() {
266        return Err(anyhow::anyhow!(
267            "Cannot search for empty spec name. Please provide a spec name or feature name to search for."
268        ));
269    }
270
271    if project_name.trim().is_empty() {
272        return Err(anyhow::anyhow!(
273            "Project name cannot be empty. Please specify a valid project name."
274        ));
275    }
276
277    let match_strategy = find_spec_match(project_name, query)?;
278
279    match &match_strategy {
280        SpecMatchStrategy::Exact(spec_name)
281        | SpecMatchStrategy::FeatureExact(spec_name)
282        | SpecMatchStrategy::FeatureFuzzy(spec_name)
283        | SpecMatchStrategy::NameFuzzy(spec_name) => {
284            let spec = load_spec(project_name, spec_name)
285                .with_context(|| format!("Failed to load matched spec '{}'", spec_name))?;
286            Ok((spec, match_strategy))
287        }
288        SpecMatchStrategy::Multiple(candidates) => {
289            // Provide detailed disambiguation with suggestions
290            let candidate_list = candidates
291                .iter()
292                .enumerate()
293                .map(|(i, name)| format!("  {}. {}", i + 1, name))
294                .collect::<Vec<_>>()
295                .join("\n");
296
297            Err(anyhow::anyhow!(
298                "Multiple specs match '{}':\n{}\n\nPlease specify which one you want to load by using the exact spec name or a more specific query.",
299                query,
300                candidate_list
301            ))
302        }
303        SpecMatchStrategy::None => {
304            // Get available specs for helpful error message
305            let available_specs = list_specs(project_name)?;
306            if available_specs.is_empty() {
307                Err(anyhow::anyhow!(
308                    "No specs found in project '{}'. This project doesn't have any specifications yet.\n\nTo create your first spec, use:\n  mcp_foundry_create_spec {} <feature_name>\n\nFor example:\n  mcp_foundry_create_spec {} user_authentication",
309                    project_name,
310                    project_name,
311                    project_name
312                ))
313            } else {
314                // Show available specs with better formatting
315                let spec_list = if available_specs.len() <= 10 {
316                    available_specs
317                        .iter()
318                        .map(|s| format!("  - {} ({})", s.name, s.feature_name))
319                        .collect::<Vec<_>>()
320                        .join("\n")
321                } else {
322                    format!(
323                        "  {} specs available (showing first 10):\n{}",
324                        available_specs.len(),
325                        available_specs
326                            .iter()
327                            .take(10)
328                            .map(|s| format!("  - {} ({})", s.name, s.feature_name))
329                            .collect::<Vec<_>>()
330                            .join("\n")
331                    )
332                };
333
334                Err(anyhow::anyhow!(
335                    "No specs found matching '{}'.\n\nAvailable specs:\n{}\n\nTry using a more specific search term or use the exact spec name.",
336                    query,
337                    spec_list
338                ))
339            }
340        }
341    }
342}
343
344/// Load a specific spec with validation
345pub fn load_spec(project_name: &str, spec_name: &str) -> Result<Spec> {
346    let foundry = get_default_foundry()?;
347    run_async(foundry.load_spec(project_name, spec_name))
348}
349
350/// Get the file path for a spec.md file
351pub fn get_spec_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
352    let spec_path = get_spec_path(project_name, spec_name)?;
353    Ok(spec_path.join("spec.md"))
354}
355
356/// Get the file path for a task-list.md file
357pub fn get_task_list_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
358    let spec_path = get_spec_path(project_name, spec_name)?;
359    Ok(spec_path.join("task-list.md"))
360}
361
362/// Get the file path for a notes.md file
363pub fn get_notes_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
364    let spec_path = get_spec_path(project_name, spec_name)?;
365    Ok(spec_path.join("notes.md"))
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::test_environment::TestEnvironment;
372    use crate::types::spec::{SpecConfig, SpecFileType, SpecFilter};
373
374    // Removed legacy mutex-based testing in favor of modern environment isolation
375
376    #[test]
377    fn test_spec_filtering() {
378        let env = TestEnvironment::new().unwrap();
379        let project_name = "test-spec-filtering";
380
381        env.with_env_async(|| async {
382            // First create the project
383            env.create_test_project(project_name).await.unwrap();
384
385            // Create test specs using helper methods
386            env.create_test_spec(
387                project_name,
388                "user_auth",
389                "User authentication specification",
390            )
391            .await
392            .unwrap();
393            env.create_test_spec(project_name, "user_profile", "User profile management")
394                .await
395                .unwrap();
396
397            // Use spawn_blocking to run sync functions from async context
398            let project_name_clone = project_name.to_string();
399            let filtered_specs = tokio::task::spawn_blocking(move || {
400                let filter = SpecFilter {
401                    feature_name_contains: Some("user".to_string()),
402                    ..Default::default()
403                };
404                list_specs_filtered(&project_name_clone, filter)
405            })
406            .await
407            .unwrap()
408            .unwrap();
409            assert_eq!(filtered_specs.len(), 2);
410
411            // Test filtering with limit
412            let project_name_clone = project_name.to_string();
413            let limited_specs = tokio::task::spawn_blocking(move || {
414                let filter = SpecFilter {
415                    limit: Some(1),
416                    ..Default::default()
417                };
418                list_specs_filtered(&project_name_clone, filter)
419            })
420            .await
421            .unwrap()
422            .unwrap();
423            assert_eq!(limited_specs.len(), 1);
424        });
425    }
426
427    #[test]
428    fn test_spec_existence_and_counting() {
429        let env = TestEnvironment::new().unwrap();
430        let project_name = "test-spec-existence";
431
432        env.with_env_async(|| async {
433            // First create the project
434            env.create_test_project(project_name).await.unwrap();
435
436            // Use spawn_blocking to run sync functions from async context
437            let project_name_clone = project_name.to_string();
438            let count = tokio::task::spawn_blocking(move || count_specs(&project_name_clone))
439                .await
440                .unwrap()
441                .unwrap();
442            assert_eq!(count, 0);
443
444            let project_name_clone = project_name.to_string();
445            let exists = tokio::task::spawn_blocking(move || {
446                spec_exists(&project_name_clone, "nonexistent_spec")
447            })
448            .await
449            .unwrap()
450            .unwrap();
451            assert!(!exists);
452
453            // Create a test spec
454            env.create_test_spec(project_name, "test_feature", "Test specification")
455                .await
456                .unwrap();
457
458            // Test counting and existence
459            let project_name_clone = project_name.to_string();
460            let count = tokio::task::spawn_blocking(move || count_specs(&project_name_clone))
461                .await
462                .unwrap()
463                .unwrap();
464            assert_eq!(count, 1);
465
466            // List specs to get the actual spec name for existence check
467            let project_name_clone = project_name.to_string();
468            let specs = tokio::task::spawn_blocking(move || list_specs(&project_name_clone))
469                .await
470                .unwrap()
471                .unwrap();
472            assert_eq!(specs.len(), 1);
473
474            let project_name_clone = project_name.to_string();
475            let spec_name = specs[0].name.clone();
476            let exists =
477                tokio::task::spawn_blocking(move || spec_exists(&project_name_clone, &spec_name))
478                    .await
479                    .unwrap()
480                    .unwrap();
481            assert!(exists);
482        });
483    }
484
485    #[test]
486    fn test_spec_content_updates() {
487        let env = TestEnvironment::new().unwrap();
488        let project_name = "test-spec-content-updates";
489
490        env.with_env_async(|| async {
491            // First create the project
492            env.create_test_project(project_name).await.unwrap();
493
494            // Create a test spec
495            env.create_test_spec(project_name, "updatable_spec", "Original specification")
496                .await
497                .unwrap();
498
499            // Use spawn_blocking to run sync functions from async context
500            let project_name_clone = project_name.to_string();
501            let specs = tokio::task::spawn_blocking(move || list_specs(&project_name_clone))
502                .await
503                .unwrap()
504                .unwrap();
505            assert_eq!(specs.len(), 1);
506            let spec_name = specs[0].name.clone();
507
508            // Update task list
509            let new_tasks = "- Updated task\n- New task\n- [ ] Completed task";
510            let project_name_clone = project_name.to_string();
511            let spec_name_clone = spec_name.clone();
512            let new_tasks_clone = new_tasks.to_string();
513            tokio::task::spawn_blocking(move || {
514                update_spec_content(
515                    &project_name_clone,
516                    &spec_name_clone,
517                    SpecFileType::TaskList,
518                    &new_tasks_clone,
519                )
520            })
521            .await
522            .unwrap()
523            .unwrap();
524
525            // Verify update
526            let project_name_clone = project_name.to_string();
527            let spec_name_clone = spec_name.clone();
528            let loaded_spec = tokio::task::spawn_blocking(move || {
529                load_spec(&project_name_clone, &spec_name_clone)
530            })
531            .await
532            .unwrap()
533            .unwrap();
534            assert_eq!(loaded_spec.content.tasks, new_tasks);
535            // Note: The spec content will be longer than "Original specification" due to our template
536            assert!(loaded_spec.content.spec.contains("Original specification"));
537        });
538    }
539
540    #[test]
541    fn test_spec_validation() {
542        let env = TestEnvironment::new().unwrap();
543        let project_name = "test-spec-validation";
544
545        env.with_env_async(|| async {
546            // First create the project
547            env.create_test_project(project_name).await.unwrap();
548
549            // Create a test spec
550            env.create_test_spec(
551                project_name,
552                "validation_test",
553                "Valid specification content",
554            )
555            .await
556            .unwrap();
557
558            // Use spawn_blocking to run sync functions from async context
559            let project_name_clone = project_name.to_string();
560            let specs = tokio::task::spawn_blocking(move || list_specs(&project_name_clone))
561                .await
562                .unwrap()
563                .unwrap();
564            assert_eq!(specs.len(), 1);
565            let spec_name = specs[0].name.clone();
566
567            // Validate the spec
568            let project_name_clone = project_name.to_string();
569            let spec_name_clone = spec_name.clone();
570            let validation_result = tokio::task::spawn_blocking(move || {
571                validate_spec_files(&project_name_clone, &spec_name_clone)
572            })
573            .await
574            .unwrap()
575            .unwrap();
576
577            assert!(validation_result.is_valid());
578            assert!(validation_result.spec_file_exists);
579            assert!(validation_result.notes_file_exists);
580            assert!(validation_result.task_list_file_exists);
581            assert!(validation_result.content_validation.spec_valid);
582            assert!(validation_result.content_validation.notes_valid);
583            assert!(validation_result.content_validation.task_list_valid);
584            assert!(validation_result.validation_errors.is_empty());
585            assert_eq!(validation_result.summary(), "Spec is valid");
586        });
587    }
588
589    #[test]
590    fn test_latest_spec_retrieval() {
591        let env = TestEnvironment::new().unwrap();
592        let project_name = "test-latest-spec-retrieval";
593
594        env.with_env_async(|| async {
595            // Create test project first
596            env.create_test_project(project_name).await.unwrap();
597
598            // Initially no specs
599            let project_name_clone = project_name.to_string();
600            let latest = tokio::task::spawn_blocking(move || get_latest_spec(&project_name_clone))
601                .await
602                .unwrap()
603                .unwrap();
604            assert!(latest.is_none());
605
606            // Create first spec
607            env.create_test_spec(project_name, "first_spec", "First specification")
608                .await
609                .unwrap();
610
611            // Delay to ensure different timestamps (need at least 1 second difference)
612            tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
613
614            // Create second spec
615            env.create_test_spec(project_name, "second_spec", "Second specification")
616                .await
617                .unwrap();
618
619            // Get all specs to verify we have both
620            let project_name_clone = project_name.to_string();
621            let specs = tokio::task::spawn_blocking(move || list_specs(&project_name_clone))
622                .await
623                .unwrap()
624                .unwrap();
625            assert_eq!(specs.len(), 2);
626
627            // Get latest spec (should be the second one based on timestamp)
628            let project_name_clone = project_name.to_string();
629            let latest = tokio::task::spawn_blocking(move || get_latest_spec(&project_name_clone))
630                .await
631                .unwrap()
632                .unwrap()
633                .unwrap();
634
635            // Find the second spec by feature name to compare
636            let second_spec = specs
637                .iter()
638                .find(|s| s.feature_name == "second_spec")
639                .unwrap();
640            assert_eq!(latest.name, second_spec.name);
641            assert_eq!(latest.feature_name, "second_spec");
642        });
643    }
644
645    #[test]
646    fn test_directory_management() {
647        let env = TestEnvironment::new().unwrap();
648
649        env.with_env_async(|| async {
650            // Use a consistent project name for this test
651            let project_name = "test-directory-management-project";
652
653            // Create test project first
654            env.create_test_project(project_name).await.unwrap();
655
656            // Use the async foundry directly to avoid nested runtime issues
657            let foundry = crate::core::foundry::get_default_foundry().unwrap();
658
659            // Test directory creation
660            let specs_dir = ensure_specs_directory(project_name).unwrap();
661            assert!(specs_dir.exists());
662            assert!(specs_dir.is_dir());
663
664            // Test path getters
665            let specs_dir_path = get_specs_directory(project_name).unwrap();
666            assert_eq!(specs_dir, specs_dir_path);
667
668            // Create a spec and test spec path using async API
669            let config = SpecConfig {
670                project_name: project_name.to_string(),
671                feature_name: "path_test".to_string(),
672                content: SpecContentData {
673                    spec: "Path test spec".to_string(),
674                    notes: "Path test notes".to_string(),
675                    tasks: "- Path test task".to_string(),
676                },
677            };
678
679            let created_spec = foundry.create_spec(config).await.unwrap();
680            let spec_path = get_spec_path(project_name, &created_spec.name).unwrap();
681
682            // Test that the spec path exists and is correct
683            assert!(spec_path.exists());
684            assert!(spec_path.is_dir());
685            assert!(spec_path.ends_with(&created_spec.name));
686        });
687    }
688
689    #[test]
690    fn test_fuzzy_matching_exact_spec_name() {
691        let env = TestEnvironment::new().unwrap();
692        let project_name = "test-fuzzy-exact-spec";
693
694        env.with_env_async(|| async {
695            // Create test project first
696            env.create_test_project(project_name).await.unwrap();
697
698            // Create test specs using helper method
699            env.create_test_spec(project_name, "user_authentication", "Auth spec")
700                .await
701                .unwrap();
702            env.create_test_spec(project_name, "payment_processing", "Payment spec")
703                .await
704                .unwrap();
705
706            // Get the actual spec names from the isolated environment
707            let project_name_clone = project_name.to_string();
708            let specs = tokio::task::spawn_blocking(move || list_specs(&project_name_clone))
709                .await
710                .unwrap()
711                .unwrap();
712
713            assert_eq!(specs.len(), 2);
714            let auth_spec = specs
715                .iter()
716                .find(|s| s.feature_name == "user_authentication")
717                .unwrap();
718            let payment_spec = specs
719                .iter()
720                .find(|s| s.feature_name == "payment_processing")
721                .unwrap();
722
723            // Test exact spec name match
724            let project_name_clone = project_name.to_string();
725            let auth_spec_name = auth_spec.name.clone();
726            let auth_spec_name_clone = auth_spec_name.clone();
727            let result = tokio::task::spawn_blocking(move || {
728                find_spec_match(&project_name_clone, &auth_spec_name_clone)
729            })
730            .await
731            .unwrap()
732            .unwrap();
733            assert_eq!(result, SpecMatchStrategy::Exact(auth_spec_name));
734
735            let project_name_clone = project_name.to_string();
736            let payment_spec_name = payment_spec.name.clone();
737            let payment_spec_name_clone = payment_spec_name.clone();
738            let result = tokio::task::spawn_blocking(move || {
739                find_spec_match(&project_name_clone, &payment_spec_name_clone)
740            })
741            .await
742            .unwrap()
743            .unwrap();
744            assert_eq!(result, SpecMatchStrategy::Exact(payment_spec_name));
745        });
746    }
747
748    #[test]
749    fn test_fuzzy_matching_feature_name() {
750        let env = TestEnvironment::new().unwrap();
751        let project_name = "test-fuzzy-feature";
752
753        env.with_env_async(|| async {
754            // Create test project first
755            env.create_test_project(project_name).await.unwrap();
756
757            // Create test specs using helper method
758            env.create_test_spec(project_name, "user_authentication", "Auth spec")
759                .await
760                .unwrap();
761            env.create_test_spec(project_name, "payment_processing", "Payment spec")
762                .await
763                .unwrap();
764
765            // Get the actual spec names from the isolated environment
766            let project_name_clone = project_name.to_string();
767            let specs = tokio::task::spawn_blocking(move || list_specs(&project_name_clone))
768                .await
769                .unwrap()
770                .unwrap();
771
772            assert_eq!(specs.len(), 2);
773            let auth_spec = specs
774                .iter()
775                .find(|s| s.feature_name == "user_authentication")
776                .unwrap();
777            let payment_spec = specs
778                .iter()
779                .find(|s| s.feature_name == "payment_processing")
780                .unwrap();
781
782            // Test exact feature name match using spawn_blocking
783            let project_name_clone = project_name.to_string();
784            let auth_spec_name = auth_spec.name.clone();
785            let result = tokio::task::spawn_blocking(move || {
786                find_spec_match(&project_name_clone, "user_authentication")
787            })
788            .await
789            .unwrap()
790            .unwrap();
791            assert_eq!(result, SpecMatchStrategy::FeatureExact(auth_spec_name));
792
793            let project_name_clone = project_name.to_string();
794            let payment_spec_name = payment_spec.name.clone();
795            let result = tokio::task::spawn_blocking(move || {
796                find_spec_match(&project_name_clone, "payment_processing")
797            })
798            .await
799            .unwrap()
800            .unwrap();
801            assert_eq!(result, SpecMatchStrategy::FeatureExact(payment_spec_name));
802
803            // Test feature name substring match
804            let project_name_clone = project_name.to_string();
805            let auth_spec_name = auth_spec.name.clone();
806            let result =
807                tokio::task::spawn_blocking(move || find_spec_match(&project_name_clone, "auth"))
808                    .await
809                    .unwrap()
810                    .unwrap();
811            assert_eq!(result, SpecMatchStrategy::FeatureFuzzy(auth_spec_name));
812
813            let project_name_clone = project_name.to_string();
814            let payment_spec_name = payment_spec.name.clone();
815            let result = tokio::task::spawn_blocking(move || {
816                find_spec_match(&project_name_clone, "payment")
817            })
818            .await
819            .unwrap()
820            .unwrap();
821            assert_eq!(result, SpecMatchStrategy::FeatureFuzzy(payment_spec_name));
822        });
823    }
824
825    #[test]
826    fn test_fuzzy_matching_no_matches() {
827        let env = TestEnvironment::new().unwrap();
828        let project_name = "test-fuzzy-no-matches";
829
830        env.with_env_async(|| async {
831            // Create test project first
832            env.create_test_project(project_name).await.unwrap();
833
834            // Create test spec using helper method
835            env.create_test_spec(project_name, "user_authentication", "Auth spec")
836                .await
837                .unwrap();
838
839            // Test no matches
840            let project_name_clone = project_name.to_string();
841            let result = tokio::task::spawn_blocking(move || {
842                find_spec_match(&project_name_clone, "completely_different")
843            })
844            .await
845            .unwrap()
846            .unwrap();
847            assert_eq!(result, SpecMatchStrategy::None);
848
849            let project_name_clone = project_name.to_string();
850            let result =
851                tokio::task::spawn_blocking(move || find_spec_match(&project_name_clone, "xyz"))
852                    .await
853                    .unwrap()
854                    .unwrap();
855            assert_eq!(result, SpecMatchStrategy::None);
856        });
857    }
858
859    #[test]
860    fn test_fuzzy_matching_empty_project() {
861        let env = TestEnvironment::new().unwrap();
862        let project_name = "test-fuzzy-empty";
863
864        env.with_env_async(|| async {
865            // Create test project first (but no specs)
866            env.create_test_project(project_name).await.unwrap();
867
868            // Test empty project
869            let project_name_clone = project_name.to_string();
870            let result = tokio::task::spawn_blocking(move || {
871                find_spec_match(&project_name_clone, "anything")
872            })
873            .await
874            .unwrap()
875            .unwrap();
876            assert_eq!(result, SpecMatchStrategy::None);
877        });
878    }
879
880    #[test]
881    fn test_load_spec_with_fuzzy() {
882        let env = TestEnvironment::new().unwrap();
883        let project_name = "test-load-fuzzy";
884
885        env.with_env_async(|| async {
886            // Create test project first
887            env.create_test_project(project_name).await.unwrap();
888
889            // Create test spec using helper method
890            env.create_test_spec(project_name, "user_authentication", "Auth spec")
891                .await
892                .unwrap();
893
894            // Get the actual spec name from the isolated environment
895            let project_name_clone = project_name.to_string();
896            let specs = tokio::task::spawn_blocking(move || list_specs(&project_name_clone))
897                .await
898                .unwrap()
899                .unwrap();
900
901            assert_eq!(specs.len(), 1);
902            let created_spec = &specs[0];
903
904            // Test fuzzy loading with feature name
905            let project_name_clone = project_name.to_string();
906            let (loaded_spec, match_strategy) = tokio::task::spawn_blocking(move || {
907                load_spec_with_fuzzy(&project_name_clone, "auth")
908            })
909            .await
910            .unwrap()
911            .unwrap();
912            assert_eq!(loaded_spec.name, created_spec.name);
913            assert!(matches!(match_strategy, SpecMatchStrategy::FeatureFuzzy(_)));
914
915            // Test exact loading
916            let project_name_clone = project_name.to_string();
917            let created_spec_name = created_spec.name.clone();
918            let (loaded_spec, match_strategy) = tokio::task::spawn_blocking(move || {
919                load_spec_with_fuzzy(&project_name_clone, &created_spec_name)
920            })
921            .await
922            .unwrap()
923            .unwrap();
924            assert_eq!(loaded_spec.name, created_spec.name);
925            assert_eq!(
926                match_strategy,
927                SpecMatchStrategy::Exact(created_spec.name.clone())
928            );
929        });
930    }
931
932    #[test]
933    fn test_load_spec_with_fuzzy_no_matches() {
934        let env = TestEnvironment::new().unwrap();
935        let project_name = "test-load-fuzzy-no-matches";
936
937        env.with_env_async(|| async {
938            // Create test project first
939            env.create_test_project(project_name).await.unwrap();
940
941            // Create test spec using helper method
942            env.create_test_spec(project_name, "user_authentication", "Auth spec")
943                .await
944                .unwrap();
945
946            // Test no matches
947            let project_name_clone = project_name.to_string();
948            let result = tokio::task::spawn_blocking(move || {
949                load_spec_with_fuzzy(&project_name_clone, "completely_different")
950            })
951            .await
952            .unwrap();
953            assert!(result.is_err());
954            assert!(
955                result
956                    .unwrap_err()
957                    .to_string()
958                    .contains("No specs found matching")
959            );
960        });
961    }
962
963    #[test]
964    fn test_fuzzy_matching_empty_query() {
965        let _env = TestEnvironment::new().unwrap();
966        let project_name = "test-empty-query";
967
968        _env.with_env_async(|| async {
969            _env.create_test_project(project_name).await.unwrap();
970
971            // Test empty query
972            let result = load_spec_with_fuzzy(project_name, "");
973            assert!(result.is_err());
974            let error = result.unwrap_err();
975            assert!(
976                error
977                    .to_string()
978                    .contains("Cannot search for empty spec name")
979            );
980
981            // Test whitespace-only query
982            let result = load_spec_with_fuzzy(project_name, "   ");
983            assert!(result.is_err());
984            let error = result.unwrap_err();
985            assert!(
986                error
987                    .to_string()
988                    .contains("Cannot search for empty spec name")
989            );
990        });
991    }
992
993    #[test]
994    fn test_fuzzy_matching_empty_project_name() {
995        let _env = TestEnvironment::new().unwrap();
996
997        _env.with_env_async(|| async {
998            // Test empty project name
999            let result = load_spec_with_fuzzy("", "some_query");
1000            assert!(result.is_err());
1001            let error = result.unwrap_err();
1002            assert!(error.to_string().contains("Project name cannot be empty"));
1003
1004            // Test whitespace-only project name
1005            let result = load_spec_with_fuzzy("   ", "some_query");
1006            assert!(result.is_err());
1007            let error = result.unwrap_err();
1008            assert!(error.to_string().contains("Project name cannot be empty"));
1009        });
1010    }
1011
1012    #[test]
1013    fn test_fuzzy_matching_multiple_matches() {
1014        let env = TestEnvironment::new().unwrap();
1015        let project_name = "test-multiple-matches";
1016
1017        env.with_env_async(|| async {
1018            env.create_test_project(project_name).await.unwrap();
1019            env.create_test_spec(project_name, "user_authentication", "User auth spec")
1020                .await
1021                .unwrap();
1022            env.create_test_spec(project_name, "user_management", "User management spec")
1023                .await
1024                .unwrap();
1025
1026            // Use the facade directly in async context
1027            let foundry = crate::core::foundry::get_default_foundry().unwrap();
1028            let result = foundry.find_spec_match(project_name, "user").await;
1029            assert!(result.is_ok());
1030            match result.unwrap() {
1031                SpecMatchStrategy::Multiple(candidates) => {
1032                    assert!(candidates.len() >= 2);
1033                    assert!(candidates.iter().any(|c| c.contains("user_authentication")));
1034                    assert!(candidates.iter().any(|c| c.contains("user_management")));
1035                }
1036                _ => panic!("Expected Multiple match strategy"),
1037            }
1038        });
1039    }
1040
1041    #[test]
1042    fn test_fuzzy_matching_empty_project_with_query() {
1043        let env = TestEnvironment::new().unwrap();
1044        let project_name = "test-empty-project-with-query";
1045
1046        env.with_env_async(|| async {
1047            env.create_test_project(project_name).await.unwrap();
1048
1049            // Use the facade directly in async context
1050            let foundry = crate::core::foundry::get_default_foundry().unwrap();
1051            let result = foundry.find_spec_match(project_name, "any_query").await;
1052            assert!(result.is_ok());
1053            assert_eq!(result.unwrap(), SpecMatchStrategy::None);
1054        });
1055    }
1056
1057    #[test]
1058    fn test_list_specs_performance() {
1059        let env = TestEnvironment::new().unwrap();
1060        let project_name = "test-performance";
1061
1062        env.with_env_async(|| async {
1063            env.create_test_project(project_name).await.unwrap();
1064            env.create_test_spec(project_name, "test_feature", "Test spec")
1065                .await
1066                .unwrap();
1067
1068            // Use the facade directly in async context
1069            let foundry = crate::core::foundry::get_default_foundry().unwrap();
1070
1071            // Multiple calls should work consistently (no caching, but still fast)
1072            let specs1 = foundry.list_specs(project_name).await.unwrap();
1073            assert_eq!(specs1.len(), 1);
1074
1075            let specs2 = foundry.list_specs(project_name).await.unwrap();
1076            assert_eq!(specs2.len(), 1);
1077            assert_eq!(specs1[0].name, specs2[0].name);
1078        });
1079    }
1080
1081    #[test]
1082    fn test_malformed_spec_handling() {
1083        let env = TestEnvironment::new().unwrap();
1084        let project_name = "test-malformed";
1085
1086        env.with_env_async(|| async {
1087            env.create_test_project(project_name).await.unwrap();
1088
1089            // Create a valid spec
1090            env.create_test_spec(project_name, "valid_spec", "Valid spec")
1091                .await
1092                .unwrap();
1093
1094            // Create a malformed spec directory (invalid name format)
1095            let foundry_dir = crate::core::filesystem::foundry_dir().unwrap();
1096            let specs_dir = foundry_dir.join(project_name).join("specs");
1097            let malformed_dir = specs_dir.join("invalid_spec_name");
1098            std::fs::create_dir_all(&malformed_dir).unwrap();
1099
1100            // Use the facade directly in async context
1101            let foundry = crate::core::foundry::get_default_foundry().unwrap();
1102
1103            // List specs should skip malformed ones but still return valid ones
1104            let specs = foundry.list_specs(project_name).await.unwrap();
1105            assert_eq!(specs.len(), 1);
1106            assert_eq!(specs[0].feature_name, "valid_spec");
1107        });
1108    }
1109
1110    #[test]
1111    fn test_fuzzy_matching_similarity_thresholds() {
1112        let env = TestEnvironment::new().unwrap();
1113        let project_name = "test-similarity-thresholds";
1114
1115        env.with_env_async(|| async {
1116            env.create_test_project(project_name).await.unwrap();
1117
1118            // Create test specs with similar names
1119            env.create_test_spec(project_name, "user_auth", "User auth spec")
1120                .await
1121                .unwrap();
1122            env.create_test_spec(
1123                project_name,
1124                "user_authentication",
1125                "User authentication spec",
1126            )
1127            .await
1128            .unwrap();
1129
1130            // Use the facade directly in async context
1131            let foundry = crate::core::foundry::get_default_foundry().unwrap();
1132
1133            // Test exact match (similarity = 1.0)
1134            let result = foundry
1135                .find_spec_match(project_name, "user_auth")
1136                .await
1137                .unwrap();
1138            match result {
1139                SpecMatchStrategy::FeatureExact(spec_name) => {
1140                    assert!(spec_name.ends_with("_user_auth"));
1141                    assert!(spec_name.starts_with("20")); // Valid year prefix
1142                }
1143                _ => panic!("Expected FeatureExact match"),
1144            }
1145
1146            // Test high similarity match (should match "user_auth" for "user_authentication" query)
1147            let result = foundry
1148                .find_spec_match(project_name, "user_authentication")
1149                .await
1150                .unwrap();
1151            match result {
1152                SpecMatchStrategy::FeatureExact(spec_name) => {
1153                    assert!(spec_name.ends_with("_user_authentication"));
1154                    assert!(spec_name.starts_with("20")); // Valid year prefix
1155                }
1156                _ => panic!("Expected FeatureExact match"),
1157            }
1158
1159            // Test fuzzy match with partial similarity
1160            let result = foundry
1161                .find_spec_match(project_name, "usr_auth")
1162                .await
1163                .unwrap();
1164            match result {
1165                SpecMatchStrategy::FeatureFuzzy(_) => {
1166                    // This should find a fuzzy match due to high similarity
1167                }
1168                SpecMatchStrategy::Multiple(_) => {
1169                    // Multiple matches due to both being similar
1170                }
1171                _ => panic!("Expected fuzzy or multiple match for partial similarity"),
1172            }
1173
1174            // Test low similarity (should not match above threshold)
1175            let result = foundry
1176                .find_spec_match(project_name, "completely_different")
1177                .await
1178                .unwrap();
1179            assert_eq!(result, SpecMatchStrategy::None);
1180        });
1181    }
1182
1183    #[test]
1184    fn test_fuzzy_matching_edge_cases() {
1185        let env = TestEnvironment::new().unwrap();
1186        let project_name = "test-fuzzy-edge-cases";
1187
1188        env.with_env_async(|| async {
1189            env.create_test_project(project_name).await.unwrap();
1190
1191            // Test empty string similarity
1192            let similarity = strsim::normalized_levenshtein("", "");
1193            assert_eq!(similarity, 1.0);
1194
1195            // Test single character similarity
1196            let similarity = strsim::normalized_levenshtein("a", "a");
1197            assert_eq!(similarity, 1.0);
1198
1199            let similarity = strsim::normalized_levenshtein("a", "b");
1200            assert_eq!(similarity, 0.0);
1201
1202            // Test case sensitivity (strsim is case sensitive)
1203            let similarity = strsim::normalized_levenshtein("User", "user");
1204            assert!(similarity < 1.0); // Should be less than perfect match
1205
1206            // Test with actual spec data
1207            env.create_test_spec(project_name, "test_feature", "Test spec")
1208                .await
1209                .unwrap();
1210
1211            // Use the facade directly in async context
1212            let foundry = crate::core::foundry::get_default_foundry().unwrap();
1213
1214            // Test exact case match
1215            let result = foundry
1216                .find_spec_match(project_name, "test_feature")
1217                .await
1218                .unwrap();
1219            match result {
1220                SpecMatchStrategy::FeatureExact(spec_name) => {
1221                    assert!(spec_name.ends_with("_test_feature"));
1222                    assert!(spec_name.starts_with("20")); // Valid year prefix
1223                }
1224                _ => panic!("Expected FeatureExact match"),
1225            }
1226
1227            // Test case mismatch (should not find exact match)
1228            let result = foundry
1229                .find_spec_match(project_name, "Test_Feature")
1230                .await
1231                .unwrap();
1232            match result {
1233                SpecMatchStrategy::FeatureFuzzy(_) => {
1234                    // Should find fuzzy match due to case difference
1235                }
1236                SpecMatchStrategy::None => {
1237                    // Could be no match if similarity is below threshold
1238                }
1239                _ => panic!("Unexpected match strategy for case mismatch"),
1240            }
1241        });
1242    }
1243
1244    #[test]
1245    fn test_logging_hygiene_no_stderr_output() {
1246        let env = TestEnvironment::new().unwrap();
1247        let project_name = "test-logging-hygiene";
1248
1249        env.with_env_async(|| async {
1250            env.create_test_project(project_name).await.unwrap();
1251
1252            // Create a spec with a malformed directory to trigger logging
1253            env.create_test_spec(project_name, "valid_spec", "Valid spec")
1254                .await
1255                .unwrap();
1256
1257            // Create a malformed directory manually to trigger warning logs
1258            let foundry_dir = crate::core::filesystem::foundry_dir().unwrap();
1259            let specs_dir = foundry_dir.join(project_name).join("specs");
1260            let malformed_dir = specs_dir.join("invalid_format_spec");
1261            std::fs::create_dir_all(&malformed_dir).unwrap();
1262
1263            // Use the facade directly in async context
1264            let foundry = crate::core::foundry::get_default_foundry().unwrap();
1265
1266            // Verify no eprintln! output from core functions (stderr is empty)
1267            // This would require more complex setup to capture stderr
1268            // For now, we just ensure the function calls work without panicking
1269            let specs = foundry.list_specs(project_name).await.unwrap();
1270
1271            // Verify we still get the valid spec despite the malformed one
1272            assert_eq!(specs.len(), 1);
1273            assert_eq!(specs[0].feature_name, "valid_spec");
1274
1275            // In a real test, we'd check that stderr_buf is empty
1276            // For now, this test ensures the functions work correctly
1277        });
1278    }
1279}