use crate::config::OpenCodeConfig;
use crate::error::{OpenCodeError, Result};
use crate::models::*;
use std::path::Path;
use std::process::Stdio;
use std::time::Instant;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tracing::{debug, info};
pub struct OpenCodeClient {
config: OpenCodeConfig,
platform: String,
}
impl OpenCodeClient {
pub fn new(config: OpenCodeConfig) -> Result<Self> {
config.validate()?;
let platform = config
.platform
.clone()
.unwrap_or_else(|| "Shopify".to_string());
Ok(Self { config, platform })
}
pub fn with_platform(mut self, platform: &str) -> Self {
self.platform = platform.to_string();
self
}
pub fn from_env() -> Result<Self> {
let config = OpenCodeConfig::from_env()?;
Self::new(config)
}
pub fn auto() -> Result<Self> {
let config = OpenCodeConfig::new()?;
Self::new(config)
}
pub fn cli_path(&self) -> &Path {
&self.config.cli_path
}
pub fn model(&self) -> &str {
&self.config.model
}
pub fn has_mcp(&self) -> bool {
self.config.has_mcp()
}
pub async fn enrich_finding(
&self,
finding: &RawFinding,
file_context: &FileContext,
) -> Result<EnrichedFinding> {
let prompt = self.build_enrichment_prompt(finding, file_context);
debug!(
"Enriching finding {} in {}",
finding.rule_id, finding.file_path
);
let output = self.run_opencode(&prompt, Some(&file_context.path)).await?;
self.parse_enriched_finding(finding, &output)
}
pub async fn enrich_findings(
&self,
findings: Vec<RawFinding>,
codebase_path: &Path,
) -> Result<Vec<EnrichedFinding>> {
if findings.is_empty() {
return Ok(Vec::new());
}
info!(
"Enriching {} findings with OpenCode (single batch call)",
findings.len()
);
let prompt = self.build_batch_enrichment_prompt(&findings, codebase_path);
let output = self
.run_opencode_streaming(&prompt, Some(codebase_path))
.await?;
self.parse_batch_enrichment(&findings, &output)
}
pub async fn analyze_file(
&self,
file_path: &Path,
categories: &[&str],
) -> Result<Vec<RawFinding>> {
let content = tokio::fs::read_to_string(file_path).await?;
let relative_path = file_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.display().to_string());
let file_context = FileContext::new(&relative_path, &content);
let prompt = self.build_analysis_prompt(&file_context, categories);
let output = self.run_opencode(&prompt, Some(&relative_path)).await?;
self.parse_analysis_results(&output, &relative_path)
}
async fn run_opencode_streaming(
&self,
prompt: &str,
working_dir: Option<&Path>,
) -> Result<String> {
use tokio::io::{AsyncBufReadExt, BufReader};
let start = Instant::now();
let mut cmd = Command::new(&self.config.cli_path);
cmd.arg("run")
.arg(prompt)
.arg("--model")
.arg(&self.config.model)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = working_dir {
if dir.exists() && dir.is_dir() {
cmd.current_dir(dir);
info!("OpenCode working directory: {}", dir.display());
}
}
for (key, value) in &self.config.env_vars {
cmd.env(key, value);
}
info!("Starting OpenCode CLI (streaming mode)...");
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
OpenCodeError::CliNotFound(self.config.cli_path.display().to_string())
} else {
OpenCodeError::CliExecution(e.to_string())
}
})?;
let stderr = child.stderr.take();
let stderr_handle = tokio::spawn(async move {
let mut stderr_content = String::new();
if let Some(stderr) = stderr {
let mut reader = BufReader::new(stderr).lines();
while let Ok(Some(line)) = reader.next_line().await {
info!("[OpenCode] {}", line);
stderr_content.push_str(&line);
stderr_content.push('\n');
}
}
stderr_content
});
let stdout = child.stdout.take();
let stdout_handle = tokio::spawn(async move {
let mut stdout_content = String::new();
if let Some(stdout) = stdout {
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
debug!("[OpenCode stdout] {}", line);
stdout_content.push_str(&line);
stdout_content.push('\n');
}
}
stdout_content
});
let timeout = self.config.timeout;
let result = tokio::time::timeout(timeout, async {
let status = child.wait().await?;
let stdout = stdout_handle.await.unwrap_or_default();
let stderr = stderr_handle.await.unwrap_or_default();
Ok::<_, std::io::Error>((stdout, stderr, status))
})
.await;
let elapsed = start.elapsed();
info!("OpenCode completed in {:?}", elapsed);
match result {
Ok(Ok((stdout, stderr, status))) => {
if status.success() {
Ok(stdout)
} else if stderr.contains("auth") || stderr.contains("credential") {
Err(OpenCodeError::AuthRequired)
} else if stderr.contains("rate limit") {
Err(OpenCodeError::RateLimited)
} else {
Err(OpenCodeError::exit_error(
status.code().unwrap_or(-1),
stderr,
))
}
}
Ok(Err(e)) => Err(OpenCodeError::CliExecution(e.to_string())),
Err(_) => {
let _ = child.kill().await;
Err(OpenCodeError::timeout(timeout.as_secs()))
}
}
}
async fn run_opencode(&self, prompt: &str, working_dir: Option<&str>) -> Result<String> {
let start = Instant::now();
let mut cmd = Command::new(&self.config.cli_path);
cmd.arg("run")
.arg(prompt)
.arg("--model")
.arg(&self.config.model)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = working_dir.or(self
.config
.working_dir
.as_ref()
.map(|p| p.to_str().unwrap_or(".")))
{
if let Some(parent) = Path::new(dir).parent() {
if parent.exists() {
cmd.current_dir(parent);
}
}
}
for (key, value) in &self.config.env_vars {
cmd.env(key, value);
}
debug!("Running: {:?}", cmd);
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
OpenCodeError::CliNotFound(self.config.cli_path.display().to_string())
} else {
OpenCodeError::CliExecution(e.to_string())
}
})?;
let timeout = self.config.timeout;
let result = tokio::time::timeout(timeout, async {
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(ref mut out) = child.stdout {
out.read_to_string(&mut stdout).await?;
}
if let Some(ref mut err) = child.stderr {
err.read_to_string(&mut stderr).await?;
}
let status = child.wait().await?;
Ok::<_, std::io::Error>((stdout, stderr, status))
})
.await;
let elapsed = start.elapsed();
debug!("OpenCode completed in {:?}", elapsed);
match result {
Ok(Ok((stdout, stderr, status))) => {
if status.success() {
Ok(stdout)
} else {
if stderr.contains("auth") || stderr.contains("credential") {
Err(OpenCodeError::AuthRequired)
} else if stderr.contains("rate limit") {
Err(OpenCodeError::RateLimited)
} else if stderr.contains("model") && stderr.contains("not") {
Err(OpenCodeError::ModelUnavailable(self.config.model.clone()))
} else {
Err(OpenCodeError::exit_error(
status.code().unwrap_or(-1),
stderr,
))
}
}
}
Ok(Err(e)) => Err(OpenCodeError::CliExecution(e.to_string())),
Err(_) => {
let _ = child.kill().await;
Err(OpenCodeError::timeout(timeout.as_secs()))
}
}
}
fn build_batch_enrichment_prompt(
&self,
findings: &[RawFinding],
codebase_path: &Path,
) -> String {
let findings_json: Vec<serde_json::Value> = findings
.iter()
.map(|f| {
serde_json::json!({
"rule_id": f.rule_id,
"category": f.category,
"severity": format!("{:?}", f.severity).to_lowercase(),
"file_path": f.file_path,
"line": f.line,
"message": f.message,
"raw_match": f.raw_match
})
})
.collect();
format!(
r#"You are a {platform} marketplace compliance expert. Analyze this codebase for the following validation findings.
CODEBASE: {codebase_path}
FINDINGS TO ANALYZE:
{findings_json}
INSTRUCTIONS:
1. For each finding, explore the codebase to understand the context
2. Determine if the finding is a true positive or false positive
3. Provide specific fix recommendations with code examples
4. Reference {platform} documentation where applicable
Respond with a JSON array containing enriched findings:
```json
{{
"enriched_findings": [
{{
"rule_id": "RULE001",
"is_valid": true,
"issue": {{
"title": "Brief title",
"description": "Detailed explanation",
"impact": "What happens if not fixed"
}},
"fix": {{
"action": "add_code|modify_code|remove_code",
"steps": ["Step 1", "Step 2"],
"code_snippet": "// example fix"
}},
"confidence": 0.95
}}
]
}}
```
Analyze ALL {count} findings and return enrichments for each."#,
platform = self.platform,
codebase_path = codebase_path.display(),
findings_json = serde_json::to_string_pretty(&findings_json).unwrap_or_default(),
count = findings.len()
)
}
fn build_enrichment_prompt(&self, finding: &RawFinding, context: &FileContext) -> String {
format!(
r#"You are analyzing a {platform} app for compliance issues.
FINDING:
- Rule: {rule_id}
- Category: {category}
- Severity: {severity}
- File: {file_path}:{line}
- Message: {message}
- Match: {raw_match}
FILE CONTEXT ({language}):
```{language}
{content}
```
INSTRUCTIONS:
1. Use the search_docs_for_rule tool with rule_id="{rule_id}" to get relevant {platform} documentation
2. Analyze the code against {platform}'s requirements
3. Provide your response in this EXACT JSON format:
```json
{{
"issue": {{
"title": "Brief issue title",
"description": "Detailed explanation of the problem",
"impact": "What happens if not fixed"
}},
"analysis": {{
"confidence": 0.95,
"reasoning": "Why this is an issue based on docs",
"related_rules": ["OTHER001"]
}},
"fix": {{
"action": "add_code|modify_code|remove_code|add_file|update_config",
"target_file": "path/to/file.ts",
"code_snippet": "// code to add or modify",
"steps": ["Step 1", "Step 2"],
"complexity": "simple|medium|complex"
}},
"references": [
{{"title": "Doc title", "url": "https://shopify.dev/...", "relevance": 0.9}}
]
}}
```
Respond ONLY with the JSON block, no other text."#,
platform = self.platform,
rule_id = finding.rule_id,
category = finding.category,
severity = finding.severity,
file_path = finding.file_path,
line = finding.line.unwrap_or(0),
message = finding.message,
raw_match = finding.raw_match,
language = context.language,
content = Self::truncate_content(&context.content, 2000),
)
}
fn build_analysis_prompt(&self, context: &FileContext, categories: &[&str]) -> String {
let categories_str = categories.join(", ");
format!(
r#"Analyze this {platform} app file for compliance issues in these categories: {categories}
FILE: {path} ({language})
```{language}
{content}
```
INSTRUCTIONS:
1. Check for issues related to: {categories}
2. Use search_docs tool to verify {platform} requirements
3. Return findings in this JSON format:
```json
{{
"findings": [
{{
"rule_id": "CATEGORY###",
"severity": "critical|warning|info",
"category": "category_name",
"line": 42,
"message": "Brief description",
"raw_match": "the problematic code"
}}
]
}}
```
If no issues found, return: {{"findings": []}}
Respond ONLY with the JSON block."#,
platform = self.platform,
categories = categories_str,
path = context.path,
language = context.language,
content = Self::truncate_content(&context.content, 3000),
)
}
fn truncate_content(content: &str, max_len: usize) -> String {
if content.len() <= max_len {
return content.to_string();
}
let mut result = String::with_capacity(max_len);
for line in content.lines() {
if result.len() + line.len() + 1 > max_len {
result.push_str("\n... (truncated)");
break;
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
result
}
fn parse_enriched_finding(&self, raw: &RawFinding, output: &str) -> Result<EnrichedFinding> {
let json_str = Self::extract_json(output)?;
let parsed: serde_json::Value = serde_json::from_str(&json_str)?;
let mut enriched = EnrichedFinding::from_raw(raw);
if let Some(issue) = parsed.get("issue") {
enriched.issue = IssueDetails {
title: issue
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(&raw.message)
.to_string(),
description: issue
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
impact: issue
.get("impact")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
};
}
if let Some(analysis) = parsed.get("analysis") {
enriched.analysis = AnalysisContext {
confidence: analysis
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.5) as f32,
reasoning: analysis
.get("reasoning")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
rag_sources: Vec::new(), related_rules: analysis
.get("related_rules")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
};
}
if let Some(fix) = parsed.get("fix") {
enriched.fix = FixRecommendation {
action: fix
.get("action")
.and_then(|v| v.as_str())
.map(|s| match s {
"add_code" => FixAction::AddCode,
"modify_code" => FixAction::ModifyCode,
"remove_code" => FixAction::RemoveCode,
"add_file" => FixAction::AddFile,
"update_config" => FixAction::UpdateConfig,
_ => FixAction::None,
})
.unwrap_or(FixAction::None),
target_file: fix
.get("target_file")
.and_then(|v| v.as_str())
.unwrap_or(&raw.file_path)
.to_string(),
code_snippet: fix
.get("code_snippet")
.and_then(|v| v.as_str())
.map(String::from),
steps: fix
.get("steps")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
complexity: fix
.get("complexity")
.and_then(|v| v.as_str())
.map(|s| match s {
"simple" => FixComplexity::Simple,
"complex" => FixComplexity::Complex,
_ => FixComplexity::Medium,
})
.unwrap_or(FixComplexity::Medium),
};
}
if let Some(refs) = parsed.get("references").and_then(|v| v.as_array()) {
enriched.references = refs
.iter()
.filter_map(|r| {
let title = r.get("title").and_then(|v| v.as_str())?;
let url = r.get("url").and_then(|v| v.as_str())?;
let relevance =
r.get("relevance").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
Some(DocReference::new(title, url).with_relevance(relevance))
})
.collect();
}
Ok(enriched)
}
fn parse_analysis_results(&self, output: &str, file_path: &str) -> Result<Vec<RawFinding>> {
let json_str = Self::extract_json(output)?;
let parsed: serde_json::Value = serde_json::from_str(&json_str)?;
let findings = parsed
.get("findings")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|f| {
let rule_id = f.get("rule_id").and_then(|v| v.as_str())?;
let severity = f
.get("severity")
.and_then(|v| v.as_str())
.map(|s| match s {
"critical" => Severity::Critical,
"warning" => Severity::Warning,
_ => Severity::Info,
})
.unwrap_or(Severity::Info);
let category = f
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let message = f.get("message").and_then(|v| v.as_str()).unwrap_or("");
let mut finding =
RawFinding::new(rule_id, severity, category, file_path, message);
if let Some(line) = f.get("line").and_then(|v| v.as_u64()) {
finding = finding.with_line(line as usize);
}
if let Some(raw_match) = f.get("raw_match").and_then(|v| v.as_str()) {
finding = finding.with_match(raw_match);
}
Some(finding)
})
.collect()
})
.unwrap_or_default();
Ok(findings)
}
fn parse_batch_enrichment(
&self,
original_findings: &[RawFinding],
output: &str,
) -> Result<Vec<EnrichedFinding>> {
let json_str = Self::extract_json(output)?;
let parsed: serde_json::Value = serde_json::from_str(&json_str)?;
let enriched_array = parsed
.get("enriched_findings")
.and_then(|v| v.as_array())
.ok_or_else(|| OpenCodeError::parse_error("Missing enriched_findings array"))?;
let mut results = Vec::with_capacity(original_findings.len());
for original in original_findings {
let enrichment = enriched_array.iter().find(|e| {
e.get("rule_id")
.and_then(|v| v.as_str())
.map(|id| id == original.rule_id)
.unwrap_or(false)
});
let mut enriched = EnrichedFinding::from_raw(original);
if let Some(e) = enrichment {
if let Some(issue) = e.get("issue") {
enriched.issue = IssueDetails {
title: issue
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(&original.message)
.to_string(),
description: issue
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
impact: issue
.get("impact")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
};
}
if let Some(fix) = e.get("fix") {
enriched.fix = FixRecommendation {
action: fix
.get("action")
.and_then(|v| v.as_str())
.map(|s| match s {
"add_code" => FixAction::AddCode,
"modify_code" => FixAction::ModifyCode,
"remove_code" => FixAction::RemoveCode,
"add_file" => FixAction::AddFile,
"update_config" => FixAction::UpdateConfig,
_ => FixAction::None,
})
.unwrap_or(FixAction::None),
target_file: original.file_path.clone(),
code_snippet: fix
.get("code_snippet")
.and_then(|v| v.as_str())
.map(String::from),
steps: fix
.get("steps")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
complexity: FixComplexity::Medium,
};
}
if let Some(conf) = e.get("confidence").and_then(|v| v.as_f64()) {
enriched.analysis.confidence = conf as f32;
}
let is_valid = e.get("is_valid").and_then(|v| v.as_bool()).unwrap_or(true);
if !is_valid {
enriched.analysis.confidence = 0.1; }
}
results.push(enriched);
}
info!(
"Parsed {} enriched findings from batch response",
results.len()
);
Ok(results)
}
fn extract_json(output: &str) -> Result<String> {
if let Some(start) = output.find("```json") {
let start = start + 7;
if let Some(end) = output[start..].find("```") {
return Ok(output[start..start + end].trim().to_string());
}
}
if let Some(start) = output.find("```") {
let start = start + 3;
let start = output[start..]
.find('\n')
.map(|n| start + n + 1)
.unwrap_or(start);
if let Some(end) = output[start..].find("```") {
return Ok(output[start..start + end].trim().to_string());
}
}
if let Some(start) = output.find('{') {
if let Some(end) = output.rfind('}') {
if end > start {
return Ok(output[start..=end].to_string());
}
}
}
Err(OpenCodeError::parse_error("No JSON found in output"))
}
#[allow(dead_code)]
fn load_file_context(&self, abs_path: &Path, relative_path: &str) -> Result<FileContext> {
let content = std::fs::read_to_string(abs_path).map_err(|e| {
OpenCodeError::FileCollection(format!("Failed to read {}: {}", abs_path.display(), e))
})?;
Ok(FileContext::new(relative_path, content))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_json_from_markdown() {
let output = r#"Here's the analysis:
```json
{"issue": {"title": "Test"}}
```
Done!"#;
let json = OpenCodeClient::extract_json(output).unwrap();
assert!(json.contains("issue"));
}
#[test]
fn test_extract_json_raw() {
let output = r#"{"findings": []}"#;
let json = OpenCodeClient::extract_json(output).unwrap();
assert_eq!(json, r#"{"findings": []}"#);
}
#[test]
fn test_truncate_content() {
let content = "line1\nline2\nline3\nline4\nline5";
let truncated = OpenCodeClient::truncate_content(content, 20);
assert!(truncated.contains("truncated") || truncated.len() <= 20);
}
#[test]
fn test_build_enrichment_prompt() {
let config = OpenCodeConfig::with_cli_path(std::path::PathBuf::from("/usr/bin/opencode"));
let client = OpenCodeClient {
config,
platform: "Shopify".to_string(),
};
let finding = RawFinding::new(
"WH001",
Severity::Critical,
"webhooks",
"src/app.ts",
"Missing webhook",
);
let context = FileContext::new("src/app.ts", "const app = express();");
let prompt = client.build_enrichment_prompt(&finding, &context);
assert!(prompt.contains("WH001"));
assert!(prompt.contains("webhooks"));
assert!(prompt.contains("search_docs_for_rule"));
}
}