1mod claude_md;
14mod codex_md;
15mod hooks;
16mod settings;
17
18use std::collections::HashSet;
19use std::fs;
20use std::path::PathBuf;
21
22use serde::{Deserialize, Serialize};
23
24use crate::models::RequirementsStore;
25use crate::templates::TemplateLoader;
26
27pub const SCAFFOLD_VERSION: &str = "2.0.0";
29
30fn compute_checksum(content: &str) -> String {
32 use std::collections::hash_map::DefaultHasher;
33 use std::hash::{Hash, Hasher};
34 let mut hasher = DefaultHasher::new();
35 content.hash(&mut hasher);
36 format!("{:016x}", hasher.finish())[..8].to_string()
37}
38
39fn generate_aida_header(content: &str) -> String {
41 let checksum = compute_checksum(content);
42 format!(
43 "<!-- AIDA Generated: v{} | checksum:{} | DO NOT EDIT DIRECTLY -->\n\
44 <!-- To customize: copy this file and modify the copy -->\n\n",
45 SCAFFOLD_VERSION, checksum
46 )
47}
48
49fn generate_aida_header_shell(content: &str) -> String {
51 let checksum = compute_checksum(content);
52 format!(
53 "# AIDA Generated: v{} | checksum:{}\n\
54 # To customize: copy this file and modify the copy\n",
55 SCAFFOLD_VERSION, checksum
56 )
57}
58
59fn find_aida_header_line(content: &str) -> Option<&str> {
61 if content.starts_with("---\n") || content.starts_with("---\r\n") {
63 let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
65 if let Some(close_pos) = content[after_open..].find("\n---") {
66 let after_close = after_open + close_pos + 4; let rest = content[after_close..]
69 .trim_start_matches('\r')
70 .trim_start_matches('\n');
71 return rest.lines().next();
72 }
73 }
74
75 content.lines().next()
77}
78
79fn check_file_status(file_path: &PathBuf, expected_content: &str) -> FileStatus {
81 let content = match fs::read_to_string(file_path) {
82 Ok(c) => c,
83 Err(_) => return FileStatus::New,
84 };
85
86 let md_header_pattern = regex::Regex::new(
89 r"^<!-- AIDA Generated: v([0-9.]+) \| checksum:([a-f0-9]+) \| DO NOT EDIT DIRECTLY -->",
90 )
91 .unwrap();
92
93 let shell_header_pattern =
96 regex::Regex::new(r"^# AIDA Generated: v([0-9.]+) \| checksum:([a-f0-9]+)").unwrap();
97
98 let header_line = find_aida_header_line(&content).unwrap_or("");
100
101 if let Some(caps) = md_header_pattern.captures(header_line) {
103 let file_version = caps.get(1).map(|m| m.as_str()).unwrap_or("");
104 let stored_checksum = caps.get(2).map(|m| m.as_str()).unwrap_or("");
105
106 if file_version != SCAFFOLD_VERSION {
108 return FileStatus::OlderVersion {
109 file_version: file_version.to_string(),
110 };
111 }
112
113 let expected_checksum = compute_checksum(expected_content);
115
116 if stored_checksum == expected_checksum {
117 return FileStatus::Unmodified;
118 } else {
119 return FileStatus::Modified {
120 expected_checksum,
121 actual_checksum: stored_checksum.to_string(),
122 };
123 }
124 }
125
126 if let Some(caps) = shell_header_pattern.captures(header_line) {
128 let file_version = caps.get(1).map(|m| m.as_str()).unwrap_or("");
129 let stored_checksum = caps.get(2).map(|m| m.as_str()).unwrap_or("");
130
131 if file_version != SCAFFOLD_VERSION {
133 return FileStatus::OlderVersion {
134 file_version: file_version.to_string(),
135 };
136 }
137
138 let expected_checksum = compute_checksum(expected_content);
140
141 if stored_checksum == expected_checksum {
142 return FileStatus::Unmodified;
143 } else {
144 return FileStatus::Modified {
145 expected_checksum,
146 actual_checksum: stored_checksum.to_string(),
147 };
148 }
149 }
150
151 FileStatus::NoHeader
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum FileStatus {
158 New,
160 Unmodified,
162 Modified {
164 expected_checksum: String,
165 actual_checksum: String,
166 },
167 NoHeader,
169 OlderVersion { file_version: String },
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ScaffoldConfig {
176 pub generate_claude_md: bool,
178 pub generate_agents_md: bool,
180 pub generate_commands: bool,
182 pub generate_skills: bool,
184 pub generate_codex_skills: bool,
186 pub include_aida_req_skill: bool,
188 pub include_aida_plan_skill: bool,
190 pub include_aida_implement_skill: bool,
192 pub include_aida_capture_skill: bool,
194 pub include_aida_docs_skill: bool,
196 pub include_aida_release_skill: bool,
198 pub include_aida_evaluate_skill: bool,
200 pub include_aida_commit_skill: bool,
202 pub include_aida_sync_skill: bool,
204 pub include_aida_test_skill: bool,
206 pub include_aida_review_skill: bool,
208 pub include_aida_onboard_skill: bool,
210 pub include_aida_sprint_skill: bool,
212 pub include_aida_search_skill: bool,
214 pub include_aida_standup_skill: bool,
216 pub generate_git_hooks: bool,
218 pub include_commit_msg_hook: bool,
220 pub include_pre_commit_hook: bool,
222 pub generate_claude_code_hooks: bool,
224 pub include_validate_commit_hook: bool,
226 pub include_track_commits_hook: bool,
228 pub project_type: ProjectType,
230 pub tech_stack: Vec<String>,
232}
233
234impl Default for ScaffoldConfig {
235 fn default() -> Self {
236 Self {
237 generate_claude_md: true,
238 generate_agents_md: true,
239 generate_commands: true,
240 generate_skills: true,
241 generate_codex_skills: true,
242 include_aida_req_skill: true,
243 include_aida_plan_skill: true,
244 include_aida_implement_skill: true,
245 include_aida_capture_skill: true,
246 include_aida_docs_skill: true,
247 include_aida_release_skill: true,
248 include_aida_evaluate_skill: true,
249 include_aida_commit_skill: true,
250 include_aida_sync_skill: true,
251 include_aida_test_skill: true,
252 include_aida_review_skill: true,
253 include_aida_onboard_skill: true,
254 include_aida_sprint_skill: true,
255 include_aida_search_skill: true,
256 include_aida_standup_skill: true,
257 generate_git_hooks: true,
258 include_commit_msg_hook: true,
259 include_pre_commit_hook: false, generate_claude_code_hooks: true,
261 include_validate_commit_hook: true,
262 include_track_commits_hook: true,
263 project_type: ProjectType::Generic,
264 tech_stack: Vec::new(),
265 }
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
271pub enum ProjectType {
272 #[default]
273 Generic,
274 Rust,
275 Python,
276 TypeScript,
277 Web,
278 Api,
279 Cli,
280}
281
282impl ProjectType {
283 pub fn all() -> &'static [ProjectType] {
285 &[
286 ProjectType::Generic,
287 ProjectType::Rust,
288 ProjectType::Python,
289 ProjectType::TypeScript,
290 ProjectType::Web,
291 ProjectType::Api,
292 ProjectType::Cli,
293 ]
294 }
295
296 pub fn label(&self) -> &'static str {
298 match self {
299 ProjectType::Generic => "Generic",
300 ProjectType::Rust => "Rust",
301 ProjectType::Python => "Python",
302 ProjectType::TypeScript => "TypeScript",
303 ProjectType::Web => "Web Application",
304 ProjectType::Api => "API/Backend",
305 ProjectType::Cli => "CLI Tool",
306 }
307 }
308}
309
310#[derive(Debug, Clone)]
312pub struct ScaffoldArtifact {
313 pub path: PathBuf,
315 pub content: String,
317 pub description: String,
319 pub exists: bool,
321 pub file_status: FileStatus,
323}
324
325#[derive(Debug, Clone)]
327pub struct ScaffoldPreview {
328 pub artifacts: Vec<ScaffoldArtifact>,
330 pub overwrites: Vec<PathBuf>,
332 pub new_files: Vec<PathBuf>,
334 pub new_dirs: Vec<PathBuf>,
336 pub modified_files: Vec<PathBuf>,
338 pub upgradeable_files: Vec<PathBuf>,
340}
341
342#[derive(Debug, Clone, Default)]
344pub struct ApplyOptions {
345 pub force: bool,
347}
348
349pub struct Scaffolder {
351 project_root: PathBuf,
353 config: ScaffoldConfig,
355 database_path: Option<PathBuf>,
357 #[allow(dead_code)]
359 template_loader: TemplateLoader,
360}
361
362impl Scaffolder {
363 pub fn new(project_root: PathBuf, config: ScaffoldConfig) -> Self {
365 let template_loader = TemplateLoader::with_project_root(&project_root);
366 Self {
367 project_root,
368 config,
369 database_path: None,
370 template_loader,
371 }
372 }
373
374 pub fn with_database(
376 project_root: PathBuf,
377 config: ScaffoldConfig,
378 database_path: PathBuf,
379 ) -> Self {
380 let template_loader = TemplateLoader::with_project_root(&project_root);
381 Self {
382 project_root,
383 config,
384 database_path: Some(database_path),
385 template_loader,
386 }
387 }
388
389 fn is_sqlite_database(&self) -> bool {
391 self.database_path
392 .as_ref()
393 .map(|p| p.extension().map(|e| e == "db").unwrap_or(false))
394 .unwrap_or(false)
395 }
396
397 fn database_filename(&self) -> String {
399 self.database_path
400 .as_ref()
401 .and_then(|p| p.file_name())
402 .map(|n| n.to_string_lossy().to_string())
403 .unwrap_or_else(|| "requirements.yaml".to_string())
404 }
405
406 #[allow(dead_code)]
408 fn load_template(&mut self, key: &str) -> Option<String> {
409 self.template_loader.load(key)
410 }
411
412 fn create_artifact(
414 &self,
415 path: PathBuf,
416 raw_content: String,
417 description: String,
418 is_shell: bool,
419 ) -> ScaffoldArtifact {
420 let full_path = self.project_root.join(&path);
421 let exists = full_path.exists();
422
423 let file_status = if exists {
425 check_file_status(&full_path, &raw_content)
426 } else {
427 FileStatus::New
428 };
429
430 let content = if is_shell {
433 format!(
434 "{}{}",
435 generate_aida_header_shell(&raw_content),
436 raw_content
437 )
438 } else if raw_content.starts_with("---\n") {
439 let after_open = 4; if let Some(close_pos) = raw_content[after_open..].find("\n---\n") {
442 let fm_end = after_open + close_pos + 5; let (frontmatter, body) = raw_content.split_at(fm_end);
444 format!("{}{}{}", frontmatter, generate_aida_header(body), body)
445 } else {
446 format!("{}{}", generate_aida_header(&raw_content), raw_content)
447 }
448 } else {
449 format!("{}{}", generate_aida_header(&raw_content), raw_content)
450 };
451
452 ScaffoldArtifact {
453 path,
454 content,
455 description,
456 exists,
457 file_status,
458 }
459 }
460
461 pub fn preview(&mut self, store: &RequirementsStore) -> ScaffoldPreview {
463 let mut artifacts = Vec::new();
464 let mut overwrites = Vec::new();
465 let mut new_files = Vec::new();
466 let mut new_dirs = HashSet::new();
467 let mut modified_files = Vec::new();
468 let mut upgradeable_files = Vec::new();
469
470 if self.config.generate_claude_md {
472 let path = PathBuf::from("CLAUDE.md");
473 let full_path = self.project_root.join(&path);
474 let exists = full_path.exists();
475 let content = self.generate_claude_md(store);
476
477 if exists {
478 overwrites.push(path.clone());
479 } else {
480 new_files.push(path.clone());
481 }
482
483 artifacts.push(ScaffoldArtifact {
484 path,
485 content,
486 description: "Project instructions for Claude Code".to_string(),
487 exists,
488 file_status: if exists {
489 FileStatus::NoHeader
490 } else {
491 FileStatus::New
492 },
493 });
494 }
495
496 if self.config.generate_agents_md {
498 let path = PathBuf::from("AGENTS.md");
499 let full_path = self.project_root.join(&path);
500 let exists = full_path.exists();
501 let content = self.generate_agents_md(store);
502
503 if exists {
504 overwrites.push(path.clone());
505 } else {
506 new_files.push(path.clone());
507 }
508
509 artifacts.push(ScaffoldArtifact {
510 path,
511 content,
512 description: "Project instructions for Codex-compatible agents".to_string(),
513 exists,
514 file_status: if exists {
515 FileStatus::NoHeader
516 } else {
517 FileStatus::New
518 },
519 });
520 }
521
522 if self.config.generate_commands {
524 new_dirs.insert(PathBuf::from(".claude/commands"));
525
526 let commands = self.generate_commands(store);
528 for (name, content, desc) in commands {
529 let path = PathBuf::from(format!(".claude/commands/{}.md", name));
530 let artifact = self.create_artifact(path.clone(), content, desc, false);
531
532 match &artifact.file_status {
533 FileStatus::New => new_files.push(path),
534 FileStatus::Modified { .. } | FileStatus::NoHeader => {
535 modified_files.push(artifact.path.clone())
536 }
537 FileStatus::OlderVersion { .. } => {
538 upgradeable_files.push(artifact.path.clone())
539 }
540 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
541 }
542
543 artifacts.push(artifact);
544 }
545 }
546
547 if self.config.generate_skills {
549 new_dirs.insert(PathBuf::from(".claude/skills"));
550
551 if self.config.include_aida_req_skill {
553 let path = PathBuf::from(".claude/skills/aida-req.md");
554 let artifact = self.create_artifact(
555 path.clone(),
556 self.generate_aida_req_skill(),
557 "Skill for adding requirements with AI evaluation".to_string(),
558 false,
559 );
560
561 match &artifact.file_status {
562 FileStatus::New => new_files.push(path),
563 FileStatus::Modified { .. } | FileStatus::NoHeader => {
564 modified_files.push(artifact.path.clone())
565 }
566 FileStatus::OlderVersion { .. } => {
567 upgradeable_files.push(artifact.path.clone())
568 }
569 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
570 }
571
572 artifacts.push(artifact);
573 }
574
575 if self.config.include_aida_plan_skill {
577 let path = PathBuf::from(".claude/skills/aida-plan.md");
578 let artifact = self.create_artifact(
579 path.clone(),
580 self.generate_aida_plan_skill(),
581 "Skill for planning requirement implementation".to_string(),
582 false,
583 );
584
585 match &artifact.file_status {
586 FileStatus::New => new_files.push(path),
587 FileStatus::Modified { .. } | FileStatus::NoHeader => {
588 modified_files.push(artifact.path.clone())
589 }
590 FileStatus::OlderVersion { .. } => {
591 upgradeable_files.push(artifact.path.clone())
592 }
593 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
594 }
595
596 artifacts.push(artifact);
597 }
598
599 if self.config.include_aida_implement_skill {
601 let path = PathBuf::from(".claude/skills/aida-implement.md");
602 let artifact = self.create_artifact(
603 path.clone(),
604 self.generate_aida_implement_skill(),
605 "Skill for implementing requirements with traceability".to_string(),
606 false,
607 );
608
609 match &artifact.file_status {
610 FileStatus::New => new_files.push(path),
611 FileStatus::Modified { .. } | FileStatus::NoHeader => {
612 modified_files.push(artifact.path.clone())
613 }
614 FileStatus::OlderVersion { .. } => {
615 upgradeable_files.push(artifact.path.clone())
616 }
617 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
618 }
619
620 artifacts.push(artifact);
621 }
622
623 if self.config.include_aida_capture_skill {
625 let path = PathBuf::from(".claude/skills/aida-capture.md");
626 let artifact = self.create_artifact(
627 path.clone(),
628 self.generate_aida_capture_skill(),
629 "Skill for capturing missed requirements from session".to_string(),
630 false,
631 );
632
633 match &artifact.file_status {
634 FileStatus::New => new_files.push(path),
635 FileStatus::Modified { .. } | FileStatus::NoHeader => {
636 modified_files.push(artifact.path.clone())
637 }
638 FileStatus::OlderVersion { .. } => {
639 upgradeable_files.push(artifact.path.clone())
640 }
641 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
642 }
643
644 artifacts.push(artifact);
645 }
646
647 if self.config.include_aida_docs_skill {
649 let path = PathBuf::from(".claude/skills/aida-docs.md");
650 let artifact = self.create_artifact(
651 path.clone(),
652 self.generate_aida_docs_skill(),
653 "Skill for documentation management and generation".to_string(),
654 false,
655 );
656
657 match &artifact.file_status {
658 FileStatus::New => new_files.push(path),
659 FileStatus::Modified { .. } | FileStatus::NoHeader => {
660 modified_files.push(artifact.path.clone())
661 }
662 FileStatus::OlderVersion { .. } => {
663 upgradeable_files.push(artifact.path.clone())
664 }
665 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
666 }
667
668 artifacts.push(artifact);
669 }
670
671 if self.config.include_aida_release_skill {
673 let path = PathBuf::from(".claude/skills/aida-release.md");
674 let artifact = self.create_artifact(
675 path.clone(),
676 self.generate_aida_release_skill(),
677 "Skill for release management and version bumping".to_string(),
678 false,
679 );
680
681 match &artifact.file_status {
682 FileStatus::New => new_files.push(path),
683 FileStatus::Modified { .. } | FileStatus::NoHeader => {
684 modified_files.push(artifact.path.clone())
685 }
686 FileStatus::OlderVersion { .. } => {
687 upgradeable_files.push(artifact.path.clone())
688 }
689 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
690 }
691
692 artifacts.push(artifact);
693 }
694
695 if self.config.include_aida_evaluate_skill {
697 let path = PathBuf::from(".claude/skills/aida-evaluate.md");
698 let artifact = self.create_artifact(
699 path.clone(),
700 self.generate_aida_evaluate_skill(),
701 "Skill for evaluating requirement quality".to_string(),
702 false,
703 );
704
705 match &artifact.file_status {
706 FileStatus::New => new_files.push(path),
707 FileStatus::Modified { .. } | FileStatus::NoHeader => {
708 modified_files.push(artifact.path.clone())
709 }
710 FileStatus::OlderVersion { .. } => {
711 upgradeable_files.push(artifact.path.clone())
712 }
713 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
714 }
715
716 artifacts.push(artifact);
717 }
718
719 if self.config.include_aida_commit_skill {
721 let path = PathBuf::from(".claude/skills/aida-commit.md");
722 let artifact = self.create_artifact(
723 path.clone(),
724 self.generate_aida_commit_skill(),
725 "Skill for committing with requirement linking".to_string(),
726 false,
727 );
728
729 match &artifact.file_status {
730 FileStatus::New => new_files.push(path),
731 FileStatus::Modified { .. } | FileStatus::NoHeader => {
732 modified_files.push(artifact.path.clone())
733 }
734 FileStatus::OlderVersion { .. } => {
735 upgradeable_files.push(artifact.path.clone())
736 }
737 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
738 }
739
740 artifacts.push(artifact);
741 }
742
743 if self.config.include_aida_sync_skill {
745 let path = PathBuf::from(".claude/skills/aida-sync.md");
746 let artifact = self.create_artifact(
747 path.clone(),
748 self.generate_aida_sync_skill(),
749 "Skill for template synchronization".to_string(),
750 false,
751 );
752
753 match &artifact.file_status {
754 FileStatus::New => new_files.push(path),
755 FileStatus::Modified { .. } | FileStatus::NoHeader => {
756 modified_files.push(artifact.path.clone())
757 }
758 FileStatus::OlderVersion { .. } => {
759 upgradeable_files.push(artifact.path.clone())
760 }
761 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
762 }
763
764 artifacts.push(artifact);
765 }
766
767 if self.config.include_aida_test_skill {
769 let path = PathBuf::from(".claude/skills/aida-test.md");
770 let artifact = self.create_artifact(
771 path.clone(),
772 self.generate_aida_test_skill(),
773 "Skill for generating tests linked to requirements".to_string(),
774 false,
775 );
776
777 match &artifact.file_status {
778 FileStatus::New => new_files.push(path),
779 FileStatus::Modified { .. } | FileStatus::NoHeader => {
780 modified_files.push(artifact.path.clone())
781 }
782 FileStatus::OlderVersion { .. } => {
783 upgradeable_files.push(artifact.path.clone())
784 }
785 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
786 }
787
788 artifacts.push(artifact);
789 }
790
791 if self.config.include_aida_review_skill {
793 let path = PathBuf::from(".claude/skills/aida-review.md");
794 let artifact = self.create_artifact(
795 path.clone(),
796 self.generate_aida_review_skill(),
797 "Skill for reviewing code changes against specs".to_string(),
798 false,
799 );
800
801 match &artifact.file_status {
802 FileStatus::New => new_files.push(path),
803 FileStatus::Modified { .. } | FileStatus::NoHeader => {
804 modified_files.push(artifact.path.clone())
805 }
806 FileStatus::OlderVersion { .. } => {
807 upgradeable_files.push(artifact.path.clone())
808 }
809 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
810 }
811
812 artifacts.push(artifact);
813 }
814
815 if self.config.include_aida_onboard_skill {
817 let path = PathBuf::from(".claude/skills/aida-onboard.md");
818 let artifact = self.create_artifact(
819 path.clone(),
820 self.generate_aida_onboard_skill(),
821 "Skill for project onboarding".to_string(),
822 false,
823 );
824
825 match &artifact.file_status {
826 FileStatus::New => new_files.push(path),
827 FileStatus::Modified { .. } | FileStatus::NoHeader => {
828 modified_files.push(artifact.path.clone())
829 }
830 FileStatus::OlderVersion { .. } => {
831 upgradeable_files.push(artifact.path.clone())
832 }
833 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
834 }
835
836 artifacts.push(artifact);
837 }
838
839 if self.config.include_aida_sprint_skill {
841 let path = PathBuf::from(".claude/skills/aida-sprint.md");
842 let artifact = self.create_artifact(
843 path.clone(),
844 self.generate_aida_sprint_skill(),
845 "Skill for sprint planning".to_string(),
846 false,
847 );
848
849 match &artifact.file_status {
850 FileStatus::New => new_files.push(path),
851 FileStatus::Modified { .. } | FileStatus::NoHeader => {
852 modified_files.push(artifact.path.clone())
853 }
854 FileStatus::OlderVersion { .. } => {
855 upgradeable_files.push(artifact.path.clone())
856 }
857 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
858 }
859
860 artifacts.push(artifact);
861 }
862
863 if self.config.include_aida_search_skill {
865 let path = PathBuf::from(".claude/skills/aida-search.md");
866 let artifact = self.create_artifact(
867 path.clone(),
868 self.generate_aida_search_skill(),
869 "Skill for unified search across requirements and code".to_string(),
870 false,
871 );
872
873 match &artifact.file_status {
874 FileStatus::New => new_files.push(path),
875 FileStatus::Modified { .. } | FileStatus::NoHeader => {
876 modified_files.push(artifact.path.clone())
877 }
878 FileStatus::OlderVersion { .. } => {
879 upgradeable_files.push(artifact.path.clone())
880 }
881 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
882 }
883
884 artifacts.push(artifact);
885 }
886
887 if self.config.include_aida_standup_skill {
889 let path = PathBuf::from(".claude/skills/aida-standup.md");
890 let artifact = self.create_artifact(
891 path.clone(),
892 self.generate_aida_standup_skill(),
893 "Skill for daily standup generation".to_string(),
894 false,
895 );
896
897 match &artifact.file_status {
898 FileStatus::New => new_files.push(path),
899 FileStatus::Modified { .. } | FileStatus::NoHeader => {
900 modified_files.push(artifact.path.clone())
901 }
902 FileStatus::OlderVersion { .. } => {
903 upgradeable_files.push(artifact.path.clone())
904 }
905 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
906 }
907
908 artifacts.push(artifact);
909 }
910 }
911
912 if self.config.generate_codex_skills {
914 new_dirs.insert(PathBuf::from(".codex/skills"));
915
916 let codex_skill_defs = [
917 ("aida-req", self.config.include_aida_req_skill),
918 ("aida-plan", self.config.include_aida_plan_skill),
919 ("aida-implement", self.config.include_aida_implement_skill),
920 ("aida-capture", self.config.include_aida_capture_skill),
921 ("aida-docs", self.config.include_aida_docs_skill),
922 ("aida-release", self.config.include_aida_release_skill),
923 ("aida-evaluate", self.config.include_aida_evaluate_skill),
924 ("aida-commit", self.config.include_aida_commit_skill),
925 ("aida-sync", self.config.include_aida_sync_skill),
926 ("aida-test", self.config.include_aida_test_skill),
927 ("aida-review", self.config.include_aida_review_skill),
928 ("aida-onboard", self.config.include_aida_onboard_skill),
929 ("aida-sprint", self.config.include_aida_sprint_skill),
930 ("aida-search", self.config.include_aida_search_skill),
931 ("aida-standup", self.config.include_aida_standup_skill),
932 ];
933
934 for (name, enabled) in codex_skill_defs {
935 if !enabled {
936 continue;
937 }
938 let path = PathBuf::from(format!(".codex/skills/{}/SKILL.md", name));
939 let artifact = self.create_artifact(
940 path.clone(),
941 self.generate_codex_skill(name),
942 format!("Codex-compatible skill {}", name),
943 false,
944 );
945
946 match &artifact.file_status {
947 FileStatus::New => new_files.push(path),
948 FileStatus::Modified { .. } | FileStatus::NoHeader => {
949 modified_files.push(artifact.path.clone())
950 }
951 FileStatus::OlderVersion { .. } => {
952 upgradeable_files.push(artifact.path.clone())
953 }
954 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
955 }
956
957 artifacts.push(artifact);
958 }
959 }
960
961 {
963 let mcp_content = r#"{
964 "mcpServers": {
965 "aida": {
966 "type": "stdio",
967 "command": "aida",
968 "args": ["mcp-serve"]
969 }
970 }
971}"#
972 .to_string();
973 let path = PathBuf::from(".mcp.json");
974 let artifact = self.create_artifact(
975 path.clone(),
976 mcp_content,
977 "MCP server configuration for Claude Code".to_string(),
978 false,
979 );
980
981 match &artifact.file_status {
982 FileStatus::New => new_files.push(path),
983 FileStatus::Modified { .. } | FileStatus::NoHeader => {
984 modified_files.push(artifact.path.clone())
985 }
986 FileStatus::OlderVersion { .. } => upgradeable_files.push(artifact.path.clone()),
987 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
988 }
989
990 artifacts.push(artifact);
991 }
992
993 if self.config.generate_git_hooks && self.project_root.join(".git").exists() {
995 new_dirs.insert(PathBuf::from(".git/hooks"));
996
997 if self.config.include_commit_msg_hook {
999 let path = PathBuf::from(".git/hooks/commit-msg");
1000 let artifact = self.create_artifact(
1001 path.clone(),
1002 self.generate_commit_msg_hook(),
1003 "Git hook for validating AI attribution in commit messages".to_string(),
1004 true, );
1006
1007 match &artifact.file_status {
1008 FileStatus::New => new_files.push(path),
1009 FileStatus::Modified { .. } | FileStatus::NoHeader => {
1010 modified_files.push(artifact.path.clone())
1011 }
1012 FileStatus::OlderVersion { .. } => {
1013 upgradeable_files.push(artifact.path.clone())
1014 }
1015 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
1016 }
1017
1018 artifacts.push(artifact);
1019 }
1020
1021 if self.config.include_pre_commit_hook {
1023 let path = PathBuf::from(".git/hooks/pre-commit");
1024 let artifact = self.create_artifact(
1025 path.clone(),
1026 self.generate_pre_commit_hook(),
1027 "Git hook for validating trace comments before commit".to_string(),
1028 true, );
1030
1031 match &artifact.file_status {
1032 FileStatus::New => new_files.push(path),
1033 FileStatus::Modified { .. } | FileStatus::NoHeader => {
1034 modified_files.push(artifact.path.clone())
1035 }
1036 FileStatus::OlderVersion { .. } => {
1037 upgradeable_files.push(artifact.path.clone())
1038 }
1039 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
1040 }
1041
1042 artifacts.push(artifact);
1043 }
1044 }
1045
1046 if self.config.generate_claude_code_hooks {
1048 new_dirs.insert(PathBuf::from(".claude/hooks"));
1049
1050 if self.config.include_validate_commit_hook {
1052 let path = PathBuf::from(".claude/hooks/aida-validate-commit.sh");
1053 let artifact = self.create_artifact(
1054 path.clone(),
1055 self.generate_validate_commit_hook(),
1056 "Claude Code hook for validating commit messages reference requirements"
1057 .to_string(),
1058 true, );
1060
1061 match &artifact.file_status {
1062 FileStatus::New => new_files.push(path),
1063 FileStatus::Modified { .. } | FileStatus::NoHeader => {
1064 modified_files.push(artifact.path.clone())
1065 }
1066 FileStatus::OlderVersion { .. } => {
1067 upgradeable_files.push(artifact.path.clone())
1068 }
1069 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
1070 }
1071
1072 artifacts.push(artifact);
1073 }
1074
1075 if self.config.include_track_commits_hook {
1077 let path = PathBuf::from(".claude/hooks/aida-track-commits.sh");
1078 let artifact = self.create_artifact(
1079 path.clone(),
1080 self.generate_track_commits_hook(),
1081 "Claude Code hook for updating requirement status after commits".to_string(),
1082 true, );
1084
1085 match &artifact.file_status {
1086 FileStatus::New => new_files.push(path),
1087 FileStatus::Modified { .. } | FileStatus::NoHeader => {
1088 modified_files.push(artifact.path.clone())
1089 }
1090 FileStatus::OlderVersion { .. } => {
1091 upgradeable_files.push(artifact.path.clone())
1092 }
1093 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
1094 }
1095
1096 artifacts.push(artifact);
1097 }
1098
1099 let path = PathBuf::from(".claude/settings.json");
1101 let artifact = self.create_artifact(
1102 path.clone(),
1103 self.generate_claude_settings_json(),
1104 "Claude Code settings with AIDA hook configuration".to_string(),
1105 false, );
1107
1108 match &artifact.file_status {
1109 FileStatus::New => new_files.push(path),
1110 FileStatus::Modified { .. } | FileStatus::NoHeader => {
1111 modified_files.push(artifact.path.clone())
1112 }
1113 FileStatus::OlderVersion { .. } => upgradeable_files.push(artifact.path.clone()),
1114 FileStatus::Unmodified => overwrites.push(artifact.path.clone()),
1115 }
1116
1117 artifacts.push(artifact);
1118 }
1119
1120 let new_dirs: Vec<PathBuf> = new_dirs
1122 .into_iter()
1123 .filter(|d| !self.project_root.join(d).exists())
1124 .collect();
1125
1126 ScaffoldPreview {
1127 artifacts,
1128 overwrites,
1129 new_files,
1130 new_dirs,
1131 modified_files,
1132 upgradeable_files,
1133 }
1134 }
1135
1136 pub fn apply(&self, preview: &ScaffoldPreview) -> Result<Vec<PathBuf>, ScaffoldError> {
1139 self.apply_with_options(preview, &ApplyOptions::default())
1140 }
1141
1142 pub fn apply_with_options(
1144 &self,
1145 preview: &ScaffoldPreview,
1146 options: &ApplyOptions,
1147 ) -> Result<Vec<PathBuf>, ScaffoldError> {
1148 let mut written_files = Vec::new();
1149 let mut skipped_files = Vec::new();
1150
1151 for dir in &preview.new_dirs {
1153 let full_path = self.project_root.join(dir);
1154 fs::create_dir_all(&full_path).map_err(|e| ScaffoldError::IoError {
1155 path: full_path.clone(),
1156 message: e.to_string(),
1157 })?;
1158 }
1159
1160 for artifact in &preview.artifacts {
1162 if let Some(parent) = artifact.path.parent() {
1163 let full_parent = self.project_root.join(parent);
1164 if !full_parent.exists() {
1165 fs::create_dir_all(&full_parent).map_err(|e| ScaffoldError::IoError {
1166 path: full_parent.clone(),
1167 message: e.to_string(),
1168 })?;
1169 }
1170 }
1171 }
1172
1173 for artifact in &preview.artifacts {
1175 let should_write = match &artifact.file_status {
1176 FileStatus::New => true,
1177 FileStatus::Unmodified => true,
1178 FileStatus::OlderVersion { .. } => true, FileStatus::Modified { .. } => {
1180 options.force
1182 }
1183 FileStatus::NoHeader => {
1184 options.force
1186 }
1187 };
1188
1189 if !should_write {
1190 skipped_files.push(artifact.path.clone());
1191 continue;
1192 }
1193
1194 let full_path = self.project_root.join(&artifact.path);
1195 fs::write(&full_path, &artifact.content).map_err(|e| ScaffoldError::IoError {
1196 path: full_path.clone(),
1197 message: e.to_string(),
1198 })?;
1199
1200 #[cfg(unix)]
1202 if artifact.path.starts_with(".git/hooks/")
1203 || artifact.path.starts_with(".claude/hooks/")
1204 {
1205 use std::os::unix::fs::PermissionsExt;
1206 let mut perms = fs::metadata(&full_path)
1207 .map_err(|e| ScaffoldError::IoError {
1208 path: full_path.clone(),
1209 message: e.to_string(),
1210 })?
1211 .permissions();
1212 perms.set_mode(0o755);
1213 fs::set_permissions(&full_path, perms).map_err(|e| ScaffoldError::IoError {
1214 path: full_path.clone(),
1215 message: e.to_string(),
1216 })?;
1217 }
1218
1219 written_files.push(artifact.path.clone());
1220 }
1221
1222 Ok(written_files)
1223 }
1224
1225 fn generate_commands(&self, _store: &RequirementsStore) -> Vec<(String, String, String)> {
1227 use crate::templates::EMBEDDED_TEMPLATES;
1228
1229 let command_defs = [
1231 (
1232 "commands/aida-status.md",
1233 "aida-status",
1234 "Show project requirements status",
1235 ),
1236 (
1237 "commands/aida-review.md",
1238 "aida-review",
1239 "Review a requirement for quality",
1240 ),
1241 (
1242 "commands/aida-req.md",
1243 "aida-req",
1244 "Add a new requirement with AI evaluation",
1245 ),
1246 (
1247 "commands/aida-implement.md",
1248 "aida-implement",
1249 "Implement a requirement with traceability",
1250 ),
1251 (
1252 "commands/aida-capture.md",
1253 "aida-capture",
1254 "Capture missed requirements from session",
1255 ),
1256 (
1257 "commands/aida-evaluate.md",
1258 "aida-evaluate",
1259 "Evaluate requirement quality with AI",
1260 ),
1261 (
1262 "commands/aida-commit.md",
1263 "aida-commit",
1264 "Commit with requirement linking",
1265 ),
1266 (
1267 "commands/aida-sync.md",
1268 "aida-sync",
1269 "Sync templates and scaffolding",
1270 ),
1271 (
1272 "commands/aida-test.md",
1273 "aida-test",
1274 "Generate tests linked to requirements",
1275 ),
1276 (
1277 "commands/aida-onboard.md",
1278 "aida-onboard",
1279 "Project onboarding for new team members",
1280 ),
1281 (
1282 "commands/aida-sprint.md",
1283 "aida-sprint",
1284 "Sprint planning from approved requirements",
1285 ),
1286 (
1287 "commands/aida-search.md",
1288 "aida-search",
1289 "Unified search across requirements and code",
1290 ),
1291 (
1292 "commands/aida-standup.md",
1293 "aida-standup",
1294 "Daily standup summary from recent activity",
1295 ),
1296 ];
1297
1298 command_defs
1299 .iter()
1300 .filter_map(|(key, name, desc)| {
1301 EMBEDDED_TEMPLATES
1302 .get(key)
1303 .map(|content| (name.to_string(), content.to_string(), desc.to_string()))
1304 })
1305 .collect()
1306 }
1307
1308 fn generate_aida_req_skill(&self) -> String {
1310 use crate::templates::EMBEDDED_TEMPLATES;
1311 EMBEDDED_TEMPLATES
1312 .get("skills/aida-req.md")
1313 .map(|s| s.to_string())
1314 .unwrap_or_else(|| {
1315 "# AIDA Requirement Creation Skill\n\n(template not found)".to_string()
1316 })
1317 }
1318
1319 fn generate_aida_implement_skill(&self) -> String {
1321 use crate::templates::EMBEDDED_TEMPLATES;
1322 EMBEDDED_TEMPLATES
1323 .get("skills/aida-implement.md")
1324 .map(|s| s.to_string())
1325 .unwrap_or_else(|| "# AIDA Implementation Skill\n\n(template not found)".to_string())
1326 }
1327
1328 fn generate_aida_plan_skill(&self) -> String {
1330 use crate::templates::EMBEDDED_TEMPLATES;
1331 EMBEDDED_TEMPLATES
1332 .get("skills/aida-plan.md")
1333 .map(|s| s.to_string())
1334 .unwrap_or_else(|| "# AIDA Planning Skill\n\n(template not found)".to_string())
1335 }
1336
1337 fn generate_aida_capture_skill(&self) -> String {
1339 use crate::templates::EMBEDDED_TEMPLATES;
1340 EMBEDDED_TEMPLATES
1341 .get("skills/aida-capture.md")
1342 .map(|s| s.to_string())
1343 .unwrap_or_else(|| "# AIDA Session Capture Skill\n\n(template not found)".to_string())
1344 }
1345
1346 fn generate_aida_docs_skill(&self) -> String {
1348 use crate::templates::EMBEDDED_TEMPLATES;
1349 EMBEDDED_TEMPLATES
1350 .get("skills/aida-docs.md")
1351 .map(|s| s.to_string())
1352 .unwrap_or_else(|| "# AIDA Documentation Skill\n\n(template not found)".to_string())
1353 }
1354
1355 fn generate_aida_release_skill(&self) -> String {
1357 use crate::templates::EMBEDDED_TEMPLATES;
1358 EMBEDDED_TEMPLATES
1359 .get("skills/aida-release.md")
1360 .map(|s| s.to_string())
1361 .unwrap_or_else(|| {
1362 "# AIDA Release Management Skill\n\n(template not found)".to_string()
1363 })
1364 }
1365
1366 fn generate_aida_evaluate_skill(&self) -> String {
1368 use crate::templates::EMBEDDED_TEMPLATES;
1370 EMBEDDED_TEMPLATES
1371 .get("skills/aida-evaluate.md")
1372 .map(|s| s.to_string())
1373 .unwrap_or_else(|| {
1374 r#"# AIDA Requirement Evaluation Skill
1375
1376## Purpose
1377
1378Evaluate a requirement's quality using AI analysis.
1379
1380## When to Use
1381
1382Use this skill when:
1383- User wants to evaluate a specific requirement's quality
1384- User asks to "evaluate", "assess", or "review" a requirement
1385
1386## Workflow
1387
13881. Load the requirement from database: `aida show <SPEC-ID>`
13892. Run AI evaluation for clarity, testability, completeness, consistency
13903. Display quality score and issues found
13914. Offer follow-up actions: improve, split, or accept
1392"#
1393 .to_string()
1394 })
1395 }
1396
1397 fn generate_aida_commit_skill(&self) -> String {
1399 use crate::templates::EMBEDDED_TEMPLATES;
1401 EMBEDDED_TEMPLATES
1402 .get("skills/aida-commit.md")
1403 .map(|s| s.to_string())
1404 .unwrap_or_else(|| {
1405 r#"# AIDA Commit Skill
1406
1407## Purpose
1408
1409Create git commits with automatic requirement linkage.
1410
1411## When to Use
1412
1413Use this skill when:
1414- User wants to commit changes with requirement traceability
1415- User says "commit" after implementing features
1416
1417## Workflow
1418
14191. Analyze staged changes and extract requirement traces
14202. Check for untraced implementation code
14213. Offer to create requirements for untraced work
14224. Create commit with requirement links
14235. Update linked requirement statuses
1424"#
1425 .to_string()
1426 })
1427 }
1428
1429 fn generate_aida_sync_skill(&self) -> String {
1431 use crate::templates::EMBEDDED_TEMPLATES;
1433 EMBEDDED_TEMPLATES
1434 .get("skills/aida-sync.md")
1435 .map(|s| s.to_string())
1436 .unwrap_or_else(|| {
1437 r#"# AIDA Sync Skill
1438
1439## Purpose
1440
1441Maintain consistency between AIDA templates and scaffolded projects.
1442
1443## When to Use
1444
1445Use this skill when:
1446- You've modified templates in `aida-core/templates/`
1447- You want to check scaffold status
1448- At the end of an AIDA development session
1449
1450## Workflow
1451
14521. Detect environment (AIDA repo vs scaffolded project)
14532. For AIDA repo: Check template integrity
14543. For other projects: Check scaffold status
14554. Ensure templates and skills are consistent
1456"#
1457 .to_string()
1458 })
1459 }
1460
1461 fn generate_aida_test_skill(&self) -> String {
1463 use crate::templates::EMBEDDED_TEMPLATES;
1464 EMBEDDED_TEMPLATES
1465 .get("skills/aida-test.md")
1466 .map(|s| s.to_string())
1467 .unwrap_or_else(|| "# AIDA Test Generation Skill\n\n(template not found)".to_string())
1468 }
1469
1470 fn generate_aida_review_skill(&self) -> String {
1472 use crate::templates::EMBEDDED_TEMPLATES;
1473 EMBEDDED_TEMPLATES
1474 .get("skills/aida-review.md")
1475 .map(|s| s.to_string())
1476 .unwrap_or_else(|| "# AIDA Code Review Skill\n\n(template not found)".to_string())
1477 }
1478
1479 fn generate_aida_onboard_skill(&self) -> String {
1481 use crate::templates::EMBEDDED_TEMPLATES;
1482 EMBEDDED_TEMPLATES
1483 .get("skills/aida-onboard.md")
1484 .map(|s| s.to_string())
1485 .unwrap_or_else(|| {
1486 "# AIDA Project Onboarding Skill\n\n(template not found)".to_string()
1487 })
1488 }
1489
1490 fn generate_aida_sprint_skill(&self) -> String {
1492 use crate::templates::EMBEDDED_TEMPLATES;
1493 EMBEDDED_TEMPLATES
1494 .get("skills/aida-sprint.md")
1495 .map(|s| s.to_string())
1496 .unwrap_or_else(|| "# AIDA Sprint Planning Skill\n\n(template not found)".to_string())
1497 }
1498
1499 fn generate_aida_search_skill(&self) -> String {
1501 use crate::templates::EMBEDDED_TEMPLATES;
1502 EMBEDDED_TEMPLATES
1503 .get("skills/aida-search.md")
1504 .map(|s| s.to_string())
1505 .unwrap_or_else(|| "# AIDA Unified Search Skill\n\n(template not found)".to_string())
1506 }
1507
1508 fn generate_aida_standup_skill(&self) -> String {
1510 use crate::templates::EMBEDDED_TEMPLATES;
1511 EMBEDDED_TEMPLATES
1512 .get("skills/aida-standup.md")
1513 .map(|s| s.to_string())
1514 .unwrap_or_else(|| "# AIDA Standup Skill\n\n(template not found)".to_string())
1515 }
1516
1517 fn generate_codex_skill(&self, skill_name: &str) -> String {
1520 use crate::templates::EMBEDDED_TEMPLATES;
1521
1522 let key = format!("skills/{}.md", skill_name);
1523 let raw = EMBEDDED_TEMPLATES
1524 .get(key.as_str())
1525 .map(|s| s.to_string())
1526 .unwrap_or_else(|| format!("# {}\n\n(template not found)", skill_name));
1527
1528 strip_yaml_frontmatter(&raw)
1529 }
1530}
1531
1532fn strip_yaml_frontmatter(content: &str) -> String {
1533 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
1534 return content.to_string();
1535 }
1536
1537 let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
1538 let rest = &content[after_open..];
1539 if let Some(close_pos) = rest.find("\n---\n") {
1540 let body_start = after_open + close_pos + 5;
1541 return content[body_start..].to_string();
1542 }
1543 if let Some(close_pos) = rest.find("\n---\r\n") {
1544 let body_start = after_open + close_pos + 6;
1545 return content[body_start..].to_string();
1546 }
1547
1548 content.to_string()
1549}
1550
1551#[derive(Debug)]
1553pub enum ScaffoldError {
1554 IoError { path: PathBuf, message: String },
1556}
1557
1558impl std::fmt::Display for ScaffoldError {
1559 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1560 match self {
1561 ScaffoldError::IoError { path, message } => {
1562 write!(f, "IO error at {}: {}", path.display(), message)
1563 }
1564 }
1565 }
1566}
1567
1568impl std::error::Error for ScaffoldError {}
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::*;
1573 use tempfile::TempDir;
1574
1575 fn create_test_store() -> RequirementsStore {
1576 RequirementsStore {
1577 name: "test-project".to_string(),
1578 title: "Test Project".to_string(),
1579 description: "A test project for scaffolding".to_string(),
1580 ..Default::default()
1581 }
1582 }
1583
1584 #[test]
1585 fn test_default_config() {
1586 let config = ScaffoldConfig::default();
1587 assert!(config.generate_claude_md);
1588 assert!(config.generate_agents_md);
1589 assert!(config.generate_commands);
1590 assert!(config.generate_skills);
1591 assert!(config.generate_codex_skills);
1592 assert!(config.include_aida_req_skill);
1593 assert!(config.include_aida_implement_skill);
1594 assert!(config.include_aida_capture_skill);
1595 assert_eq!(config.project_type, ProjectType::Generic);
1596 }
1597
1598 #[test]
1599 fn test_preview_generates_expected_artifacts() {
1600 let temp_dir = TempDir::new().unwrap();
1601 let config = ScaffoldConfig::default();
1602 let mut scaffolder = Scaffolder::new(temp_dir.path().to_path_buf(), config);
1603 let store = create_test_store();
1604
1605 let preview = scaffolder.preview(&store);
1606
1607 assert!(!preview.artifacts.is_empty());
1609
1610 let claude_md = preview
1612 .artifacts
1613 .iter()
1614 .find(|a| a.path == PathBuf::from("CLAUDE.md"));
1615 assert!(claude_md.is_some());
1616 assert!(claude_md.unwrap().content.contains("Test Project"));
1617 }
1618
1619 #[test]
1620 fn test_apply_creates_files() {
1621 let temp_dir = TempDir::new().unwrap();
1622 let config = ScaffoldConfig::default();
1623 let mut scaffolder = Scaffolder::new(temp_dir.path().to_path_buf(), config);
1624 let store = create_test_store();
1625
1626 let preview = scaffolder.preview(&store);
1627 let result = scaffolder.apply(&preview);
1628
1629 assert!(result.is_ok());
1630
1631 assert!(temp_dir.path().join("CLAUDE.md").exists());
1633 assert!(temp_dir.path().join("AGENTS.md").exists());
1634
1635 assert!(temp_dir.path().join(".claude/commands").exists());
1637 assert!(temp_dir.path().join(".claude/skills").exists());
1638 assert!(temp_dir.path().join(".codex/skills").exists());
1639 }
1640
1641 #[test]
1642 fn test_project_type_labels() {
1643 assert_eq!(ProjectType::Rust.label(), "Rust");
1644 assert_eq!(ProjectType::Python.label(), "Python");
1645 assert_eq!(ProjectType::Generic.label(), "Generic");
1646 }
1647}