1use crate::approval::ApprovalManager;
7use crate::error::SpecError;
8use crate::models::{
9 ConversationMessage, MessageRole, Spec, SpecMetadata, SpecPhase, SpecStatus,
10 SpecWritingSession, Steering,
11};
12use crate::steering::SteeringLoader;
13use crate::validation::ValidationEngine;
14use chrono::Utc;
15
16#[derive(Debug, Clone)]
18pub struct AISpecWriter;
19
20#[derive(Debug, Clone)]
22pub struct GapAnalysis {
23 pub missing_sections: Vec<String>,
25 pub incomplete_sections: Vec<String>,
27 pub suggestions: Vec<String>,
29}
30
31impl AISpecWriter {
32 pub fn new() -> Self {
34 AISpecWriter
35 }
36
37 pub fn initialize_session(spec_id: &str, spec_name: &str) -> SpecWritingSession {
51 let now = Utc::now();
52 let spec = Spec {
53 id: spec_id.to_string(),
54 name: spec_name.to_string(),
55 version: "0.1.0".to_string(),
56 requirements: vec![],
57 design: None,
58 tasks: vec![],
59 metadata: SpecMetadata {
60 author: None,
61 created_at: now,
62 updated_at: now,
63 phase: SpecPhase::Discovery,
64 status: SpecStatus::Draft,
65 },
66 inheritance: None,
67 };
68
69 SpecWritingSession {
70 id: format!("session-{}", uuid::Uuid::new_v4()),
71 spec_id: spec.id.clone(),
72 phase: SpecPhase::Discovery,
73 conversation_history: vec![],
74 approval_gates: ApprovalManager::initialize_gates(),
75 created_at: now,
76 updated_at: now,
77 }
78 }
79
80 pub fn add_message(
94 session: &mut SpecWritingSession,
95 role: MessageRole,
96 content: &str,
97 ) -> Result<(), SpecError> {
98 let message = ConversationMessage {
99 id: format!("msg-{}", uuid::Uuid::new_v4()),
100 spec_id: session.spec_id.clone(),
101 role,
102 content: content.to_string(),
103 timestamp: Utc::now(),
104 };
105
106 session.conversation_history.push(message);
107 session.updated_at = Utc::now();
108
109 Ok(())
110 }
111
112 pub fn get_phase_guidance(session: &SpecWritingSession) -> String {
124 match session.phase {
125 SpecPhase::Discovery => {
126 "Discovery Phase: Research the problem space, identify user personas, define scope, and establish feature hierarchy. Focus on understanding the problem before designing solutions.".to_string()
127 }
128 SpecPhase::Requirements => {
129 "Requirements Phase: Define what must be built using user stories and acceptance criteria. Write EARS-compliant requirements with clear success conditions.".to_string()
130 }
131 SpecPhase::Design => {
132 "Design Phase: Create technical design and architecture to satisfy requirements. Define data models, algorithms, and integration points.".to_string()
133 }
134 SpecPhase::Tasks => {
135 "Tasks Phase: Break design into hierarchical implementation tasks. Define dependencies, ordering, and exit criteria for each task.".to_string()
136 }
137 SpecPhase::Execution => {
138 "Execution Phase: implement according to spec and validate against acceptance criteria. Track task completion and validate requirements are met.".to_string()
139 }
140 }
141 }
142
143 pub fn analyze_gaps(session: &SpecWritingSession, spec: &Spec) -> GapAnalysis {
157 let mut missing_sections = Vec::new();
158 let mut incomplete_sections = Vec::new();
159 let mut suggestions = Vec::new();
160
161 match session.phase {
162 SpecPhase::Discovery => {
163 if spec.requirements.is_empty() {
164 suggestions.push(
165 "Consider researching similar products and existing solutions".to_string(),
166 );
167 }
168 suggestions.push("Define feature hierarchy (MVP → Phase 2 → Phase 3)".to_string());
169 suggestions.push("Document constraints and dependencies".to_string());
170 }
171 SpecPhase::Requirements => {
172 if spec.requirements.is_empty() {
173 missing_sections.push("Requirements".to_string());
174 suggestions.push(
175 "Add at least one requirement with user story and acceptance criteria"
176 .to_string(),
177 );
178 }
179
180 for req in &spec.requirements {
181 if req.user_story.is_empty() {
182 incomplete_sections
183 .push(format!("Requirement {}: missing user story", req.id));
184 }
185 if req.acceptance_criteria.is_empty() {
186 incomplete_sections.push(format!(
187 "Requirement {}: missing acceptance criteria",
188 req.id
189 ));
190 }
191 }
192
193 suggestions
194 .push("Ensure each requirement has clear acceptance criteria".to_string());
195 suggestions.push("Prioritize requirements (MUST/SHOULD/COULD)".to_string());
196 }
197 SpecPhase::Design => {
198 if spec.design.is_none() {
199 missing_sections.push("Design".to_string());
200 suggestions
201 .push("Create design document with overview and architecture".to_string());
202 } else if let Some(design) = &spec.design {
203 if design.overview.is_empty() {
204 incomplete_sections.push("Design: missing overview".to_string());
205 }
206 if design.architecture.is_empty() {
207 incomplete_sections.push("Design: missing architecture".to_string());
208 }
209 if design.components.is_empty() {
210 incomplete_sections.push("Design: missing components".to_string());
211 }
212 if design.correctness_properties.is_empty() {
213 incomplete_sections
214 .push("Design: missing correctness properties".to_string());
215 }
216 }
217
218 suggestions.push("Define data models and type definitions".to_string());
219 suggestions.push("Specify algorithms and key logic".to_string());
220 suggestions.push("Document integration points and dependencies".to_string());
221 }
222 SpecPhase::Tasks => {
223 if spec.tasks.is_empty() {
224 missing_sections.push("Tasks".to_string());
225 suggestions.push("Break design into concrete, implementable tasks".to_string());
226 }
227
228 for task in &spec.tasks {
229 if task.description.is_empty() {
230 incomplete_sections.push(format!("Task {}: missing description", task.id));
231 }
232 if task.requirements.is_empty() {
233 incomplete_sections
234 .push(format!("Task {}: missing requirement links", task.id));
235 }
236 }
237
238 suggestions.push("Define clear dependencies between tasks".to_string());
239 suggestions.push("Specify exit criteria for each task".to_string());
240 }
241 SpecPhase::Execution => {
242 suggestions.push("Implement tasks in dependency order".to_string());
243 suggestions.push("Validate each task against exit criteria".to_string());
244 suggestions.push("Run acceptance tests against requirements".to_string());
245 }
246 }
247
248 GapAnalysis {
249 missing_sections,
250 incomplete_sections,
251 suggestions,
252 }
253 }
254
255 pub fn validate_spec(spec: &Spec) -> Result<(), SpecError> {
267 ValidationEngine::validate(spec)
268 }
269
270 pub fn request_approval(
285 session: &mut SpecWritingSession,
286 approver: &str,
287 feedback: Option<String>,
288 ) -> Result<(), SpecError> {
289 ApprovalManager::approve_phase(session, approver, feedback)
290 }
291
292 pub fn transition_to_next_phase(session: &mut SpecWritingSession) -> Result<(), SpecError> {
305 ApprovalManager::transition_to_next_phase(session)
306 }
307
308 pub fn can_transition(session: &SpecWritingSession) -> bool {
312 ApprovalManager::can_transition(session)
313 }
314
315 pub fn get_conversation_history(session: &SpecWritingSession) -> &[ConversationMessage] {
319 &session.conversation_history
320 }
321
322 pub fn get_current_phase(session: &SpecWritingSession) -> SpecPhase {
324 session.phase
325 }
326
327 pub fn are_phases_approved_up_to(
331 session: &SpecWritingSession,
332 target_phase: SpecPhase,
333 ) -> bool {
334 ApprovalManager::are_phases_approved_up_to(session, target_phase)
335 }
336
337 pub fn build_prompt_with_steering_context(
353 session: &SpecWritingSession,
354 global_steering: &Steering,
355 project_steering: &Steering,
356 user_input: &str,
357 ) -> Result<String, SpecError> {
358 let merged_steering = SteeringLoader::merge(global_steering, project_steering)?;
360
361 let mut prompt = String::new();
363
364 let phase_guidance = Self::get_phase_guidance(session);
366 prompt.push_str(&format!("## Phase Guidance\n{}\n\n", phase_guidance));
367
368 if !merged_steering.rules.is_empty() {
370 prompt.push_str("## Steering Rules\n");
371 for rule in &merged_steering.rules {
372 prompt.push_str(&format!(
373 "- **{}**: {} (Pattern: {}, Action: {})\n",
374 rule.id, rule.description, rule.pattern, rule.action
375 ));
376 }
377 prompt.push('\n');
378 }
379
380 if !merged_steering.standards.is_empty() {
382 prompt.push_str("## Standards\n");
383 for standard in &merged_steering.standards {
384 prompt.push_str(&format!(
385 "- **{}**: {}\n",
386 standard.id, standard.description
387 ));
388 }
389 prompt.push('\n');
390 }
391
392 if !merged_steering.templates.is_empty() {
394 prompt.push_str("## Available Templates\n");
395 for template in &merged_steering.templates {
396 prompt.push_str(&format!("- **{}**: {}\n", template.id, template.path));
397 }
398 prompt.push('\n');
399 }
400
401 if !session.conversation_history.is_empty() {
403 prompt.push_str("## Conversation History\n");
404 for msg in &session.conversation_history {
405 let role_str = match msg.role {
406 MessageRole::User => "User",
407 MessageRole::Assistant => "Assistant",
408 MessageRole::System => "System",
409 };
410 prompt.push_str(&format!("**{}**: {}\n", role_str, msg.content));
411 }
412 prompt.push('\n');
413 }
414
415 prompt.push_str(&format!("## Current Request\n{}\n", user_input));
417
418 Ok(prompt)
419 }
420
421 pub fn validate_spec_against_steering(
436 spec: &Spec,
437 steering: &Steering,
438 ) -> Result<Vec<String>, SpecError> {
439 let mut violations = Vec::new();
440
441 if spec.metadata.author.is_none() {
443 violations.push("Spec missing author in metadata".to_string());
444 }
445
446 if spec.version.is_empty() {
448 violations.push("Spec missing version".to_string());
449 }
450
451 if spec.name.is_empty() {
454 violations.push("Spec missing name".to_string());
455 }
456
457 if spec.metadata.phase == SpecPhase::Requirements && spec.requirements.is_empty() {
459 violations.push("Requirements phase spec has no requirements".to_string());
460 }
461
462 if spec.metadata.phase == SpecPhase::Design && spec.design.is_none() {
464 violations.push("Design phase spec has no design".to_string());
465 }
466
467 if spec.metadata.phase == SpecPhase::Tasks && spec.tasks.is_empty() {
469 violations.push("Tasks phase spec has no tasks".to_string());
470 }
471
472 for standard in &steering.standards {
474 if standard.id == "require-acceptance-criteria" {
476 for req in &spec.requirements {
477 if req.acceptance_criteria.is_empty() {
478 violations.push(format!(
479 "Requirement {} violates standard {}: missing acceptance criteria",
480 req.id, standard.id
481 ));
482 }
483 }
484 }
485
486 if standard.id == "require-task-descriptions" {
488 for task in &spec.tasks {
489 if task.description.is_empty() {
490 violations.push(format!(
491 "Task {} violates standard {}: missing description",
492 task.id, standard.id
493 ));
494 }
495 }
496 }
497 }
498
499 Ok(violations)
500 }
501
502 pub fn format_steering_context(steering: &Steering) -> String {
514 let mut output = String::new();
515
516 if !steering.rules.is_empty() {
517 output.push_str("### Steering Rules\n");
518 for rule in &steering.rules {
519 output.push_str(&format!(
520 "- `{}`: {} ({})\n",
521 rule.id, rule.description, rule.action
522 ));
523 }
524 output.push('\n');
525 }
526
527 if !steering.standards.is_empty() {
528 output.push_str("### Standards\n");
529 for standard in &steering.standards {
530 output.push_str(&format!("- `{}`: {}\n", standard.id, standard.description));
531 }
532 output.push('\n');
533 }
534
535 if !steering.templates.is_empty() {
536 output.push_str("### Templates\n");
537 for template in &steering.templates {
538 output.push_str(&format!("- `{}`: {}\n", template.id, template.path));
539 }
540 }
541
542 output
543 }
544}
545
546impl Default for AISpecWriter {
547 fn default() -> Self {
548 Self::new()
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::models::{AcceptanceCriterion, Design, Priority, Requirement, Task};
556
557 fn create_test_session() -> SpecWritingSession {
558 AISpecWriter::initialize_session("test-spec", "Test Spec")
559 }
560
561 fn create_test_spec() -> Spec {
562 let now = Utc::now();
563 Spec {
564 id: "test-spec".to_string(),
565 name: "Test Spec".to_string(),
566 version: "0.1.0".to_string(),
567 requirements: vec![],
568 design: None,
569 tasks: vec![],
570 metadata: SpecMetadata {
571 author: None,
572 created_at: now,
573 updated_at: now,
574 phase: SpecPhase::Discovery,
575 status: SpecStatus::Draft,
576 },
577 inheritance: None,
578 }
579 }
580
581 #[test]
582 fn test_initialize_session() {
583 let session = AISpecWriter::initialize_session("test-spec", "Test Spec");
584
585 assert_eq!(session.spec_id, "test-spec");
586 assert_eq!(session.phase, SpecPhase::Discovery);
587 assert!(session.conversation_history.is_empty());
588 assert_eq!(session.approval_gates.len(), 5);
589 }
590
591 #[test]
592 fn test_add_message_user() {
593 let mut session = create_test_session();
594
595 let result =
596 AISpecWriter::add_message(&mut session, MessageRole::User, "What should we build?");
597 assert!(result.is_ok());
598 assert_eq!(session.conversation_history.len(), 1);
599 assert_eq!(session.conversation_history[0].role, MessageRole::User);
600 assert_eq!(
601 session.conversation_history[0].content,
602 "What should we build?"
603 );
604 }
605
606 #[test]
607 fn test_add_message_assistant() {
608 let mut session = create_test_session();
609
610 AISpecWriter::add_message(&mut session, MessageRole::User, "What should we build?")
611 .unwrap();
612 AISpecWriter::add_message(
613 &mut session,
614 MessageRole::Assistant,
615 "Let's build a task manager",
616 )
617 .unwrap();
618
619 assert_eq!(session.conversation_history.len(), 2);
620 assert_eq!(session.conversation_history[1].role, MessageRole::Assistant);
621 }
622
623 #[test]
624 fn test_add_multiple_messages() {
625 let mut session = create_test_session();
626
627 for i in 0..5 {
628 AISpecWriter::add_message(&mut session, MessageRole::User, &format!("Message {}", i))
629 .unwrap();
630 }
631
632 assert_eq!(session.conversation_history.len(), 5);
633 }
634
635 #[test]
636 fn test_get_phase_guidance_discovery() {
637 let session = create_test_session();
638 let guidance = AISpecWriter::get_phase_guidance(&session);
639
640 assert!(guidance.contains("Discovery"));
641 assert!(guidance.contains("problem space"));
642 }
643
644 #[test]
645 fn test_get_phase_guidance_requirements() {
646 let mut session = create_test_session();
647 session.phase = SpecPhase::Requirements;
648
649 let guidance = AISpecWriter::get_phase_guidance(&session);
650
651 assert!(guidance.contains("Requirements"));
652 assert!(guidance.contains("user stories"));
653 }
654
655 #[test]
656 fn test_get_phase_guidance_design() {
657 let mut session = create_test_session();
658 session.phase = SpecPhase::Design;
659
660 let guidance = AISpecWriter::get_phase_guidance(&session);
661
662 assert!(guidance.contains("Design"));
663 assert!(guidance.contains("architecture"));
664 }
665
666 #[test]
667 fn test_get_phase_guidance_tasks() {
668 let mut session = create_test_session();
669 session.phase = SpecPhase::Tasks;
670
671 let guidance = AISpecWriter::get_phase_guidance(&session);
672
673 assert!(guidance.contains("Tasks"));
674 assert!(guidance.contains("implementation"));
675 }
676
677 #[test]
678 fn test_get_phase_guidance_execution() {
679 let mut session = create_test_session();
680 session.phase = SpecPhase::Execution;
681
682 let guidance = AISpecWriter::get_phase_guidance(&session);
683
684 assert!(guidance.contains("Execution"));
685 assert!(guidance.contains("implement"));
686 }
687
688 #[test]
689 fn test_analyze_gaps_discovery_empty() {
690 let session = create_test_session();
691 let spec = create_test_spec();
692
693 let gaps = AISpecWriter::analyze_gaps(&session, &spec);
694
695 assert!(!gaps.suggestions.is_empty());
696 assert!(gaps.suggestions.iter().any(|s| s.contains("research")));
697 }
698
699 #[test]
700 fn test_analyze_gaps_requirements_missing() {
701 let mut session = create_test_session();
702 session.phase = SpecPhase::Requirements;
703 let spec = create_test_spec();
704
705 let gaps = AISpecWriter::analyze_gaps(&session, &spec);
706
707 assert!(gaps.missing_sections.contains(&"Requirements".to_string()));
708 }
709
710 #[test]
711 fn test_analyze_gaps_requirements_incomplete() {
712 let mut session = create_test_session();
713 session.phase = SpecPhase::Requirements;
714
715 let mut spec = create_test_spec();
716 spec.requirements.push(Requirement {
717 id: "REQ-1".to_string(),
718 user_story: "".to_string(),
719 acceptance_criteria: vec![],
720 priority: Priority::Must,
721 });
722
723 let gaps = AISpecWriter::analyze_gaps(&session, &spec);
724
725 assert!(!gaps.incomplete_sections.is_empty());
726 }
727
728 #[test]
729 fn test_analyze_gaps_design_missing() {
730 let mut session = create_test_session();
731 session.phase = SpecPhase::Design;
732 let spec = create_test_spec();
733
734 let gaps = AISpecWriter::analyze_gaps(&session, &spec);
735
736 assert!(gaps.missing_sections.contains(&"Design".to_string()));
737 }
738
739 #[test]
740 fn test_analyze_gaps_tasks_missing() {
741 let mut session = create_test_session();
742 session.phase = SpecPhase::Tasks;
743 let spec = create_test_spec();
744
745 let gaps = AISpecWriter::analyze_gaps(&session, &spec);
746
747 assert!(gaps.missing_sections.contains(&"Tasks".to_string()));
748 }
749
750 #[test]
751 fn test_request_approval() {
752 let mut session = create_test_session();
753
754 let result = AISpecWriter::request_approval(
755 &mut session,
756 "reviewer",
757 Some("Looks good".to_string()),
758 );
759 assert!(result.is_ok());
760
761 let gate = session
762 .approval_gates
763 .iter()
764 .find(|g| g.phase == SpecPhase::Discovery)
765 .unwrap();
766 assert!(gate.approved);
767 }
768
769 #[test]
770 fn test_can_transition_before_approval() {
771 let session = create_test_session();
772
773 assert!(!AISpecWriter::can_transition(&session));
774 }
775
776 #[test]
777 fn test_can_transition_after_approval() {
778 let mut session = create_test_session();
779
780 AISpecWriter::request_approval(&mut session, "reviewer", None).unwrap();
781
782 assert!(AISpecWriter::can_transition(&session));
783 }
784
785 #[test]
786 fn test_transition_to_next_phase() {
787 let mut session = create_test_session();
788
789 AISpecWriter::request_approval(&mut session, "reviewer", None).unwrap();
790 let result = AISpecWriter::transition_to_next_phase(&mut session);
791
792 assert!(result.is_ok());
793 assert_eq!(session.phase, SpecPhase::Requirements);
794 }
795
796 #[test]
797 fn test_transition_fails_without_approval() {
798 let mut session = create_test_session();
799
800 let result = AISpecWriter::transition_to_next_phase(&mut session);
801
802 assert!(result.is_err());
803 assert_eq!(session.phase, SpecPhase::Discovery);
804 }
805
806 #[test]
807 fn test_get_conversation_history() {
808 let mut session = create_test_session();
809
810 AISpecWriter::add_message(&mut session, MessageRole::User, "Message 1").unwrap();
811 AISpecWriter::add_message(&mut session, MessageRole::Assistant, "Message 2").unwrap();
812
813 let history = AISpecWriter::get_conversation_history(&session);
814
815 assert_eq!(history.len(), 2);
816 assert_eq!(history[0].role, MessageRole::User);
817 assert_eq!(history[1].role, MessageRole::Assistant);
818 }
819
820 #[test]
821 fn test_get_current_phase() {
822 let mut session = create_test_session();
823
824 assert_eq!(
825 AISpecWriter::get_current_phase(&session),
826 SpecPhase::Discovery
827 );
828
829 session.phase = SpecPhase::Requirements;
830
831 assert_eq!(
832 AISpecWriter::get_current_phase(&session),
833 SpecPhase::Requirements
834 );
835 }
836
837 #[test]
838 fn test_are_phases_approved_up_to() {
839 let mut session = create_test_session();
840
841 assert!(!AISpecWriter::are_phases_approved_up_to(
842 &session,
843 SpecPhase::Requirements
844 ));
845
846 AISpecWriter::request_approval(&mut session, "reviewer", None).unwrap();
847 AISpecWriter::transition_to_next_phase(&mut session).unwrap();
848 AISpecWriter::request_approval(&mut session, "reviewer", None).unwrap();
849
850 assert!(AISpecWriter::are_phases_approved_up_to(
851 &session,
852 SpecPhase::Requirements
853 ));
854 }
855
856 #[test]
857 fn test_sequential_phase_workflow() {
858 let mut session = create_test_session();
859 let mut spec = create_test_spec();
860
861 assert_eq!(session.phase, SpecPhase::Discovery);
863 let guidance = AISpecWriter::get_phase_guidance(&session);
864 assert!(guidance.contains("Discovery"));
865
866 AISpecWriter::add_message(
867 &mut session,
868 MessageRole::User,
869 "Let's build a task manager",
870 )
871 .unwrap();
872 AISpecWriter::request_approval(&mut session, "reviewer", None).unwrap();
873 AISpecWriter::transition_to_next_phase(&mut session).unwrap();
874
875 assert_eq!(session.phase, SpecPhase::Requirements);
877 spec.requirements.push(Requirement {
878 id: "REQ-1".to_string(),
879 user_story: "As a user, I want to create tasks".to_string(),
880 acceptance_criteria: vec![AcceptanceCriterion {
881 id: "AC-1.1".to_string(),
882 when: "user enters task".to_string(),
883 then: "task is added".to_string(),
884 }],
885 priority: Priority::Must,
886 });
887
888 AISpecWriter::request_approval(&mut session, "reviewer", None).unwrap();
889 AISpecWriter::transition_to_next_phase(&mut session).unwrap();
890
891 assert_eq!(session.phase, SpecPhase::Design);
893 spec.design = Some(Design {
894 overview: "Task management system".to_string(),
895 architecture: "Layered architecture".to_string(),
896 components: vec![],
897 data_models: vec![],
898 correctness_properties: vec![],
899 });
900
901 AISpecWriter::request_approval(&mut session, "reviewer", None).unwrap();
902 AISpecWriter::transition_to_next_phase(&mut session).unwrap();
903
904 assert_eq!(session.phase, SpecPhase::Tasks);
906 spec.tasks.push(Task {
907 id: "1".to_string(),
908 description: "Implement task manager".to_string(),
909 subtasks: vec![],
910 requirements: vec!["REQ-1".to_string()],
911 status: crate::models::TaskStatus::NotStarted,
912 optional: false,
913 });
914
915 AISpecWriter::request_approval(&mut session, "reviewer", None).unwrap();
916 AISpecWriter::transition_to_next_phase(&mut session).unwrap();
917
918 assert_eq!(session.phase, SpecPhase::Execution);
920 }
921
922 #[test]
923 fn test_conversation_history_preserved() {
924 let mut session = create_test_session();
925
926 for i in 0..10 {
927 AISpecWriter::add_message(&mut session, MessageRole::User, &format!("Message {}", i))
928 .unwrap();
929 }
930
931 let history = AISpecWriter::get_conversation_history(&session);
932 assert_eq!(history.len(), 10);
933
934 for (i, msg) in history.iter().enumerate() {
935 assert_eq!(msg.content, format!("Message {}", i));
936 assert!(msg.timestamp <= Utc::now());
937 }
938 }
939
940 #[test]
941 fn test_gap_analysis_suggestions_vary_by_phase() {
942 let spec = create_test_spec();
943
944 let mut session = create_test_session();
945 session.phase = SpecPhase::Discovery;
946 let discovery_gaps = AISpecWriter::analyze_gaps(&session, &spec);
947
948 session.phase = SpecPhase::Requirements;
949 let requirements_gaps = AISpecWriter::analyze_gaps(&session, &spec);
950
951 session.phase = SpecPhase::Design;
952 let design_gaps = AISpecWriter::analyze_gaps(&session, &spec);
953
954 assert_ne!(discovery_gaps.suggestions, requirements_gaps.suggestions);
956 assert_ne!(requirements_gaps.suggestions, design_gaps.suggestions);
957 }
958
959 #[test]
960 fn test_build_prompt_with_steering_context() {
961 use crate::models::SteeringRule;
962
963 let session = create_test_session();
964 let global_steering = Steering {
965 rules: vec![SteeringRule {
966 id: "global-rule".to_string(),
967 description: "Global rule".to_string(),
968 pattern: "pattern".to_string(),
969 action: "enforce".to_string(),
970 }],
971 standards: vec![],
972 templates: vec![],
973 };
974
975 let project_steering = Steering {
976 rules: vec![SteeringRule {
977 id: "project-rule".to_string(),
978 description: "Project rule".to_string(),
979 pattern: "pattern".to_string(),
980 action: "enforce".to_string(),
981 }],
982 standards: vec![],
983 templates: vec![],
984 };
985
986 let result = AISpecWriter::build_prompt_with_steering_context(
987 &session,
988 &global_steering,
989 &project_steering,
990 "Create a task manager",
991 );
992
993 assert!(result.is_ok());
994 let prompt = result.unwrap();
995
996 assert!(prompt.contains("Phase Guidance"));
998 assert!(prompt.contains("Steering Rules"));
999 assert!(prompt.contains("global-rule"));
1000 assert!(prompt.contains("project-rule"));
1001 assert!(prompt.contains("Create a task manager"));
1002 }
1003
1004 #[test]
1005 fn test_build_prompt_with_steering_context_includes_conversation() {
1006 let mut session = create_test_session();
1007 AISpecWriter::add_message(&mut session, MessageRole::User, "What should we build?")
1008 .unwrap();
1009 AISpecWriter::add_message(
1010 &mut session,
1011 MessageRole::Assistant,
1012 "Let's build a task manager",
1013 )
1014 .unwrap();
1015
1016 let global_steering = Steering {
1017 rules: vec![],
1018 standards: vec![],
1019 templates: vec![],
1020 };
1021
1022 let project_steering = Steering {
1023 rules: vec![],
1024 standards: vec![],
1025 templates: vec![],
1026 };
1027
1028 let result = AISpecWriter::build_prompt_with_steering_context(
1029 &session,
1030 &global_steering,
1031 &project_steering,
1032 "Continue with requirements",
1033 );
1034
1035 assert!(result.is_ok());
1036 let prompt = result.unwrap();
1037
1038 assert!(prompt.contains("Conversation History"));
1040 assert!(prompt.contains("What should we build?"));
1041 assert!(prompt.contains("Let's build a task manager"));
1042 }
1043
1044 #[test]
1045 fn test_build_prompt_with_steering_context_project_precedence() {
1046 use crate::models::SteeringRule;
1047
1048 let session = create_test_session();
1049 let global_steering = Steering {
1050 rules: vec![SteeringRule {
1051 id: "rule-1".to_string(),
1052 description: "Global version".to_string(),
1053 pattern: "global".to_string(),
1054 action: "warn".to_string(),
1055 }],
1056 standards: vec![],
1057 templates: vec![],
1058 };
1059
1060 let project_steering = Steering {
1061 rules: vec![SteeringRule {
1062 id: "rule-1".to_string(),
1063 description: "Project version".to_string(),
1064 pattern: "project".to_string(),
1065 action: "enforce".to_string(),
1066 }],
1067 standards: vec![],
1068 templates: vec![],
1069 };
1070
1071 let result = AISpecWriter::build_prompt_with_steering_context(
1072 &session,
1073 &global_steering,
1074 &project_steering,
1075 "Test",
1076 );
1077
1078 assert!(result.is_ok());
1079 let prompt = result.unwrap();
1080
1081 assert!(prompt.contains("Project version"));
1083 assert!(prompt.contains("project"));
1084 assert!(!prompt.contains("Global version"));
1085 }
1086
1087 #[test]
1088 fn test_validate_spec_against_steering_valid() {
1089 let spec = Spec {
1090 id: "test".to_string(),
1091 name: "Test Spec".to_string(),
1092 version: "1.0.0".to_string(),
1093 requirements: vec![Requirement {
1094 id: "REQ-1".to_string(),
1095 user_story: "As a user".to_string(),
1096 acceptance_criteria: vec![AcceptanceCriterion {
1097 id: "AC-1.1".to_string(),
1098 when: "when".to_string(),
1099 then: "then".to_string(),
1100 }],
1101 priority: Priority::Must,
1102 }],
1103 design: None,
1104 tasks: vec![],
1105 metadata: SpecMetadata {
1106 author: Some("Test Author".to_string()),
1107 created_at: Utc::now(),
1108 updated_at: Utc::now(),
1109 phase: SpecPhase::Requirements,
1110 status: SpecStatus::Draft,
1111 },
1112 inheritance: None,
1113 };
1114
1115 let steering = Steering {
1116 rules: vec![],
1117 standards: vec![],
1118 templates: vec![],
1119 };
1120
1121 let result = AISpecWriter::validate_spec_against_steering(&spec, &steering);
1122 assert!(result.is_ok());
1123 let violations = result.unwrap();
1124 assert!(violations.is_empty());
1125 }
1126
1127 #[test]
1128 fn test_validate_spec_against_steering_missing_author() {
1129 let spec = Spec {
1130 id: "test".to_string(),
1131 name: "Test Spec".to_string(),
1132 version: "1.0.0".to_string(),
1133 requirements: vec![],
1134 design: None,
1135 tasks: vec![],
1136 metadata: SpecMetadata {
1137 author: None,
1138 created_at: Utc::now(),
1139 updated_at: Utc::now(),
1140 phase: SpecPhase::Discovery,
1141 status: SpecStatus::Draft,
1142 },
1143 inheritance: None,
1144 };
1145
1146 let steering = Steering {
1147 rules: vec![],
1148 standards: vec![],
1149 templates: vec![],
1150 };
1151
1152 let result = AISpecWriter::validate_spec_against_steering(&spec, &steering);
1153 assert!(result.is_ok());
1154 let violations = result.unwrap();
1155 assert!(!violations.is_empty());
1156 assert!(violations.iter().any(|v| v.contains("author")));
1157 }
1158
1159 #[test]
1160 fn test_validate_spec_against_steering_requirements_phase_no_requirements() {
1161 let spec = Spec {
1162 id: "test".to_string(),
1163 name: "Test Spec".to_string(),
1164 version: "1.0.0".to_string(),
1165 requirements: vec![],
1166 design: None,
1167 tasks: vec![],
1168 metadata: SpecMetadata {
1169 author: Some("Author".to_string()),
1170 created_at: Utc::now(),
1171 updated_at: Utc::now(),
1172 phase: SpecPhase::Requirements,
1173 status: SpecStatus::Draft,
1174 },
1175 inheritance: None,
1176 };
1177
1178 let steering = Steering {
1179 rules: vec![],
1180 standards: vec![],
1181 templates: vec![],
1182 };
1183
1184 let result = AISpecWriter::validate_spec_against_steering(&spec, &steering);
1185 assert!(result.is_ok());
1186 let violations = result.unwrap();
1187 assert!(!violations.is_empty());
1188 assert!(violations.iter().any(|v| v.contains("Requirements phase")));
1189 }
1190
1191 #[test]
1192 fn test_format_steering_context() {
1193 use crate::models::{Standard, SteeringRule, TemplateRef};
1194
1195 let steering = Steering {
1196 rules: vec![SteeringRule {
1197 id: "rule-1".to_string(),
1198 description: "Use snake_case".to_string(),
1199 pattern: "^[a-z_]+$".to_string(),
1200 action: "enforce".to_string(),
1201 }],
1202 standards: vec![Standard {
1203 id: "std-1".to_string(),
1204 description: "Test all public APIs".to_string(),
1205 }],
1206 templates: vec![TemplateRef {
1207 id: "tpl-1".to_string(),
1208 path: "templates/entity.rs".to_string(),
1209 }],
1210 };
1211
1212 let output = AISpecWriter::format_steering_context(&steering);
1213
1214 assert!(output.contains("Steering Rules"));
1215 assert!(output.contains("rule-1"));
1216 assert!(output.contains("Standards"));
1217 assert!(output.contains("std-1"));
1218 assert!(output.contains("Templates"));
1219 assert!(output.contains("tpl-1"));
1220 }
1221
1222 #[test]
1223 fn test_format_steering_context_empty() {
1224 let steering = Steering {
1225 rules: vec![],
1226 standards: vec![],
1227 templates: vec![],
1228 };
1229
1230 let output = AISpecWriter::format_steering_context(&steering);
1231
1232 assert!(output.is_empty() || output.trim().is_empty());
1234 }
1235}