1use std::path::{Path, PathBuf};
2use std::time::{Duration, Instant};
3
4use tokio::sync::mpsc;
5use tracing::{debug, info, warn};
6use uuid::Uuid;
7
8use terraphim_types::{FindingCategory, FindingSeverity, ReviewAgentOutput, ReviewFinding};
9
10use crate::config::CompoundReviewConfig;
11use crate::error::OrchestratorError;
12use crate::scope::WorktreeManager;
13
14const PROMPT_SECURITY: &str = include_str!("../prompts/review-security.md");
18const PROMPT_ARCHITECTURE: &str = include_str!("../prompts/review-architecture.md");
19const PROMPT_PERFORMANCE: &str = include_str!("../prompts/review-performance.md");
20const PROMPT_QUALITY: &str = include_str!("../prompts/review-quality.md");
21const PROMPT_DOMAIN: &str = include_str!("../prompts/review-domain.md");
22const PROMPT_DESIGN_QUALITY: &str = include_str!("../prompts/review-design-quality.md");
23
24#[derive(Debug, Clone)]
26pub struct ReviewGroupDef {
27 pub agent_name: String,
29 pub category: FindingCategory,
31 pub llm_tier: String,
33 pub cli_tool: String,
35 pub model: Option<String>,
37 pub prompt_template: String,
39 pub prompt_content: &'static str,
41 pub visual_only: bool,
43 pub persona: Option<String>,
45}
46
47impl ReviewGroupDef {
48 pub fn prompt(&self) -> &str {
50 self.prompt_content
51 }
52}
53
54#[derive(Debug, Clone)]
56pub struct SwarmConfig {
57 pub groups: Vec<ReviewGroupDef>,
59 pub timeout: Duration,
61 pub worktree_root: PathBuf,
63 pub repo_path: PathBuf,
65 pub base_branch: String,
67 pub max_concurrent_agents: usize,
69 pub create_prs: bool,
71}
72
73impl SwarmConfig {
74 pub fn from_compound_config(config: &CompoundReviewConfig) -> Self {
76 let mut groups = default_groups();
77
78 if let Some(ref cli_tool) = config.cli_tool {
80 for group in &mut groups {
81 group.cli_tool = cli_tool.clone();
82 }
83 }
84 if let Some(ref model) = config.model {
85 let composed = if let Some(ref provider) = config.provider {
87 let cli_tool_name = config.cli_tool.as_deref().unwrap_or("");
88 let cli_name = std::path::Path::new(cli_tool_name)
89 .file_name()
90 .and_then(|n| n.to_str())
91 .unwrap_or(cli_tool_name);
92 if cli_name == "opencode" {
93 format!("{}/{}", provider, model)
94 } else {
95 model.clone()
96 }
97 } else {
98 model.clone()
99 };
100 for group in &mut groups {
101 group.model = Some(composed.clone());
102 }
103 }
104
105 Self {
106 groups,
107 timeout: Duration::from_secs(config.max_duration_secs),
108 worktree_root: config.worktree_root.clone(),
109 repo_path: config.repo_path.clone(),
110 base_branch: config.base_branch.clone(),
111 max_concurrent_agents: config.max_concurrent_agents,
112 create_prs: config.create_prs,
113 }
114 }
115
116 pub fn from_compound_config_empty(config: &CompoundReviewConfig) -> Self {
119 Self {
120 groups: vec![],
121 timeout: Duration::from_secs(300),
122 worktree_root: config.worktree_root.clone(),
123 repo_path: config.repo_path.clone(),
124 base_branch: config.base_branch.clone(),
125 max_concurrent_agents: config.max_concurrent_agents,
126 create_prs: config.create_prs,
127 }
128 }
129}
130
131#[derive(Debug, Clone)]
133pub struct CompoundReviewResult {
134 pub correlation_id: Uuid,
136 pub findings: Vec<ReviewFinding>,
138 pub agent_outputs: Vec<ReviewAgentOutput>,
140 pub pass: bool,
142 pub duration: Duration,
144 pub agents_run: usize,
146 pub agents_failed: usize,
148}
149
150impl CompoundReviewResult {
151 pub fn format_report(&self) -> String {
153 let verdict = if self.pass { "✅ PASS" } else { "❌ NO-GO" };
154 let duration_secs = self.duration.as_secs();
155
156 let mut report = "## Compound Review\n\n".to_string();
157 report.push_str(&format!(
158 "**Verdict: {}** | Duration: {}s | Agents: {} ({} failed)\n\n",
159 verdict, duration_secs, self.agents_run, self.agents_failed
160 ));
161
162 if !self.findings.is_empty() {
164 report.push_str(&format!("### Findings ({})\n\n", self.findings.len()));
165 report.push_str("| Severity | File | Finding | Conf |\n");
166 report.push_str("|----------|------|---------|------|\n");
167 for f in &self.findings {
168 let sev = format!("{:?}", f.severity);
169 let file_loc = if !f.file.is_empty() {
170 if f.line > 0 {
171 format!("{}:{}", f.file, f.line)
172 } else {
173 f.file.clone()
174 }
175 } else {
176 "-".to_string()
177 };
178 let finding_text = if f.finding.len() > 120 {
180 format!("{}...", &f.finding[..117])
181 } else {
182 f.finding.clone()
183 };
184 report.push_str(&format!(
185 "| {} | {} | {} | {:.0}% |\n",
186 sev,
187 file_loc,
188 finding_text,
189 f.confidence * 100.0
190 ));
191 }
192 report.push('\n');
193 } else {
194 report.push_str("**No findings.**\n\n");
195 }
196
197 report.push_str("### Per-Agent Summary\n\n");
199 for output in &self.agent_outputs {
200 let status = if output.pass { "✅" } else { "❌" };
201 report.push_str(&format!(
202 "- {} {}: {} finding(s) — {}\n",
203 status,
204 output.agent,
205 output.findings.len(),
206 output.summary
207 ));
208 }
209
210 report
211 }
212
213 pub fn actionable_findings(&self) -> Vec<&ReviewFinding> {
215 self.findings
216 .iter()
217 .filter(|f| {
218 matches!(
219 f.severity,
220 FindingSeverity::Critical | FindingSeverity::High
221 )
222 })
223 .collect()
224 }
225}
226
227#[derive(Debug)]
232pub struct CompoundReviewWorkflow {
233 config: SwarmConfig,
234 worktree_manager: WorktreeManager,
235}
236
237impl CompoundReviewWorkflow {
238 pub fn new(config: SwarmConfig) -> Self {
240 let worktree_manager = WorktreeManager::with_base(&config.repo_path, &config.worktree_root);
241 Self {
242 config,
243 worktree_manager,
244 }
245 }
246
247 pub fn from_compound_config(config: CompoundReviewConfig) -> Self {
249 let swarm_config = SwarmConfig::from_compound_config(&config);
250 Self::new(swarm_config)
251 }
252
253 pub async fn run(
262 &self,
263 git_ref: &str,
264 base_ref: &str,
265 ) -> Result<CompoundReviewResult, OrchestratorError> {
266 let start = Instant::now();
267 let correlation_id = Uuid::new_v4();
268
269 info!(
270 correlation_id = %correlation_id,
271 git_ref = %git_ref,
272 base_ref = %base_ref,
273 "starting compound review swarm"
274 );
275
276 let changed_files = self.get_changed_files(git_ref, base_ref).await?;
278 debug!(count = changed_files.len(), "found changed files");
279
280 let has_visual = has_visual_changes(&changed_files);
282 let active_groups: Vec<&ReviewGroupDef> = self
283 .config
284 .groups
285 .iter()
286 .filter(|g| !g.visual_only || has_visual)
287 .collect();
288
289 info!(
290 total_groups = self.config.groups.len(),
291 active_groups = active_groups.len(),
292 has_visual_changes = has_visual,
293 "filtered review groups"
294 );
295
296 let worktree_name = format!("review-{}", correlation_id);
298 let worktree_path = self
299 .worktree_manager
300 .create_worktree(&worktree_name, git_ref)
301 .await
302 .map_err(|e| {
303 OrchestratorError::CompoundReviewFailed(format!("failed to create worktree: {}", e))
304 })?;
305
306 let (tx, mut rx) = mpsc::channel::<AgentResult>(active_groups.len().max(1));
308
309 let mut spawned_count = 0;
311 for group in active_groups {
312 let tx = tx.clone();
313 let group = group.clone();
314 let worktree_path = worktree_path.clone();
315 let changed_files = changed_files.clone();
316 let timeout = self.config.timeout;
317 let cli_tool = group.cli_tool.clone();
318
319 tokio::spawn(async move {
320 let result = run_single_agent(
321 &group,
322 &worktree_path,
323 &changed_files,
324 correlation_id,
325 timeout,
326 &cli_tool,
327 )
328 .await;
329 let _ = tx.send(result).await;
330 });
331 spawned_count += 1;
332 }
333
334 drop(tx);
336 let mut agent_outputs = Vec::new();
337 let mut failed_count = 0;
338 let collect_deadline =
339 tokio::time::Instant::now() + self.config.timeout + Duration::from_secs(10);
340
341 loop {
342 match tokio::time::timeout_at(collect_deadline, rx.recv()).await {
343 Ok(Some(result)) => match result {
344 AgentResult::Success(output) => {
345 info!(agent = %output.agent, findings = output.findings.len(), "agent completed");
346 agent_outputs.push(output);
347 }
348 AgentResult::Failed { agent_name, reason } => {
349 warn!(agent = %agent_name, error = %reason, "agent failed");
350 failed_count += 1;
351 agent_outputs.push(ReviewAgentOutput {
352 agent: agent_name,
353 findings: vec![],
354 summary: format!("Agent failed: {}", reason),
355 pass: false,
356 });
357 }
358 },
359 Ok(None) => break, Err(_) => {
361 warn!("collection deadline exceeded, using partial results");
362 break;
363 }
364 }
365 }
366
367 if let Err(e) = self.worktree_manager.remove_worktree(&worktree_name).await {
369 warn!(error = %e, "failed to cleanup worktree");
370 }
371
372 let all_findings: Vec<ReviewFinding> = agent_outputs
374 .iter()
375 .flat_map(|o| o.findings.clone())
376 .collect();
377 let deduplicated = terraphim_types::deduplicate_findings(all_findings);
378
379 let pass = agent_outputs.iter().all(|o| o.pass) && failed_count == 0;
381
382 let duration = start.elapsed();
383 info!(
384 correlation_id = %correlation_id,
385 agents_run = spawned_count,
386 agents_failed = failed_count,
387 total_findings = deduplicated.len(),
388 pass = %pass,
389 duration = ?duration,
390 "compound review completed"
391 );
392
393 Ok(CompoundReviewResult {
394 correlation_id,
395 findings: deduplicated,
396 agent_outputs,
397 pass,
398 duration,
399 agents_run: spawned_count,
400 agents_failed: failed_count,
401 })
402 }
403
404 pub fn default_groups() -> Vec<ReviewGroupDef> {
406 default_groups()
407 }
408
409 pub fn has_visual_changes(changed_files: &[String]) -> bool {
411 has_visual_changes(changed_files)
412 }
413
414 pub fn extract_review_output(
416 stdout: &str,
417 agent_name: &str,
418 category: FindingCategory,
419 ) -> ReviewAgentOutput {
420 extract_review_output(stdout, agent_name, category)
421 }
422
423 async fn get_changed_files(
425 &self,
426 git_ref: &str,
427 base_ref: &str,
428 ) -> Result<Vec<String>, OrchestratorError> {
429 let output = tokio::process::Command::new("git")
430 .args([
431 "-C",
432 self.config.repo_path.to_str().unwrap_or("."),
433 "diff",
434 "--name-only",
435 base_ref,
436 git_ref,
437 ])
438 .env_remove("GIT_INDEX_FILE")
439 .output()
440 .await
441 .map_err(|e| {
442 OrchestratorError::CompoundReviewFailed(format!("git diff failed: {}", e))
443 })?;
444
445 if !output.status.success() {
446 let stderr = String::from_utf8_lossy(&output.stderr);
447 return Err(OrchestratorError::CompoundReviewFailed(format!(
448 "git diff returned non-zero: {}",
449 stderr
450 )));
451 }
452
453 let stdout = String::from_utf8_lossy(&output.stdout);
454 let files: Vec<String> = stdout
455 .lines()
456 .filter(|line| !line.trim().is_empty())
457 .map(|line| line.to_string())
458 .collect();
459
460 Ok(files)
461 }
462
463 pub fn is_dry_run(&self) -> bool {
465 !self.config.create_prs
466 }
467}
468
469enum AgentResult {
471 Success(ReviewAgentOutput),
472 Failed { agent_name: String, reason: String },
473}
474
475async fn run_single_agent(
477 group: &ReviewGroupDef,
478 worktree_path: &Path,
479 changed_files: &[String],
480 _correlation_id: Uuid,
481 timeout: Duration,
482 cli_tool: &str,
483) -> AgentResult {
484 let agent_name = &group.agent_name;
485
486 let prompt = group.prompt_content;
488
489 let mut cmd = tokio::process::Command::new(cli_tool);
491
492 let cli_name = std::path::Path::new(cli_tool)
494 .file_name()
495 .and_then(|n| n.to_str())
496 .unwrap_or(cli_tool);
497
498 match cli_name {
499 "opencode" => {
500 cmd.arg("run").arg("--format").arg("json");
501 if let Some(ref model) = group.model {
502 cmd.arg("-m").arg(model);
503 }
504 cmd.arg(prompt);
505 }
506 "claude" | "claude-code" => {
507 cmd.arg("-p").arg(prompt);
508 if let Some(ref model) = group.model {
509 cmd.arg("--model").arg(model);
510 }
511 }
512 "codex" => {
513 cmd.arg("exec").arg("--full-auto");
514 if let Some(ref model) = group.model {
515 cmd.arg("-m").arg(model);
516 }
517 cmd.arg(prompt);
518 }
519 _ => {
520 cmd.arg(prompt);
521 }
522 }
523 cmd.current_dir(worktree_path);
524
525 for file in changed_files {
527 cmd.arg(file);
528 }
529
530 debug!(
531 agent = %agent_name,
532 command = ?cmd,
533 "spawning review agent"
534 );
535
536 let result = tokio::time::timeout(timeout, cmd.output()).await;
538
539 match result {
540 Ok(Ok(output)) => {
541 let stdout = String::from_utf8_lossy(&output.stdout);
542 let review_output = extract_review_output(&stdout, agent_name, group.category);
543 AgentResult::Success(review_output)
544 }
545 Ok(Err(e)) => AgentResult::Failed {
546 agent_name: agent_name.clone(),
547 reason: format!("command execution failed: {}", e),
548 },
549 Err(_) => AgentResult::Failed {
550 agent_name: agent_name.clone(),
551 reason: "timeout exceeded".to_string(),
552 },
553 }
554}
555
556fn extract_review_output(
560 stdout: &str,
561 agent_name: &str,
562 category: FindingCategory,
563) -> ReviewAgentOutput {
564 let unwrapped = unwrap_opencode_protocol(stdout);
569
570 for line in unwrapped.lines() {
572 let trimmed = line.trim();
573 if trimmed.is_empty() {
574 continue;
575 }
576
577 if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(trimmed) {
579 return output;
580 }
581
582 if trimmed.starts_with("```json") {
584 let json_content = trimmed
585 .strip_prefix("```json")
586 .and_then(|s| s.strip_suffix("```"))
587 .or_else(|| {
588 trimmed
589 .strip_prefix("```json")
590 .map(|s| s.trim_end_matches("```"))
591 });
592
593 if let Some(content) = json_content {
594 let clean_content = content.trim();
595 if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(clean_content) {
596 return output;
597 }
598 }
599 }
600 }
601
602 if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(&unwrapped) {
604 return output;
605 }
606
607 let mut findings = vec![];
609 let _lower = unwrapped.to_lowercase();
610 for line in unwrapped.lines() {
611 let line_lower = line.to_lowercase();
612 if line_lower.contains("critical")
613 || line_lower.contains("vulnerability")
614 || line_lower.contains("cve-")
615 || line_lower.contains("rustsec-")
616 {
617 let severity = if line_lower.contains("critical") {
618 FindingSeverity::Critical
619 } else if line_lower.contains("high") {
620 FindingSeverity::High
621 } else {
622 FindingSeverity::Medium
623 };
624 findings.push(ReviewFinding {
625 file: String::new(),
626 line: 0,
627 severity,
628 category,
629 finding: line.trim().to_string(),
630 confidence: 0.7,
631 suggestion: None,
632 });
633 }
634 }
635
636 if !findings.is_empty() {
637 let count = findings.len();
638 return ReviewAgentOutput {
639 agent: agent_name.to_string(),
640 findings,
641 summary: format!("Extracted {} findings from unstructured output", count),
642 pass: false,
643 };
644 }
645
646 ReviewAgentOutput {
648 agent: agent_name.to_string(),
649 findings: vec![],
650 summary: format!(
651 "No structured output found in agent response. Output length: {} chars",
652 unwrapped.len()
653 ),
654 pass: false,
655 }
656}
657
658fn unwrap_opencode_protocol(stdout: &str) -> String {
667 use serde_json::Value;
668
669 let mut result = String::new();
670 let mut has_protocol = false;
671
672 for line in stdout.lines() {
673 let trimmed = line.trim();
674 if trimmed.is_empty() {
675 continue;
676 }
677
678 if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
679 if val.is_object() {
681 if let Some(text) = val
682 .get("part")
683 .and_then(|p| p.get("text"))
684 .and_then(|t| t.as_str())
685 {
686 has_protocol = true;
687 result.push_str(text);
688 result.push('\n');
689 continue;
690 }
691 if let Some(text) = val.get("text").and_then(|t| t.as_str()) {
693 has_protocol = true;
694 result.push_str(text);
695 result.push('\n');
696 continue;
697 }
698 if let Some(msg_type) = val.get("type").and_then(|t| t.as_str()) {
702 has_protocol = true;
703 let tool_name = val
704 .get("part")
705 .and_then(|p| p.get("tool"))
706 .and_then(|t| t.as_str())
707 .unwrap_or("unknown");
708 let status = val
709 .get("part")
710 .and_then(|p| p.get("state"))
711 .and_then(|s| s.get("status"))
712 .and_then(|s| s.as_str())
713 .unwrap_or("");
714 let input_path = val
715 .get("part")
716 .and_then(|p| p.get("state"))
717 .and_then(|s| s.get("input"))
718 .and_then(|i| {
719 i.get("filePath")
720 .or_else(|| i.get("path"))
721 .or_else(|| i.get("command"))
722 })
723 .and_then(|v| v.as_str())
724 .unwrap_or("");
725 if input_path.is_empty() {
726 result.push_str(&format!("[{}: {}]\n", msg_type, tool_name));
727 } else {
728 result.push_str(&format!(
729 "[{}: {} {} {}]\n",
730 msg_type, tool_name, input_path, status
731 ));
732 }
733 continue;
734 }
735 }
736 }
737
738 result.push_str(trimmed);
740 result.push('\n');
741 }
742
743 if has_protocol {
744 result
745 } else {
746 stdout.to_string()
747 }
748}
749
750fn has_visual_changes(changed_files: &[String]) -> bool {
752 let visual_patterns = get_visual_patterns();
753
754 for file in changed_files {
755 for pattern in &visual_patterns {
756 if glob_matches(file, pattern) {
757 return true;
758 }
759 }
760 }
761
762 false
763}
764
765fn get_visual_patterns() -> Vec<&'static str> {
767 vec![
768 "*.css",
769 "*.scss",
770 "tokens.*",
771 "DESIGN.md",
772 "*.svelte",
773 "*.tsx",
774 "*.vue",
775 "src/components/*",
776 "src/ui/*",
777 "design-system/*",
778 ]
779}
780
781fn glob_matches(file: &str, pattern: &str) -> bool {
784 if file == pattern {
786 return true;
787 }
788
789 if pattern.starts_with("*.") {
791 let ext = &pattern[1..]; if file.ends_with(ext) {
793 return true;
794 }
795 }
796
797 if pattern.ends_with(".*") {
799 let prefix = &pattern[..pattern.len() - 1]; if file.starts_with(prefix) {
801 return true;
802 }
803 }
804
805 if pattern.ends_with("/*") {
807 let prefix = &pattern[..pattern.len() - 1]; if file.starts_with(prefix) {
809 return true;
810 }
811 }
812
813 if pattern.ends_with('/') && file.starts_with(pattern) {
815 return true;
816 }
817
818 false
819}
820
821fn default_groups() -> Vec<ReviewGroupDef> {
823 vec![
824 ReviewGroupDef {
825 agent_name: "security-sentinel".to_string(),
826 category: FindingCategory::Security,
827 llm_tier: "Quick".to_string(),
828 cli_tool: "opencode".to_string(),
829 model: None,
830 prompt_template: "crates/terraphim_orchestrator/prompts/review-security.md".to_string(),
831 prompt_content: PROMPT_SECURITY,
832 visual_only: false,
833 persona: Some("Vigil".to_string()),
834 },
835 ReviewGroupDef {
836 agent_name: "architecture-strategist".to_string(),
837 category: FindingCategory::Architecture,
838 llm_tier: "Deep".to_string(),
839 cli_tool: "claude".to_string(),
840 model: None,
841 prompt_template: "crates/terraphim_orchestrator/prompts/review-architecture.md"
842 .to_string(),
843 prompt_content: PROMPT_ARCHITECTURE,
844 visual_only: false,
845 persona: Some("Carthos".to_string()),
846 },
847 ReviewGroupDef {
848 agent_name: "performance-oracle".to_string(),
849 category: FindingCategory::Performance,
850 llm_tier: "Deep".to_string(),
851 cli_tool: "claude".to_string(),
852 model: None,
853 prompt_template: "crates/terraphim_orchestrator/prompts/review-performance.md"
854 .to_string(),
855 prompt_content: PROMPT_PERFORMANCE,
856 visual_only: false,
857 persona: Some("Ferrox".to_string()),
858 },
859 ReviewGroupDef {
860 agent_name: "rust-reviewer".to_string(),
861 category: FindingCategory::Quality,
862 llm_tier: "Deep".to_string(),
863 cli_tool: "claude".to_string(),
864 model: None,
865 prompt_template: "crates/terraphim_orchestrator/prompts/review-quality.md".to_string(),
866 prompt_content: PROMPT_QUALITY,
867 visual_only: false,
868 persona: Some("Ferrox".to_string()),
869 },
870 ReviewGroupDef {
871 agent_name: "domain-model-reviewer".to_string(),
872 category: FindingCategory::Domain,
873 llm_tier: "Quick".to_string(),
874 cli_tool: "opencode".to_string(),
875 model: None,
876 prompt_template: "crates/terraphim_orchestrator/prompts/review-domain.md".to_string(),
877 prompt_content: PROMPT_DOMAIN,
878 visual_only: false,
879 persona: Some("Carthos".to_string()),
880 },
881 ReviewGroupDef {
882 agent_name: "design-fidelity-reviewer".to_string(),
883 category: FindingCategory::DesignQuality,
884 llm_tier: "Deep".to_string(),
885 cli_tool: "claude".to_string(),
886 model: None,
887 prompt_template: "crates/terraphim_orchestrator/prompts/review-design-quality.md"
888 .to_string(),
889 prompt_content: PROMPT_DESIGN_QUALITY,
890 visual_only: true,
891 persona: Some("Lux".to_string()),
892 },
893 ]
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899 use terraphim_types::FindingSeverity;
900
901 #[test]
904 fn test_visual_file_detection_css() {
905 let files = vec!["styles.css".to_string()];
906 assert!(has_visual_changes(&files));
907 }
908
909 #[test]
910 fn test_visual_file_detection_tsx() {
911 let files = vec!["src/components/Button.tsx".to_string()];
912 assert!(has_visual_changes(&files));
913 }
914
915 #[test]
916 fn test_visual_file_detection_design_md() {
917 let files = vec!["DESIGN.md".to_string()];
918 assert!(has_visual_changes(&files));
919 }
920
921 #[test]
922 fn test_visual_file_detection_rust_only() {
923 let files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
924 assert!(!has_visual_changes(&files));
925 }
926
927 #[test]
928 fn test_visual_file_detection_component_dir() {
929 let files = vec!["src/components/mod.rs".to_string()];
930 assert!(has_visual_changes(&files));
931 }
932
933 #[test]
934 fn test_visual_file_detection_tokens() {
935 let files = vec!["tokens.json".to_string()];
936 assert!(has_visual_changes(&files));
937 }
938
939 #[test]
942 fn test_extract_review_output_valid_json() {
943 let json = r#"{"agent":"test-agent","findings":[],"summary":"All good","pass":true}"#;
944 let output = extract_review_output(json, "test-agent", FindingCategory::Quality);
945 assert_eq!(output.agent, "test-agent");
946 assert!(output.pass);
947 assert_eq!(output.findings.len(), 0);
948 }
949
950 #[test]
951 fn test_extract_review_output_mixed_output() {
952 let mixed = r#"Some log output here
953{"agent":"test-agent","findings":[{"file":"src/lib.rs","line":42,"severity":"high","category":"security","finding":"Test issue","confidence":0.9}],"summary":"Found 1 issue","pass":false}
954More logs..."#;
955 let output = extract_review_output(mixed, "test-agent", FindingCategory::Security);
956 assert_eq!(output.agent, "test-agent");
957 assert!(!output.pass);
958 assert_eq!(output.findings.len(), 1);
959 assert_eq!(output.findings[0].severity, FindingSeverity::High);
960 }
961
962 #[test]
963 fn test_extract_review_output_no_json() {
964 let no_json = "Just some plain text output without JSON";
965 let output = extract_review_output(no_json, "test-agent", FindingCategory::Quality);
966 assert_eq!(output.agent, "test-agent");
967 assert!(!output.pass); assert_eq!(output.findings.len(), 0);
969 }
970
971 #[test]
972 fn test_extract_review_output_markdown_code_block() {
973 let markdown = r#"Here's my review:
974
975```json
976{"agent":"test-agent","findings":[],"summary":"No issues","pass":true}
977```
978
979Done!"#;
980 let output = extract_review_output(markdown, "test-agent", FindingCategory::Quality);
981 assert_eq!(output.agent, "test-agent");
982 assert!(output.pass);
983 }
984
985 #[test]
988 fn test_default_groups_count() {
989 let groups = default_groups();
990 assert_eq!(groups.len(), 6);
991 }
992
993 #[test]
994 fn test_default_groups_one_visual_only() {
995 let groups = default_groups();
996 let visual_only_count = groups.iter().filter(|g| g.visual_only).count();
997 assert_eq!(visual_only_count, 1);
998
999 let visual_group = groups.iter().find(|g| g.visual_only).unwrap();
1001 assert_eq!(visual_group.agent_name, "design-fidelity-reviewer");
1002 assert_eq!(visual_group.category, FindingCategory::DesignQuality);
1003 }
1004
1005 #[test]
1006 fn test_default_groups_categories() {
1007 let groups = default_groups();
1008 let categories: Vec<_> = groups.iter().map(|g| g.category).collect();
1009
1010 assert!(categories.contains(&FindingCategory::Security));
1011 assert!(categories.contains(&FindingCategory::Architecture));
1012 assert!(categories.contains(&FindingCategory::Performance));
1013 assert!(categories.contains(&FindingCategory::Quality));
1014 assert!(categories.contains(&FindingCategory::Domain));
1015 assert!(categories.contains(&FindingCategory::DesignQuality));
1016 }
1017
1018 #[test]
1021 fn test_glob_matches_extension() {
1022 assert!(glob_matches("styles.css", "*.css"));
1023 assert!(glob_matches("app.scss", "*.scss"));
1024 assert!(glob_matches("Component.tsx", "*.tsx"));
1025 assert!(!glob_matches("main.rs", "*.css"));
1026 }
1027
1028 #[test]
1029 fn test_glob_matches_directory() {
1030 assert!(glob_matches("src/components/Button.rs", "src/components/*"));
1031 assert!(glob_matches("src/ui/mod.rs", "src/ui/*"));
1032 assert!(!glob_matches("src/main.rs", "src/components/*"));
1033 }
1034
1035 #[test]
1036 fn test_glob_matches_exact() {
1037 assert!(glob_matches("DESIGN.md", "DESIGN.md"));
1038 assert!(!glob_matches("README.md", "DESIGN.md"));
1039 }
1040
1041 #[test]
1042 fn test_glob_matches_design_system() {
1043 assert!(glob_matches("design-system/tokens.css", "design-system/*"));
1044 assert!(glob_matches(
1045 "design-system/components/button.css",
1046 "design-system/*"
1047 ));
1048 }
1049
1050 #[tokio::test]
1053 async fn test_compound_review_dry_run() {
1054 let swarm_config = SwarmConfig {
1055 groups: default_groups(),
1056 timeout: Duration::from_secs(60),
1057 worktree_root: std::env::temp_dir().join("test-compound-review-worktrees"),
1058 repo_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."),
1059 base_branch: "main".to_string(),
1060 max_concurrent_agents: 3,
1061 create_prs: false,
1062 };
1063
1064 let workflow = CompoundReviewWorkflow::new(swarm_config);
1065 assert!(workflow.is_dry_run());
1066 }
1067
1068 #[tokio::test]
1069 async fn test_get_changed_files_real_repo() {
1070 let swarm_config = SwarmConfig {
1071 groups: default_groups(),
1072 timeout: Duration::from_secs(60),
1073 worktree_root: std::env::temp_dir().join("test-compound-review-worktrees"),
1074 repo_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."),
1075 base_branch: "main".to_string(),
1076 max_concurrent_agents: 3,
1077 create_prs: false,
1078 };
1079
1080 let workflow = CompoundReviewWorkflow::new(swarm_config);
1081
1082 let result = workflow.get_changed_files("HEAD", "HEAD~1").await;
1084
1085 match result {
1087 Ok(files) => {
1088 for file in &files {
1090 assert!(!file.is_empty());
1091 }
1092 }
1093 Err(_) => {
1094 }
1096 }
1097 }
1098
1099 #[test]
1100 fn test_swarm_config_from_compound_config() {
1101 let compound_config = CompoundReviewConfig {
1102 schedule: "0 2 * * *".to_string(),
1103 max_duration_secs: 1800,
1104 repo_path: PathBuf::from("/tmp/repo"),
1105 create_prs: false,
1106 worktree_root: PathBuf::from("/tmp/worktrees"),
1107 base_branch: "main".to_string(),
1108 max_concurrent_agents: 3,
1109 cli_tool: None,
1110 provider: None,
1111 model: None,
1112 ..Default::default()
1113 };
1114
1115 let swarm_config = SwarmConfig::from_compound_config(&compound_config);
1116
1117 assert_eq!(swarm_config.repo_path, PathBuf::from("/tmp/repo"));
1118 assert_eq!(swarm_config.worktree_root, PathBuf::from("/tmp/worktrees"));
1119 assert_eq!(swarm_config.base_branch, "main");
1120 assert_eq!(swarm_config.max_concurrent_agents, 3);
1121 assert!(!swarm_config.create_prs);
1122 assert_eq!(swarm_config.groups.len(), 6);
1123 }
1124
1125 #[test]
1126 fn test_compound_review_result_structure() {
1127 let result = CompoundReviewResult {
1128 correlation_id: Uuid::new_v4(),
1129 findings: vec![],
1130 agent_outputs: vec![],
1131 pass: true,
1132 duration: Duration::from_secs(10),
1133 agents_run: 6,
1134 agents_failed: 0,
1135 };
1136
1137 assert!(result.pass);
1138 assert_eq!(result.agents_run, 6);
1139 assert_eq!(result.agents_failed, 0);
1140 }
1141
1142 #[test]
1145 fn test_review_security_contains_vigil() {
1146 let prompt = include_str!("../prompts/review-security.md");
1147 assert!(
1148 prompt.contains("Vigil"),
1149 "review-security.md should contain 'Vigil'"
1150 );
1151 assert!(
1152 prompt.contains("Security Engineer"),
1153 "review-security.md should mention Security Engineer"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_review_architecture_contains_carthos() {
1159 let prompt = include_str!("../prompts/review-architecture.md");
1160 assert!(
1161 prompt.contains("Carthos"),
1162 "review-architecture.md should contain 'Carthos'"
1163 );
1164 assert!(
1165 prompt.contains("Domain Architect"),
1166 "review-architecture.md should mention Domain Architect"
1167 );
1168 }
1169
1170 #[test]
1171 fn test_review_quality_contains_ferrox() {
1172 let prompt = include_str!("../prompts/review-quality.md");
1173 assert!(
1174 prompt.contains("Ferrox"),
1175 "review-quality.md should contain 'Ferrox'"
1176 );
1177 assert!(
1178 prompt.contains("Rust Engineer"),
1179 "review-quality.md should mention Rust Engineer"
1180 );
1181 }
1182
1183 #[test]
1184 fn test_review_performance_contains_ferrox() {
1185 let prompt = include_str!("../prompts/review-performance.md");
1186 assert!(
1187 prompt.contains("Ferrox"),
1188 "review-performance.md should contain 'Ferrox'"
1189 );
1190 assert!(
1191 prompt.contains("Rust Engineer"),
1192 "review-performance.md should mention Rust Engineer"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_review_domain_contains_carthos() {
1198 let prompt = include_str!("../prompts/review-domain.md");
1199 assert!(
1200 prompt.contains("Carthos"),
1201 "review-domain.md should contain 'Carthos'"
1202 );
1203 assert!(
1204 prompt.contains("Domain Architect"),
1205 "review-domain.md should mention Domain Architect"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_review_design_contains_lux() {
1211 let prompt = include_str!("../prompts/review-design-quality.md");
1212 assert!(
1213 prompt.contains("Lux"),
1214 "review-design-quality.md should contain 'Lux'"
1215 );
1216 assert!(
1217 prompt.contains("TypeScript Engineer"),
1218 "review-design-quality.md should mention TypeScript Engineer"
1219 );
1220 }
1221
1222 #[test]
1223 fn test_default_groups_all_have_persona() {
1224 let groups = default_groups();
1225 for group in &groups {
1226 assert!(
1227 group.persona.is_some(),
1228 "Group '{}' should have a persona set",
1229 group.agent_name
1230 );
1231 }
1232
1233 let vigil = groups
1235 .iter()
1236 .find(|g| g.agent_name == "security-sentinel")
1237 .unwrap();
1238 assert_eq!(vigil.persona.as_ref().unwrap(), "Vigil");
1239
1240 let carthos_arch = groups
1241 .iter()
1242 .find(|g| g.agent_name == "architecture-strategist")
1243 .unwrap();
1244 assert_eq!(carthos_arch.persona.as_ref().unwrap(), "Carthos");
1245
1246 let ferrox_perf = groups
1247 .iter()
1248 .find(|g| g.agent_name == "performance-oracle")
1249 .unwrap();
1250 assert_eq!(ferrox_perf.persona.as_ref().unwrap(), "Ferrox");
1251
1252 let ferrox_qual = groups
1253 .iter()
1254 .find(|g| g.agent_name == "rust-reviewer")
1255 .unwrap();
1256 assert_eq!(ferrox_qual.persona.as_ref().unwrap(), "Ferrox");
1257
1258 let carthos_domain = groups
1259 .iter()
1260 .find(|g| g.agent_name == "domain-model-reviewer")
1261 .unwrap();
1262 assert_eq!(carthos_domain.persona.as_ref().unwrap(), "Carthos");
1263
1264 let lux = groups
1265 .iter()
1266 .find(|g| g.agent_name == "design-fidelity-reviewer")
1267 .unwrap();
1268 assert_eq!(lux.persona.as_ref().unwrap(), "Lux");
1269 }
1270
1271 #[test]
1272 fn test_extract_review_output_with_persona_agent_name() {
1273 let json = r#"{"agent":"Vigil-security-sentinel","findings":[{"file":"src/lib.rs","line":42,"severity":"high","category":"security","finding":"Test issue","confidence":0.9}],"summary":"Found 1 security issue","pass":false}"#;
1275 let output =
1276 extract_review_output(json, "Vigil-security-sentinel", FindingCategory::Security);
1277 assert_eq!(output.agent, "Vigil-security-sentinel");
1278 assert!(!output.pass);
1279 assert_eq!(output.findings.len(), 1);
1280 }
1281
1282 #[test]
1287 fn test_compound_config_cli_tool_override() {
1288 let config = CompoundReviewConfig {
1289 schedule: "0 2 * * *".to_string(),
1290 max_duration_secs: 1800,
1291 repo_path: PathBuf::from("/tmp"),
1292 create_prs: false,
1293 worktree_root: PathBuf::from("/tmp/worktrees"),
1294 base_branch: "main".to_string(),
1295 max_concurrent_agents: 3,
1296 cli_tool: Some("/home/alex/.bun/bin/opencode".to_string()),
1297 provider: Some("opencode-go".to_string()),
1298 model: Some("glm-5".to_string()),
1299 ..Default::default()
1300 };
1301 let swarm = SwarmConfig::from_compound_config(&config);
1302 for group in &swarm.groups {
1303 assert_eq!(group.cli_tool, "/home/alex/.bun/bin/opencode");
1304 assert_eq!(group.model, Some("opencode-go/glm-5".to_string()));
1305 }
1306 }
1307
1308 #[test]
1309 fn test_compound_config_no_override() {
1310 let config = CompoundReviewConfig {
1311 schedule: "0 2 * * *".to_string(),
1312 max_duration_secs: 1800,
1313 repo_path: PathBuf::from("/tmp"),
1314 create_prs: false,
1315 worktree_root: PathBuf::from("/tmp/worktrees"),
1316 base_branch: "main".to_string(),
1317 max_concurrent_agents: 3,
1318 cli_tool: None,
1319 provider: None,
1320 model: None,
1321 ..Default::default()
1322 };
1323 let swarm = SwarmConfig::from_compound_config(&config);
1324 assert_eq!(swarm.groups[0].cli_tool, "opencode");
1326 assert!(swarm.groups[0].model.is_none());
1327 }
1328
1329 #[test]
1332 fn test_unwrap_opencode_protocol_formats_tool_use() {
1333 let protocol_output = r#"{"type":"text","part":{"type":"text","text":"Starting review..."}}
1338{"type":"tool_use","timestamp":1775340045267,"sessionID":"ses_abc","part":{"id":"prt_123","tool":"read","state":{"status":"completed","input":{"filePath":"/tmp/test.rs"},"output":"fn critical_path() { }"}}}
1339{"type":"text","part":{"type":"text","text":"Review complete. No issues found."}}"#;
1340
1341 let unwrapped = unwrap_opencode_protocol(protocol_output);
1342 assert!(
1344 !unwrapped.contains("critical_path"),
1345 "tool_use payload content should not leak through"
1346 );
1347 assert!(
1349 unwrapped.contains("[tool_use: read /tmp/test.rs completed]"),
1350 "tool_use should be formatted as summary, got: {}",
1351 unwrapped
1352 );
1353 assert!(unwrapped.contains("Starting review..."));
1354 assert!(unwrapped.contains("Review complete."));
1355 }
1356
1357 #[test]
1358 fn test_extract_review_output_no_false_critical_from_tool_use() {
1359 let protocol_output = r#"{"type":"text","part":{"type":"text","text":"Reviewing code..."}}
1362{"type":"tool_use","part":{"tool":"read","state":{"output":"FindingSeverity::Critical is used here"}}}
1363{"type":"text","part":{"type":"text","text":"All looks good, no issues."}}"#;
1364
1365 let output =
1366 extract_review_output(protocol_output, "test-agent", FindingCategory::Security);
1367 assert_eq!(
1369 output.findings.len(),
1370 0,
1371 "tool_use payloads must not generate synthetic findings"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_compound_config_timeout_uses_max_duration() {
1377 let config = CompoundReviewConfig {
1378 schedule: "0 2 * * *".to_string(),
1379 max_duration_secs: 900,
1380 repo_path: PathBuf::from("/tmp"),
1381 create_prs: false,
1382 worktree_root: PathBuf::from("/tmp/worktrees"),
1383 base_branch: "main".to_string(),
1384 max_concurrent_agents: 3,
1385 cli_tool: None,
1386 provider: None,
1387 model: None,
1388 ..Default::default()
1389 };
1390 let swarm = SwarmConfig::from_compound_config(&config);
1391 assert_eq!(swarm.timeout, Duration::from_secs(900));
1392 }
1393}