1use anyhow::{Context, Result};
4use chrono::{Datelike, Timelike, Utc};
5use std::fs;
6use std::path::PathBuf;
7use tracing::warn;
8
9use crate::core::filesystem;
10use crate::types::spec::{
11 ContentValidationStatus, Spec, SpecConfig, SpecContentData, SpecFileType, SpecFilter,
12 SpecMetadata, SpecValidationResult,
13};
14use crate::utils::timestamp;
15
16pub fn generate_spec_name(feature_name: &str) -> String {
18 let now = Utc::now();
19 format!(
20 "{:04}{:02}{:02}_{:02}{:02}{:02}_{}",
21 now.year(),
22 now.month(),
23 now.day(),
24 now.hour(),
25 now.minute(),
26 now.second(),
27 feature_name
28 )
29}
30
31pub fn create_spec(config: SpecConfig) -> Result<Spec> {
33 let foundry_dir = filesystem::foundry_dir()?;
34 let project_path = foundry_dir.join(&config.project_name);
35 let specs_dir = project_path.join("specs");
36 let spec_name = generate_spec_name(&config.feature_name);
37 let spec_path = specs_dir.join(&spec_name);
38 let created_at = Utc::now().to_rfc3339();
39
40 filesystem::create_dir_all(&spec_path)?;
42
43 filesystem::write_file_atomic(spec_path.join("spec.md"), &config.content.spec)?;
45 filesystem::write_file_atomic(spec_path.join("notes.md"), &config.content.notes)?;
46
47 filesystem::write_file_atomic(spec_path.join("task-list.md"), &config.content.tasks)?;
48
49 Ok(Spec {
50 name: spec_name,
51 created_at,
52 path: spec_path,
53 project_name: config.project_name,
54 content: config.content,
55 })
56}
57
58pub fn validate_spec_name(spec_name: &str) -> Result<()> {
60 if timestamp::parse_spec_timestamp(spec_name).is_none() {
61 return Err(anyhow::anyhow!(
62 "Invalid spec name format. Expected: YYYYMMDD_HHMMSS_feature_name, got: {}",
63 spec_name
64 ));
65 }
66
67 if let Some(feature_name) = timestamp::extract_feature_name(spec_name) {
69 if feature_name.is_empty() {
70 return Err(anyhow::anyhow!(
71 "Spec name must include a feature name after the timestamp"
72 ));
73 }
74
75 if !feature_name
77 .chars()
78 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
79 || feature_name.starts_with('_')
80 || feature_name.ends_with('_')
81 || feature_name.contains("__")
82 {
83 return Err(anyhow::anyhow!(
84 "Feature name must be in snake_case format: {}",
85 feature_name
86 ));
87 }
88 } else {
89 return Err(anyhow::anyhow!(
90 "Could not extract feature name from spec name: {}",
91 spec_name
92 ));
93 }
94
95 Ok(())
96}
97pub fn list_specs(project_name: &str) -> Result<Vec<SpecMetadata>> {
99 let foundry_dir = filesystem::foundry_dir()?;
100 let specs_dir = foundry_dir.join(project_name).join("specs");
101
102 if !specs_dir.exists() {
103 return Ok(Vec::new());
104 }
105
106 let mut specs = Vec::new();
107 let mut malformed_count = 0;
108
109 for entry in fs::read_dir(specs_dir)? {
110 let entry = match entry {
111 Ok(entry) => entry,
112 Err(e) => {
113 warn!("Failed to read directory entry: {}", e);
114 continue;
115 }
116 };
117
118 if let Ok(file_type) = entry.file_type() {
119 if file_type.is_dir() {
120 let spec_name = entry.file_name().to_string_lossy().to_string();
121
122 match (
124 timestamp::parse_spec_timestamp(&spec_name),
125 timestamp::extract_feature_name(&spec_name),
126 ) {
127 (Some(timestamp_str), Some(feature_name)) => {
128 let created_at = timestamp::spec_timestamp_to_iso(×tamp_str)
130 .unwrap_or_else(|_| timestamp::iso_timestamp());
131
132 specs.push(SpecMetadata {
133 name: spec_name.clone(),
134 created_at,
135 feature_name,
136 project_name: project_name.to_string(),
137 });
138 }
139 _ => {
140 malformed_count += 1;
141 warn!("Skipping malformed spec directory: '{}'", spec_name);
142 }
143 }
144 }
145 } else {
146 warn!(
147 "Failed to determine file type for entry: {:?}",
148 entry.path()
149 );
150 }
151 }
152
153 if malformed_count > 0 {
155 warn!(
156 "Skipped {} malformed spec directories in project '{}'",
157 malformed_count, project_name
158 );
159 }
160
161 specs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
163
164 Ok(specs)
165}
166
167pub fn list_specs_filtered(project_name: &str, filter: SpecFilter) -> Result<Vec<SpecMetadata>> {
169 let specs = list_specs(project_name)?;
170
171 let mut filtered_specs: Vec<SpecMetadata> = specs
172 .into_iter()
173 .filter(|spec| {
174 if let Some(name_filter) = &filter.feature_name_contains
176 && !spec
177 .feature_name
178 .to_lowercase()
179 .contains(&name_filter.to_lowercase())
180 {
181 return false;
182 }
183
184 if let Some(after) = &filter.created_after
186 && spec.created_at < *after
187 {
188 return false;
189 }
190
191 if let Some(before) = &filter.created_before
192 && spec.created_at > *before
193 {
194 return false;
195 }
196
197 true
198 })
199 .collect();
200
201 if let Some(limit) = filter.limit {
203 filtered_specs.truncate(limit);
204 }
205
206 Ok(filtered_specs)
207}
208
209pub fn get_latest_spec(project_name: &str) -> Result<Option<SpecMetadata>> {
211 let specs = list_specs(project_name)?;
212 Ok(specs.into_iter().next()) }
214
215pub fn count_specs(project_name: &str) -> Result<usize> {
217 let specs = list_specs(project_name)?;
218 Ok(specs.len())
219}
220
221pub fn spec_exists(project_name: &str, spec_name: &str) -> Result<bool> {
223 let foundry_dir = filesystem::foundry_dir()?;
224 let spec_path = foundry_dir.join(project_name).join("specs").join(spec_name);
225
226 Ok(spec_path.exists() && spec_path.is_dir())
227}
228
229pub fn update_spec_content(
231 project_name: &str,
232 spec_name: &str,
233 file_type: SpecFileType,
234 new_content: &str,
235) -> Result<()> {
236 validate_spec_name(spec_name)?;
238 if !spec_exists(project_name, spec_name)? {
239 return Err(anyhow::anyhow!(
240 "Spec '{}' not found in project '{}'",
241 spec_name,
242 project_name
243 ));
244 }
245
246 let foundry_dir = filesystem::foundry_dir()?;
247 let spec_path = foundry_dir.join(project_name).join("specs").join(spec_name);
248
249 let file_path = match file_type {
250 SpecFileType::Spec => spec_path.join("spec.md"),
251 SpecFileType::Notes => spec_path.join("notes.md"),
252 SpecFileType::TaskList => spec_path.join("task-list.md"),
253 };
254
255 filesystem::write_file_atomic(&file_path, new_content)
256 .with_context(|| format!("Failed to update {:?} for spec '{}'", file_type, spec_name))?;
257
258 Ok(())
259}
260
261pub fn get_spec_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
263 let foundry_dir = filesystem::foundry_dir()?;
264 Ok(foundry_dir.join(project_name).join("specs").join(spec_name))
265}
266
267pub fn get_specs_directory(project_name: &str) -> Result<PathBuf> {
269 let foundry_dir = filesystem::foundry_dir()?;
270 Ok(foundry_dir.join(project_name).join("specs"))
271}
272
273pub fn ensure_specs_directory(project_name: &str) -> Result<PathBuf> {
275 let specs_dir = get_specs_directory(project_name)?;
276 filesystem::create_dir_all(&specs_dir).with_context(|| {
277 format!(
278 "Failed to create specs directory for project '{}'",
279 project_name
280 )
281 })?;
282 Ok(specs_dir)
283}
284
285pub fn delete_spec(project_name: &str, spec_name: &str) -> Result<()> {
287 validate_spec_name(spec_name)?;
288
289 let spec_path = get_spec_path(project_name, spec_name)?;
290
291 if !spec_path.exists() {
292 return Err(anyhow::anyhow!(
293 "Spec '{}' not found in project '{}'",
294 spec_name,
295 project_name
296 ));
297 }
298
299 std::fs::remove_dir_all(&spec_path).with_context(|| {
300 format!(
301 "Failed to delete spec '{}' from project '{}'",
302 spec_name, project_name
303 )
304 })?;
305
306 Ok(())
307}
308
309pub fn validate_spec_files(project_name: &str, spec_name: &str) -> Result<SpecValidationResult> {
311 let spec_path = get_spec_path(project_name, spec_name)?;
312
313 if !spec_path.exists() {
314 return Err(anyhow::anyhow!(
315 "Spec '{}' not found in project '{}'",
316 spec_name,
317 project_name
318 ));
319 }
320
321 let spec_file = spec_path.join("spec.md");
322 let notes_file = spec_path.join("notes.md");
323 let task_list_file = spec_path.join("task-list.md");
324
325 let mut result = SpecValidationResult {
326 spec_name: spec_name.to_string(),
327 project_name: project_name.to_string(),
328 spec_file_exists: spec_file.exists(),
329 notes_file_exists: notes_file.exists(),
330 task_list_file_exists: task_list_file.exists(),
331 content_validation: ContentValidationStatus {
332 spec_valid: false,
333 notes_valid: false,
334 task_list_valid: false,
335 },
336 validation_errors: Vec::new(),
337 };
338
339 if result.spec_file_exists {
341 match filesystem::read_file(&spec_file) {
342 Ok(content) => {
343 result.content_validation.spec_valid = !content.trim().is_empty();
344 if !result.content_validation.spec_valid {
345 result
346 .validation_errors
347 .push("Spec file is empty".to_string());
348 }
349 }
350 Err(e) => {
351 result
352 .validation_errors
353 .push(format!("Cannot read spec file: {}", e));
354 }
355 }
356 } else {
357 result
358 .validation_errors
359 .push("Spec file missing".to_string());
360 }
361
362 if result.notes_file_exists {
363 match filesystem::read_file(¬es_file) {
364 Ok(content) => {
365 result.content_validation.notes_valid = !content.trim().is_empty();
366 }
367 Err(e) => {
368 result
369 .validation_errors
370 .push(format!("Cannot read notes file: {}", e));
371 }
372 }
373 }
374
375 if result.task_list_file_exists {
376 match filesystem::read_file(&task_list_file) {
377 Ok(content) => {
378 result.content_validation.task_list_valid = !content.trim().is_empty();
379 }
380 Err(e) => {
381 result
382 .validation_errors
383 .push(format!("Cannot read task list file: {}", e));
384 }
385 }
386 }
387
388 Ok(result)
389}
390
391#[derive(Debug, Clone, PartialEq)]
393pub enum SpecMatchStrategy {
394 Exact(String),
396 FeatureExact(String),
398 FeatureFuzzy(String),
400 NameFuzzy(String),
402 Multiple(Vec<String>),
404 None,
406}
407
408pub fn find_spec_match(project_name: &str, query: &str) -> Result<SpecMatchStrategy> {
410 if query.trim().is_empty() {
412 return Err(anyhow::anyhow!("Query cannot be empty"));
413 }
414
415 if project_name.trim().is_empty() {
416 return Err(anyhow::anyhow!("Project name cannot be empty"));
417 }
418
419 let available_specs = list_specs(project_name)?;
420
421 if available_specs.is_empty() {
422 return Ok(SpecMatchStrategy::None);
423 }
424
425 if let Some(exact_match) = available_specs.iter().find(|s| s.name == query) {
427 return Ok(SpecMatchStrategy::Exact(exact_match.name.clone()));
428 }
429
430 if let Some(feature_match) = available_specs.iter().find(|s| s.feature_name == query) {
432 return Ok(SpecMatchStrategy::FeatureExact(feature_match.name.clone()));
433 }
434
435 let query_lower = query.to_lowercase();
437 let substring_matches: Vec<&SpecMetadata> = available_specs
438 .iter()
439 .filter(|s| s.feature_name.to_lowercase().contains(&query_lower))
440 .collect();
441
442 if substring_matches.len() == 1 {
443 return Ok(SpecMatchStrategy::FeatureFuzzy(
444 substring_matches[0].name.clone(),
445 ));
446 } else if substring_matches.len() > 1 {
447 let mut names: Vec<String> = substring_matches
449 .into_iter()
450 .map(|s| s.name.clone())
451 .collect();
452 names.sort();
453 return Ok(SpecMatchStrategy::Multiple(names));
454 }
455
456 let feature_matches: Vec<(String, f32)> = available_specs
458 .iter()
459 .map(|s| {
460 let similarity = strsim::normalized_levenshtein(query, &s.feature_name) as f32;
461 (s.name.clone(), similarity)
462 })
463 .filter(|(_, confidence)| *confidence > 0.8) .collect();
465
466 if feature_matches.len() == 1 {
467 return Ok(SpecMatchStrategy::FeatureFuzzy(
468 feature_matches[0].0.clone(),
469 ));
470 } else if feature_matches.len() > 1 {
471 let mut names: Vec<String> = feature_matches.into_iter().map(|(name, _)| name).collect();
473 names.sort();
474 return Ok(SpecMatchStrategy::Multiple(names));
475 }
476
477 let name_matches: Vec<(String, f32)> = available_specs
479 .iter()
480 .map(|s| {
481 let similarity = strsim::normalized_levenshtein(query, &s.name) as f32;
482 (s.name.clone(), similarity)
483 })
484 .filter(|(_, confidence)| *confidence > 0.8) .collect();
486
487 if name_matches.len() == 1 {
488 return Ok(SpecMatchStrategy::NameFuzzy(name_matches[0].0.clone()));
489 } else if name_matches.len() > 1 {
490 let mut names: Vec<String> = name_matches.into_iter().map(|(name, _)| name).collect();
492 names.sort();
493 return Ok(SpecMatchStrategy::Multiple(names));
494 }
495
496 Ok(SpecMatchStrategy::None)
497}
498
499pub fn load_spec_with_fuzzy(project_name: &str, query: &str) -> Result<(Spec, SpecMatchStrategy)> {
501 if query.trim().is_empty() {
503 return Err(anyhow::anyhow!(
504 "Cannot search for empty spec name. Please provide a spec name or feature name to search for."
505 ));
506 }
507
508 if project_name.trim().is_empty() {
509 return Err(anyhow::anyhow!(
510 "Project name cannot be empty. Please specify a valid project name."
511 ));
512 }
513
514 let match_strategy = find_spec_match(project_name, query)?;
515
516 match &match_strategy {
517 SpecMatchStrategy::Exact(spec_name)
518 | SpecMatchStrategy::FeatureExact(spec_name)
519 | SpecMatchStrategy::FeatureFuzzy(spec_name)
520 | SpecMatchStrategy::NameFuzzy(spec_name) => {
521 let spec = load_spec(project_name, spec_name)
522 .with_context(|| format!("Failed to load matched spec '{}'", spec_name))?;
523 Ok((spec, match_strategy))
524 }
525 SpecMatchStrategy::Multiple(candidates) => {
526 let candidate_list = candidates
528 .iter()
529 .enumerate()
530 .map(|(i, name)| format!(" {}. {}", i + 1, name))
531 .collect::<Vec<_>>()
532 .join("\n");
533
534 Err(anyhow::anyhow!(
535 "Multiple specs match '{}':\n{}\n\nPlease specify which one you want to load by using the exact spec name or a more specific query.",
536 query,
537 candidate_list
538 ))
539 }
540 SpecMatchStrategy::None => {
541 let available_specs = list_specs(project_name)?;
543 if available_specs.is_empty() {
544 Err(anyhow::anyhow!(
545 "No specs found in project '{}'. This project doesn't have any specifications yet.\n\nTo create your first spec, use:\n foundry create-spec {} <feature_name>\n\nFor example:\n foundry create-spec {} user_authentication",
546 project_name,
547 project_name,
548 project_name
549 ))
550 } else {
551 let spec_list = if available_specs.len() <= 10 {
553 available_specs
554 .iter()
555 .map(|s| format!(" - {} ({})", s.name, s.feature_name))
556 .collect::<Vec<_>>()
557 .join("\n")
558 } else {
559 format!(
560 " {} specs available (showing first 10):\n{}",
561 available_specs.len(),
562 available_specs
563 .iter()
564 .take(10)
565 .map(|s| format!(" - {} ({})", s.name, s.feature_name))
566 .collect::<Vec<_>>()
567 .join("\n")
568 )
569 };
570
571 Err(anyhow::anyhow!(
572 "No specs found matching '{}'.\n\nAvailable specs:\n{}\n\nTry using a more specific search term or use the exact spec name.",
573 query,
574 spec_list
575 ))
576 }
577 }
578 }
579}
580
581pub fn load_spec(project_name: &str, spec_name: &str) -> Result<Spec> {
583 validate_spec_name(spec_name).with_context(|| format!("Invalid spec name: {}", spec_name))?;
585
586 let foundry_dir = filesystem::foundry_dir()?;
587 let spec_path = foundry_dir.join(project_name).join("specs").join(spec_name);
588
589 if !spec_path.exists() {
590 return Err(anyhow::anyhow!(
591 "Spec '{}' not found in project '{}'",
592 spec_name,
593 project_name
594 ));
595 }
596
597 let spec_content = filesystem::read_file(spec_path.join("spec.md"))?;
599 let notes = filesystem::read_file(spec_path.join("notes.md"))?;
600 let task_list = filesystem::read_file(spec_path.join("task-list.md"))?;
601
602 let created_at = timestamp::parse_spec_timestamp(spec_name).map_or_else(
604 || {
605 fs::metadata(&spec_path)
607 .and_then(|metadata| metadata.created())
608 .map_err(anyhow::Error::from)
609 .and_then(|time| {
610 time.duration_since(std::time::UNIX_EPOCH)
611 .map_err(anyhow::Error::from)
612 })
613 .map(|duration| {
614 chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0)
615 .unwrap_or_else(chrono::Utc::now)
616 .to_rfc3339()
617 })
618 .unwrap_or_else(|_| timestamp::iso_timestamp())
619 },
620 |timestamp_str| {
621 timestamp::spec_timestamp_to_iso(×tamp_str)
622 .unwrap_or_else(|_| timestamp::iso_timestamp())
623 },
624 );
625
626 Ok(Spec {
627 name: spec_name.to_string(),
628 created_at,
629 path: spec_path,
630 project_name: project_name.to_string(),
631 content: SpecContentData {
632 spec: spec_content,
633 notes,
634 tasks: task_list,
635 },
636 })
637}
638
639pub fn get_spec_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
641 let spec_path = get_spec_path(project_name, spec_name)?;
642 Ok(spec_path.join("spec.md"))
643}
644
645pub fn get_task_list_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
647 let spec_path = get_spec_path(project_name, spec_name)?;
648 Ok(spec_path.join("task-list.md"))
649}
650
651pub fn get_notes_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
653 let spec_path = get_spec_path(project_name, spec_name)?;
654 Ok(spec_path.join("notes.md"))
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::types::spec::{SpecConfig, SpecFileType, SpecFilter};
661 use std::sync::Mutex;
662
663 static TEST_MUTEX: Mutex<()> = Mutex::new(());
665
666 fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
668 TEST_MUTEX.lock().unwrap_or_else(|poisoned| {
669 poisoned.into_inner()
671 })
672 }
673
674 #[test]
677 fn test_spec_filtering() {
678 use crate::test_utils::TestEnvironment;
679 let _lock = acquire_test_lock();
680 let _env = TestEnvironment::new().unwrap();
681 let project_name = "test-spec-filtering";
682
683 let spec_configs = vec![
685 SpecConfig {
686 project_name: project_name.to_string(),
687 feature_name: "user_auth".to_string(),
688 content: SpecContentData {
689 spec: "User authentication specification".to_string(),
690 notes: "Authentication notes".to_string(),
691 tasks: "- Implement login\n- Implement logout".to_string(),
692 },
693 },
694 SpecConfig {
695 project_name: project_name.to_string(),
696 feature_name: "user_profile".to_string(),
697 content: SpecContentData {
698 spec: "User profile management".to_string(),
699 notes: "Profile notes".to_string(),
700 tasks: "- Profile CRUD\n- Avatar upload".to_string(),
701 },
702 },
703 ];
704
705 for config in spec_configs {
706 create_spec(config).unwrap();
707 }
708
709 let filter = SpecFilter {
711 feature_name_contains: Some("user".to_string()),
712 ..Default::default()
713 };
714
715 let filtered_specs = list_specs_filtered(project_name, filter).unwrap();
716 assert_eq!(filtered_specs.len(), 2);
717
718 let filter = SpecFilter {
720 limit: Some(1),
721 ..Default::default()
722 };
723
724 let limited_specs = list_specs_filtered(project_name, filter).unwrap();
725 assert_eq!(limited_specs.len(), 1);
726 }
727
728 #[test]
729 fn test_spec_existence_and_counting() {
730 use crate::test_utils::TestEnvironment;
731 let _lock = acquire_test_lock();
732 let _env = TestEnvironment::new().unwrap();
733 let project_name = "test-spec-existence";
734
735 assert_eq!(count_specs(project_name).unwrap(), 0);
737 assert!(!spec_exists(project_name, "nonexistent_spec").unwrap());
738
739 let config = SpecConfig {
741 project_name: project_name.to_string(),
742 feature_name: "test_feature".to_string(),
743 content: SpecContentData {
744 spec: "Test specification".to_string(),
745 notes: "Test notes".to_string(),
746 tasks: "- Test task".to_string(),
747 },
748 };
749
750 let created_spec = create_spec(config).unwrap();
751
752 assert_eq!(count_specs(project_name).unwrap(), 1);
754 assert!(spec_exists(project_name, &created_spec.name).unwrap());
755 }
756
757 #[test]
758 fn test_spec_content_updates() {
759 use crate::test_utils::TestEnvironment;
760 let _lock = acquire_test_lock();
761 let _env = TestEnvironment::new().unwrap();
762 let project_name = "test-spec-content-updates";
763
764 let config = SpecConfig {
766 project_name: project_name.to_string(),
767 feature_name: "updatable_spec".to_string(),
768 content: SpecContentData {
769 spec: "Original specification".to_string(),
770 notes: "Original notes".to_string(),
771 tasks: "- Original task".to_string(),
772 },
773 };
774
775 let created_spec = create_spec(config).unwrap();
776
777 let new_tasks = "- Updated task\n- New task\n- [ ] Completed task";
779 update_spec_content(
780 project_name,
781 &created_spec.name,
782 SpecFileType::TaskList,
783 new_tasks,
784 )
785 .unwrap();
786
787 let loaded_spec = load_spec(project_name, &created_spec.name).unwrap();
789 assert_eq!(loaded_spec.content.tasks, new_tasks);
790 assert_eq!(loaded_spec.content.spec, "Original specification");
791 }
792
793 #[test]
794 fn test_spec_validation() {
795 use crate::test_utils::TestEnvironment;
796 let _lock = acquire_test_lock();
797 let _env = TestEnvironment::new().unwrap();
798 let project_name = "test-spec-validation";
799
800 let config = SpecConfig {
802 project_name: project_name.to_string(),
803 feature_name: "validation_test".to_string(),
804 content: SpecContentData {
805 spec: "Valid specification content".to_string(),
806 notes: "Valid notes".to_string(),
807 tasks: "- Valid task".to_string(),
808 },
809 };
810
811 let created_spec = create_spec(config).unwrap();
812
813 let validation_result = validate_spec_files(project_name, &created_spec.name).unwrap();
815
816 assert!(validation_result.is_valid());
817 assert!(validation_result.spec_file_exists);
818 assert!(validation_result.notes_file_exists);
819 assert!(validation_result.task_list_file_exists);
820 assert!(validation_result.content_validation.spec_valid);
821 assert!(validation_result.content_validation.notes_valid);
822 assert!(validation_result.content_validation.task_list_valid);
823 assert!(validation_result.validation_errors.is_empty());
824 assert_eq!(validation_result.summary(), "Spec is valid");
825 }
826
827 #[test]
828 fn test_latest_spec_retrieval() {
829 use crate::test_utils::TestEnvironment;
830 let _lock = acquire_test_lock();
831 let _env = TestEnvironment::new().unwrap();
832 let project_name = "test-latest-spec-retrieval";
833
834 assert!(get_latest_spec(project_name).unwrap().is_none());
836
837 let config1 = SpecConfig {
839 project_name: project_name.to_string(),
840 feature_name: "first_spec".to_string(),
841 content: SpecContentData {
842 spec: "First specification".to_string(),
843 notes: "First notes".to_string(),
844 tasks: "- First task".to_string(),
845 },
846 };
847
848 let _spec1 = create_spec(config1).unwrap();
849
850 std::thread::sleep(std::time::Duration::from_millis(1100));
852
853 let config2 = SpecConfig {
855 project_name: project_name.to_string(),
856 feature_name: "second_spec".to_string(),
857 content: SpecContentData {
858 spec: "Second specification".to_string(),
859 notes: "Second notes".to_string(),
860 tasks: "- Second task".to_string(),
861 },
862 };
863
864 let spec2 = create_spec(config2).unwrap();
865
866 let latest = get_latest_spec(project_name).unwrap().unwrap();
868 assert_eq!(latest.name, spec2.name);
869 assert_eq!(latest.feature_name, "second_spec");
870 }
871
872 #[test]
873 fn test_directory_management() {
874 use crate::test_utils::TestEnvironment;
876 let _env = TestEnvironment::new().unwrap();
877
878 let project_name = "test-directory-management-project";
880
881 let specs_dir = ensure_specs_directory(project_name).unwrap();
883 assert!(specs_dir.exists());
884 assert!(specs_dir.is_dir());
885
886 let specs_dir_path = get_specs_directory(project_name).unwrap();
888 assert_eq!(specs_dir, specs_dir_path);
889
890 let config = SpecConfig {
892 project_name: project_name.to_string(),
893 feature_name: "path_test".to_string(),
894 content: SpecContentData {
895 spec: "Path test spec".to_string(),
896 notes: "Path test notes".to_string(),
897 tasks: "- Path test task".to_string(),
898 },
899 };
900
901 let created_spec = create_spec(config).unwrap();
902 let spec_path = get_spec_path(project_name, &created_spec.name).unwrap();
903
904 assert!(spec_path.exists());
906 assert!(spec_path.is_dir());
907 assert!(spec_path.ends_with(&created_spec.name));
908 }
909
910 #[test]
911 fn test_fuzzy_matching_exact_spec_name() {
912 use crate::test_utils::TestEnvironment;
913 let _env = TestEnvironment::new().unwrap();
914 let project_name = "test-fuzzy-exact-spec";
915
916 let config1 = SpecConfig {
918 project_name: project_name.to_string(),
919 feature_name: "user_authentication".to_string(),
920 content: SpecContentData {
921 spec: "Auth spec".to_string(),
922 notes: "Auth notes".to_string(),
923 tasks: "- Auth task".to_string(),
924 },
925 };
926 let spec1 = create_spec(config1).unwrap();
927
928 let config2 = SpecConfig {
929 project_name: project_name.to_string(),
930 feature_name: "payment_processing".to_string(),
931 content: SpecContentData {
932 spec: "Payment spec".to_string(),
933 notes: "Payment notes".to_string(),
934 tasks: "- Payment task".to_string(),
935 },
936 };
937 let spec2 = create_spec(config2).unwrap();
938
939 let result = find_spec_match(project_name, &spec1.name).unwrap();
941 assert_eq!(result, SpecMatchStrategy::Exact(spec1.name));
942
943 let result = find_spec_match(project_name, &spec2.name).unwrap();
944 assert_eq!(result, SpecMatchStrategy::Exact(spec2.name));
945 }
946
947 #[test]
948 fn test_fuzzy_matching_feature_name() {
949 use crate::test_utils::TestEnvironment;
950 let _env = TestEnvironment::new().unwrap();
951 let project_name = "test-fuzzy-feature";
952
953 let config1 = SpecConfig {
955 project_name: project_name.to_string(),
956 feature_name: "user_authentication".to_string(),
957 content: SpecContentData {
958 spec: "Auth spec".to_string(),
959 notes: "Auth notes".to_string(),
960 tasks: "- Auth task".to_string(),
961 },
962 };
963 let spec1 = create_spec(config1).unwrap();
964
965 let config2 = SpecConfig {
966 project_name: project_name.to_string(),
967 feature_name: "payment_processing".to_string(),
968 content: SpecContentData {
969 spec: "Payment spec".to_string(),
970 notes: "Payment notes".to_string(),
971 tasks: "- Payment task".to_string(),
972 },
973 };
974 let spec2 = create_spec(config2).unwrap();
975
976 let result = find_spec_match(project_name, "user_authentication").unwrap();
978 assert_eq!(result, SpecMatchStrategy::FeatureExact(spec1.name.clone()));
979
980 let result = find_spec_match(project_name, "payment_processing").unwrap();
981 assert_eq!(result, SpecMatchStrategy::FeatureExact(spec2.name.clone()));
982
983 let result = find_spec_match(project_name, "auth").unwrap();
985 assert_eq!(result, SpecMatchStrategy::FeatureFuzzy(spec1.name));
986
987 let result = find_spec_match(project_name, "payment").unwrap();
988 assert_eq!(result, SpecMatchStrategy::FeatureFuzzy(spec2.name));
989 }
990
991 #[test]
992 fn test_fuzzy_matching_no_matches() {
993 use crate::test_utils::TestEnvironment;
994 let _env = TestEnvironment::new().unwrap();
995 let project_name = "test-fuzzy-no-matches";
996
997 let config = SpecConfig {
999 project_name: project_name.to_string(),
1000 feature_name: "user_authentication".to_string(),
1001 content: SpecContentData {
1002 spec: "Auth spec".to_string(),
1003 notes: "Auth notes".to_string(),
1004 tasks: "- Auth task".to_string(),
1005 },
1006 };
1007 let _spec = create_spec(config).unwrap();
1008
1009 let result = find_spec_match(project_name, "completely_different").unwrap();
1011 assert_eq!(result, SpecMatchStrategy::None);
1012
1013 let result = find_spec_match(project_name, "xyz").unwrap();
1014 assert_eq!(result, SpecMatchStrategy::None);
1015 }
1016
1017 #[test]
1018 fn test_fuzzy_matching_empty_project() {
1019 use crate::test_utils::TestEnvironment;
1020 let _env = TestEnvironment::new().unwrap();
1021 let project_name = "test-fuzzy-empty";
1022
1023 let result = find_spec_match(project_name, "anything").unwrap();
1025 assert_eq!(result, SpecMatchStrategy::None);
1026 }
1027
1028 #[test]
1029 fn test_load_spec_with_fuzzy() {
1030 use crate::test_utils::TestEnvironment;
1031 let _env = TestEnvironment::new().unwrap();
1032 let project_name = "test-load-fuzzy";
1033
1034 let config = SpecConfig {
1036 project_name: project_name.to_string(),
1037 feature_name: "user_authentication".to_string(),
1038 content: SpecContentData {
1039 spec: "Auth spec".to_string(),
1040 notes: "Auth notes".to_string(),
1041 tasks: "- Auth task".to_string(),
1042 },
1043 };
1044 let created_spec = create_spec(config).unwrap();
1045
1046 let (loaded_spec, match_strategy) = load_spec_with_fuzzy(project_name, "auth").unwrap();
1048 assert_eq!(loaded_spec.name, created_spec.name);
1049 assert!(matches!(match_strategy, SpecMatchStrategy::FeatureFuzzy(_)));
1050
1051 let (loaded_spec, match_strategy) =
1053 load_spec_with_fuzzy(project_name, &created_spec.name).unwrap();
1054 assert_eq!(loaded_spec.name, created_spec.name);
1055 assert_eq!(match_strategy, SpecMatchStrategy::Exact(created_spec.name));
1056 }
1057
1058 #[test]
1059 fn test_load_spec_with_fuzzy_no_matches() {
1060 use crate::test_utils::TestEnvironment;
1061 let _env = TestEnvironment::new().unwrap();
1062 let project_name = "test-load-fuzzy-no-matches";
1063
1064 let config = SpecConfig {
1066 project_name: project_name.to_string(),
1067 feature_name: "user_authentication".to_string(),
1068 content: SpecContentData {
1069 spec: "Auth spec".to_string(),
1070 notes: "Auth notes".to_string(),
1071 tasks: "- Auth task".to_string(),
1072 },
1073 };
1074 let _spec = create_spec(config).unwrap();
1075
1076 let result = load_spec_with_fuzzy(project_name, "completely_different");
1078 assert!(result.is_err());
1079 assert!(
1080 result
1081 .unwrap_err()
1082 .to_string()
1083 .contains("No specs found matching")
1084 );
1085 }
1086
1087 #[test]
1088 fn test_fuzzy_matching_empty_query() {
1089 use crate::test_utils::TestEnvironment;
1090 let _env = TestEnvironment::new().unwrap();
1091 let project_name = "test-empty-query";
1092
1093 _env.with_env_async(|| async {
1094 _env.create_test_project(project_name).await.unwrap();
1095
1096 let result = load_spec_with_fuzzy(project_name, "");
1098 assert!(result.is_err());
1099 let error = result.unwrap_err();
1100 assert!(
1101 error
1102 .to_string()
1103 .contains("Cannot search for empty spec name")
1104 );
1105
1106 let result = load_spec_with_fuzzy(project_name, " ");
1108 assert!(result.is_err());
1109 let error = result.unwrap_err();
1110 assert!(
1111 error
1112 .to_string()
1113 .contains("Cannot search for empty spec name")
1114 );
1115 });
1116 }
1117
1118 #[test]
1119 fn test_fuzzy_matching_empty_project_name() {
1120 use crate::test_utils::TestEnvironment;
1121 let _env = TestEnvironment::new().unwrap();
1122
1123 _env.with_env_async(|| async {
1124 let result = load_spec_with_fuzzy("", "some_query");
1126 assert!(result.is_err());
1127 let error = result.unwrap_err();
1128 assert!(error.to_string().contains("Project name cannot be empty"));
1129
1130 let result = load_spec_with_fuzzy(" ", "some_query");
1132 assert!(result.is_err());
1133 let error = result.unwrap_err();
1134 assert!(error.to_string().contains("Project name cannot be empty"));
1135 });
1136 }
1137
1138 #[test]
1139 fn test_fuzzy_matching_multiple_matches() {
1140 use crate::test_utils::TestEnvironment;
1141 let _env = TestEnvironment::new().unwrap();
1142 let project_name = "test-multiple-matches";
1143
1144 _env.with_env_async(|| async {
1145 _env.create_test_project(project_name).await.unwrap();
1146 _env.create_test_spec(project_name, "user_authentication", "User auth spec")
1147 .await
1148 .unwrap();
1149 _env.create_test_spec(project_name, "user_management", "User management spec")
1150 .await
1151 .unwrap();
1152
1153 let result = load_spec_with_fuzzy(project_name, "user");
1155 assert!(result.is_err());
1156 let error = result.unwrap_err();
1157 assert!(error.to_string().contains("Multiple specs match"));
1158 assert!(error.to_string().contains("user_authentication"));
1159 assert!(error.to_string().contains("user_management"));
1160 });
1161 }
1162
1163 #[test]
1164 fn test_fuzzy_matching_empty_project_with_query() {
1165 use crate::test_utils::TestEnvironment;
1166 let _env = TestEnvironment::new().unwrap();
1167 let project_name = "test-empty-project-with-query";
1168
1169 _env.with_env_async(|| async {
1170 _env.create_test_project(project_name).await.unwrap();
1171
1172 let result = load_spec_with_fuzzy(project_name, "any_query");
1174 assert!(result.is_err());
1175 let error = result.unwrap_err();
1176 assert!(error.to_string().contains("No specs found in project"));
1177 assert!(error.to_string().contains("create-spec"));
1178 });
1179 }
1180
1181 #[test]
1182 fn test_list_specs_performance() {
1183 use crate::test_utils::TestEnvironment;
1184 let _env = TestEnvironment::new().unwrap();
1185 let project_name = "test-performance";
1186
1187 _env.with_env_async(|| async {
1188 _env.create_test_project(project_name).await.unwrap();
1189 _env.create_test_spec(project_name, "test_feature", "Test spec")
1190 .await
1191 .unwrap();
1192
1193 let specs1 = list_specs(project_name).unwrap();
1195 assert_eq!(specs1.len(), 1);
1196
1197 let specs2 = list_specs(project_name).unwrap();
1198 assert_eq!(specs2.len(), 1);
1199 assert_eq!(specs1[0].name, specs2[0].name);
1200 });
1201 }
1202
1203 #[test]
1204 fn test_malformed_spec_handling() {
1205 use crate::test_utils::TestEnvironment;
1206 let _env = TestEnvironment::new().unwrap();
1207 let project_name = "test-malformed";
1208
1209 _env.with_env_async(|| async {
1210 _env.create_test_project(project_name).await.unwrap();
1211
1212 _env.create_test_spec(project_name, "valid_spec", "Valid spec")
1214 .await
1215 .unwrap();
1216
1217 let foundry_dir = filesystem::foundry_dir().unwrap();
1219 let specs_dir = foundry_dir.join(project_name).join("specs");
1220 let malformed_dir = specs_dir.join("invalid_spec_name");
1221 std::fs::create_dir_all(&malformed_dir).unwrap();
1222
1223 let specs = list_specs(project_name).unwrap();
1225 assert_eq!(specs.len(), 1);
1226 assert_eq!(specs[0].feature_name, "valid_spec");
1227 });
1228 }
1229
1230 #[test]
1231 fn test_fuzzy_matching_similarity_thresholds() {
1232 use crate::test_utils::TestEnvironment;
1233 let env = TestEnvironment::new().unwrap();
1234 let project_name = "test-similarity-thresholds";
1235
1236 env.with_env_async(|| async {
1237 env.create_test_project(project_name).await.unwrap();
1238
1239 env.create_test_spec(project_name, "user_auth", "User auth spec")
1241 .await
1242 .unwrap();
1243 env.create_test_spec(
1244 project_name,
1245 "user_authentication",
1246 "User authentication spec",
1247 )
1248 .await
1249 .unwrap();
1250
1251 let result = find_spec_match(project_name, "user_auth").unwrap();
1253 match result {
1254 SpecMatchStrategy::FeatureExact(spec_name) => {
1255 assert!(spec_name.ends_with("_user_auth"));
1256 assert!(spec_name.starts_with("20")); }
1258 _ => panic!("Expected FeatureExact match"),
1259 }
1260
1261 let result = find_spec_match(project_name, "user_authentication").unwrap();
1263 match result {
1264 SpecMatchStrategy::FeatureExact(spec_name) => {
1265 assert!(spec_name.ends_with("_user_authentication"));
1266 assert!(spec_name.starts_with("20")); }
1268 _ => panic!("Expected FeatureExact match"),
1269 }
1270
1271 let result = find_spec_match(project_name, "usr_auth").unwrap();
1273 match result {
1274 SpecMatchStrategy::FeatureFuzzy(_) => {
1275 }
1277 SpecMatchStrategy::Multiple(_) => {
1278 }
1280 _ => panic!("Expected fuzzy or multiple match for partial similarity"),
1281 }
1282
1283 let result = find_spec_match(project_name, "completely_different").unwrap();
1285 assert_eq!(result, SpecMatchStrategy::None);
1286 });
1287 }
1288
1289 #[test]
1290 fn test_fuzzy_matching_edge_cases() {
1291 use crate::test_utils::TestEnvironment;
1292 let env = TestEnvironment::new().unwrap();
1293 let project_name = "test-fuzzy-edge-cases";
1294
1295 env.with_env_async(|| async {
1296 env.create_test_project(project_name).await.unwrap();
1297
1298 let similarity = strsim::normalized_levenshtein("", "");
1300 assert_eq!(similarity, 1.0);
1301
1302 let similarity = strsim::normalized_levenshtein("a", "a");
1304 assert_eq!(similarity, 1.0);
1305
1306 let similarity = strsim::normalized_levenshtein("a", "b");
1307 assert_eq!(similarity, 0.0);
1308
1309 let similarity = strsim::normalized_levenshtein("User", "user");
1311 assert!(similarity < 1.0); env.create_test_spec(project_name, "test_feature", "Test spec")
1315 .await
1316 .unwrap();
1317
1318 let result = find_spec_match(project_name, "test_feature").unwrap();
1320 match result {
1321 SpecMatchStrategy::FeatureExact(spec_name) => {
1322 assert!(spec_name.ends_with("_test_feature"));
1323 assert!(spec_name.starts_with("20")); }
1325 _ => panic!("Expected FeatureExact match"),
1326 }
1327
1328 let result = find_spec_match(project_name, "Test_Feature").unwrap();
1330 match result {
1331 SpecMatchStrategy::FeatureFuzzy(_) => {
1332 }
1334 SpecMatchStrategy::None => {
1335 }
1337 _ => panic!("Unexpected match strategy for case mismatch"),
1338 }
1339 });
1340 }
1341
1342 #[test]
1343 fn test_logging_hygiene_no_stderr_output() {
1344 use crate::test_utils::TestEnvironment;
1345
1346 let env = TestEnvironment::new().unwrap();
1347 let project_name = "test-logging-hygiene";
1348
1349 env.with_env_async(|| async {
1350 env.create_test_project(project_name).await.unwrap();
1351
1352 env.create_test_spec(project_name, "valid_spec", "Valid spec")
1354 .await
1355 .unwrap();
1356
1357 let foundry_dir = crate::core::filesystem::foundry_dir().unwrap();
1359 let specs_dir = foundry_dir.join(project_name).join("specs");
1360 let malformed_dir = specs_dir.join("invalid_format_spec");
1361 std::fs::create_dir_all(&malformed_dir).unwrap();
1362
1363 let specs = list_specs(project_name).unwrap();
1368
1369 assert_eq!(specs.len(), 1);
1371 assert_eq!(specs[0].feature_name, "valid_spec");
1372
1373 });
1376 }
1377}