1use 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
17pub struct OpenCodeClient {
22 config: OpenCodeConfig,
23 platform: String,
24}
25
26impl OpenCodeClient {
27 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 pub fn with_platform(mut self, platform: &str) -> Self {
39 self.platform = platform.to_string();
40 self
41 }
42
43 pub fn from_env() -> Result<Self> {
45 let config = OpenCodeConfig::from_env()?;
46 Self::new(config)
47 }
48
49 pub fn auto() -> Result<Self> {
51 let config = OpenCodeConfig::new()?;
52 Self::new(config)
53 }
54
55 pub fn cli_path(&self) -> &Path {
57 &self.config.cli_path
58 }
59
60 pub fn model(&self) -> &str {
62 &self.config.model
63 }
64
65 pub fn has_mcp(&self) -> bool {
67 self.config.has_mcp()
68 }
69
70 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 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 let prompt = self.build_batch_enrichment_prompt(&findings, codebase_path);
116
117 let output = self
119 .run_opencode_streaming(&prompt, Some(codebase_path))
120 .await?;
121
122 self.parse_batch_enrichment(&findings, &output)
124 }
125
126 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 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 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 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 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 info!("[OpenCode] {}", line);
203 stderr_content.push_str(&line);
204 stderr_content.push('\n');
205 }
206 }
207 stderr_content
208 });
209
210 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 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 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 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 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 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 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 let _ = child.kill().await;
348 Err(OpenCodeError::timeout(timeout.as_secs()))
349 }
350 }
351 }
352
353 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 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 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 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 fn parse_enriched_finding(&self, raw: &RawFinding, output: &str) -> Result<EnrichedFinding> {
553 let json_str = Self::extract_json(output)?;
555
556 let parsed: serde_json::Value = serde_json::from_str(&json_str)?;
558
559 let mut enriched = EnrichedFinding::from_raw(raw);
560
561 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 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(), 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 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 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 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 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 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 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 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 if let Some(conf) = e.get("confidence").and_then(|v| v.as_f64()) {
799 enriched.analysis.confidence = conf as f32;
800 }
801
802 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; }
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 fn extract_json(output: &str) -> Result<String> {
821 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 if let Some(start) = output.find("```") {
831 let start = start + 3;
832 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 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 #[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 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}