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        self.generate_amendments_with_options(repo_view, false)
42            .await
43    }
44
45    /// Generate commit message amendments from repository view with options
46    ///
47    /// If `fresh` is true, ignores existing commit messages and generates new ones
48    /// based solely on the diff content.
49    pub async fn generate_amendments_with_options(
50        &self,
51        repo_view: &RepositoryView,
52        fresh: bool,
53    ) -> Result<AmendmentFile> {
54        // Convert to AI-enhanced view with diff content
55        let ai_repo_view =
56            RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
57                .context("Failed to enhance repository view with diff content")?;
58
59        // Convert repository view to YAML
60        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
61            .context("Failed to serialize repository view to YAML")?;
62
63        // Generate user prompt
64        let user_prompt = prompts::generate_user_prompt(&repo_yaml);
65
66        // Send request using AI client
67        let content = self
68            .ai_client
69            .send_request(prompts::SYSTEM_PROMPT, &user_prompt)
70            .await?;
71
72        // Parse YAML response to AmendmentFile
73        self.parse_amendment_response(&content)
74    }
75
76    /// Generate contextual commit message amendments with enhanced intelligence
77    pub async fn generate_contextual_amendments(
78        &self,
79        repo_view: &RepositoryView,
80        context: &CommitContext,
81    ) -> Result<AmendmentFile> {
82        self.generate_contextual_amendments_with_options(repo_view, context, false)
83            .await
84    }
85
86    /// Generate contextual commit message amendments with options
87    ///
88    /// If `fresh` is true, ignores existing commit messages and generates new ones
89    /// based solely on the diff content.
90    pub async fn generate_contextual_amendments_with_options(
91        &self,
92        repo_view: &RepositoryView,
93        context: &CommitContext,
94        fresh: bool,
95    ) -> Result<AmendmentFile> {
96        // Convert to AI-enhanced view with diff content
97        let ai_repo_view =
98            RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
99                .context("Failed to enhance repository view with diff content")?;
100
101        // Convert repository view to YAML
102        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
103            .context("Failed to serialize repository view to YAML")?;
104
105        // Generate contextual prompts using intelligence
106        let provider = self.ai_client.get_metadata().provider;
107        let provider_name = if provider.to_lowercase().contains("openai")
108            || provider.to_lowercase().contains("ollama")
109        {
110            "openai"
111        } else {
112            "claude"
113        };
114        let system_prompt =
115            prompts::generate_contextual_system_prompt_for_provider(context, provider_name);
116        let user_prompt = prompts::generate_contextual_user_prompt(&repo_yaml, context);
117
118        // Debug logging to troubleshoot custom commit type issue
119        match &context.project.commit_guidelines {
120            Some(guidelines) => {
121                debug!(length = guidelines.len(), "Project commit guidelines found");
122                debug!(guidelines = %guidelines, "Commit guidelines content");
123            }
124            None => {
125                debug!("No project commit guidelines found");
126            }
127        }
128
129        // Send request using AI client
130        let content = self
131            .ai_client
132            .send_request(&system_prompt, &user_prompt)
133            .await?;
134
135        // Parse YAML response to AmendmentFile
136        self.parse_amendment_response(&content)
137    }
138
139    /// Parse Claude's YAML response into AmendmentFile
140    fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
141        // Extract YAML from potential markdown wrapper
142        let yaml_content = self.extract_yaml_from_response(content);
143
144        // Try to parse YAML using our hybrid YAML parser
145        let amendment_file: AmendmentFile = crate::data::from_yaml(&yaml_content).map_err(|e| {
146            debug!(
147                error = %e,
148                content_length = content.len(),
149                yaml_length = yaml_content.len(),
150                "YAML parsing failed"
151            );
152            debug!(content = %content, "Raw Claude response");
153            debug!(yaml = %yaml_content, "Extracted YAML content");
154
155            // Try to provide more helpful error messages for common issues
156            if yaml_content.lines().any(|line| line.contains('\t')) {
157                ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
158            } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
159                ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
160            } else {
161                ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {}", e))
162            }
163        })?;
164
165        // Validate the parsed amendments
166        amendment_file
167            .validate()
168            .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {}", e)))?;
169
170        Ok(amendment_file)
171    }
172
173    /// Generate AI-powered PR content (title + description) from repository view and template
174    pub async fn generate_pr_content(
175        &self,
176        repo_view: &RepositoryView,
177        pr_template: &str,
178    ) -> Result<crate::cli::git::PrContent> {
179        // Convert to AI-enhanced view with diff content
180        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
181            .context("Failed to enhance repository view with diff content")?;
182
183        // Convert repository view to YAML
184        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
185            .context("Failed to serialize repository view to YAML")?;
186
187        // Generate prompts for PR description
188        let user_prompt = prompts::generate_pr_description_prompt(&repo_yaml, pr_template);
189
190        // Send request using AI client
191        let content = self
192            .ai_client
193            .send_request(prompts::PR_GENERATION_SYSTEM_PROMPT, &user_prompt)
194            .await?;
195
196        // The AI response should be treated as YAML directly
197        let yaml_content = content.trim();
198
199        // Parse the YAML response using our hybrid YAML parser
200        let pr_content: crate::cli::git::PrContent = crate::data::from_yaml(yaml_content).context(
201            "Failed to parse AI response as YAML. AI may have returned malformed output.",
202        )?;
203
204        Ok(pr_content)
205    }
206
207    /// Generate AI-powered PR content with project context (title + description)
208    pub async fn generate_pr_content_with_context(
209        &self,
210        repo_view: &RepositoryView,
211        pr_template: &str,
212        context: &crate::data::context::CommitContext,
213    ) -> Result<crate::cli::git::PrContent> {
214        // Convert to AI-enhanced view with diff content
215        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
216            .context("Failed to enhance repository view with diff content")?;
217
218        // Convert repository view to YAML
219        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
220            .context("Failed to serialize repository view to YAML")?;
221
222        // Generate contextual prompts for PR description with provider-specific handling
223        let provider = self.ai_client.get_metadata().provider;
224        let provider_name = if provider.to_lowercase().contains("openai")
225            || provider.to_lowercase().contains("ollama")
226        {
227            "openai"
228        } else {
229            "claude"
230        };
231        let system_prompt =
232            prompts::generate_pr_system_prompt_with_context_for_provider(context, provider_name);
233        let user_prompt =
234            prompts::generate_pr_description_prompt_with_context(&repo_yaml, pr_template, context);
235
236        // Send request using AI client
237        let content = self
238            .ai_client
239            .send_request(&system_prompt, &user_prompt)
240            .await?;
241
242        // The AI response should be treated as YAML directly
243        let yaml_content = content.trim();
244
245        debug!(
246            content_length = content.len(),
247            yaml_content_length = yaml_content.len(),
248            yaml_content = %yaml_content,
249            "Extracted YAML content from AI response"
250        );
251
252        // Parse the YAML response using our hybrid YAML parser
253        let pr_content: crate::cli::git::PrContent = crate::data::from_yaml(yaml_content).context(
254            "Failed to parse AI response as YAML. AI may have returned malformed output.",
255        )?;
256
257        debug!(
258            parsed_title = %pr_content.title,
259            parsed_description_length = pr_content.description.len(),
260            parsed_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
261            "Successfully parsed PR content from YAML"
262        );
263
264        Ok(pr_content)
265    }
266
267    /// Check commit messages against guidelines and return a report
268    ///
269    /// Validates commit messages against project guidelines or defaults,
270    /// returning a structured report with issues and suggestions.
271    pub async fn check_commits(
272        &self,
273        repo_view: &RepositoryView,
274        guidelines: Option<&str>,
275        include_suggestions: bool,
276    ) -> Result<crate::data::check::CheckReport> {
277        self.check_commits_with_retry(repo_view, guidelines, include_suggestions, 2)
278            .await
279    }
280
281    /// Check commit messages with retry logic for parse failures
282    async fn check_commits_with_retry(
283        &self,
284        repo_view: &RepositoryView,
285        guidelines: Option<&str>,
286        include_suggestions: bool,
287        max_retries: u32,
288    ) -> Result<crate::data::check::CheckReport> {
289        // Convert to AI-enhanced view with diff content
290        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
291            .context("Failed to enhance repository view with diff content")?;
292
293        // Convert repository view to YAML
294        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
295            .context("Failed to serialize repository view to YAML")?;
296
297        // Generate prompts
298        let system_prompt = prompts::generate_check_system_prompt(guidelines);
299        let user_prompt = prompts::generate_check_user_prompt(&repo_yaml, include_suggestions);
300
301        let mut last_error = None;
302
303        for attempt in 0..=max_retries {
304            // Send request using AI client
305            match self
306                .ai_client
307                .send_request(&system_prompt, &user_prompt)
308                .await
309            {
310                Ok(content) => match self.parse_check_response(&content, repo_view) {
311                    Ok(report) => return Ok(report),
312                    Err(e) => {
313                        if attempt < max_retries {
314                            eprintln!(
315                                "warning: failed to parse AI response (attempt {}), retrying...",
316                                attempt + 1
317                            );
318                            debug!(error = %e, attempt = attempt + 1, "Check response parse failed, retrying");
319                        }
320                        last_error = Some(e);
321                    }
322                },
323                Err(e) => {
324                    if attempt < max_retries {
325                        eprintln!(
326                            "warning: AI request failed (attempt {}), retrying...",
327                            attempt + 1
328                        );
329                        debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
330                    }
331                    last_error = Some(e);
332                }
333            }
334        }
335
336        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Check failed after retries")))
337    }
338
339    /// Parse the check response from AI
340    fn parse_check_response(
341        &self,
342        content: &str,
343        repo_view: &RepositoryView,
344    ) -> Result<crate::data::check::CheckReport> {
345        use crate::data::check::{
346            AiCheckResponse, CheckReport, CommitCheckResult as CheckResultType,
347        };
348
349        // Extract YAML from potential markdown wrapper
350        let yaml_content = self.extract_yaml_from_check_response(content);
351
352        // Parse YAML response
353        let ai_response: AiCheckResponse = crate::data::from_yaml(&yaml_content).map_err(|e| {
354            debug!(
355                error = %e,
356                content_length = content.len(),
357                yaml_length = yaml_content.len(),
358                "Check YAML parsing failed"
359            );
360            debug!(content = %content, "Raw AI response");
361            debug!(yaml = %yaml_content, "Extracted YAML content");
362            ClaudeError::AmendmentParsingFailed(format!("Check response parsing error: {}", e))
363        })?;
364
365        // Create a map of commit hashes to original messages for lookup
366        let commit_messages: std::collections::HashMap<&str, &str> = repo_view
367            .commits
368            .iter()
369            .map(|c| (c.hash.as_str(), c.original_message.as_str()))
370            .collect();
371
372        // Convert AI response to CheckReport
373        let results: Vec<CheckResultType> = ai_response
374            .checks
375            .into_iter()
376            .map(|check| {
377                let mut result: CheckResultType = check.into();
378                // Fill in the original message from repo_view
379                if let Some(msg) = commit_messages.get(result.hash.as_str()) {
380                    result.message = msg.lines().next().unwrap_or("").to_string();
381                } else {
382                    // Try to find by prefix
383                    for (hash, msg) in &commit_messages {
384                        if hash.starts_with(&result.hash) || result.hash.starts_with(*hash) {
385                            result.message = msg.lines().next().unwrap_or("").to_string();
386                            break;
387                        }
388                    }
389                }
390                result
391            })
392            .collect();
393
394        Ok(CheckReport::new(results))
395    }
396
397    /// Extract YAML content from check response, handling markdown wrappers
398    fn extract_yaml_from_check_response(&self, content: &str) -> String {
399        let content = content.trim();
400
401        // If content already starts with "checks:", it's pure YAML - return as-is
402        if content.starts_with("checks:") {
403            return content.to_string();
404        }
405
406        // Try to extract from ```yaml blocks first
407        if let Some(yaml_start) = content.find("```yaml") {
408            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
409                return yaml_content.trim().to_string();
410            }
411        }
412
413        // Try to extract from generic ``` blocks
414        if let Some(code_start) = content.find("```") {
415            if let Some(code_content) = content[code_start + 3..].split("```").next() {
416                let potential_yaml = code_content.trim();
417                // Check if it looks like YAML (starts with expected structure)
418                if potential_yaml.starts_with("checks:") {
419                    return potential_yaml.to_string();
420                }
421            }
422        }
423
424        // If no markdown blocks found or extraction failed, return trimmed content
425        content.to_string()
426    }
427
428    /// Extract YAML content from Claude response, handling markdown wrappers
429    fn extract_yaml_from_response(&self, content: &str) -> String {
430        let content = content.trim();
431
432        // If content already starts with "amendments:", it's pure YAML - return as-is
433        if content.starts_with("amendments:") {
434            return content.to_string();
435        }
436
437        // Try to extract from ```yaml blocks first
438        if let Some(yaml_start) = content.find("```yaml") {
439            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
440                return yaml_content.trim().to_string();
441            }
442        }
443
444        // Try to extract from generic ``` blocks
445        if let Some(code_start) = content.find("```") {
446            if let Some(code_content) = content[code_start + 3..].split("```").next() {
447                let potential_yaml = code_content.trim();
448                // Check if it looks like YAML (starts with expected structure)
449                if potential_yaml.starts_with("amendments:") {
450                    return potential_yaml.to_string();
451                }
452            }
453        }
454
455        // If no markdown blocks found or extraction failed, return trimmed content
456        content.to_string()
457    }
458}
459
460/// Create a default Claude client using environment variables and settings
461pub fn create_default_claude_client(model: Option<String>) -> Result<ClaudeClient> {
462    use crate::claude::ai::openai::OpenAiAiClient;
463    use crate::utils::settings::{get_env_var, get_env_vars};
464
465    // Check if we should use OpenAI-compatible API (OpenAI or Ollama)
466    let use_openai = get_env_var("USE_OPENAI")
467        .map(|val| val == "true")
468        .unwrap_or(false);
469
470    let use_ollama = get_env_var("USE_OLLAMA")
471        .map(|val| val == "true")
472        .unwrap_or(false);
473
474    // Check if we should use Bedrock
475    let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK")
476        .map(|val| val == "true")
477        .unwrap_or(false);
478
479    debug!(
480        use_openai = use_openai,
481        use_ollama = use_ollama,
482        use_bedrock = use_bedrock,
483        "Client selection flags"
484    );
485
486    // Handle Ollama configuration
487    if use_ollama {
488        let ollama_model = model
489            .or_else(|| get_env_var("OLLAMA_MODEL").ok())
490            .unwrap_or_else(|| "llama2".to_string());
491        let base_url = get_env_var("OLLAMA_BASE_URL").ok();
492        let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url);
493        return Ok(ClaudeClient::new(Box::new(ai_client)));
494    }
495
496    // Handle OpenAI configuration
497    if use_openai {
498        debug!("Creating OpenAI client");
499        let openai_model = model
500            .or_else(|| get_env_var("OPENAI_MODEL").ok())
501            .unwrap_or_else(|| "gpt-5".to_string());
502        debug!(openai_model = %openai_model, "Selected OpenAI model");
503
504        let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
505            debug!(error = ?e, "Failed to get OpenAI API key");
506            ClaudeError::ApiKeyNotFound
507        })?;
508        debug!("OpenAI API key found");
509
510        let ai_client = OpenAiAiClient::new_openai(openai_model, api_key);
511        debug!("OpenAI client created successfully");
512        return Ok(ClaudeClient::new(Box::new(ai_client)));
513    }
514
515    // For Claude clients, try to get model from env vars or use default
516    let claude_model = model
517        .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
518        .unwrap_or_else(|| "claude-opus-4-1-20250805".to_string());
519
520    if use_bedrock {
521        // Check if we should skip Bedrock auth
522        let skip_bedrock_auth = get_env_var("CLAUDE_CODE_SKIP_BEDROCK_AUTH")
523            .map(|val| val == "true")
524            .unwrap_or(false);
525
526        if skip_bedrock_auth {
527            // Use Bedrock AI client
528            let auth_token =
529                get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
530
531            let base_url = get_env_var("ANTHROPIC_BEDROCK_BASE_URL")
532                .map_err(|_| ClaudeError::ApiKeyNotFound)?;
533
534            let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url);
535            return Ok(ClaudeClient::new(Box::new(ai_client)));
536        }
537    }
538
539    // Default: use standard Claude AI client
540    debug!("Falling back to Claude client");
541    let api_key = get_env_vars(&[
542        "CLAUDE_API_KEY",
543        "ANTHROPIC_API_KEY",
544        "ANTHROPIC_AUTH_TOKEN",
545    ])
546    .map_err(|_| ClaudeError::ApiKeyNotFound)?;
547
548    let ai_client = ClaudeAiClient::new(claude_model, api_key);
549    debug!("Claude client created successfully");
550    Ok(ClaudeClient::new(Box::new(ai_client)))
551}