omni_dev/claude/
client.rs

1//! Claude API client implementation
2
3use crate::claude::{error::ClaudeError, prompts};
4use crate::data::{amendments::AmendmentFile, RepositoryView, RepositoryViewForAI};
5use anyhow::{Context, Result};
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8use tracing::debug;
9
10/// Claude API request message
11#[derive(Serialize)]
12struct Message {
13    role: String,
14    content: String,
15}
16
17/// Claude API request body
18#[derive(Serialize)]
19struct ClaudeRequest {
20    model: String,
21    max_tokens: i32,
22    system: String,
23    messages: Vec<Message>,
24}
25
26/// Claude API response content
27#[derive(Deserialize)]
28struct Content {
29    #[serde(rename = "type")]
30    content_type: String,
31    text: String,
32}
33
34/// Claude API response
35#[derive(Deserialize)]
36struct ClaudeResponse {
37    content: Vec<Content>,
38}
39
40/// Claude client for commit message improvement
41pub struct ClaudeClient {
42    client: Client,
43    api_key: String,
44    model: String,
45}
46
47impl ClaudeClient {
48    /// Create new Claude client from environment variable
49    pub fn new(model: String) -> Result<Self> {
50        let api_key = std::env::var("CLAUDE_API_KEY")
51            .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
52            .map_err(|_| ClaudeError::ApiKeyNotFound)?;
53
54        let client = Client::new();
55        Ok(Self {
56            client,
57            api_key,
58            model,
59        })
60    }
61
62    /// Generate commit message amendments from repository view
63    pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
64        // Convert to AI-enhanced view with diff content
65        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
66            .context("Failed to enhance repository view with diff content")?;
67
68        // Convert repository view to YAML
69        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
70            .context("Failed to serialize repository view to YAML")?;
71
72        // Generate user prompt
73        let user_prompt = prompts::generate_user_prompt(&repo_yaml);
74
75        // Build the request
76        let request = ClaudeRequest {
77            model: self.model.clone(),
78            max_tokens: 4000,
79            system: prompts::SYSTEM_PROMPT.to_string(),
80            messages: vec![Message {
81                role: "user".to_string(),
82                content: user_prompt,
83            }],
84        };
85
86        // Request debugging can be enabled if needed for troubleshooting
87
88        // Send request to Claude API
89        let response = self
90            .client
91            .post("https://api.anthropic.com/v1/messages")
92            .header("x-api-key", &self.api_key)
93            .header("anthropic-version", "2023-06-01")
94            .header("content-type", "application/json")
95            .json(&request)
96            .send()
97            .await
98            .map_err(|e| ClaudeError::NetworkError(e.to_string()))?;
99
100        if !response.status().is_success() {
101            let status = response.status();
102            let error_text = response.text().await.unwrap_or_default();
103            return Err(
104                ClaudeError::ApiRequestFailed(format!("HTTP {}: {}", status, error_text)).into(),
105            );
106        }
107
108        let claude_response: ClaudeResponse = response
109            .json()
110            .await
111            .map_err(|e| ClaudeError::InvalidResponseFormat(e.to_string()))?;
112
113        // Extract text content from response
114        let content = claude_response
115            .content
116            .first()
117            .filter(|c| c.content_type == "text")
118            .map(|c| c.text.as_str())
119            .ok_or_else(|| {
120                ClaudeError::InvalidResponseFormat("No text content in response".to_string())
121            })?;
122
123        // Response debugging can be enabled if needed for troubleshooting
124
125        // Parse YAML response to AmendmentFile
126        self.parse_amendment_response(content)
127    }
128
129    /// Generate contextual commit message amendments with enhanced intelligence
130    pub async fn generate_contextual_amendments(
131        &self,
132        repo_view: &RepositoryView,
133        context: &crate::data::context::CommitContext,
134    ) -> Result<AmendmentFile> {
135        // Convert to AI-enhanced view with diff content
136        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
137            .context("Failed to enhance repository view with diff content")?;
138
139        // Convert repository view to YAML
140        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
141            .context("Failed to serialize repository view to YAML")?;
142
143        // Generate contextual prompts using Phase 3 intelligence
144        let system_prompt = prompts::generate_contextual_system_prompt(context);
145        let user_prompt = prompts::generate_contextual_user_prompt(&repo_yaml, context);
146
147        // Debug logging to troubleshoot custom commit type issue
148        match &context.project.commit_guidelines {
149            Some(guidelines) => {
150                debug!(length = guidelines.len(), "Project commit guidelines found");
151                debug!(guidelines = %guidelines, "Commit guidelines content");
152            }
153            None => {
154                debug!("No project commit guidelines found");
155            }
156        }
157
158        // Build the request with contextual prompts
159        let request = ClaudeRequest {
160            model: self.model.clone(),
161            max_tokens: if context.is_significant_change() {
162                6000
163            } else {
164                4000
165            },
166            system: system_prompt,
167            messages: vec![Message {
168                role: "user".to_string(),
169                content: user_prompt,
170            }],
171        };
172
173        // Send request to Claude API
174        let response = self
175            .client
176            .post("https://api.anthropic.com/v1/messages")
177            .header("x-api-key", &self.api_key)
178            .header("anthropic-version", "2023-06-01")
179            .header("content-type", "application/json")
180            .json(&request)
181            .send()
182            .await
183            .map_err(|e| ClaudeError::NetworkError(e.to_string()))?;
184
185        if !response.status().is_success() {
186            let status = response.status();
187            let error_text = response.text().await.unwrap_or_default();
188            return Err(
189                ClaudeError::ApiRequestFailed(format!("HTTP {}: {}", status, error_text)).into(),
190            );
191        }
192
193        let claude_response: ClaudeResponse = response
194            .json()
195            .await
196            .map_err(|e| ClaudeError::InvalidResponseFormat(e.to_string()))?;
197
198        // Extract text content from response
199        let content = claude_response
200            .content
201            .first()
202            .filter(|c| c.content_type == "text")
203            .map(|c| c.text.as_str())
204            .ok_or_else(|| {
205                ClaudeError::InvalidResponseFormat("No text content in response".to_string())
206            })?;
207
208        // Contextual response debugging can be enabled if needed for troubleshooting
209
210        // Parse YAML response to AmendmentFile
211        self.parse_amendment_response(content)
212    }
213
214    /// Parse Claude's YAML response into AmendmentFile
215    fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
216        // Extract YAML block from markdown if present
217        let yaml_content = if content.contains("```yaml") {
218            content
219                .split("```yaml")
220                .nth(1)
221                .and_then(|s| s.split("```").next())
222                .unwrap_or(content)
223                .trim()
224        } else if content.contains("```") {
225            // Handle generic code blocks
226            content
227                .split("```")
228                .nth(1)
229                .and_then(|s| s.split("```").next())
230                .unwrap_or(content)
231                .trim()
232        } else {
233            content.trim()
234        };
235
236        // Try to parse YAML
237        let amendment_file: AmendmentFile = serde_yaml::from_str(yaml_content).map_err(|e| {
238            debug!(
239                error = %e,
240                content_length = content.len(),
241                yaml_length = yaml_content.len(),
242                "YAML parsing failed"
243            );
244            debug!(content = %content, "Raw Claude response");
245            debug!(yaml = %yaml_content, "Extracted YAML content");
246
247            // Try to provide more helpful error messages for common issues
248            if yaml_content.lines().any(|line| line.contains('\t')) {
249                ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
250            } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
251                ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
252            } else {
253                ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {}", e))
254            }
255        })?;
256
257        // Validate the parsed amendments
258        amendment_file
259            .validate()
260            .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {}", e)))?;
261
262        Ok(amendment_file)
263    }
264}