1use 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#[cfg(test)]
17use crate::types::spec::SpecContentData;
18
19fn run_async<F, R>(f: F) -> Result<R>
21where
22 F: std::future::Future<Output = Result<R>>,
23{
24 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
32pub 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
37pub fn create_spec(config: SpecConfig) -> Result<Spec> {
39 let foundry = get_default_foundry()?;
40 run_async(foundry.create_spec(config))
41}
42
43pub fn validate_spec_name(spec_name: &str) -> Result<()> {
45 crate::core::foundry::Foundry::<crate::core::backends::filesystem::FilesystemBackend>::validate_spec_name(spec_name)
46}
47pub 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
53pub 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 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 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 if let Some(limit) = filter.limit {
89 filtered_specs.truncate(limit);
90 }
91
92 Ok(filtered_specs)
93}
94
95pub 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
101pub fn count_specs(project_name: &str) -> Result<usize> {
103 let foundry = get_default_foundry()?;
104 run_async(foundry.count_specs(project_name))
105}
106
107pub fn spec_exists(project_name: &str, spec_name: &str) -> Result<bool> {
109 run_async(async {
110 let backend = crate::core::backends::filesystem::FilesystemBackend::new();
112 backend.spec_exists(project_name, spec_name).await
113 })
114}
115
116pub 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
127pub 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
133pub 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
139pub 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
151pub 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
157pub 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 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(¬es_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#[derive(Debug, Clone, PartialEq)]
241pub enum SpecMatchStrategy {
242 Exact(String),
244 FeatureExact(String),
246 FeatureFuzzy(String),
248 NameFuzzy(String),
250 Multiple(Vec<String>),
252 None,
254}
255
256pub 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
262pub fn load_spec_with_fuzzy(project_name: &str, query: &str) -> Result<(Spec, SpecMatchStrategy)> {
264 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 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 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 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
344pub 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
350pub 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
356pub 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
362pub 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 #[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 env.create_test_project(project_name).await.unwrap();
384
385 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 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 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 env.create_test_project(project_name).await.unwrap();
435
436 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 env.create_test_spec(project_name, "test_feature", "Test specification")
455 .await
456 .unwrap();
457
458 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 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 env.create_test_project(project_name).await.unwrap();
493
494 env.create_test_spec(project_name, "updatable_spec", "Original specification")
496 .await
497 .unwrap();
498
499 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 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 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 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 env.create_test_project(project_name).await.unwrap();
548
549 env.create_test_spec(
551 project_name,
552 "validation_test",
553 "Valid specification content",
554 )
555 .await
556 .unwrap();
557
558 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 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 env.create_test_project(project_name).await.unwrap();
597
598 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 env.create_test_spec(project_name, "first_spec", "First specification")
608 .await
609 .unwrap();
610
611 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
613
614 env.create_test_spec(project_name, "second_spec", "Second specification")
616 .await
617 .unwrap();
618
619 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 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 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 let project_name = "test-directory-management-project";
652
653 env.create_test_project(project_name).await.unwrap();
655
656 let foundry = crate::core::foundry::get_default_foundry().unwrap();
658
659 let specs_dir = ensure_specs_directory(project_name).unwrap();
661 assert!(specs_dir.exists());
662 assert!(specs_dir.is_dir());
663
664 let specs_dir_path = get_specs_directory(project_name).unwrap();
666 assert_eq!(specs_dir, specs_dir_path);
667
668 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 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 env.create_test_project(project_name).await.unwrap();
697
698 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 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 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 env.create_test_project(project_name).await.unwrap();
756
757 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 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 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 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 env.create_test_project(project_name).await.unwrap();
833
834 env.create_test_spec(project_name, "user_authentication", "Auth spec")
836 .await
837 .unwrap();
838
839 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 env.create_test_project(project_name).await.unwrap();
867
868 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 env.create_test_project(project_name).await.unwrap();
888
889 env.create_test_spec(project_name, "user_authentication", "Auth spec")
891 .await
892 .unwrap();
893
894 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 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 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 env.create_test_project(project_name).await.unwrap();
940
941 env.create_test_spec(project_name, "user_authentication", "Auth spec")
943 .await
944 .unwrap();
945
946 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 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 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 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 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 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 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 let foundry = crate::core::foundry::get_default_foundry().unwrap();
1070
1071 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 env.create_test_spec(project_name, "valid_spec", "Valid spec")
1091 .await
1092 .unwrap();
1093
1094 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 let foundry = crate::core::foundry::get_default_foundry().unwrap();
1102
1103 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 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 let foundry = crate::core::foundry::get_default_foundry().unwrap();
1132
1133 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")); }
1143 _ => panic!("Expected FeatureExact match"),
1144 }
1145
1146 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")); }
1156 _ => panic!("Expected FeatureExact match"),
1157 }
1158
1159 let result = foundry
1161 .find_spec_match(project_name, "usr_auth")
1162 .await
1163 .unwrap();
1164 match result {
1165 SpecMatchStrategy::FeatureFuzzy(_) => {
1166 }
1168 SpecMatchStrategy::Multiple(_) => {
1169 }
1171 _ => panic!("Expected fuzzy or multiple match for partial similarity"),
1172 }
1173
1174 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 let similarity = strsim::normalized_levenshtein("", "");
1193 assert_eq!(similarity, 1.0);
1194
1195 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 let similarity = strsim::normalized_levenshtein("User", "user");
1204 assert!(similarity < 1.0); env.create_test_spec(project_name, "test_feature", "Test spec")
1208 .await
1209 .unwrap();
1210
1211 let foundry = crate::core::foundry::get_default_foundry().unwrap();
1213
1214 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")); }
1224 _ => panic!("Expected FeatureExact match"),
1225 }
1226
1227 let result = foundry
1229 .find_spec_match(project_name, "Test_Feature")
1230 .await
1231 .unwrap();
1232 match result {
1233 SpecMatchStrategy::FeatureFuzzy(_) => {
1234 }
1236 SpecMatchStrategy::None => {
1237 }
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 env.create_test_spec(project_name, "valid_spec", "Valid spec")
1254 .await
1255 .unwrap();
1256
1257 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 let foundry = crate::core::foundry::get_default_foundry().unwrap();
1265
1266 let specs = foundry.list_specs(project_name).await.unwrap();
1270
1271 assert_eq!(specs.len(), 1);
1273 assert_eq!(specs[0].feature_name, "valid_spec");
1274
1275 });
1278 }
1279}