1use anyhow::{Context, Result};
11use serde_json::json;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use crate::bus::AgentBus;
16use crate::okr::{KrOutcome, KrOutcomeType, Okr, OkrRun, OkrRunStatus};
17use crate::provider::{CompletionRequest, ContentPart, Message, Provider, ProviderRegistry, Role};
18use crate::ralph::store_http::HttpStore;
19use crate::ralph::{Prd, QualityChecks, RalphConfig, RalphLoop, RalphStatus};
20
21#[derive(Debug, Clone)]
23pub struct GoRalphResult {
24 pub prd_path: PathBuf,
25 pub feature_branch: String,
26 pub passed: usize,
27 pub total: usize,
28 pub all_passed: bool,
29 pub iterations: usize,
30 pub max_iterations: usize,
31 pub status: RalphStatus,
32 pub stories: Vec<StoryResult>,
33}
34
35#[derive(Debug, Clone)]
36pub struct StoryResult {
37 pub id: String,
38 pub title: String,
39 pub passed: bool,
40}
41
42pub async fn generate_prd_from_task(
44 task: &str,
45 okr: &Okr,
46 provider: &dyn Provider,
47 model: &str,
48) -> Result<Prd> {
49 let kr_descriptions: Vec<String> = okr
50 .key_results
51 .iter()
52 .enumerate()
53 .map(|(i, kr)| {
54 format!(
55 "KR-{}: {} (target: {} {})",
56 i + 1,
57 kr.title,
58 kr.target_value,
59 kr.unit
60 )
61 })
62 .collect();
63
64 let prompt = format!(
65 r#"You are a PRD generator. Given a task and key results, produce a JSON PRD with concrete user stories.
66
67Task: {task}
68
69Key Results:
70{krs}
71
72Generate a PRD JSON with this exact structure (no markdown, no commentary, ONLY valid JSON):
73{{
74 "project": "<short project name>",
75 "feature": "<feature name>",
76 "branch_name": "feature/<kebab-case-name>",
77 "version": "1.0",
78 "user_stories": [
79 {{
80 "id": "US-001",
81 "title": "<concise title>",
82 "description": "<what to implement>",
83 "acceptance_criteria": ["<criterion 1>", "<criterion 2>"],
84 "passes": false,
85 "priority": 1,
86 "depends_on": [],
87 "complexity": 3
88 }}
89 ],
90 "technical_requirements": ["<requirement>"],
91 "quality_checks": {{
92 "typecheck": null,
93 "test": null,
94 "lint": null,
95 "build": null
96 }}
97}}
98
99Rules:
100- Each key result should map to at least one user story
101- Stories should be concrete, implementable, and testable
102- Use priority 1 for critical stories, 2 for important, 3 for nice-to-have
103- Set depends_on when stories have real dependencies
104- Complexity: 1=trivial, 2=simple, 3=moderate, 4=complex, 5=very complex
105- quality_checks should match the project's toolchain.
106 - If you can confidently infer the toolchain, fill in commands.
107 - If unsure, set fields to null (do NOT guess) and we will auto-detect.
108- Output ONLY the JSON object, nothing else"#,
109 krs = kr_descriptions.join("\n"),
110 );
111
112 let request = CompletionRequest {
113 messages: vec![Message {
114 role: Role::User,
115 content: vec![ContentPart::Text {
116 text: prompt.clone(),
117 }],
118 }],
119 tools: vec![],
120 model: model.to_string(),
121 temperature: Some(0.3),
122 top_p: None,
123 max_tokens: Some(4096),
124 stop: vec![],
125 };
126
127 let mut last_text = String::new();
129 let mut last_error = String::new();
130
131 for attempt in 0..3 {
132 let req = if attempt == 0 {
133 request.clone()
134 } else {
135 tracing::warn!(
137 attempt,
138 error = %last_error,
139 "PRD JSON extraction failed, retrying with repair prompt"
140 );
141 let repair = format!(
142 "Your previous response was not valid JSON. Here is the error:\n{err}\n\n\
143 Here is what you returned:\n```\n{text}\n```\n\n\
144 Please output ONLY the corrected JSON object — no markdown fences, \
145 no commentary, no trailing commas, no comments. Start with {{ and end with }}.",
146 err = last_error,
147 text = if last_text.len() > 2000 {
148 &last_text[..2000]
149 } else {
150 &last_text
151 },
152 );
153 CompletionRequest {
154 messages: vec![
155 Message {
156 role: Role::User,
157 content: vec![ContentPart::Text {
158 text: prompt.clone(),
159 }],
160 },
161 Message {
162 role: Role::Assistant,
163 content: vec![ContentPart::Text {
164 text: last_text.clone(),
165 }],
166 },
167 Message {
168 role: Role::User,
169 content: vec![ContentPart::Text { text: repair }],
170 },
171 ],
172 tools: vec![],
173 model: model.to_string(),
174 temperature: Some(0.1),
175 top_p: None,
176 max_tokens: Some(4096),
177 stop: vec![],
178 }
179 };
180
181 let response = provider
182 .complete(req)
183 .await
184 .context("Failed to generate PRD from LLM")?;
185
186 last_text = response
187 .message
188 .content
189 .iter()
190 .filter_map(|part| match part {
191 ContentPart::Text { text } => Some(text.as_str()),
192 _ => None,
193 })
194 .collect::<Vec<_>>()
195 .join("");
196
197 match extract_json(&last_text) {
199 Some(json_str) => match serde_json::from_str::<Prd>(&json_str) {
200 Ok(prd) => {
201 if attempt > 0 {
202 tracing::info!(attempt, "PRD JSON repair succeeded");
203 }
204 let mut prd = prd;
206 let now = chrono::Utc::now().to_rfc3339();
207 prd.created_at = now.clone();
208 prd.updated_at = now;
209
210 let cwd = std::env::current_dir().unwrap_or_default();
215 let detected = detect_quality_checks();
216 let looks_like_cargo = prd
217 .quality_checks
218 .typecheck
219 .as_deref()
220 .map(|c| c.to_ascii_lowercase().contains("cargo"))
221 .unwrap_or(false);
222 let looks_like_npm = prd
223 .quality_checks
224 .typecheck
225 .as_deref()
226 .map(|c| {
227 let c = c.to_ascii_lowercase();
228 c.contains("npm")
229 || c.contains("pnpm")
230 || c.contains("yarn")
231 || c.contains("npx")
232 })
233 .unwrap_or(false);
234 let looks_like_go = prd
235 .quality_checks
236 .typecheck
237 .as_deref()
238 .map(|c| c.to_ascii_lowercase().contains("go vet"))
239 .unwrap_or(false);
240
241 if prd.quality_checks.typecheck.is_none() {
242 prd.quality_checks = detected;
243 } else if looks_like_cargo && !cwd.join("Cargo.toml").exists() {
244 prd.quality_checks = detected;
245 } else if looks_like_npm && !cwd.join("package.json").exists() {
246 prd.quality_checks = detected;
247 } else if looks_like_go && !cwd.join("go.mod").exists() {
248 prd.quality_checks = detected;
249 }
250
251 return Ok(prd);
252 }
253 Err(e) => {
254 last_error = format!("JSON parses but doesn't match PRD schema: {e}");
255 }
256 },
257 None => {
258 last_error = "Response contains no valid JSON object".to_string();
259 }
260 }
261 }
262
263 anyhow::bail!("Failed to extract valid PRD JSON after 3 attempts. Last error: {last_error}");
264}
265
266pub async fn execute_go_ralph(
268 task: &str,
269 okr: &mut Okr,
270 okr_run: &mut OkrRun,
271 provider: Arc<dyn Provider>,
272 model: &str,
273 max_iterations: usize,
274 bus: Option<Arc<AgentBus>>,
275 max_concurrent_stories: usize,
276 registry: Option<Arc<ProviderRegistry>>,
277) -> Result<GoRalphResult> {
278 tracing::info!(task = %task, okr_id = %okr.id, "Generating PRD from task and key results");
280 let prd = generate_prd_from_task(task, okr, provider.as_ref(), model).await?;
281
282 let prd_filename = format!("prd_{}.json", okr_run.id.to_string().replace('-', "_"));
284 let prd_path = PathBuf::from(&prd_filename);
285 prd.save(&prd_path)
286 .await
287 .context("Failed to save generated PRD")?;
288
289 tracing::info!(
290 prd_path = %prd_path.display(),
291 stories = prd.user_stories.len(),
292 feature = %prd.feature,
293 "PRD generated and saved"
294 );
295
296 if let Some(audit) = crate::audit::try_audit_log() {
298 audit
299 .log_with_correlation(
300 crate::audit::AuditCategory::Cognition,
301 "go_ralph_prd_generated",
302 crate::audit::AuditOutcome::Success,
303 Some("codetether-agent".to_string()),
304 Some(json!({
305 "task": task,
306 "prd_path": prd_path.display().to_string(),
307 "stories": prd.user_stories.len(),
308 "feature": prd.feature,
309 "project": prd.project,
310 })),
311 Some(okr.id.to_string()),
312 Some(okr_run.id.to_string()),
313 None,
314 okr_run.session_id.clone(),
315 )
316 .await;
317 }
318
319 if let Err(e) = okr_run.start() {
321 tracing::warn!(error = %e, "OKR run start transition failed, forcing Running status");
322 okr_run.status = OkrRunStatus::Running;
323 }
324 okr_run.relay_checkpoint_id = Some(prd_filename.clone());
325
326 let config = RalphConfig {
328 prd_path: prd_path.to_string_lossy().to_string(),
329 max_iterations,
330 progress_path: format!("progress_{}.txt", okr_run.id.to_string().replace('-', "_")),
331 quality_checks_enabled: true,
332 auto_commit: true,
333 model: Some(model.to_string()),
334 use_rlm: false,
335 parallel_enabled: true,
336 max_concurrent_stories,
337 worktree_enabled: true,
338 story_timeout_secs: 300,
339 conflict_timeout_secs: 120,
340 relay_enabled: false,
341 relay_max_agents: 8,
342 relay_max_rounds: 3,
343 max_steps_per_story: 30,
344 };
345
346 let mut ralph = RalphLoop::new(
347 prd_path.clone(),
348 Arc::clone(&provider),
349 model.to_string(),
350 config,
351 )
352 .await
353 .context("Failed to initialize Ralph loop")?;
354
355 if let Some(bus) = bus {
357 ralph = ralph.with_bus(bus);
358 }
359
360 if let Some(registry) = registry {
362 ralph = ralph.with_registry(registry);
363 }
364
365 ralph = ralph.with_store(Arc::new(HttpStore::from_env()));
367
368 let state = ralph.run().await.context("Ralph loop execution failed")?;
369
370 let stories: Vec<StoryResult> = state
372 .prd
373 .user_stories
374 .iter()
375 .map(|s| StoryResult {
376 id: s.id.clone(),
377 title: s.title.clone(),
378 passed: s.passes,
379 })
380 .collect();
381
382 let passed = state.prd.passed_count();
383 let total = state.prd.user_stories.len();
384
385 map_stories_to_kr_outcomes(okr, okr_run, &state.prd, &state);
386 let all_passed = okr.is_complete() || passed == total;
387
388 if all_passed {
390 okr_run.complete();
391 } else if state.status == RalphStatus::Stopped || state.status == RalphStatus::QualityFailed {
392 okr_run.status = OkrRunStatus::Failed;
393 } else {
394 okr_run.status = OkrRunStatus::Completed;
395 }
396 okr_run.iterations = state.current_iteration as u32;
397 okr_run.relay_checkpoint_id = None; if let Some(audit) = crate::audit::try_audit_log() {
401 let outcome = if all_passed {
402 crate::audit::AuditOutcome::Success
403 } else {
404 crate::audit::AuditOutcome::Failure
405 };
406 audit
407 .log_with_correlation(
408 crate::audit::AuditCategory::Cognition,
409 "go_ralph_completed",
410 outcome,
411 Some("codetether-agent".to_string()),
412 Some(json!({
413 "prd_path": prd_path.display().to_string(),
414 "passed": passed,
415 "total": total,
416 "status": format!("{:?}", state.status),
417 "iterations": state.current_iteration,
418 "feature_branch": state.prd.branch_name,
419 })),
420 Some(okr.id.to_string()),
421 Some(okr_run.id.to_string()),
422 None,
423 okr_run.session_id.clone(),
424 )
425 .await;
426 }
427
428 Ok(GoRalphResult {
429 prd_path,
430 feature_branch: state.prd.branch_name.clone(),
431 passed,
432 total,
433 all_passed,
434 iterations: state.current_iteration,
435 max_iterations: state.max_iterations,
436 status: state.status,
437 stories,
438 })
439}
440
441fn map_stories_to_kr_outcomes(
443 okr: &mut Okr,
444 run: &mut OkrRun,
445 prd: &Prd,
446 state: &crate::ralph::RalphState,
447) {
448 let passed = prd.passed_count();
449 let total = prd.user_stories.len();
450 let ratio = if total > 0 {
451 passed as f64 / total as f64
452 } else {
453 0.0
454 };
455
456 let story_evidence: Vec<String> = prd
458 .user_stories
459 .iter()
460 .map(|s| {
461 format!(
462 "{}:{} ({})",
463 s.id,
464 s.title,
465 if s.passes { "PASSED" } else { "FAILED" }
466 )
467 })
468 .collect();
469
470 let outcome_type = if ratio >= 1.0 {
471 KrOutcomeType::FeatureDelivered
472 } else {
473 KrOutcomeType::Evidence
474 };
475
476 for kr in &mut okr.key_results {
478 let kr_value = ratio * kr.target_value;
480 kr.update_progress(kr_value);
481 run.update_kr_progress(&kr.id.to_string(), kr_value);
482
483 let mut evidence = story_evidence.clone();
484 evidence.push(format!("prd:{}", prd.feature));
485 evidence.push(format!("iterations:{}", state.current_iteration));
486 evidence.push(format!("status:{:?}", state.status));
487 if !prd.branch_name.is_empty() {
488 evidence.push(format!("branch:{}", prd.branch_name));
489 }
490
491 let mut outcome = KrOutcome::new(
492 kr.id,
493 format!(
494 "Ralph PRD execution: {}/{} stories passed for '{}'",
495 passed, total, prd.feature
496 ),
497 )
498 .with_value(kr_value);
499 outcome.run_id = Some(run.id);
500 outcome.outcome_type = outcome_type;
501 outcome.evidence = evidence;
502 outcome.source = "go_ralph".to_string();
503
504 kr.add_outcome(outcome.clone());
505 run.outcomes.push(outcome);
506 }
507}
508
509pub fn format_go_ralph_result(result: &GoRalphResult, task: &str) -> String {
511 let status_icon = if result.all_passed { "✅" } else { "❌" };
512 let status_label = format!("{:?}", result.status);
513
514 let story_lines: Vec<String> = result
515 .stories
516 .iter()
517 .map(|s| {
518 format!(
519 " {} {}: {}",
520 if s.passed { "✓" } else { "✗" },
521 s.id,
522 s.title
523 )
524 })
525 .collect();
526
527 let next_steps = if result.all_passed {
528 format!(
529 "\nNext steps:\n 1. Review changes on branch `{}`\n 2. Merge: git checkout main && git merge {} --no-ff\n 3. Push: git push",
530 result.feature_branch, result.feature_branch
531 )
532 } else {
533 let failed: Vec<String> = result
534 .stories
535 .iter()
536 .filter(|s| !s.passed)
537 .map(|s| format!(" - {}: {}", s.id, s.title))
538 .collect();
539 format!(
540 "\nIncomplete stories:\n{}\n\nNext steps:\n 1. Review progress file for learnings\n 2. Re-run with a clean objective: codetether run -- '/go <concise-task>'\n 3. Or fix manually on branch `{}`",
541 failed.join("\n"),
542 result.feature_branch
543 )
544 };
545
546 format!(
547 "{status_icon} /go Ralph {status_label}\n\n\
548 Task: {task}\n\
549 Progress: {passed}/{total} stories | Iterations: {iters}/{max}\n\
550 Feature branch: {branch}\n\
551 PRD: {prd}\n\n\
552 Stories:\n{stories}\n{next}",
553 task = task,
554 passed = result.passed,
555 total = result.total,
556 iters = result.iterations,
557 max = result.max_iterations,
558 branch = result.feature_branch,
559 prd = result.prd_path.display(),
560 stories = story_lines.join("\n"),
561 next = next_steps,
562 )
563}
564
565fn extract_json(text: &str) -> Option<String> {
567 let candidates = gather_json_candidates(text);
569 for candidate in candidates {
570 if serde_json::from_str::<serde_json::Value>(&candidate).is_ok() {
572 return Some(candidate);
573 }
574 let sanitized = sanitize_json(&candidate);
576 if serde_json::from_str::<serde_json::Value>(&sanitized).is_ok() {
577 return Some(sanitized);
578 }
579 }
580 None
581}
582
583fn gather_json_candidates(text: &str) -> Vec<String> {
585 let mut candidates = Vec::new();
586 let trimmed = text.trim();
587
588 candidates.push(trimmed.to_string());
590
591 let mut search = text;
593 while let Some(start) = search.find("```json") {
594 let after = &search[start + 7..];
595 if let Some(end) = after.find("```") {
596 candidates.push(after[..end].trim().to_string());
597 }
598 search = &search[start + 7..];
599 }
600
601 search = text;
603 while let Some(start) = search.find("```") {
604 let after = &search[start + 3..];
605 let content_start = after.find('\n').unwrap_or(0);
606 let after_tag = &after[content_start..];
607 if let Some(end) = after_tag.find("```") {
608 candidates.push(after_tag[..end].trim().to_string());
609 }
610 let skip = start + 3 + content_start + after_tag.find("```").unwrap_or(after_tag.len()) + 3;
612 if skip >= search.len() {
613 break;
614 }
615 search = &search[skip..];
616 }
617
618 if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
620 if start < end {
621 candidates.push(text[start..=end].to_string());
622 }
623 }
624
625 if let Some(balanced) = extract_balanced_braces(text) {
627 candidates.push(balanced);
628 }
629
630 candidates
631}
632
633fn extract_balanced_braces(text: &str) -> Option<String> {
635 let start = text.find('{')?;
636 let mut depth = 0i32;
637 let mut in_string = false;
638 let mut escape_next = false;
639 let bytes = text.as_bytes();
640
641 for i in start..bytes.len() {
642 let ch = bytes[i] as char;
643 if escape_next {
644 escape_next = false;
645 continue;
646 }
647 if ch == '\\' && in_string {
648 escape_next = true;
649 continue;
650 }
651 if ch == '"' {
652 in_string = !in_string;
653 continue;
654 }
655 if in_string {
656 continue;
657 }
658 match ch {
659 '{' => depth += 1,
660 '}' => {
661 depth -= 1;
662 if depth == 0 {
663 return Some(text[start..=i].to_string());
664 }
665 }
666 _ => {}
667 }
668 }
669 None
670}
671
672fn sanitize_json(text: &str) -> String {
674 let mut s = text.to_string();
675
676 s = s
678 .replace('\u{201c}', "\"") .replace('\u{201d}', "\"") .replace('\u{2018}', "'") .replace('\u{2019}', "'"); s = remove_line_comments(&s);
685
686 s = remove_trailing_commas(&s);
688
689 s
690}
691
692fn remove_line_comments(text: &str) -> String {
694 let mut result = String::with_capacity(text.len());
695 let mut in_string = false;
696 let mut escape_next = false;
697 let chars: Vec<char> = text.chars().collect();
698 let mut i = 0;
699
700 while i < chars.len() {
701 if escape_next {
702 result.push(chars[i]);
703 escape_next = false;
704 i += 1;
705 continue;
706 }
707 if chars[i] == '\\' && in_string {
708 result.push(chars[i]);
709 escape_next = true;
710 i += 1;
711 continue;
712 }
713 if chars[i] == '"' {
714 in_string = !in_string;
715 result.push(chars[i]);
716 i += 1;
717 continue;
718 }
719 if !in_string && i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '/' {
720 while i < chars.len() && chars[i] != '\n' {
722 i += 1;
723 }
724 continue;
725 }
726 result.push(chars[i]);
727 i += 1;
728 }
729 result
730}
731
732fn remove_trailing_commas(text: &str) -> String {
734 let mut result = String::with_capacity(text.len());
735 let mut in_string = false;
736 let mut escape_next = false;
737 let chars: Vec<char> = text.chars().collect();
738 let mut i = 0;
739
740 while i < chars.len() {
741 if escape_next {
742 result.push(chars[i]);
743 escape_next = false;
744 i += 1;
745 continue;
746 }
747 if chars[i] == '\\' && in_string {
748 result.push(chars[i]);
749 escape_next = true;
750 i += 1;
751 continue;
752 }
753 if chars[i] == '"' {
754 in_string = !in_string;
755 result.push(chars[i]);
756 i += 1;
757 continue;
758 }
759 if !in_string && chars[i] == ',' {
760 let mut j = i + 1;
762 while j < chars.len() && chars[j].is_whitespace() {
763 j += 1;
764 }
765 if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
766 i += 1;
768 continue;
769 }
770 }
771 result.push(chars[i]);
772 i += 1;
773 }
774 result
775}
776
777fn detect_quality_checks() -> QualityChecks {
779 let cwd = std::env::current_dir().unwrap_or_default();
780
781 if cwd.join("Cargo.toml").exists() {
782 QualityChecks {
783 typecheck: Some("cargo check".to_string()),
784 test: Some("cargo test".to_string()),
785 lint: Some("cargo clippy --all-features".to_string()),
786 build: Some("cargo build".to_string()),
787 }
788 } else if cwd.join("package.json").exists() {
789 QualityChecks {
790 typecheck: Some("npx tsc --noEmit".to_string()),
791 test: Some("npm test".to_string()),
792 lint: Some("npm run lint".to_string()),
793 build: Some("npm run build".to_string()),
794 }
795 } else if cwd.join("go.mod").exists() {
796 QualityChecks {
797 typecheck: Some("go vet ./...".to_string()),
798 test: Some("go test ./...".to_string()),
799 lint: Some("golangci-lint run".to_string()),
800 build: Some("go build ./...".to_string()),
801 }
802 } else if cwd.join("requirements.txt").exists() || cwd.join("pyproject.toml").exists() {
803 QualityChecks {
804 typecheck: Some("mypy .".to_string()),
805 test: Some("pytest".to_string()),
806 lint: Some("ruff check .".to_string()),
807 build: None,
808 }
809 } else {
810 QualityChecks::default()
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use crate::okr::KeyResult;
818 use crate::ralph::UserStory;
819 use uuid::Uuid;
820
821 #[test]
822 fn extract_json_handles_raw_json() {
823 let raw = r#"{"project": "test", "feature": "foo"}"#;
824 assert!(extract_json(raw).is_some());
825 }
826
827 #[test]
828 fn extract_json_handles_markdown_wrapped() {
829 let wrapped = "Here is the PRD:\n```json\n{\"project\": \"test\"}\n```\nDone.";
830 let result = extract_json(wrapped).unwrap();
831 assert!(result.contains("test"));
832 }
833
834 #[test]
835 fn extract_json_handles_bare_braces() {
836 let text = "The result is: {\"project\": \"test\"} and that's it.";
837 let result = extract_json(text).unwrap();
838 assert!(result.contains("test"));
839 }
840
841 #[test]
842 fn extract_json_returns_none_for_no_json() {
843 assert!(extract_json("no json here").is_none());
844 }
845
846 #[test]
847 fn extract_json_handles_trailing_commas() {
848 let text = r#"{"project": "test", "feature": "foo",}"#;
849 let result = extract_json(text).unwrap();
850 assert!(result.contains("test"));
851 serde_json::from_str::<serde_json::Value>(&result).unwrap();
853 }
854
855 #[test]
856 fn extract_json_handles_line_comments() {
857 let text = "{\n \"project\": \"test\", // this is the project\n \"feature\": \"foo\"\n}";
858 let result = extract_json(text).unwrap();
859 serde_json::from_str::<serde_json::Value>(&result).unwrap();
860 }
861
862 #[test]
863 fn extract_json_handles_curly_quotes() {
864 let text = "\u{201c}project\u{201d}: \u{201c}test\u{201d}";
865 let full = format!("{{{text}}}");
866 let result = extract_json(&full).unwrap();
867 serde_json::from_str::<serde_json::Value>(&result).unwrap();
868 }
869
870 #[test]
871 fn extract_json_handles_prose_wrapper() {
872 let text = "Sure! Here is the PRD:\n\n{\"project\": \"x\", \"feature\": \"y\"}\n\nLet me know if you need changes.";
873 let result = extract_json(text).unwrap();
874 assert!(result.contains("\"project\""));
875 }
876
877 #[test]
878 fn detect_quality_checks_returns_defaults_for_unknown() {
879 let _checks = detect_quality_checks();
882 }
883
884 #[test]
885 fn map_stories_creates_outcomes_for_each_kr() {
886 let okr_id = Uuid::new_v4();
887 let mut okr = Okr::new("Test OKR", "Test description");
888 okr.id = okr_id;
889
890 let kr1 = KeyResult::new(okr_id, "Stories complete", 100.0, "%");
891 let kr2 = KeyResult::new(okr_id, "No errors", 0.0, "count");
892 okr.add_key_result(kr1);
893 okr.add_key_result(kr2);
894
895 let mut run = OkrRun::new(okr_id, "Test Run");
896
897 let prd = Prd {
898 project: "test".to_string(),
899 feature: "test-feature".to_string(),
900 branch_name: "feature/test".to_string(),
901 version: "1.0".to_string(),
902 user_stories: vec![
903 UserStory {
904 id: "US-001".to_string(),
905 title: "Story one".to_string(),
906 description: "First story".to_string(),
907 acceptance_criteria: vec![],
908 verification_steps: vec![],
909 passes: true,
910 priority: 1,
911 depends_on: vec![],
912 complexity: 2,
913 },
914 UserStory {
915 id: "US-002".to_string(),
916 title: "Story two".to_string(),
917 description: "Second story".to_string(),
918 acceptance_criteria: vec![],
919 verification_steps: vec![],
920 passes: false,
921 priority: 2,
922 depends_on: vec![],
923 complexity: 3,
924 },
925 ],
926 technical_requirements: vec![],
927 quality_checks: QualityChecks::default(),
928 created_at: String::new(),
929 updated_at: String::new(),
930 };
931
932 let state = crate::ralph::RalphState {
933 prd: prd.clone(),
934 current_iteration: 3,
935 max_iterations: 10,
936 status: RalphStatus::MaxIterations,
937 progress_log: vec![],
938 prd_path: PathBuf::from("test.json"),
939 working_dir: PathBuf::from("."),
940 };
941
942 map_stories_to_kr_outcomes(&mut okr, &mut run, &prd, &state);
943
944 assert_eq!(run.outcomes.len(), 2);
946 assert_eq!(run.outcomes[0].kr_id, okr.key_results[0].id);
948 assert_eq!(run.outcomes[1].kr_id, okr.key_results[1].id);
949 assert_eq!(run.outcomes[0].value, Some(50.0)); assert!(
953 run.outcomes[0]
954 .evidence
955 .iter()
956 .any(|e| e.contains("US-001"))
957 );
958 assert!(
959 run.outcomes[0]
960 .evidence
961 .iter()
962 .any(|e| e.contains("PASSED"))
963 );
964 assert!(
965 run.outcomes[0]
966 .evidence
967 .iter()
968 .any(|e| e.contains("FAILED"))
969 );
970 }
971
972 #[test]
973 fn format_result_shows_status() {
974 let result = GoRalphResult {
975 prd_path: PathBuf::from("prd_test.json"),
976 feature_branch: "feature/test".to_string(),
977 passed: 2,
978 total: 3,
979 all_passed: false,
980 iterations: 5,
981 max_iterations: 10,
982 status: RalphStatus::MaxIterations,
983 stories: vec![
984 StoryResult {
985 id: "US-001".to_string(),
986 title: "Story one".to_string(),
987 passed: true,
988 },
989 StoryResult {
990 id: "US-002".to_string(),
991 title: "Story two".to_string(),
992 passed: true,
993 },
994 StoryResult {
995 id: "US-003".to_string(),
996 title: "Story three".to_string(),
997 passed: false,
998 },
999 ],
1000 };
1001
1002 let output = format_go_ralph_result(&result, "test task");
1003 assert!(output.contains("2/3 stories"));
1004 assert!(output.contains("US-003"));
1005 assert!(output.contains("Incomplete"));
1006 }
1007}