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