foundry_mcp/core/
spec.rs

1//! Spec management core logic
2
3use 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
16/// Generate timestamped spec name
17pub 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
31/// Create a new spec
32pub 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    // Ensure specs directory exists
41    filesystem::create_dir_all(&spec_path)?;
42
43    // Write spec files
44    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
58/// Validate spec directory name format
59pub 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    // Validate feature name part
68    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        // Validate feature name follows snake_case convention
76        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}
97/// List specs for a project with enhanced validation
98pub 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                // Use enhanced timestamp parsing with better error handling
123                match (
124                    timestamp::parse_spec_timestamp(&spec_name),
125                    timestamp::extract_feature_name(&spec_name),
126                ) {
127                    (Some(timestamp_str), Some(feature_name)) => {
128                        // Convert timestamp to ISO format for consistent storage
129                        let created_at = timestamp::spec_timestamp_to_iso(&timestamp_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    // Log summary of malformed specs if any were found
154    if malformed_count > 0 {
155        warn!(
156            "Skipped {} malformed spec directories in project '{}'",
157            malformed_count, project_name
158        );
159    }
160
161    // Sort by creation time (newest first)
162    specs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
163
164    Ok(specs)
165}
166
167/// List specs with filtering capabilities
168pub 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            // Apply feature name filter
175            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            // Apply date range filters
185            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    // Apply limit
202    if let Some(limit) = filter.limit {
203        filtered_specs.truncate(limit);
204    }
205
206    Ok(filtered_specs)
207}
208
209/// Get the most recent spec for a project
210pub fn get_latest_spec(project_name: &str) -> Result<Option<SpecMetadata>> {
211    let specs = list_specs(project_name)?;
212    Ok(specs.into_iter().next()) // Already sorted by creation time (newest first)
213}
214
215/// Count total specs for a project
216pub fn count_specs(project_name: &str) -> Result<usize> {
217    let specs = list_specs(project_name)?;
218    Ok(specs.len())
219}
220
221/// Check if a spec exists
222pub 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
229/// Update spec content (for task list updates)
230pub 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 exists
237    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
261/// Get spec directory path
262pub 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
267/// Get specs directory path for a project
268pub 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
273/// Ensure specs directory exists for a project
274pub 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
285/// Delete a spec (with confirmation)
286pub 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
309/// Validate spec content files exist and are readable
310pub 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    // Validate file contents if they exist
340    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(&notes_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/// Fuzzy matching strategy for spec discovery
392#[derive(Debug, Clone, PartialEq)]
393pub enum SpecMatchStrategy {
394    /// Direct exact match found
395    Exact(String),
396    /// Matched by feature name (exact)
397    FeatureExact(String),
398    /// Matched by feature name (fuzzy)
399    FeatureFuzzy(String),
400    /// Matched by spec name similarity
401    NameFuzzy(String),
402    /// Multiple candidates found
403    Multiple(Vec<String>),
404    /// No reasonable matches
405    None,
406}
407
408/// Find the best matching spec using fuzzy matching
409pub fn find_spec_match(project_name: &str, query: &str) -> Result<SpecMatchStrategy> {
410    // Validate inputs
411    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    // Try exact spec name match first (highest priority)
426    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    // Try exact feature name match
431    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    // Try feature name substring match (case-insensitive)
436    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        // Multiple substring matches - return for disambiguation
448        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    // Try fuzzy matching on feature names
457    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) // High confidence threshold
464        .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        // Multiple feature matches - return for disambiguation
472        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    // Try fuzzy matching on spec names
478    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) // High confidence threshold
485        .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        // Multiple name matches - return for disambiguation
491        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
499/// Load a spec with fuzzy matching support and comprehensive error handling
500pub fn load_spec_with_fuzzy(project_name: &str, query: &str) -> Result<(Spec, SpecMatchStrategy)> {
501    // Validate inputs with detailed error messages
502    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            // Provide detailed disambiguation with suggestions
527            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            // Get available specs for helpful error message
542            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                // Show available specs with better formatting
552                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
581/// Load a specific spec with validation
582pub fn load_spec(project_name: &str, spec_name: &str) -> Result<Spec> {
583    // Validate spec name format first
584    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    // Read spec files
598    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    // Get creation time from spec name timestamp (more reliable than filesystem metadata)
603    let created_at = timestamp::parse_spec_timestamp(spec_name).map_or_else(
604        || {
605            // Fallback to filesystem metadata if timestamp parsing fails
606            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(&timestamp_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
639/// Get the file path for a spec.md file
640pub 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
645/// Get the file path for a task-list.md file
646pub 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
651/// Get the file path for a notes.md file
652pub 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    // Use a mutex to serialize tests that modify global environment
664    static TEST_MUTEX: Mutex<()> = Mutex::new(());
665
666    /// Acquire test mutex lock, handling poisoning gracefully
667    fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
668        TEST_MUTEX.lock().unwrap_or_else(|poisoned| {
669            // Clear the poisoned state and acquire the lock
670            poisoned.into_inner()
671        })
672    }
673
674    // removed legacy setup_test_environment in favor of TestEnvironment
675
676    #[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        // Create a few test specs
684        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        // Test filtering by feature name
710        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        // Test filtering with limit
719        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        // Test empty project
736        assert_eq!(count_specs(project_name).unwrap(), 0);
737        assert!(!spec_exists(project_name, "nonexistent_spec").unwrap());
738
739        // Create a spec
740        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        // Test counting and existence
753        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        // Create a spec
765        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        // Update task list
778        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        // Verify update
788        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        // Create a spec
801        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        // Validate the spec
814        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        // Initially no specs
835        assert!(get_latest_spec(project_name).unwrap().is_none());
836
837        // Create first spec
838        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        // Delay to ensure different timestamps (need at least 1 second difference)
851        std::thread::sleep(std::time::Duration::from_millis(1100));
852
853        // Create second spec
854        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        // Get latest spec (should be the second one)
867        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 proper TestEnvironment for isolation instead of setup_test_environment
875        use crate::test_utils::TestEnvironment;
876        let _env = TestEnvironment::new().unwrap();
877
878        // Use a consistent project name for this test
879        let project_name = "test-directory-management-project";
880
881        // Test directory creation
882        let specs_dir = ensure_specs_directory(project_name).unwrap();
883        assert!(specs_dir.exists());
884        assert!(specs_dir.is_dir());
885
886        // Test path getters
887        let specs_dir_path = get_specs_directory(project_name).unwrap();
888        assert_eq!(specs_dir, specs_dir_path);
889
890        // Create a spec and test spec path
891        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        // Test that the spec path exists and is correct
905        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        // Create test specs
917        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        // Test exact spec name match
940        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        // Create test specs
954        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        // Test exact feature name match
977        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        // Test feature name substring match
984        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        // Create test specs
998        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        // Test no matches
1010        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        // Test empty project
1024        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        // Create test spec
1035        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        // Test fuzzy loading with feature name
1047        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        // Test exact loading
1052        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        // Create test spec
1065        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        // Test no matches
1077        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            // Test empty query
1097            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            // Test whitespace-only query
1107            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            // Test empty project name
1125            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            // Test whitespace-only project name
1131            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            // Test multiple matches
1154            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            // Test query on empty project
1173            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            // Multiple calls should work consistently (no caching, but still fast)
1194            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            // Create a valid spec
1213            _env.create_test_spec(project_name, "valid_spec", "Valid spec")
1214                .await
1215                .unwrap();
1216
1217            // Create a malformed spec directory (invalid name format)
1218            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            // List specs should skip malformed ones but still return valid ones
1224            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            // Create test specs with similar names
1240            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            // Test exact match (similarity = 1.0)
1252            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")); // Valid year prefix
1257                }
1258                _ => panic!("Expected FeatureExact match"),
1259            }
1260
1261            // Test high similarity match (should match "user_auth" for "user_authentication" query)
1262            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")); // Valid year prefix
1267                }
1268                _ => panic!("Expected FeatureExact match"),
1269            }
1270
1271            // Test fuzzy match with partial similarity
1272            let result = find_spec_match(project_name, "usr_auth").unwrap();
1273            match result {
1274                SpecMatchStrategy::FeatureFuzzy(_) => {
1275                    // This should find a fuzzy match due to high similarity
1276                }
1277                SpecMatchStrategy::Multiple(_) => {
1278                    // Multiple matches due to both being similar
1279                }
1280                _ => panic!("Expected fuzzy or multiple match for partial similarity"),
1281            }
1282
1283            // Test low similarity (should not match above threshold)
1284            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            // Test empty string similarity
1299            let similarity = strsim::normalized_levenshtein("", "");
1300            assert_eq!(similarity, 1.0);
1301
1302            // Test single character similarity
1303            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            // Test case sensitivity (strsim is case sensitive)
1310            let similarity = strsim::normalized_levenshtein("User", "user");
1311            assert!(similarity < 1.0); // Should be less than perfect match
1312
1313            // Test with actual spec data
1314            env.create_test_spec(project_name, "test_feature", "Test spec")
1315                .await
1316                .unwrap();
1317
1318            // Test exact case match
1319            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")); // Valid year prefix
1324                }
1325                _ => panic!("Expected FeatureExact match"),
1326            }
1327
1328            // Test case mismatch (should not find exact match)
1329            let result = find_spec_match(project_name, "Test_Feature").unwrap();
1330            match result {
1331                SpecMatchStrategy::FeatureFuzzy(_) => {
1332                    // Should find fuzzy match due to case difference
1333                }
1334                SpecMatchStrategy::None => {
1335                    // Could be no match if similarity is below threshold
1336                }
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            // Create a spec with a malformed directory to trigger logging
1353            env.create_test_spec(project_name, "valid_spec", "Valid spec")
1354                .await
1355                .unwrap();
1356
1357            // Create a malformed directory manually to trigger warning logs
1358            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            // Verify no eprintln! output from core functions (stderr is empty)
1364
1365            // This would require more complex setup to capture stderr
1366            // For now, we just ensure the function calls work without panicking
1367            let specs = list_specs(project_name).unwrap();
1368
1369            // Verify we still get the valid spec despite the malformed one
1370            assert_eq!(specs.len(), 1);
1371            assert_eq!(specs[0].feature_name, "valid_spec");
1372
1373            // In a real test, we'd check that stderr_buf is empty
1374            // For now, this test ensures the functions work correctly
1375        });
1376    }
1377}