Skip to main content

gatekpr_opencode/
client.rs

1//! OpenCode CLI client for RAG-powered validation enrichment
2//!
3//! This client invokes the OpenCode CLI to enrich validation findings
4//! with context from the RAG system. Each finding is processed in a
5//! fresh context to avoid context window explosion.
6
7use crate::config::OpenCodeConfig;
8use crate::error::{OpenCodeError, Result};
9use crate::models::*;
10use std::path::Path;
11use std::process::Stdio;
12use std::time::Instant;
13use tokio::io::AsyncReadExt;
14use tokio::process::Command;
15use tracing::{debug, info};
16
17/// OpenCode CLI client for validation enrichment
18///
19/// This client invokes `opencode run` to process findings through
20/// GLM 4.7 with access to the RAG-powered MCP server.
21pub struct OpenCodeClient {
22    config: OpenCodeConfig,
23    platform: String,
24}
25
26impl OpenCodeClient {
27    /// Create a new OpenCode client
28    pub fn new(config: OpenCodeConfig) -> Result<Self> {
29        config.validate()?;
30        let platform = config
31            .platform
32            .clone()
33            .unwrap_or_else(|| "Shopify".to_string());
34        Ok(Self { config, platform })
35    }
36
37    /// Set platform for prompt customization
38    pub fn with_platform(mut self, platform: &str) -> Self {
39        self.platform = platform.to_string();
40        self
41    }
42
43    /// Create client from environment variables
44    pub fn from_env() -> Result<Self> {
45        let config = OpenCodeConfig::from_env()?;
46        Self::new(config)
47    }
48
49    /// Create client with auto-detected configuration
50    pub fn auto() -> Result<Self> {
51        let config = OpenCodeConfig::new()?;
52        Self::new(config)
53    }
54
55    /// Get the CLI path
56    pub fn cli_path(&self) -> &Path {
57        &self.config.cli_path
58    }
59
60    /// Get the model being used
61    pub fn model(&self) -> &str {
62        &self.config.model
63    }
64
65    /// Check if MCP integration is available
66    pub fn has_mcp(&self) -> bool {
67        self.config.has_mcp()
68    }
69
70    // =========================================================================
71    // ENRICHMENT OPERATIONS
72    // =========================================================================
73
74    /// Enrich a single finding with RAG context
75    ///
76    /// Invokes OpenCode with the finding details and file context.
77    /// OpenCode will use MCP tools (search_docs_for_rule) to fetch
78    /// relevant documentation and provide a detailed analysis.
79    pub async fn enrich_finding(
80        &self,
81        finding: &RawFinding,
82        file_context: &FileContext,
83    ) -> Result<EnrichedFinding> {
84        let prompt = self.build_enrichment_prompt(finding, file_context);
85
86        debug!(
87            "Enriching finding {} in {}",
88            finding.rule_id, finding.file_path
89        );
90
91        let output = self.run_opencode(&prompt, Some(&file_context.path)).await?;
92        self.parse_enriched_finding(finding, &output)
93    }
94
95    /// Enrich multiple findings in a SINGLE OpenCode call
96    ///
97    /// Batches all findings together and sends them to OpenCode once,
98    /// which analyzes the codebase and returns enriched results for all.
99    /// This is much faster than processing findings one-by-one.
100    pub async fn enrich_findings(
101        &self,
102        findings: Vec<RawFinding>,
103        codebase_path: &Path,
104    ) -> Result<Vec<EnrichedFinding>> {
105        if findings.is_empty() {
106            return Ok(Vec::new());
107        }
108
109        info!(
110            "Enriching {} findings with OpenCode (single batch call)",
111            findings.len()
112        );
113
114        // Build a single prompt with ALL findings
115        let prompt = self.build_batch_enrichment_prompt(&findings, codebase_path);
116
117        // Run OpenCode ONCE with streaming output
118        let output = self
119            .run_opencode_streaming(&prompt, Some(codebase_path))
120            .await?;
121
122        // Parse the batch response
123        self.parse_batch_enrichment(&findings, &output)
124    }
125
126    /// Analyze a file for potential issues
127    ///
128    /// Uses OpenCode to scan a file and identify any marketplace-related
129    /// issues that the local pipeline might have missed.
130    pub async fn analyze_file(
131        &self,
132        file_path: &Path,
133        categories: &[&str],
134    ) -> Result<Vec<RawFinding>> {
135        let content = tokio::fs::read_to_string(file_path).await?;
136        let relative_path = file_path
137            .file_name()
138            .map(|s| s.to_string_lossy().to_string())
139            .unwrap_or_else(|| file_path.display().to_string());
140
141        let file_context = FileContext::new(&relative_path, &content);
142        let prompt = self.build_analysis_prompt(&file_context, categories);
143
144        let output = self.run_opencode(&prompt, Some(&relative_path)).await?;
145        self.parse_analysis_results(&output, &relative_path)
146    }
147
148    // =========================================================================
149    // CLI EXECUTION
150    // =========================================================================
151
152    /// Run OpenCode CLI with streaming output (logs as it runs)
153    async fn run_opencode_streaming(
154        &self,
155        prompt: &str,
156        working_dir: Option<&Path>,
157    ) -> Result<String> {
158        use tokio::io::{AsyncBufReadExt, BufReader};
159
160        let start = Instant::now();
161
162        let mut cmd = Command::new(&self.config.cli_path);
163        cmd.arg("run")
164            .arg(prompt)
165            .arg("--model")
166            .arg(&self.config.model)
167            .stdin(Stdio::null())
168            .stdout(Stdio::piped())
169            .stderr(Stdio::piped());
170
171        // Set working directory to codebase path
172        if let Some(dir) = working_dir {
173            if dir.exists() && dir.is_dir() {
174                cmd.current_dir(dir);
175                info!("OpenCode working directory: {}", dir.display());
176            }
177        }
178
179        // Add environment variables
180        for (key, value) in &self.config.env_vars {
181            cmd.env(key, value);
182        }
183
184        info!("Starting OpenCode CLI (streaming mode)...");
185
186        let mut child = cmd.spawn().map_err(|e| {
187            if e.kind() == std::io::ErrorKind::NotFound {
188                OpenCodeError::CliNotFound(self.config.cli_path.display().to_string())
189            } else {
190                OpenCodeError::CliExecution(e.to_string())
191            }
192        })?;
193
194        // Stream stderr (OpenCode's progress output) in real-time
195        let stderr = child.stderr.take();
196        let stderr_handle = tokio::spawn(async move {
197            let mut stderr_content = String::new();
198            if let Some(stderr) = stderr {
199                let mut reader = BufReader::new(stderr).lines();
200                while let Ok(Some(line)) = reader.next_line().await {
201                    // Log each line from OpenCode as it comes
202                    info!("[OpenCode] {}", line);
203                    stderr_content.push_str(&line);
204                    stderr_content.push('\n');
205                }
206            }
207            stderr_content
208        });
209
210        // Stream stdout (the actual response)
211        let stdout = child.stdout.take();
212        let stdout_handle = tokio::spawn(async move {
213            let mut stdout_content = String::new();
214            if let Some(stdout) = stdout {
215                let mut reader = BufReader::new(stdout).lines();
216                while let Ok(Some(line)) = reader.next_line().await {
217                    debug!("[OpenCode stdout] {}", line);
218                    stdout_content.push_str(&line);
219                    stdout_content.push('\n');
220                }
221            }
222            stdout_content
223        });
224
225        // Wait for completion with timeout
226        let timeout = self.config.timeout;
227        let result = tokio::time::timeout(timeout, async {
228            let status = child.wait().await?;
229            let stdout = stdout_handle.await.unwrap_or_default();
230            let stderr = stderr_handle.await.unwrap_or_default();
231            Ok::<_, std::io::Error>((stdout, stderr, status))
232        })
233        .await;
234
235        let elapsed = start.elapsed();
236        info!("OpenCode completed in {:?}", elapsed);
237
238        match result {
239            Ok(Ok((stdout, stderr, status))) => {
240                if status.success() {
241                    Ok(stdout)
242                } else if stderr.contains("auth") || stderr.contains("credential") {
243                    Err(OpenCodeError::AuthRequired)
244                } else if stderr.contains("rate limit") {
245                    Err(OpenCodeError::RateLimited)
246                } else {
247                    Err(OpenCodeError::exit_error(
248                        status.code().unwrap_or(-1),
249                        stderr,
250                    ))
251                }
252            }
253            Ok(Err(e)) => Err(OpenCodeError::CliExecution(e.to_string())),
254            Err(_) => {
255                let _ = child.kill().await;
256                Err(OpenCodeError::timeout(timeout.as_secs()))
257            }
258        }
259    }
260
261    /// Run OpenCode CLI with a prompt (non-streaming, for single finding)
262    async fn run_opencode(&self, prompt: &str, working_dir: Option<&str>) -> Result<String> {
263        let start = Instant::now();
264
265        let mut cmd = Command::new(&self.config.cli_path);
266        cmd.arg("run")
267            .arg(prompt)
268            .arg("--model")
269            .arg(&self.config.model)
270            .stdin(Stdio::null())
271            .stdout(Stdio::piped())
272            .stderr(Stdio::piped());
273
274        // Set working directory if specified
275        if let Some(dir) = working_dir.or(self
276            .config
277            .working_dir
278            .as_ref()
279            .map(|p| p.to_str().unwrap_or(".")))
280        {
281            if let Some(parent) = Path::new(dir).parent() {
282                if parent.exists() {
283                    cmd.current_dir(parent);
284                }
285            }
286        }
287
288        // Add environment variables
289        for (key, value) in &self.config.env_vars {
290            cmd.env(key, value);
291        }
292
293        debug!("Running: {:?}", cmd);
294
295        let mut child = cmd.spawn().map_err(|e| {
296            if e.kind() == std::io::ErrorKind::NotFound {
297                OpenCodeError::CliNotFound(self.config.cli_path.display().to_string())
298            } else {
299                OpenCodeError::CliExecution(e.to_string())
300            }
301        })?;
302
303        // Read stdout with timeout
304        let timeout = self.config.timeout;
305        let result = tokio::time::timeout(timeout, async {
306            let mut stdout = String::new();
307            let mut stderr = String::new();
308
309            if let Some(ref mut out) = child.stdout {
310                out.read_to_string(&mut stdout).await?;
311            }
312            if let Some(ref mut err) = child.stderr {
313                err.read_to_string(&mut stderr).await?;
314            }
315
316            let status = child.wait().await?;
317            Ok::<_, std::io::Error>((stdout, stderr, status))
318        })
319        .await;
320
321        let elapsed = start.elapsed();
322        debug!("OpenCode completed in {:?}", elapsed);
323
324        match result {
325            Ok(Ok((stdout, stderr, status))) => {
326                if status.success() {
327                    Ok(stdout)
328                } else {
329                    // Check for common error patterns
330                    if stderr.contains("auth") || stderr.contains("credential") {
331                        Err(OpenCodeError::AuthRequired)
332                    } else if stderr.contains("rate limit") {
333                        Err(OpenCodeError::RateLimited)
334                    } else if stderr.contains("model") && stderr.contains("not") {
335                        Err(OpenCodeError::ModelUnavailable(self.config.model.clone()))
336                    } else {
337                        Err(OpenCodeError::exit_error(
338                            status.code().unwrap_or(-1),
339                            stderr,
340                        ))
341                    }
342                }
343            }
344            Ok(Err(e)) => Err(OpenCodeError::CliExecution(e.to_string())),
345            Err(_) => {
346                // Timeout - kill the process
347                let _ = child.kill().await;
348                Err(OpenCodeError::timeout(timeout.as_secs()))
349            }
350        }
351    }
352
353    // =========================================================================
354    // PROMPT BUILDING
355    // =========================================================================
356
357    /// Build a single prompt with ALL findings for batch enrichment
358    fn build_batch_enrichment_prompt(
359        &self,
360        findings: &[RawFinding],
361        codebase_path: &Path,
362    ) -> String {
363        let findings_json: Vec<serde_json::Value> = findings
364            .iter()
365            .map(|f| {
366                serde_json::json!({
367                    "rule_id": f.rule_id,
368                    "category": f.category,
369                    "severity": format!("{:?}", f.severity).to_lowercase(),
370                    "file_path": f.file_path,
371                    "line": f.line,
372                    "message": f.message,
373                    "raw_match": f.raw_match
374                })
375            })
376            .collect();
377
378        format!(
379            r#"You are a {platform} marketplace compliance expert. Analyze this codebase for the following validation findings.
380
381CODEBASE: {codebase_path}
382
383FINDINGS TO ANALYZE:
384{findings_json}
385
386INSTRUCTIONS:
3871. For each finding, explore the codebase to understand the context
3882. Determine if the finding is a true positive or false positive
3893. Provide specific fix recommendations with code examples
3904. Reference {platform} documentation where applicable
391
392Respond with a JSON array containing enriched findings:
393
394```json
395{{
396  "enriched_findings": [
397    {{
398      "rule_id": "RULE001",
399      "is_valid": true,
400      "issue": {{
401        "title": "Brief title",
402        "description": "Detailed explanation",
403        "impact": "What happens if not fixed"
404      }},
405      "fix": {{
406        "action": "add_code|modify_code|remove_code",
407        "steps": ["Step 1", "Step 2"],
408        "code_snippet": "// example fix"
409      }},
410      "confidence": 0.95
411    }}
412  ]
413}}
414```
415
416Analyze ALL {count} findings and return enrichments for each."#,
417            platform = self.platform,
418            codebase_path = codebase_path.display(),
419            findings_json = serde_json::to_string_pretty(&findings_json).unwrap_or_default(),
420            count = findings.len()
421        )
422    }
423
424    /// Build prompt for enriching a finding
425    fn build_enrichment_prompt(&self, finding: &RawFinding, context: &FileContext) -> String {
426        format!(
427            r#"You are analyzing a {platform} app for compliance issues.
428
429FINDING:
430- Rule: {rule_id}
431- Category: {category}
432- Severity: {severity}
433- File: {file_path}:{line}
434- Message: {message}
435- Match: {raw_match}
436
437FILE CONTEXT ({language}):
438```{language}
439{content}
440```
441
442INSTRUCTIONS:
4431. Use the search_docs_for_rule tool with rule_id="{rule_id}" to get relevant {platform} documentation
4442. Analyze the code against {platform}'s requirements
4453. Provide your response in this EXACT JSON format:
446
447```json
448{{
449  "issue": {{
450    "title": "Brief issue title",
451    "description": "Detailed explanation of the problem",
452    "impact": "What happens if not fixed"
453  }},
454  "analysis": {{
455    "confidence": 0.95,
456    "reasoning": "Why this is an issue based on docs",
457    "related_rules": ["OTHER001"]
458  }},
459  "fix": {{
460    "action": "add_code|modify_code|remove_code|add_file|update_config",
461    "target_file": "path/to/file.ts",
462    "code_snippet": "// code to add or modify",
463    "steps": ["Step 1", "Step 2"],
464    "complexity": "simple|medium|complex"
465  }},
466  "references": [
467    {{"title": "Doc title", "url": "https://shopify.dev/...", "relevance": 0.9}}
468  ]
469}}
470```
471
472Respond ONLY with the JSON block, no other text."#,
473            platform = self.platform,
474            rule_id = finding.rule_id,
475            category = finding.category,
476            severity = finding.severity,
477            file_path = finding.file_path,
478            line = finding.line.unwrap_or(0),
479            message = finding.message,
480            raw_match = finding.raw_match,
481            language = context.language,
482            content = Self::truncate_content(&context.content, 2000),
483        )
484    }
485
486    /// Build prompt for file analysis
487    fn build_analysis_prompt(&self, context: &FileContext, categories: &[&str]) -> String {
488        let categories_str = categories.join(", ");
489        format!(
490            r#"Analyze this {platform} app file for compliance issues in these categories: {categories}
491
492FILE: {path} ({language})
493```{language}
494{content}
495```
496
497INSTRUCTIONS:
4981. Check for issues related to: {categories}
4992. Use search_docs tool to verify {platform} requirements
5003. Return findings in this JSON format:
501
502```json
503{{
504  "findings": [
505    {{
506      "rule_id": "CATEGORY###",
507      "severity": "critical|warning|info",
508      "category": "category_name",
509      "line": 42,
510      "message": "Brief description",
511      "raw_match": "the problematic code"
512    }}
513  ]
514}}
515```
516
517If no issues found, return: {{"findings": []}}
518Respond ONLY with the JSON block."#,
519            platform = self.platform,
520            categories = categories_str,
521            path = context.path,
522            language = context.language,
523            content = Self::truncate_content(&context.content, 3000),
524        )
525    }
526
527    /// Truncate content to max length, preserving line boundaries
528    fn truncate_content(content: &str, max_len: usize) -> String {
529        if content.len() <= max_len {
530            return content.to_string();
531        }
532
533        let mut result = String::with_capacity(max_len);
534        for line in content.lines() {
535            if result.len() + line.len() + 1 > max_len {
536                result.push_str("\n... (truncated)");
537                break;
538            }
539            if !result.is_empty() {
540                result.push('\n');
541            }
542            result.push_str(line);
543        }
544        result
545    }
546
547    // =========================================================================
548    // OUTPUT PARSING
549    // =========================================================================
550
551    /// Parse enriched finding from OpenCode output
552    fn parse_enriched_finding(&self, raw: &RawFinding, output: &str) -> Result<EnrichedFinding> {
553        // Extract JSON from output (may have markdown code blocks)
554        let json_str = Self::extract_json(output)?;
555
556        // Parse the JSON
557        let parsed: serde_json::Value = serde_json::from_str(&json_str)?;
558
559        let mut enriched = EnrichedFinding::from_raw(raw);
560
561        // Parse issue details
562        if let Some(issue) = parsed.get("issue") {
563            enriched.issue = IssueDetails {
564                title: issue
565                    .get("title")
566                    .and_then(|v| v.as_str())
567                    .unwrap_or(&raw.message)
568                    .to_string(),
569                description: issue
570                    .get("description")
571                    .and_then(|v| v.as_str())
572                    .unwrap_or("")
573                    .to_string(),
574                impact: issue
575                    .get("impact")
576                    .and_then(|v| v.as_str())
577                    .unwrap_or("")
578                    .to_string(),
579            };
580        }
581
582        // Parse analysis
583        if let Some(analysis) = parsed.get("analysis") {
584            enriched.analysis = AnalysisContext {
585                confidence: analysis
586                    .get("confidence")
587                    .and_then(|v| v.as_f64())
588                    .unwrap_or(0.5) as f32,
589                reasoning: analysis
590                    .get("reasoning")
591                    .and_then(|v| v.as_str())
592                    .unwrap_or("")
593                    .to_string(),
594                rag_sources: Vec::new(), // Populated by MCP calls
595                related_rules: analysis
596                    .get("related_rules")
597                    .and_then(|v| v.as_array())
598                    .map(|arr| {
599                        arr.iter()
600                            .filter_map(|v| v.as_str().map(String::from))
601                            .collect()
602                    })
603                    .unwrap_or_default(),
604            };
605        }
606
607        // Parse fix recommendation
608        if let Some(fix) = parsed.get("fix") {
609            enriched.fix = FixRecommendation {
610                action: fix
611                    .get("action")
612                    .and_then(|v| v.as_str())
613                    .map(|s| match s {
614                        "add_code" => FixAction::AddCode,
615                        "modify_code" => FixAction::ModifyCode,
616                        "remove_code" => FixAction::RemoveCode,
617                        "add_file" => FixAction::AddFile,
618                        "update_config" => FixAction::UpdateConfig,
619                        _ => FixAction::None,
620                    })
621                    .unwrap_or(FixAction::None),
622                target_file: fix
623                    .get("target_file")
624                    .and_then(|v| v.as_str())
625                    .unwrap_or(&raw.file_path)
626                    .to_string(),
627                code_snippet: fix
628                    .get("code_snippet")
629                    .and_then(|v| v.as_str())
630                    .map(String::from),
631                steps: fix
632                    .get("steps")
633                    .and_then(|v| v.as_array())
634                    .map(|arr| {
635                        arr.iter()
636                            .filter_map(|v| v.as_str().map(String::from))
637                            .collect()
638                    })
639                    .unwrap_or_default(),
640                complexity: fix
641                    .get("complexity")
642                    .and_then(|v| v.as_str())
643                    .map(|s| match s {
644                        "simple" => FixComplexity::Simple,
645                        "complex" => FixComplexity::Complex,
646                        _ => FixComplexity::Medium,
647                    })
648                    .unwrap_or(FixComplexity::Medium),
649            };
650        }
651
652        // Parse references
653        if let Some(refs) = parsed.get("references").and_then(|v| v.as_array()) {
654            enriched.references = refs
655                .iter()
656                .filter_map(|r| {
657                    let title = r.get("title").and_then(|v| v.as_str())?;
658                    let url = r.get("url").and_then(|v| v.as_str())?;
659                    let relevance =
660                        r.get("relevance").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
661                    Some(DocReference::new(title, url).with_relevance(relevance))
662                })
663                .collect();
664        }
665
666        Ok(enriched)
667    }
668
669    /// Parse analysis results from OpenCode output
670    fn parse_analysis_results(&self, output: &str, file_path: &str) -> Result<Vec<RawFinding>> {
671        let json_str = Self::extract_json(output)?;
672        let parsed: serde_json::Value = serde_json::from_str(&json_str)?;
673
674        let findings = parsed
675            .get("findings")
676            .and_then(|v| v.as_array())
677            .map(|arr| {
678                arr.iter()
679                    .filter_map(|f| {
680                        let rule_id = f.get("rule_id").and_then(|v| v.as_str())?;
681                        let severity = f
682                            .get("severity")
683                            .and_then(|v| v.as_str())
684                            .map(|s| match s {
685                                "critical" => Severity::Critical,
686                                "warning" => Severity::Warning,
687                                _ => Severity::Info,
688                            })
689                            .unwrap_or(Severity::Info);
690                        let category = f
691                            .get("category")
692                            .and_then(|v| v.as_str())
693                            .unwrap_or("unknown");
694                        let message = f.get("message").and_then(|v| v.as_str()).unwrap_or("");
695
696                        let mut finding =
697                            RawFinding::new(rule_id, severity, category, file_path, message);
698
699                        if let Some(line) = f.get("line").and_then(|v| v.as_u64()) {
700                            finding = finding.with_line(line as usize);
701                        }
702                        if let Some(raw_match) = f.get("raw_match").and_then(|v| v.as_str()) {
703                            finding = finding.with_match(raw_match);
704                        }
705
706                        Some(finding)
707                    })
708                    .collect()
709            })
710            .unwrap_or_default();
711
712        Ok(findings)
713    }
714
715    /// Parse batch enrichment response from OpenCode
716    fn parse_batch_enrichment(
717        &self,
718        original_findings: &[RawFinding],
719        output: &str,
720    ) -> Result<Vec<EnrichedFinding>> {
721        let json_str = Self::extract_json(output)?;
722        let parsed: serde_json::Value = serde_json::from_str(&json_str)?;
723
724        let enriched_array = parsed
725            .get("enriched_findings")
726            .and_then(|v| v.as_array())
727            .ok_or_else(|| OpenCodeError::parse_error("Missing enriched_findings array"))?;
728
729        let mut results = Vec::with_capacity(original_findings.len());
730
731        for original in original_findings {
732            // Find matching enrichment by rule_id
733            let enrichment = enriched_array.iter().find(|e| {
734                e.get("rule_id")
735                    .and_then(|v| v.as_str())
736                    .map(|id| id == original.rule_id)
737                    .unwrap_or(false)
738            });
739
740            let mut enriched = EnrichedFinding::from_raw(original);
741
742            if let Some(e) = enrichment {
743                // Parse issue details
744                if let Some(issue) = e.get("issue") {
745                    enriched.issue = IssueDetails {
746                        title: issue
747                            .get("title")
748                            .and_then(|v| v.as_str())
749                            .unwrap_or(&original.message)
750                            .to_string(),
751                        description: issue
752                            .get("description")
753                            .and_then(|v| v.as_str())
754                            .unwrap_or("")
755                            .to_string(),
756                        impact: issue
757                            .get("impact")
758                            .and_then(|v| v.as_str())
759                            .unwrap_or("")
760                            .to_string(),
761                    };
762                }
763
764                // Parse fix recommendation
765                if let Some(fix) = e.get("fix") {
766                    enriched.fix = FixRecommendation {
767                        action: fix
768                            .get("action")
769                            .and_then(|v| v.as_str())
770                            .map(|s| match s {
771                                "add_code" => FixAction::AddCode,
772                                "modify_code" => FixAction::ModifyCode,
773                                "remove_code" => FixAction::RemoveCode,
774                                "add_file" => FixAction::AddFile,
775                                "update_config" => FixAction::UpdateConfig,
776                                _ => FixAction::None,
777                            })
778                            .unwrap_or(FixAction::None),
779                        target_file: original.file_path.clone(),
780                        code_snippet: fix
781                            .get("code_snippet")
782                            .and_then(|v| v.as_str())
783                            .map(String::from),
784                        steps: fix
785                            .get("steps")
786                            .and_then(|v| v.as_array())
787                            .map(|arr| {
788                                arr.iter()
789                                    .filter_map(|v| v.as_str().map(String::from))
790                                    .collect()
791                            })
792                            .unwrap_or_default(),
793                        complexity: FixComplexity::Medium,
794                    };
795                }
796
797                // Parse confidence
798                if let Some(conf) = e.get("confidence").and_then(|v| v.as_f64()) {
799                    enriched.analysis.confidence = conf as f32;
800                }
801
802                // Mark as valid finding if is_valid is true or not specified
803                let is_valid = e.get("is_valid").and_then(|v| v.as_bool()).unwrap_or(true);
804                if !is_valid {
805                    enriched.analysis.confidence = 0.1; // Low confidence for false positives
806                }
807            }
808
809            results.push(enriched);
810        }
811
812        info!(
813            "Parsed {} enriched findings from batch response",
814            results.len()
815        );
816        Ok(results)
817    }
818
819    /// Extract JSON from OpenCode output (handles markdown code blocks)
820    fn extract_json(output: &str) -> Result<String> {
821        // Try to find JSON in markdown code block first
822        if let Some(start) = output.find("```json") {
823            let start = start + 7;
824            if let Some(end) = output[start..].find("```") {
825                return Ok(output[start..start + end].trim().to_string());
826            }
827        }
828
829        // Try generic code block
830        if let Some(start) = output.find("```") {
831            let start = start + 3;
832            // Skip language identifier if present
833            let start = output[start..]
834                .find('\n')
835                .map(|n| start + n + 1)
836                .unwrap_or(start);
837            if let Some(end) = output[start..].find("```") {
838                return Ok(output[start..start + end].trim().to_string());
839            }
840        }
841
842        // Try to find raw JSON
843        if let Some(start) = output.find('{') {
844            if let Some(end) = output.rfind('}') {
845                if end > start {
846                    return Ok(output[start..=end].to_string());
847                }
848            }
849        }
850
851        Err(OpenCodeError::parse_error("No JSON found in output"))
852    }
853
854    // =========================================================================
855    // HELPERS
856    // =========================================================================
857
858    /// Load file context for a finding (used by single-finding enrichment)
859    #[allow(dead_code)]
860    fn load_file_context(&self, abs_path: &Path, relative_path: &str) -> Result<FileContext> {
861        let content = std::fs::read_to_string(abs_path).map_err(|e| {
862            OpenCodeError::FileCollection(format!("Failed to read {}: {}", abs_path.display(), e))
863        })?;
864
865        Ok(FileContext::new(relative_path, content))
866    }
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872
873    #[test]
874    fn test_extract_json_from_markdown() {
875        let output = r#"Here's the analysis:
876
877```json
878{"issue": {"title": "Test"}}
879```
880
881Done!"#;
882
883        let json = OpenCodeClient::extract_json(output).unwrap();
884        assert!(json.contains("issue"));
885    }
886
887    #[test]
888    fn test_extract_json_raw() {
889        let output = r#"{"findings": []}"#;
890        let json = OpenCodeClient::extract_json(output).unwrap();
891        assert_eq!(json, r#"{"findings": []}"#);
892    }
893
894    #[test]
895    fn test_truncate_content() {
896        let content = "line1\nline2\nline3\nline4\nline5";
897        let truncated = OpenCodeClient::truncate_content(content, 20);
898        // The truncated content includes "... (truncated)" suffix
899        assert!(truncated.contains("truncated") || truncated.len() <= 20);
900    }
901
902    #[test]
903    fn test_build_enrichment_prompt() {
904        let config = OpenCodeConfig::with_cli_path(std::path::PathBuf::from("/usr/bin/opencode"));
905        let client = OpenCodeClient {
906            config,
907            platform: "Shopify".to_string(),
908        };
909
910        let finding = RawFinding::new(
911            "WH001",
912            Severity::Critical,
913            "webhooks",
914            "src/app.ts",
915            "Missing webhook",
916        );
917        let context = FileContext::new("src/app.ts", "const app = express();");
918
919        let prompt = client.build_enrichment_prompt(&finding, &context);
920
921        assert!(prompt.contains("WH001"));
922        assert!(prompt.contains("webhooks"));
923        assert!(prompt.contains("search_docs_for_rule"));
924    }
925}