1use 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
15pub 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
30pub 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 filesystem::create_dir_all(&spec_path)?;
41
42 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
57pub 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 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 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
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
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 if let Some(timestamp_str) = timestamp::parse_spec_timestamp(&spec_name)
115 && let Some(feature_name) = timestamp::extract_feature_name(&spec_name)
116 {
117 let created_at = timestamp::spec_timestamp_to_iso(×tamp_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 }
130 }
131
132 specs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
134
135 Ok(specs)
136}
137
138pub 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 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 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 if let Some(limit) = filter.limit {
174 filtered_specs.truncate(limit);
175 }
176
177 Ok(filtered_specs)
178}
179
180pub fn get_latest_spec(project_name: &str) -> Result<Option<SpecMetadata>> {
182 let specs = list_specs(project_name)?;
183 Ok(specs.into_iter().next()) }
185
186pub fn count_specs(project_name: &str) -> Result<usize> {
188 let specs = list_specs(project_name)?;
189 Ok(specs.len())
190}
191
192pub 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
200pub 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_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
232pub 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
238pub 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
244pub 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
256pub 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
280pub 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 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(¬es_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
362pub fn load_spec(project_name: &str, spec_name: &str) -> Result<Spec> {
364 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 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 let created_at = timestamp::parse_spec_timestamp(spec_name).map_or_else(
385 || {
386 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(×tamp_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
420pub 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
426pub 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
432pub 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 static TEST_MUTEX: Mutex<()> = Mutex::new(());
449
450 fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
452 TEST_MUTEX.lock().unwrap_or_else(|poisoned| {
453 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 let foundry_path = temp_dir.path().join(".foundry");
470 fs::create_dir_all(&foundry_path).unwrap();
471
472 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 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 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 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 assert_eq!(count_specs(&project_name).unwrap(), 0);
540 assert!(!spec_exists(&project_name, "nonexistent_spec").unwrap());
541
542 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 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 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 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 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 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 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 assert!(get_latest_spec(&project_name).unwrap().is_none());
639
640 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 std::thread::sleep(std::time::Duration::from_millis(1100));
655
656 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 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 crate::test_utils::TestEnvironment;
680 let _env = TestEnvironment::new().unwrap();
681
682 let project_name = "test_directory_management_project";
684
685 let specs_dir = ensure_specs_directory(project_name).unwrap();
687 assert!(specs_dir.exists());
688 assert!(specs_dir.is_dir());
689
690 let specs_dir_path = get_specs_directory(project_name).unwrap();
692 assert_eq!(specs_dir, specs_dir_path);
693
694 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}