omni_dev/claude/
client.rs

1//! Claude client for commit message improvement
2
3use crate::claude::{ai::bedrock::BedrockAiClient, ai::claude::ClaudeAiClient};
4use crate::claude::{ai::AiClient, error::ClaudeError, prompts};
5use crate::data::{
6    amendments::AmendmentFile, context::CommitContext, RepositoryView, RepositoryViewForAI,
7};
8use anyhow::{Context, Result};
9use tracing::debug;
10
11/// Claude client for commit message improvement
12pub struct ClaudeClient {
13    /// AI client implementation
14    ai_client: Box<dyn AiClient>,
15}
16
17impl ClaudeClient {
18    /// Create new Claude client with provided AI client implementation
19    pub fn new(ai_client: Box<dyn AiClient>) -> Self {
20        Self { ai_client }
21    }
22
23    /// Get metadata about the AI client
24    pub fn get_ai_client_metadata(&self) -> crate::claude::ai::AiClientMetadata {
25        self.ai_client.get_metadata()
26    }
27
28    /// Create new Claude client with API key from environment variables
29    pub fn from_env(model: String) -> Result<Self> {
30        // Try to get API key from environment variables
31        let api_key = std::env::var("CLAUDE_API_KEY")
32            .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
33            .map_err(|_| ClaudeError::ApiKeyNotFound)?;
34
35        let ai_client = ClaudeAiClient::new(model, api_key);
36        Ok(Self::new(Box::new(ai_client)))
37    }
38
39    /// Generate commit message amendments from repository view
40    pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
41        // Convert to AI-enhanced view with diff content
42        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
43            .context("Failed to enhance repository view with diff content")?;
44
45        // Convert repository view to YAML
46        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
47            .context("Failed to serialize repository view to YAML")?;
48
49        // Generate user prompt
50        let user_prompt = prompts::generate_user_prompt(&repo_yaml);
51
52        // Send request using AI client
53        let content = self
54            .ai_client
55            .send_request(prompts::SYSTEM_PROMPT, &user_prompt)
56            .await?;
57
58        // Parse YAML response to AmendmentFile
59        self.parse_amendment_response(&content)
60    }
61
62    /// Generate contextual commit message amendments with enhanced intelligence
63    pub async fn generate_contextual_amendments(
64        &self,
65        repo_view: &RepositoryView,
66        context: &CommitContext,
67    ) -> Result<AmendmentFile> {
68        // Convert to AI-enhanced view with diff content
69        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
70            .context("Failed to enhance repository view with diff content")?;
71
72        // Convert repository view to YAML
73        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
74            .context("Failed to serialize repository view to YAML")?;
75
76        // Generate contextual prompts using intelligence
77        let provider = self.ai_client.get_metadata().provider;
78        let provider_name = if provider.to_lowercase().contains("openai")
79            || provider.to_lowercase().contains("ollama")
80        {
81            "openai"
82        } else {
83            "claude"
84        };
85        let system_prompt =
86            prompts::generate_contextual_system_prompt_for_provider(context, provider_name);
87        let user_prompt = prompts::generate_contextual_user_prompt(&repo_yaml, context);
88
89        // Debug logging to troubleshoot custom commit type issue
90        match &context.project.commit_guidelines {
91            Some(guidelines) => {
92                debug!(length = guidelines.len(), "Project commit guidelines found");
93                debug!(guidelines = %guidelines, "Commit guidelines content");
94            }
95            None => {
96                debug!("No project commit guidelines found");
97            }
98        }
99
100        // Send request using AI client
101        let content = self
102            .ai_client
103            .send_request(&system_prompt, &user_prompt)
104            .await?;
105
106        // Parse YAML response to AmendmentFile
107        self.parse_amendment_response(&content)
108    }
109
110    /// Parse Claude's YAML response into AmendmentFile
111    fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
112        // Extract YAML from potential markdown wrapper
113        let yaml_content = self.extract_yaml_from_response(content);
114
115        // Try to parse YAML using our hybrid YAML parser
116        let amendment_file: AmendmentFile = crate::data::from_yaml(&yaml_content).map_err(|e| {
117            debug!(
118                error = %e,
119                content_length = content.len(),
120                yaml_length = yaml_content.len(),
121                "YAML parsing failed"
122            );
123            debug!(content = %content, "Raw Claude response");
124            debug!(yaml = %yaml_content, "Extracted YAML content");
125
126            // Try to provide more helpful error messages for common issues
127            if yaml_content.lines().any(|line| line.contains('\t')) {
128                ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
129            } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
130                ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
131            } else {
132                ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {}", e))
133            }
134        })?;
135
136        // Validate the parsed amendments
137        amendment_file
138            .validate()
139            .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {}", e)))?;
140
141        Ok(amendment_file)
142    }
143
144    /// Generate AI-powered PR content (title + description) from repository view and template
145    pub async fn generate_pr_content(
146        &self,
147        repo_view: &RepositoryView,
148        pr_template: &str,
149    ) -> Result<crate::cli::git::PrContent> {
150        // Convert to AI-enhanced view with diff content
151        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
152            .context("Failed to enhance repository view with diff content")?;
153
154        // Convert repository view to YAML
155        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
156            .context("Failed to serialize repository view to YAML")?;
157
158        // Generate prompts for PR description
159        let user_prompt = prompts::generate_pr_description_prompt(&repo_yaml, pr_template);
160
161        // Send request using AI client
162        let content = self
163            .ai_client
164            .send_request(prompts::PR_GENERATION_SYSTEM_PROMPT, &user_prompt)
165            .await?;
166
167        // The AI response should be treated as YAML directly
168        let yaml_content = content.trim();
169
170        // Parse the YAML response using our hybrid YAML parser
171        let pr_content: crate::cli::git::PrContent = crate::data::from_yaml(yaml_content).context(
172            "Failed to parse AI response as YAML. AI may have returned malformed output.",
173        )?;
174
175        Ok(pr_content)
176    }
177
178    /// Generate AI-powered PR content with project context (title + description)
179    pub async fn generate_pr_content_with_context(
180        &self,
181        repo_view: &RepositoryView,
182        pr_template: &str,
183        context: &crate::data::context::CommitContext,
184    ) -> Result<crate::cli::git::PrContent> {
185        // Convert to AI-enhanced view with diff content
186        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
187            .context("Failed to enhance repository view with diff content")?;
188
189        // Convert repository view to YAML
190        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
191            .context("Failed to serialize repository view to YAML")?;
192
193        // Generate contextual prompts for PR description with provider-specific handling
194        let provider = self.ai_client.get_metadata().provider;
195        let provider_name = if provider.to_lowercase().contains("openai")
196            || provider.to_lowercase().contains("ollama")
197        {
198            "openai"
199        } else {
200            "claude"
201        };
202        let system_prompt =
203            prompts::generate_pr_system_prompt_with_context_for_provider(context, provider_name);
204        let user_prompt =
205            prompts::generate_pr_description_prompt_with_context(&repo_yaml, pr_template, context);
206
207        // Send request using AI client
208        let content = self
209            .ai_client
210            .send_request(&system_prompt, &user_prompt)
211            .await?;
212
213        // The AI response should be treated as YAML directly
214        let yaml_content = content.trim();
215
216        debug!(
217            content_length = content.len(),
218            yaml_content_length = yaml_content.len(),
219            yaml_content = %yaml_content,
220            "Extracted YAML content from AI response"
221        );
222
223        // Parse the YAML response using our hybrid YAML parser
224        let pr_content: crate::cli::git::PrContent = crate::data::from_yaml(yaml_content).context(
225            "Failed to parse AI response as YAML. AI may have returned malformed output.",
226        )?;
227
228        debug!(
229            parsed_title = %pr_content.title,
230            parsed_description_length = pr_content.description.len(),
231            parsed_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
232            "Successfully parsed PR content from YAML"
233        );
234
235        Ok(pr_content)
236    }
237
238    /// Extract YAML content from Claude response, handling markdown wrappers
239    fn extract_yaml_from_response(&self, content: &str) -> String {
240        let content = content.trim();
241
242        // If content already starts with "amendments:", it's pure YAML - return as-is
243        if content.starts_with("amendments:") {
244            return content.to_string();
245        }
246
247        // Try to extract from ```yaml blocks first
248        if let Some(yaml_start) = content.find("```yaml") {
249            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
250                return yaml_content.trim().to_string();
251            }
252        }
253
254        // Try to extract from generic ``` blocks
255        if let Some(code_start) = content.find("```") {
256            if let Some(code_content) = content[code_start + 3..].split("```").next() {
257                let potential_yaml = code_content.trim();
258                // Check if it looks like YAML (starts with expected structure)
259                if potential_yaml.starts_with("amendments:") {
260                    return potential_yaml.to_string();
261                }
262            }
263        }
264
265        // If no markdown blocks found or extraction failed, return trimmed content
266        content.to_string()
267    }
268}
269
270/// Create a default Claude client using environment variables and settings
271pub fn create_default_claude_client(model: Option<String>) -> Result<ClaudeClient> {
272    use crate::claude::ai::openai::OpenAiAiClient;
273    use crate::utils::settings::{get_env_var, get_env_vars};
274
275    // Check if we should use OpenAI-compatible API (OpenAI or Ollama)
276    let use_openai = get_env_var("USE_OPENAI")
277        .map(|val| val == "true")
278        .unwrap_or(false);
279
280    let use_ollama = get_env_var("USE_OLLAMA")
281        .map(|val| val == "true")
282        .unwrap_or(false);
283
284    // Check if we should use Bedrock
285    let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK")
286        .map(|val| val == "true")
287        .unwrap_or(false);
288
289    debug!(
290        use_openai = use_openai,
291        use_ollama = use_ollama,
292        use_bedrock = use_bedrock,
293        "Client selection flags"
294    );
295
296    // Handle Ollama configuration
297    if use_ollama {
298        let ollama_model = model
299            .or_else(|| get_env_var("OLLAMA_MODEL").ok())
300            .unwrap_or_else(|| "llama2".to_string());
301        let base_url = get_env_var("OLLAMA_BASE_URL").ok();
302        let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url);
303        return Ok(ClaudeClient::new(Box::new(ai_client)));
304    }
305
306    // Handle OpenAI configuration
307    if use_openai {
308        debug!("Creating OpenAI client");
309        let openai_model = model
310            .or_else(|| get_env_var("OPENAI_MODEL").ok())
311            .unwrap_or_else(|| "gpt-5".to_string());
312        debug!(openai_model = %openai_model, "Selected OpenAI model");
313
314        let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
315            debug!(error = ?e, "Failed to get OpenAI API key");
316            ClaudeError::ApiKeyNotFound
317        })?;
318        debug!("OpenAI API key found");
319
320        let ai_client = OpenAiAiClient::new_openai(openai_model, api_key);
321        debug!("OpenAI client created successfully");
322        return Ok(ClaudeClient::new(Box::new(ai_client)));
323    }
324
325    // For Claude clients, try to get model from env vars or use default
326    let claude_model = model
327        .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
328        .unwrap_or_else(|| "claude-opus-4-1-20250805".to_string());
329
330    if use_bedrock {
331        // Check if we should skip Bedrock auth
332        let skip_bedrock_auth = get_env_var("CLAUDE_CODE_SKIP_BEDROCK_AUTH")
333            .map(|val| val == "true")
334            .unwrap_or(false);
335
336        if skip_bedrock_auth {
337            // Use Bedrock AI client
338            let auth_token =
339                get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
340
341            let base_url = get_env_var("ANTHROPIC_BEDROCK_BASE_URL")
342                .map_err(|_| ClaudeError::ApiKeyNotFound)?;
343
344            let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url);
345            return Ok(ClaudeClient::new(Box::new(ai_client)));
346        }
347    }
348
349    // Default: use standard Claude AI client
350    debug!("Falling back to Claude client");
351    let api_key = get_env_vars(&[
352        "CLAUDE_API_KEY",
353        "ANTHROPIC_API_KEY",
354        "ANTHROPIC_AUTH_TOKEN",
355    ])
356    .map_err(|_| ClaudeError::ApiKeyNotFound)?;
357
358    let ai_client = ClaudeAiClient::new(claude_model, api_key);
359    debug!("Claude client created successfully");
360    Ok(ClaudeClient::new(Box::new(ai_client)))
361}