1use std::path::{Path, PathBuf};
2use std::time::{Duration, Instant};
3
4use tokio::task::JoinSet;
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, WORKTREE_REVIEW_PREFIX};
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 fn worktree_manager(&self) -> &WorktreeManager {
262 &self.worktree_manager
263 }
264
265 pub async fn run(
274 &self,
275 git_ref: &str,
276 base_ref: &str,
277 ) -> Result<CompoundReviewResult, OrchestratorError> {
278 let start = Instant::now();
279 let correlation_id = Uuid::new_v4();
280
281 info!(
282 correlation_id = %correlation_id,
283 git_ref = %git_ref,
284 base_ref = %base_ref,
285 "starting compound review swarm"
286 );
287
288 let changed_files = self.get_changed_files(git_ref, base_ref).await?;
290 debug!(count = changed_files.len(), "found changed files");
291
292 let has_visual = has_visual_changes(&changed_files);
294 let active_groups: Vec<&ReviewGroupDef> = self
295 .config
296 .groups
297 .iter()
298 .filter(|g| !g.visual_only || has_visual)
299 .collect();
300
301 info!(
302 total_groups = self.config.groups.len(),
303 active_groups = active_groups.len(),
304 has_visual_changes = has_visual,
305 "filtered review groups"
306 );
307
308 let worktree_name = format!("{}{}", WORKTREE_REVIEW_PREFIX, correlation_id);
334 let guard = self
335 .worktree_manager
336 .create_worktree(&worktree_name, git_ref)
337 .await
338 .map_err(|e| {
339 OrchestratorError::CompoundReviewFailed(format!("failed to create worktree: {}", e))
340 })?;
341 let worktree_path = guard.path().to_path_buf();
342
343 let mut tasks: JoinSet<AgentResult> = JoinSet::new();
344
345 let mut spawned_count = 0;
349 for group in active_groups {
350 let group = group.clone();
351 let worktree_path_task = worktree_path.clone();
352 let changed_files = changed_files.clone();
353 let timeout = self.config.timeout;
354 let cli_tool = group.cli_tool.clone();
355
356 tasks.spawn(async move {
357 run_single_agent(
358 &group,
359 &worktree_path_task,
360 &changed_files,
361 correlation_id,
362 timeout,
363 &cli_tool,
364 )
365 .await
366 });
367 spawned_count += 1;
368 }
369
370 let mut agent_outputs = Vec::new();
372 let mut failed_count = 0;
373 let collect_deadline =
374 tokio::time::Instant::now() + self.config.timeout + Duration::from_secs(10);
375
376 loop {
377 match tokio::time::timeout_at(collect_deadline, tasks.join_next()).await {
378 Ok(Some(Ok(result))) => match result {
379 AgentResult::Success(output) => {
380 info!(agent = %output.agent, findings = output.findings.len(), "agent completed");
381 agent_outputs.push(output);
382 }
383 AgentResult::Failed { agent_name, reason } => {
384 warn!(agent = %agent_name, error = %reason, "agent failed");
385 failed_count += 1;
386 agent_outputs.push(ReviewAgentOutput {
387 agent: agent_name,
388 findings: vec![],
389 summary: format!("Agent failed: {}", reason),
390 pass: false,
391 });
392 }
393 },
394 Ok(Some(Err(join_err))) => {
395 warn!(error = %join_err, "agent task aborted or panicked");
396 failed_count += 1;
397 }
398 Ok(None) => break, Err(_) => {
400 warn!("collection deadline exceeded, using partial results");
401 break;
402 }
403 }
404 }
405
406 let _ = &guard;
412
413 let all_findings: Vec<ReviewFinding> = agent_outputs
415 .iter()
416 .flat_map(|o| o.findings.clone())
417 .collect();
418 let deduplicated = terraphim_types::deduplicate_findings(all_findings);
419
420 let pass = agent_outputs.iter().all(|o| o.pass) && failed_count == 0;
422
423 let duration = start.elapsed();
424 info!(
425 correlation_id = %correlation_id,
426 agents_run = spawned_count,
427 agents_failed = failed_count,
428 total_findings = deduplicated.len(),
429 pass = %pass,
430 duration = ?duration,
431 "compound review completed"
432 );
433
434 Ok(CompoundReviewResult {
435 correlation_id,
436 findings: deduplicated,
437 agent_outputs,
438 pass,
439 duration,
440 agents_run: spawned_count,
441 agents_failed: failed_count,
442 })
443 }
444
445 pub fn default_groups() -> Vec<ReviewGroupDef> {
447 default_groups()
448 }
449
450 pub fn has_visual_changes(changed_files: &[String]) -> bool {
452 has_visual_changes(changed_files)
453 }
454
455 pub fn extract_review_output(
457 stdout: &str,
458 agent_name: &str,
459 category: FindingCategory,
460 ) -> ReviewAgentOutput {
461 extract_review_output(stdout, agent_name, category)
462 }
463
464 async fn get_changed_files(
466 &self,
467 git_ref: &str,
468 base_ref: &str,
469 ) -> Result<Vec<String>, OrchestratorError> {
470 let output = tokio::process::Command::new("git")
471 .args([
472 "-C",
473 self.config.repo_path.to_str().unwrap_or("."),
474 "diff",
475 "--name-only",
476 base_ref,
477 git_ref,
478 ])
479 .env_remove("GIT_INDEX_FILE")
480 .output()
481 .await
482 .map_err(|e| {
483 OrchestratorError::CompoundReviewFailed(format!("git diff failed: {}", e))
484 })?;
485
486 if !output.status.success() {
487 let stderr = String::from_utf8_lossy(&output.stderr);
488 return Err(OrchestratorError::CompoundReviewFailed(format!(
489 "git diff returned non-zero: {}",
490 stderr
491 )));
492 }
493
494 let stdout = String::from_utf8_lossy(&output.stdout);
495 let files: Vec<String> = stdout
496 .lines()
497 .filter(|line| !line.trim().is_empty())
498 .map(|line| line.to_string())
499 .collect();
500
501 Ok(files)
502 }
503
504 pub fn is_dry_run(&self) -> bool {
506 !self.config.create_prs
507 }
508}
509
510enum AgentResult {
512 Success(ReviewAgentOutput),
513 Failed { agent_name: String, reason: String },
514}
515
516async fn run_single_agent(
518 group: &ReviewGroupDef,
519 worktree_path: &Path,
520 changed_files: &[String],
521 _correlation_id: Uuid,
522 timeout: Duration,
523 cli_tool: &str,
524) -> AgentResult {
525 let agent_name = &group.agent_name;
526
527 let prompt = group.prompt_content;
529
530 let mut cmd = tokio::process::Command::new(cli_tool);
532
533 cmd.kill_on_drop(true);
541
542 let cli_name = std::path::Path::new(cli_tool)
544 .file_name()
545 .and_then(|n| n.to_str())
546 .unwrap_or(cli_tool);
547
548 match cli_name {
549 "opencode" => {
550 cmd.arg("run").arg("--format").arg("json");
551 if let Some(ref model) = group.model {
552 cmd.arg("-m").arg(model);
553 }
554 cmd.arg(prompt);
555 }
556 "claude" | "claude-code" => {
557 cmd.arg("-p").arg(prompt);
558 if let Some(ref model) = group.model {
559 cmd.arg("--model").arg(model);
560 }
561 }
562 "codex" => {
563 cmd.arg("exec").arg("--full-auto");
564 if let Some(ref model) = group.model {
565 cmd.arg("-m").arg(model);
566 }
567 cmd.arg(prompt);
568 }
569 _ => {
570 cmd.arg(prompt);
571 }
572 }
573 cmd.current_dir(worktree_path);
574
575 for file in changed_files {
577 cmd.arg(file);
578 }
579
580 debug!(
581 agent = %agent_name,
582 command = ?cmd,
583 "spawning review agent"
584 );
585
586 let result = tokio::time::timeout(timeout, cmd.output()).await;
588
589 match result {
590 Ok(Ok(output)) => {
591 let stdout = String::from_utf8_lossy(&output.stdout);
592 let review_output = extract_review_output(&stdout, agent_name, group.category);
593 AgentResult::Success(review_output)
594 }
595 Ok(Err(e)) => AgentResult::Failed {
596 agent_name: agent_name.clone(),
597 reason: format!("command execution failed: {}", e),
598 },
599 Err(_) => AgentResult::Failed {
600 agent_name: agent_name.clone(),
601 reason: "timeout exceeded".to_string(),
602 },
603 }
604}
605
606fn extract_review_output(
610 stdout: &str,
611 agent_name: &str,
612 category: FindingCategory,
613) -> ReviewAgentOutput {
614 let unwrapped = unwrap_opencode_protocol(stdout);
619
620 for line in unwrapped.lines() {
622 let trimmed = line.trim();
623 if trimmed.is_empty() {
624 continue;
625 }
626
627 if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(trimmed) {
629 return output;
630 }
631
632 if trimmed.starts_with("```json") {
634 let json_content = trimmed
635 .strip_prefix("```json")
636 .and_then(|s| s.strip_suffix("```"))
637 .or_else(|| {
638 trimmed
639 .strip_prefix("```json")
640 .map(|s| s.trim_end_matches("```"))
641 });
642
643 if let Some(content) = json_content {
644 let clean_content = content.trim();
645 if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(clean_content) {
646 return output;
647 }
648 }
649 }
650 }
651
652 if let Ok(output) = serde_json::from_str::<ReviewAgentOutput>(&unwrapped) {
654 return output;
655 }
656
657 let mut findings = vec![];
659 let _lower = unwrapped.to_lowercase();
660 for line in unwrapped.lines() {
661 let line_lower = line.to_lowercase();
662 if line_lower.contains("critical")
663 || line_lower.contains("vulnerability")
664 || line_lower.contains("cve-")
665 || line_lower.contains("rustsec-")
666 {
667 let severity = if line_lower.contains("critical") {
668 FindingSeverity::Critical
669 } else if line_lower.contains("high") {
670 FindingSeverity::High
671 } else {
672 FindingSeverity::Medium
673 };
674 findings.push(ReviewFinding {
675 file: String::new(),
676 line: 0,
677 severity,
678 category,
679 finding: line.trim().to_string(),
680 confidence: 0.7,
681 suggestion: None,
682 });
683 }
684 }
685
686 if !findings.is_empty() {
687 let count = findings.len();
688 return ReviewAgentOutput {
689 agent: agent_name.to_string(),
690 findings,
691 summary: format!("Extracted {} findings from unstructured output", count),
692 pass: false,
693 };
694 }
695
696 ReviewAgentOutput {
698 agent: agent_name.to_string(),
699 findings: vec![],
700 summary: format!(
701 "No structured output found in agent response. Output length: {} chars",
702 unwrapped.len()
703 ),
704 pass: false,
705 }
706}
707
708fn unwrap_opencode_protocol(stdout: &str) -> String {
717 use serde_json::Value;
718
719 let mut result = String::new();
720 let mut has_protocol = false;
721
722 for line in stdout.lines() {
723 let trimmed = line.trim();
724 if trimmed.is_empty() {
725 continue;
726 }
727
728 if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
729 if val.is_object() {
731 if let Some(text) = val
732 .get("part")
733 .and_then(|p| p.get("text"))
734 .and_then(|t| t.as_str())
735 {
736 has_protocol = true;
737 result.push_str(text);
738 result.push('\n');
739 continue;
740 }
741 if let Some(text) = val.get("text").and_then(|t| t.as_str()) {
743 has_protocol = true;
744 result.push_str(text);
745 result.push('\n');
746 continue;
747 }
748 if let Some(msg_type) = val.get("type").and_then(|t| t.as_str()) {
752 has_protocol = true;
753 let tool_name = val
754 .get("part")
755 .and_then(|p| p.get("tool"))
756 .and_then(|t| t.as_str())
757 .unwrap_or("unknown");
758 let status = val
759 .get("part")
760 .and_then(|p| p.get("state"))
761 .and_then(|s| s.get("status"))
762 .and_then(|s| s.as_str())
763 .unwrap_or("");
764 let input_path = val
765 .get("part")
766 .and_then(|p| p.get("state"))
767 .and_then(|s| s.get("input"))
768 .and_then(|i| {
769 i.get("filePath")
770 .or_else(|| i.get("path"))
771 .or_else(|| i.get("command"))
772 })
773 .and_then(|v| v.as_str())
774 .unwrap_or("");
775 if input_path.is_empty() {
776 result.push_str(&format!("[{}: {}]\n", msg_type, tool_name));
777 } else {
778 result.push_str(&format!(
779 "[{}: {} {} {}]\n",
780 msg_type, tool_name, input_path, status
781 ));
782 }
783 continue;
784 }
785 }
786 }
787
788 result.push_str(trimmed);
790 result.push('\n');
791 }
792
793 if has_protocol {
794 result
795 } else {
796 stdout.to_string()
797 }
798}
799
800fn has_visual_changes(changed_files: &[String]) -> bool {
802 let visual_patterns = get_visual_patterns();
803
804 for file in changed_files {
805 for pattern in &visual_patterns {
806 if glob_matches(file, pattern) {
807 return true;
808 }
809 }
810 }
811
812 false
813}
814
815fn get_visual_patterns() -> Vec<&'static str> {
817 vec![
818 "*.css",
819 "*.scss",
820 "tokens.*",
821 "DESIGN.md",
822 "*.svelte",
823 "*.tsx",
824 "*.vue",
825 "src/components/*",
826 "src/ui/*",
827 "design-system/*",
828 ]
829}
830
831fn glob_matches(file: &str, pattern: &str) -> bool {
834 if file == pattern {
836 return true;
837 }
838
839 if pattern.starts_with("*.") {
841 let ext = &pattern[1..]; if file.ends_with(ext) {
843 return true;
844 }
845 }
846
847 if pattern.ends_with(".*") {
849 let prefix = &pattern[..pattern.len() - 1]; if file.starts_with(prefix) {
851 return true;
852 }
853 }
854
855 if pattern.ends_with("/*") {
857 let prefix = &pattern[..pattern.len() - 1]; if file.starts_with(prefix) {
859 return true;
860 }
861 }
862
863 if pattern.ends_with('/') && file.starts_with(pattern) {
865 return true;
866 }
867
868 false
869}
870
871fn default_groups() -> Vec<ReviewGroupDef> {
873 vec![
874 ReviewGroupDef {
875 agent_name: "security-sentinel".to_string(),
876 category: FindingCategory::Security,
877 llm_tier: "Quick".to_string(),
878 cli_tool: "opencode".to_string(),
879 model: None,
880 prompt_template: "crates/terraphim_orchestrator/prompts/review-security.md".to_string(),
881 prompt_content: PROMPT_SECURITY,
882 visual_only: false,
883 persona: Some("Vigil".to_string()),
884 },
885 ReviewGroupDef {
886 agent_name: "architecture-strategist".to_string(),
887 category: FindingCategory::Architecture,
888 llm_tier: "Deep".to_string(),
889 cli_tool: "claude".to_string(),
890 model: None,
891 prompt_template: "crates/terraphim_orchestrator/prompts/review-architecture.md"
892 .to_string(),
893 prompt_content: PROMPT_ARCHITECTURE,
894 visual_only: false,
895 persona: Some("Carthos".to_string()),
896 },
897 ReviewGroupDef {
898 agent_name: "performance-oracle".to_string(),
899 category: FindingCategory::Performance,
900 llm_tier: "Deep".to_string(),
901 cli_tool: "claude".to_string(),
902 model: None,
903 prompt_template: "crates/terraphim_orchestrator/prompts/review-performance.md"
904 .to_string(),
905 prompt_content: PROMPT_PERFORMANCE,
906 visual_only: false,
907 persona: Some("Ferrox".to_string()),
908 },
909 ReviewGroupDef {
910 agent_name: "rust-reviewer".to_string(),
911 category: FindingCategory::Quality,
912 llm_tier: "Deep".to_string(),
913 cli_tool: "claude".to_string(),
914 model: None,
915 prompt_template: "crates/terraphim_orchestrator/prompts/review-quality.md".to_string(),
916 prompt_content: PROMPT_QUALITY,
917 visual_only: false,
918 persona: Some("Ferrox".to_string()),
919 },
920 ReviewGroupDef {
921 agent_name: "domain-model-reviewer".to_string(),
922 category: FindingCategory::Domain,
923 llm_tier: "Quick".to_string(),
924 cli_tool: "opencode".to_string(),
925 model: None,
926 prompt_template: "crates/terraphim_orchestrator/prompts/review-domain.md".to_string(),
927 prompt_content: PROMPT_DOMAIN,
928 visual_only: false,
929 persona: Some("Carthos".to_string()),
930 },
931 ReviewGroupDef {
932 agent_name: "design-fidelity-reviewer".to_string(),
933 category: FindingCategory::DesignQuality,
934 llm_tier: "Deep".to_string(),
935 cli_tool: "claude".to_string(),
936 model: None,
937 prompt_template: "crates/terraphim_orchestrator/prompts/review-design-quality.md"
938 .to_string(),
939 prompt_content: PROMPT_DESIGN_QUALITY,
940 visual_only: true,
941 persona: Some("Lux".to_string()),
942 },
943 ]
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949 use terraphim_types::FindingSeverity;
950
951 #[test]
954 fn test_visual_file_detection_css() {
955 let files = vec!["styles.css".to_string()];
956 assert!(has_visual_changes(&files));
957 }
958
959 #[test]
960 fn test_visual_file_detection_tsx() {
961 let files = vec!["src/components/Button.tsx".to_string()];
962 assert!(has_visual_changes(&files));
963 }
964
965 #[test]
966 fn test_visual_file_detection_design_md() {
967 let files = vec!["DESIGN.md".to_string()];
968 assert!(has_visual_changes(&files));
969 }
970
971 #[test]
972 fn test_visual_file_detection_rust_only() {
973 let files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
974 assert!(!has_visual_changes(&files));
975 }
976
977 #[test]
978 fn test_visual_file_detection_component_dir() {
979 let files = vec!["src/components/mod.rs".to_string()];
980 assert!(has_visual_changes(&files));
981 }
982
983 #[test]
984 fn test_visual_file_detection_tokens() {
985 let files = vec!["tokens.json".to_string()];
986 assert!(has_visual_changes(&files));
987 }
988
989 #[test]
992 fn test_extract_review_output_valid_json() {
993 let json = r#"{"agent":"test-agent","findings":[],"summary":"All good","pass":true}"#;
994 let output = extract_review_output(json, "test-agent", FindingCategory::Quality);
995 assert_eq!(output.agent, "test-agent");
996 assert!(output.pass);
997 assert_eq!(output.findings.len(), 0);
998 }
999
1000 #[test]
1001 fn test_extract_review_output_mixed_output() {
1002 let mixed = r#"Some log output here
1003{"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}
1004More logs..."#;
1005 let output = extract_review_output(mixed, "test-agent", FindingCategory::Security);
1006 assert_eq!(output.agent, "test-agent");
1007 assert!(!output.pass);
1008 assert_eq!(output.findings.len(), 1);
1009 assert_eq!(output.findings[0].severity, FindingSeverity::High);
1010 }
1011
1012 #[test]
1013 fn test_extract_review_output_no_json() {
1014 let no_json = "Just some plain text output without JSON";
1015 let output = extract_review_output(no_json, "test-agent", FindingCategory::Quality);
1016 assert_eq!(output.agent, "test-agent");
1017 assert!(!output.pass); assert_eq!(output.findings.len(), 0);
1019 }
1020
1021 #[test]
1022 fn test_extract_review_output_markdown_code_block() {
1023 let markdown = r#"Here's my review:
1024
1025```json
1026{"agent":"test-agent","findings":[],"summary":"No issues","pass":true}
1027```
1028
1029Done!"#;
1030 let output = extract_review_output(markdown, "test-agent", FindingCategory::Quality);
1031 assert_eq!(output.agent, "test-agent");
1032 assert!(output.pass);
1033 }
1034
1035 #[test]
1038 fn test_default_groups_count() {
1039 let groups = default_groups();
1040 assert_eq!(groups.len(), 6);
1041 }
1042
1043 #[test]
1044 fn test_default_groups_one_visual_only() {
1045 let groups = default_groups();
1046 let visual_only_count = groups.iter().filter(|g| g.visual_only).count();
1047 assert_eq!(visual_only_count, 1);
1048
1049 let visual_group = groups.iter().find(|g| g.visual_only).unwrap();
1051 assert_eq!(visual_group.agent_name, "design-fidelity-reviewer");
1052 assert_eq!(visual_group.category, FindingCategory::DesignQuality);
1053 }
1054
1055 #[test]
1056 fn test_default_groups_categories() {
1057 let groups = default_groups();
1058 let categories: Vec<_> = groups.iter().map(|g| g.category).collect();
1059
1060 assert!(categories.contains(&FindingCategory::Security));
1061 assert!(categories.contains(&FindingCategory::Architecture));
1062 assert!(categories.contains(&FindingCategory::Performance));
1063 assert!(categories.contains(&FindingCategory::Quality));
1064 assert!(categories.contains(&FindingCategory::Domain));
1065 assert!(categories.contains(&FindingCategory::DesignQuality));
1066 }
1067
1068 #[test]
1071 fn test_glob_matches_extension() {
1072 assert!(glob_matches("styles.css", "*.css"));
1073 assert!(glob_matches("app.scss", "*.scss"));
1074 assert!(glob_matches("Component.tsx", "*.tsx"));
1075 assert!(!glob_matches("main.rs", "*.css"));
1076 }
1077
1078 #[test]
1079 fn test_glob_matches_directory() {
1080 assert!(glob_matches("src/components/Button.rs", "src/components/*"));
1081 assert!(glob_matches("src/ui/mod.rs", "src/ui/*"));
1082 assert!(!glob_matches("src/main.rs", "src/components/*"));
1083 }
1084
1085 #[test]
1086 fn test_glob_matches_exact() {
1087 assert!(glob_matches("DESIGN.md", "DESIGN.md"));
1088 assert!(!glob_matches("README.md", "DESIGN.md"));
1089 }
1090
1091 #[test]
1092 fn test_glob_matches_design_system() {
1093 assert!(glob_matches("design-system/tokens.css", "design-system/*"));
1094 assert!(glob_matches(
1095 "design-system/components/button.css",
1096 "design-system/*"
1097 ));
1098 }
1099
1100 #[tokio::test]
1103 async fn test_compound_review_dry_run() {
1104 let swarm_config = SwarmConfig {
1105 groups: default_groups(),
1106 timeout: Duration::from_secs(60),
1107 worktree_root: std::env::temp_dir().join("test-compound-review-worktrees"),
1108 repo_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."),
1109 base_branch: "main".to_string(),
1110 max_concurrent_agents: 3,
1111 create_prs: false,
1112 };
1113
1114 let workflow = CompoundReviewWorkflow::new(swarm_config);
1115 assert!(workflow.is_dry_run());
1116 }
1117
1118 #[tokio::test]
1119 async fn test_get_changed_files_real_repo() {
1120 let swarm_config = SwarmConfig {
1121 groups: default_groups(),
1122 timeout: Duration::from_secs(60),
1123 worktree_root: std::env::temp_dir().join("test-compound-review-worktrees"),
1124 repo_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."),
1125 base_branch: "main".to_string(),
1126 max_concurrent_agents: 3,
1127 create_prs: false,
1128 };
1129
1130 let workflow = CompoundReviewWorkflow::new(swarm_config);
1131
1132 let result = workflow.get_changed_files("HEAD", "HEAD~1").await;
1134
1135 match result {
1137 Ok(files) => {
1138 for file in &files {
1140 assert!(!file.is_empty());
1141 }
1142 }
1143 Err(_) => {
1144 }
1146 }
1147 }
1148
1149 #[test]
1150 fn test_swarm_config_from_compound_config() {
1151 let compound_config = CompoundReviewConfig {
1152 schedule: "0 2 * * *".to_string(),
1153 max_duration_secs: 1800,
1154 repo_path: PathBuf::from("/tmp/repo"),
1155 create_prs: false,
1156 worktree_root: PathBuf::from("/tmp/worktrees"),
1157 base_branch: "main".to_string(),
1158 max_concurrent_agents: 3,
1159 cli_tool: None,
1160 provider: None,
1161 model: None,
1162 ..Default::default()
1163 };
1164
1165 let swarm_config = SwarmConfig::from_compound_config(&compound_config);
1166
1167 assert_eq!(swarm_config.repo_path, PathBuf::from("/tmp/repo"));
1168 assert_eq!(swarm_config.worktree_root, PathBuf::from("/tmp/worktrees"));
1169 assert_eq!(swarm_config.base_branch, "main");
1170 assert_eq!(swarm_config.max_concurrent_agents, 3);
1171 assert!(!swarm_config.create_prs);
1172 assert_eq!(swarm_config.groups.len(), 6);
1173 }
1174
1175 #[test]
1176 fn test_compound_review_result_structure() {
1177 let result = CompoundReviewResult {
1178 correlation_id: Uuid::new_v4(),
1179 findings: vec![],
1180 agent_outputs: vec![],
1181 pass: true,
1182 duration: Duration::from_secs(10),
1183 agents_run: 6,
1184 agents_failed: 0,
1185 };
1186
1187 assert!(result.pass);
1188 assert_eq!(result.agents_run, 6);
1189 assert_eq!(result.agents_failed, 0);
1190 }
1191
1192 #[test]
1195 fn test_review_security_contains_vigil() {
1196 let prompt = include_str!("../prompts/review-security.md");
1197 assert!(
1198 prompt.contains("Vigil"),
1199 "review-security.md should contain 'Vigil'"
1200 );
1201 assert!(
1202 prompt.contains("Security Engineer"),
1203 "review-security.md should mention Security Engineer"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_review_architecture_contains_carthos() {
1209 let prompt = include_str!("../prompts/review-architecture.md");
1210 assert!(
1211 prompt.contains("Carthos"),
1212 "review-architecture.md should contain 'Carthos'"
1213 );
1214 assert!(
1215 prompt.contains("Domain Architect"),
1216 "review-architecture.md should mention Domain Architect"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_review_quality_contains_ferrox() {
1222 let prompt = include_str!("../prompts/review-quality.md");
1223 assert!(
1224 prompt.contains("Ferrox"),
1225 "review-quality.md should contain 'Ferrox'"
1226 );
1227 assert!(
1228 prompt.contains("Rust Engineer"),
1229 "review-quality.md should mention Rust Engineer"
1230 );
1231 }
1232
1233 #[test]
1234 fn test_review_performance_contains_ferrox() {
1235 let prompt = include_str!("../prompts/review-performance.md");
1236 assert!(
1237 prompt.contains("Ferrox"),
1238 "review-performance.md should contain 'Ferrox'"
1239 );
1240 assert!(
1241 prompt.contains("Rust Engineer"),
1242 "review-performance.md should mention Rust Engineer"
1243 );
1244 }
1245
1246 #[test]
1247 fn test_review_domain_contains_carthos() {
1248 let prompt = include_str!("../prompts/review-domain.md");
1249 assert!(
1250 prompt.contains("Carthos"),
1251 "review-domain.md should contain 'Carthos'"
1252 );
1253 assert!(
1254 prompt.contains("Domain Architect"),
1255 "review-domain.md should mention Domain Architect"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_review_design_contains_lux() {
1261 let prompt = include_str!("../prompts/review-design-quality.md");
1262 assert!(
1263 prompt.contains("Lux"),
1264 "review-design-quality.md should contain 'Lux'"
1265 );
1266 assert!(
1267 prompt.contains("TypeScript Engineer"),
1268 "review-design-quality.md should mention TypeScript Engineer"
1269 );
1270 }
1271
1272 #[test]
1273 fn test_default_groups_all_have_persona() {
1274 let groups = default_groups();
1275 for group in &groups {
1276 assert!(
1277 group.persona.is_some(),
1278 "Group '{}' should have a persona set",
1279 group.agent_name
1280 );
1281 }
1282
1283 let vigil = groups
1285 .iter()
1286 .find(|g| g.agent_name == "security-sentinel")
1287 .unwrap();
1288 assert_eq!(vigil.persona.as_ref().unwrap(), "Vigil");
1289
1290 let carthos_arch = groups
1291 .iter()
1292 .find(|g| g.agent_name == "architecture-strategist")
1293 .unwrap();
1294 assert_eq!(carthos_arch.persona.as_ref().unwrap(), "Carthos");
1295
1296 let ferrox_perf = groups
1297 .iter()
1298 .find(|g| g.agent_name == "performance-oracle")
1299 .unwrap();
1300 assert_eq!(ferrox_perf.persona.as_ref().unwrap(), "Ferrox");
1301
1302 let ferrox_qual = groups
1303 .iter()
1304 .find(|g| g.agent_name == "rust-reviewer")
1305 .unwrap();
1306 assert_eq!(ferrox_qual.persona.as_ref().unwrap(), "Ferrox");
1307
1308 let carthos_domain = groups
1309 .iter()
1310 .find(|g| g.agent_name == "domain-model-reviewer")
1311 .unwrap();
1312 assert_eq!(carthos_domain.persona.as_ref().unwrap(), "Carthos");
1313
1314 let lux = groups
1315 .iter()
1316 .find(|g| g.agent_name == "design-fidelity-reviewer")
1317 .unwrap();
1318 assert_eq!(lux.persona.as_ref().unwrap(), "Lux");
1319 }
1320
1321 #[test]
1322 fn test_extract_review_output_with_persona_agent_name() {
1323 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}"#;
1325 let output =
1326 extract_review_output(json, "Vigil-security-sentinel", FindingCategory::Security);
1327 assert_eq!(output.agent, "Vigil-security-sentinel");
1328 assert!(!output.pass);
1329 assert_eq!(output.findings.len(), 1);
1330 }
1331
1332 #[test]
1337 fn test_compound_config_cli_tool_override() {
1338 let config = CompoundReviewConfig {
1339 schedule: "0 2 * * *".to_string(),
1340 max_duration_secs: 1800,
1341 repo_path: PathBuf::from("/tmp"),
1342 create_prs: false,
1343 worktree_root: PathBuf::from("/tmp/worktrees"),
1344 base_branch: "main".to_string(),
1345 max_concurrent_agents: 3,
1346 cli_tool: Some("/home/alex/.bun/bin/opencode".to_string()),
1347 provider: Some("opencode-go".to_string()),
1348 model: Some("glm-5".to_string()),
1349 ..Default::default()
1350 };
1351 let swarm = SwarmConfig::from_compound_config(&config);
1352 for group in &swarm.groups {
1353 assert_eq!(group.cli_tool, "/home/alex/.bun/bin/opencode");
1354 assert_eq!(group.model, Some("opencode-go/glm-5".to_string()));
1355 }
1356 }
1357
1358 #[test]
1359 fn test_compound_config_no_override() {
1360 let config = CompoundReviewConfig {
1361 schedule: "0 2 * * *".to_string(),
1362 max_duration_secs: 1800,
1363 repo_path: PathBuf::from("/tmp"),
1364 create_prs: false,
1365 worktree_root: PathBuf::from("/tmp/worktrees"),
1366 base_branch: "main".to_string(),
1367 max_concurrent_agents: 3,
1368 cli_tool: None,
1369 provider: None,
1370 model: None,
1371 ..Default::default()
1372 };
1373 let swarm = SwarmConfig::from_compound_config(&config);
1374 assert_eq!(swarm.groups[0].cli_tool, "opencode");
1376 assert!(swarm.groups[0].model.is_none());
1377 }
1378
1379 #[test]
1382 fn test_unwrap_opencode_protocol_formats_tool_use() {
1383 let protocol_output = r#"{"type":"text","part":{"type":"text","text":"Starting review..."}}
1388{"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() { }"}}}
1389{"type":"text","part":{"type":"text","text":"Review complete. No issues found."}}"#;
1390
1391 let unwrapped = unwrap_opencode_protocol(protocol_output);
1392 assert!(
1394 !unwrapped.contains("critical_path"),
1395 "tool_use payload content should not leak through"
1396 );
1397 assert!(
1399 unwrapped.contains("[tool_use: read /tmp/test.rs completed]"),
1400 "tool_use should be formatted as summary, got: {}",
1401 unwrapped
1402 );
1403 assert!(unwrapped.contains("Starting review..."));
1404 assert!(unwrapped.contains("Review complete."));
1405 }
1406
1407 #[test]
1408 fn test_extract_review_output_no_false_critical_from_tool_use() {
1409 let protocol_output = r#"{"type":"text","part":{"type":"text","text":"Reviewing code..."}}
1412{"type":"tool_use","part":{"tool":"read","state":{"output":"FindingSeverity::Critical is used here"}}}
1413{"type":"text","part":{"type":"text","text":"All looks good, no issues."}}"#;
1414
1415 let output =
1416 extract_review_output(protocol_output, "test-agent", FindingCategory::Security);
1417 assert_eq!(
1419 output.findings.len(),
1420 0,
1421 "tool_use payloads must not generate synthetic findings"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_compound_config_timeout_uses_max_duration() {
1427 let config = CompoundReviewConfig {
1428 schedule: "0 2 * * *".to_string(),
1429 max_duration_secs: 900,
1430 repo_path: PathBuf::from("/tmp"),
1431 create_prs: false,
1432 worktree_root: PathBuf::from("/tmp/worktrees"),
1433 base_branch: "main".to_string(),
1434 max_concurrent_agents: 3,
1435 cli_tool: None,
1436 provider: None,
1437 model: None,
1438 ..Default::default()
1439 };
1440 let swarm = SwarmConfig::from_compound_config(&config);
1441 assert_eq!(swarm.timeout, Duration::from_secs(900));
1442 }
1443}