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;
7
8use crate::core::filesystem;
9use crate::types::spec::{
10    ContentValidationStatus, Spec, SpecConfig, SpecContentData, SpecFileType, SpecFilter,
11    SpecMetadata, SpecValidationResult,
12};
13use crate::utils::timestamp;
14
15/// Generate timestamped spec name
16pub fn generate_spec_name(feature_name: &str) -> String {
17    let now = Utc::now();
18    format!(
19        "{:04}{:02}{:02}_{:02}{:02}{:02}_{}",
20        now.year(),
21        now.month(),
22        now.day(),
23        now.hour(),
24        now.minute(),
25        now.second(),
26        feature_name
27    )
28}
29
30/// Create a new spec
31pub fn create_spec(config: SpecConfig) -> Result<Spec> {
32    let foundry_dir = filesystem::foundry_dir()?;
33    let project_path = foundry_dir.join(&config.project_name);
34    let specs_dir = project_path.join("specs");
35    let spec_name = generate_spec_name(&config.feature_name);
36    let spec_path = specs_dir.join(&spec_name);
37    let created_at = Utc::now().to_rfc3339();
38
39    // Ensure specs directory exists
40    filesystem::create_dir_all(&spec_path)?;
41
42    // Write spec files
43    filesystem::write_file_atomic(spec_path.join("spec.md"), &config.content.spec)?;
44    filesystem::write_file_atomic(spec_path.join("notes.md"), &config.content.notes)?;
45
46    filesystem::write_file_atomic(spec_path.join("task-list.md"), &config.content.tasks)?;
47
48    Ok(Spec {
49        name: spec_name,
50        created_at,
51        path: spec_path,
52        project_name: config.project_name,
53        content: config.content,
54    })
55}
56
57/// Validate spec directory name format
58pub fn validate_spec_name(spec_name: &str) -> Result<()> {
59    if timestamp::parse_spec_timestamp(spec_name).is_none() {
60        return Err(anyhow::anyhow!(
61            "Invalid spec name format. Expected: YYYYMMDD_HHMMSS_feature_name, got: {}",
62            spec_name
63        ));
64    }
65
66    // Validate feature name part
67    if let Some(feature_name) = timestamp::extract_feature_name(spec_name) {
68        if feature_name.is_empty() {
69            return Err(anyhow::anyhow!(
70                "Spec name must include a feature name after the timestamp"
71            ));
72        }
73
74        // Validate feature name follows snake_case convention
75        if !feature_name
76            .chars()
77            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
78            || feature_name.starts_with('_')
79            || feature_name.ends_with('_')
80            || feature_name.contains("__")
81        {
82            return Err(anyhow::anyhow!(
83                "Feature name must be in snake_case format: {}",
84                feature_name
85            ));
86        }
87    } else {
88        return Err(anyhow::anyhow!(
89            "Could not extract feature name from spec name: {}",
90            spec_name
91        ));
92    }
93
94    Ok(())
95}
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
108    for entry in fs::read_dir(specs_dir)? {
109        let entry = entry?;
110        if entry.file_type()?.is_dir() {
111            let spec_name = entry.file_name().to_string_lossy().to_string();
112
113            // Use enhanced timestamp parsing
114            if let Some(timestamp_str) = timestamp::parse_spec_timestamp(&spec_name)
115                && let Some(feature_name) = timestamp::extract_feature_name(&spec_name)
116            {
117                // Convert timestamp to ISO format for consistent storage
118                let created_at = timestamp::spec_timestamp_to_iso(&timestamp_str)
119                    .unwrap_or_else(|_| timestamp::iso_timestamp());
120
121                specs.push(SpecMetadata {
122                    name: spec_name.clone(),
123                    created_at,
124                    feature_name,
125                    project_name: project_name.to_string(),
126                });
127            }
128            // Skip invalid spec directories (they'll be ignored but won't cause errors)
129        }
130    }
131
132    // Sort by creation time (newest first)
133    specs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
134
135    Ok(specs)
136}
137
138/// List specs with filtering capabilities
139pub fn list_specs_filtered(project_name: &str, filter: SpecFilter) -> Result<Vec<SpecMetadata>> {
140    let specs = list_specs(project_name)?;
141
142    let mut filtered_specs: Vec<SpecMetadata> = specs
143        .into_iter()
144        .filter(|spec| {
145            // Apply feature name filter
146            if let Some(name_filter) = &filter.feature_name_contains
147                && !spec
148                    .feature_name
149                    .to_lowercase()
150                    .contains(&name_filter.to_lowercase())
151            {
152                return false;
153            }
154
155            // Apply date range filters
156            if let Some(after) = &filter.created_after
157                && spec.created_at < *after
158            {
159                return false;
160            }
161
162            if let Some(before) = &filter.created_before
163                && spec.created_at > *before
164            {
165                return false;
166            }
167
168            true
169        })
170        .collect();
171
172    // Apply limit
173    if let Some(limit) = filter.limit {
174        filtered_specs.truncate(limit);
175    }
176
177    Ok(filtered_specs)
178}
179
180/// Get the most recent spec for a project
181pub fn get_latest_spec(project_name: &str) -> Result<Option<SpecMetadata>> {
182    let specs = list_specs(project_name)?;
183    Ok(specs.into_iter().next()) // Already sorted by creation time (newest first)
184}
185
186/// Count total specs for a project
187pub fn count_specs(project_name: &str) -> Result<usize> {
188    let specs = list_specs(project_name)?;
189    Ok(specs.len())
190}
191
192/// Check if a spec exists
193pub fn spec_exists(project_name: &str, spec_name: &str) -> Result<bool> {
194    let foundry_dir = filesystem::foundry_dir()?;
195    let spec_path = foundry_dir.join(project_name).join("specs").join(spec_name);
196
197    Ok(spec_path.exists() && spec_path.is_dir())
198}
199
200/// Update spec content (for task list updates)
201pub fn update_spec_content(
202    project_name: &str,
203    spec_name: &str,
204    file_type: SpecFileType,
205    new_content: &str,
206) -> Result<()> {
207    // Validate spec exists
208    validate_spec_name(spec_name)?;
209    if !spec_exists(project_name, spec_name)? {
210        return Err(anyhow::anyhow!(
211            "Spec '{}' not found in project '{}'",
212            spec_name,
213            project_name
214        ));
215    }
216
217    let foundry_dir = filesystem::foundry_dir()?;
218    let spec_path = foundry_dir.join(project_name).join("specs").join(spec_name);
219
220    let file_path = match file_type {
221        SpecFileType::Spec => spec_path.join("spec.md"),
222        SpecFileType::Notes => spec_path.join("notes.md"),
223        SpecFileType::TaskList => spec_path.join("task-list.md"),
224    };
225
226    filesystem::write_file_atomic(&file_path, new_content)
227        .with_context(|| format!("Failed to update {:?} for spec '{}'", file_type, spec_name))?;
228
229    Ok(())
230}
231
232/// Get spec directory path
233pub fn get_spec_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
234    let foundry_dir = filesystem::foundry_dir()?;
235    Ok(foundry_dir.join(project_name).join("specs").join(spec_name))
236}
237
238/// Get specs directory path for a project
239pub fn get_specs_directory(project_name: &str) -> Result<PathBuf> {
240    let foundry_dir = filesystem::foundry_dir()?;
241    Ok(foundry_dir.join(project_name).join("specs"))
242}
243
244/// Ensure specs directory exists for a project
245pub fn ensure_specs_directory(project_name: &str) -> Result<PathBuf> {
246    let specs_dir = get_specs_directory(project_name)?;
247    filesystem::create_dir_all(&specs_dir).with_context(|| {
248        format!(
249            "Failed to create specs directory for project '{}'",
250            project_name
251        )
252    })?;
253    Ok(specs_dir)
254}
255
256/// Delete a spec (with confirmation)
257pub fn delete_spec(project_name: &str, spec_name: &str) -> Result<()> {
258    validate_spec_name(spec_name)?;
259
260    let spec_path = get_spec_path(project_name, spec_name)?;
261
262    if !spec_path.exists() {
263        return Err(anyhow::anyhow!(
264            "Spec '{}' not found in project '{}'",
265            spec_name,
266            project_name
267        ));
268    }
269
270    std::fs::remove_dir_all(&spec_path).with_context(|| {
271        format!(
272            "Failed to delete spec '{}' from project '{}'",
273            spec_name, project_name
274        )
275    })?;
276
277    Ok(())
278}
279
280/// Validate spec content files exist and are readable
281pub fn validate_spec_files(project_name: &str, spec_name: &str) -> Result<SpecValidationResult> {
282    let spec_path = get_spec_path(project_name, spec_name)?;
283
284    if !spec_path.exists() {
285        return Err(anyhow::anyhow!(
286            "Spec '{}' not found in project '{}'",
287            spec_name,
288            project_name
289        ));
290    }
291
292    let spec_file = spec_path.join("spec.md");
293    let notes_file = spec_path.join("notes.md");
294    let task_list_file = spec_path.join("task-list.md");
295
296    let mut result = SpecValidationResult {
297        spec_name: spec_name.to_string(),
298        project_name: project_name.to_string(),
299        spec_file_exists: spec_file.exists(),
300        notes_file_exists: notes_file.exists(),
301        task_list_file_exists: task_list_file.exists(),
302        content_validation: ContentValidationStatus {
303            spec_valid: false,
304            notes_valid: false,
305            task_list_valid: false,
306        },
307        validation_errors: Vec::new(),
308    };
309
310    // Validate file contents if they exist
311    if result.spec_file_exists {
312        match filesystem::read_file(&spec_file) {
313            Ok(content) => {
314                result.content_validation.spec_valid = !content.trim().is_empty();
315                if !result.content_validation.spec_valid {
316                    result
317                        .validation_errors
318                        .push("Spec file is empty".to_string());
319                }
320            }
321            Err(e) => {
322                result
323                    .validation_errors
324                    .push(format!("Cannot read spec file: {}", e));
325            }
326        }
327    } else {
328        result
329            .validation_errors
330            .push("Spec file missing".to_string());
331    }
332
333    if result.notes_file_exists {
334        match filesystem::read_file(&notes_file) {
335            Ok(content) => {
336                result.content_validation.notes_valid = !content.trim().is_empty();
337            }
338            Err(e) => {
339                result
340                    .validation_errors
341                    .push(format!("Cannot read notes file: {}", e));
342            }
343        }
344    }
345
346    if result.task_list_file_exists {
347        match filesystem::read_file(&task_list_file) {
348            Ok(content) => {
349                result.content_validation.task_list_valid = !content.trim().is_empty();
350            }
351            Err(e) => {
352                result
353                    .validation_errors
354                    .push(format!("Cannot read task list file: {}", e));
355            }
356        }
357    }
358
359    Ok(result)
360}
361
362/// Load a specific spec with validation
363pub fn load_spec(project_name: &str, spec_name: &str) -> Result<Spec> {
364    // Validate spec name format first
365    validate_spec_name(spec_name).with_context(|| format!("Invalid spec name: {}", spec_name))?;
366
367    let foundry_dir = filesystem::foundry_dir()?;
368    let spec_path = foundry_dir.join(project_name).join("specs").join(spec_name);
369
370    if !spec_path.exists() {
371        return Err(anyhow::anyhow!(
372            "Spec '{}' not found in project '{}'",
373            spec_name,
374            project_name
375        ));
376    }
377
378    // Read spec files
379    let spec_content = filesystem::read_file(spec_path.join("spec.md"))?;
380    let notes = filesystem::read_file(spec_path.join("notes.md"))?;
381    let task_list = filesystem::read_file(spec_path.join("task-list.md"))?;
382
383    // Get creation time from spec name timestamp (more reliable than filesystem metadata)
384    let created_at = timestamp::parse_spec_timestamp(spec_name).map_or_else(
385        || {
386            // Fallback to filesystem metadata if timestamp parsing fails
387            fs::metadata(&spec_path)
388                .and_then(|metadata| metadata.created())
389                .map_err(anyhow::Error::from)
390                .and_then(|time| {
391                    time.duration_since(std::time::UNIX_EPOCH)
392                        .map_err(anyhow::Error::from)
393                })
394                .map(|duration| {
395                    chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0)
396                        .unwrap_or_else(chrono::Utc::now)
397                        .to_rfc3339()
398                })
399                .unwrap_or_else(|_| timestamp::iso_timestamp())
400        },
401        |timestamp_str| {
402            timestamp::spec_timestamp_to_iso(&timestamp_str)
403                .unwrap_or_else(|_| timestamp::iso_timestamp())
404        },
405    );
406
407    Ok(Spec {
408        name: spec_name.to_string(),
409        created_at,
410        path: spec_path,
411        project_name: project_name.to_string(),
412        content: SpecContentData {
413            spec: spec_content,
414            notes,
415            tasks: task_list,
416        },
417    })
418}
419
420/// Get the file path for a spec.md file
421pub fn get_spec_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
422    let spec_path = get_spec_path(project_name, spec_name)?;
423    Ok(spec_path.join("spec.md"))
424}
425
426/// Get the file path for a task-list.md file
427pub fn get_task_list_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
428    let spec_path = get_spec_path(project_name, spec_name)?;
429    Ok(spec_path.join("task-list.md"))
430}
431
432/// Get the file path for a notes.md file
433pub fn get_notes_file_path(project_name: &str, spec_name: &str) -> Result<PathBuf> {
434    let spec_path = get_spec_path(project_name, spec_name)?;
435    Ok(spec_path.join("notes.md"))
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::types::spec::{SpecConfig, SpecFileType, SpecFilter};
442    use std::fs;
443    use std::sync::Mutex;
444    use temp_env;
445    use tempfile::TempDir;
446
447    // Use a mutex to serialize tests that modify global environment
448    static TEST_MUTEX: Mutex<()> = Mutex::new(());
449
450    /// Acquire test mutex lock, handling poisoning gracefully
451    fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
452        TEST_MUTEX.lock().unwrap_or_else(|poisoned| {
453            // Clear the poisoned state and acquire the lock
454            poisoned.into_inner()
455        })
456    }
457
458    fn setup_test_environment() -> (TempDir, String) {
459        let temp_dir = TempDir::new().unwrap();
460        let project_name = format!(
461            "test_project_{}",
462            std::time::SystemTime::now()
463                .duration_since(std::time::UNIX_EPOCH)
464                .unwrap()
465                .as_nanos()
466        );
467
468        // Create foundry directory structure in temp
469        let foundry_path = temp_dir.path().join(".foundry");
470        fs::create_dir_all(&foundry_path).unwrap();
471
472        // Create project structure
473        let project_path = foundry_path.join(&project_name);
474        fs::create_dir_all(&project_path).unwrap();
475        fs::create_dir_all(project_path.join("specs")).unwrap();
476
477        (temp_dir, project_name)
478    }
479
480    #[test]
481    fn test_spec_filtering() {
482        let _lock = acquire_test_lock();
483        let (temp_dir, project_name) = setup_test_environment();
484
485        temp_env::with_var("HOME", Some(temp_dir.path()), || {
486            // Create a few test specs
487            let spec_configs = vec![
488                SpecConfig {
489                    project_name: project_name.clone(),
490                    feature_name: "user_auth".to_string(),
491                    content: SpecContentData {
492                        spec: "User authentication specification".to_string(),
493                        notes: "Authentication notes".to_string(),
494                        tasks: "- Implement login\n- Implement logout".to_string(),
495                    },
496                },
497                SpecConfig {
498                    project_name: project_name.clone(),
499                    feature_name: "user_profile".to_string(),
500                    content: SpecContentData {
501                        spec: "User profile management".to_string(),
502                        notes: "Profile notes".to_string(),
503                        tasks: "- Profile CRUD\n- Avatar upload".to_string(),
504                    },
505                },
506            ];
507
508            for config in spec_configs {
509                create_spec(config).unwrap();
510            }
511
512            // Test filtering by feature name
513            let filter = SpecFilter {
514                feature_name_contains: Some("user".to_string()),
515                ..Default::default()
516            };
517
518            let filtered_specs = list_specs_filtered(&project_name, filter).unwrap();
519            assert_eq!(filtered_specs.len(), 2);
520
521            // Test filtering with limit
522            let filter = SpecFilter {
523                limit: Some(1),
524                ..Default::default()
525            };
526
527            let limited_specs = list_specs_filtered(&project_name, filter).unwrap();
528            assert_eq!(limited_specs.len(), 1);
529        });
530    }
531
532    #[test]
533    fn test_spec_existence_and_counting() {
534        let _lock = acquire_test_lock();
535        let (temp_dir, project_name) = setup_test_environment();
536
537        temp_env::with_var("HOME", Some(temp_dir.path()), || {
538            // Test empty project
539            assert_eq!(count_specs(&project_name).unwrap(), 0);
540            assert!(!spec_exists(&project_name, "nonexistent_spec").unwrap());
541
542            // Create a spec
543            let config = SpecConfig {
544                project_name: project_name.clone(),
545                feature_name: "test_feature".to_string(),
546                content: SpecContentData {
547                    spec: "Test specification".to_string(),
548                    notes: "Test notes".to_string(),
549                    tasks: "- Test task".to_string(),
550                },
551            };
552
553            let created_spec = create_spec(config).unwrap();
554
555            // Test counting and existence
556            assert_eq!(count_specs(&project_name).unwrap(), 1);
557            assert!(spec_exists(&project_name, &created_spec.name).unwrap());
558        });
559    }
560
561    #[test]
562    fn test_spec_content_updates() {
563        let _lock = acquire_test_lock();
564        let (temp_dir, project_name) = setup_test_environment();
565
566        temp_env::with_var("HOME", Some(temp_dir.path()), || {
567            // Create a spec
568            let config = SpecConfig {
569                project_name: project_name.clone(),
570                feature_name: "updatable_spec".to_string(),
571                content: SpecContentData {
572                    spec: "Original specification".to_string(),
573                    notes: "Original notes".to_string(),
574                    tasks: "- Original task".to_string(),
575                },
576            };
577
578            let created_spec = create_spec(config).unwrap();
579
580            // Update task list
581            let new_tasks = "- Updated task\n- New task\n- [ ] Completed task";
582            update_spec_content(
583                &project_name,
584                &created_spec.name,
585                SpecFileType::TaskList,
586                new_tasks,
587            )
588            .unwrap();
589
590            // Verify update
591            let loaded_spec = load_spec(&project_name, &created_spec.name).unwrap();
592            assert_eq!(loaded_spec.content.tasks, new_tasks);
593            assert_eq!(loaded_spec.content.spec, "Original specification");
594        });
595    }
596
597    #[test]
598    fn test_spec_validation() {
599        let _lock = acquire_test_lock();
600        let (temp_dir, project_name) = setup_test_environment();
601
602        temp_env::with_var("HOME", Some(temp_dir.path()), || {
603            // Create a spec
604            let config = SpecConfig {
605                project_name: project_name.clone(),
606                feature_name: "validation_test".to_string(),
607                content: SpecContentData {
608                    spec: "Valid specification content".to_string(),
609                    notes: "Valid notes".to_string(),
610                    tasks: "- Valid task".to_string(),
611                },
612            };
613
614            let created_spec = create_spec(config).unwrap();
615
616            // Validate the spec
617            let validation_result = validate_spec_files(&project_name, &created_spec.name).unwrap();
618
619            assert!(validation_result.is_valid());
620            assert!(validation_result.spec_file_exists);
621            assert!(validation_result.notes_file_exists);
622            assert!(validation_result.task_list_file_exists);
623            assert!(validation_result.content_validation.spec_valid);
624            assert!(validation_result.content_validation.notes_valid);
625            assert!(validation_result.content_validation.task_list_valid);
626            assert!(validation_result.validation_errors.is_empty());
627            assert_eq!(validation_result.summary(), "Spec is valid");
628        });
629    }
630
631    #[test]
632    fn test_latest_spec_retrieval() {
633        let _lock = acquire_test_lock();
634        let (temp_dir, project_name) = setup_test_environment();
635
636        temp_env::with_var("HOME", Some(temp_dir.path()), || {
637            // Initially no specs
638            assert!(get_latest_spec(&project_name).unwrap().is_none());
639
640            // Create first spec
641            let config1 = SpecConfig {
642                project_name: project_name.clone(),
643                feature_name: "first_spec".to_string(),
644                content: SpecContentData {
645                    spec: "First specification".to_string(),
646                    notes: "First notes".to_string(),
647                    tasks: "- First task".to_string(),
648                },
649            };
650
651            let _spec1 = create_spec(config1).unwrap();
652
653            // Delay to ensure different timestamps (need at least 1 second difference)
654            std::thread::sleep(std::time::Duration::from_millis(1100));
655
656            // Create second spec
657            let config2 = SpecConfig {
658                project_name: project_name.clone(),
659                feature_name: "second_spec".to_string(),
660                content: SpecContentData {
661                    spec: "Second specification".to_string(),
662                    notes: "Second notes".to_string(),
663                    tasks: "- Second task".to_string(),
664                },
665            };
666
667            let spec2 = create_spec(config2).unwrap();
668
669            // Get latest spec (should be the second one)
670            let latest = get_latest_spec(&project_name).unwrap().unwrap();
671            assert_eq!(latest.name, spec2.name);
672            assert_eq!(latest.feature_name, "second_spec");
673        });
674    }
675
676    #[test]
677    fn test_directory_management() {
678        // Use proper TestEnvironment for isolation instead of setup_test_environment
679        use crate::test_utils::TestEnvironment;
680        let _env = TestEnvironment::new().unwrap();
681
682        // Use a consistent project name for this test
683        let project_name = "test_directory_management_project";
684
685        // Test directory creation
686        let specs_dir = ensure_specs_directory(project_name).unwrap();
687        assert!(specs_dir.exists());
688        assert!(specs_dir.is_dir());
689
690        // Test path getters
691        let specs_dir_path = get_specs_directory(project_name).unwrap();
692        assert_eq!(specs_dir, specs_dir_path);
693
694        // Create a spec and test spec path
695        let config = SpecConfig {
696            project_name: project_name.to_string(),
697            feature_name: "path_test".to_string(),
698            content: SpecContentData {
699                spec: "Path test spec".to_string(),
700                notes: "Path test notes".to_string(),
701                tasks: "- Path test task".to_string(),
702            },
703        };
704
705        let created_spec = create_spec(config).unwrap();
706        let spec_path = get_spec_path(project_name, &created_spec.name).unwrap();
707
708        assert_eq!(spec_path, created_spec.path);
709        assert!(spec_path.exists());
710    }
711}