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_scopes(repo_view, guidelines, &[], include_suggestions)
278            .await
279    }
280
281    /// Check commit messages against guidelines with valid scopes and return a report
282    ///
283    /// Validates commit messages against project guidelines or defaults,
284    /// using the provided valid scopes for scope validation.
285    pub async fn check_commits_with_scopes(
286        &self,
287        repo_view: &RepositoryView,
288        guidelines: Option<&str>,
289        valid_scopes: &[crate::data::context::ScopeDefinition],
290        include_suggestions: bool,
291    ) -> Result<crate::data::check::CheckReport> {
292        self.check_commits_with_retry(repo_view, guidelines, valid_scopes, include_suggestions, 2)
293            .await
294    }
295
296    /// Check commit messages with retry logic for parse failures
297    async fn check_commits_with_retry(
298        &self,
299        repo_view: &RepositoryView,
300        guidelines: Option<&str>,
301        valid_scopes: &[crate::data::context::ScopeDefinition],
302        include_suggestions: bool,
303        max_retries: u32,
304    ) -> Result<crate::data::check::CheckReport> {
305        // Convert to AI-enhanced view with diff content
306        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
307            .context("Failed to enhance repository view with diff content")?;
308
309        // Convert repository view to YAML
310        let repo_yaml = crate::data::to_yaml(&ai_repo_view)
311            .context("Failed to serialize repository view to YAML")?;
312
313        // Generate prompts with scopes
314        let system_prompt =
315            prompts::generate_check_system_prompt_with_scopes(guidelines, valid_scopes);
316        let user_prompt = prompts::generate_check_user_prompt(&repo_yaml, include_suggestions);
317
318        let mut last_error = None;
319
320        for attempt in 0..=max_retries {
321            // Send request using AI client
322            match self
323                .ai_client
324                .send_request(&system_prompt, &user_prompt)
325                .await
326            {
327                Ok(content) => match self.parse_check_response(&content, repo_view) {
328                    Ok(report) => return Ok(report),
329                    Err(e) => {
330                        if attempt < max_retries {
331                            eprintln!(
332                                "warning: failed to parse AI response (attempt {}), retrying...",
333                                attempt + 1
334                            );
335                            debug!(error = %e, attempt = attempt + 1, "Check response parse failed, retrying");
336                        }
337                        last_error = Some(e);
338                    }
339                },
340                Err(e) => {
341                    if attempt < max_retries {
342                        eprintln!(
343                            "warning: AI request failed (attempt {}), retrying...",
344                            attempt + 1
345                        );
346                        debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
347                    }
348                    last_error = Some(e);
349                }
350            }
351        }
352
353        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Check failed after retries")))
354    }
355
356    /// Parse the check response from AI
357    fn parse_check_response(
358        &self,
359        content: &str,
360        repo_view: &RepositoryView,
361    ) -> Result<crate::data::check::CheckReport> {
362        use crate::data::check::{
363            AiCheckResponse, CheckReport, CommitCheckResult as CheckResultType,
364        };
365
366        // Extract YAML from potential markdown wrapper
367        let yaml_content = self.extract_yaml_from_check_response(content);
368
369        // Parse YAML response
370        let ai_response: AiCheckResponse = crate::data::from_yaml(&yaml_content).map_err(|e| {
371            debug!(
372                error = %e,
373                content_length = content.len(),
374                yaml_length = yaml_content.len(),
375                "Check YAML parsing failed"
376            );
377            debug!(content = %content, "Raw AI response");
378            debug!(yaml = %yaml_content, "Extracted YAML content");
379            ClaudeError::AmendmentParsingFailed(format!("Check response parsing error: {}", e))
380        })?;
381
382        // Create a map of commit hashes to original messages for lookup
383        let commit_messages: std::collections::HashMap<&str, &str> = repo_view
384            .commits
385            .iter()
386            .map(|c| (c.hash.as_str(), c.original_message.as_str()))
387            .collect();
388
389        // Convert AI response to CheckReport
390        let results: Vec<CheckResultType> = ai_response
391            .checks
392            .into_iter()
393            .map(|check| {
394                let mut result: CheckResultType = check.into();
395                // Fill in the original message from repo_view
396                if let Some(msg) = commit_messages.get(result.hash.as_str()) {
397                    result.message = msg.lines().next().unwrap_or("").to_string();
398                } else {
399                    // Try to find by prefix
400                    for (hash, msg) in &commit_messages {
401                        if hash.starts_with(&result.hash) || result.hash.starts_with(*hash) {
402                            result.message = msg.lines().next().unwrap_or("").to_string();
403                            break;
404                        }
405                    }
406                }
407                result
408            })
409            .collect();
410
411        Ok(CheckReport::new(results))
412    }
413
414    /// Extract YAML content from check response, handling markdown wrappers
415    fn extract_yaml_from_check_response(&self, content: &str) -> String {
416        let content = content.trim();
417
418        // If content already starts with "checks:", it's pure YAML - return as-is
419        if content.starts_with("checks:") {
420            return content.to_string();
421        }
422
423        // Try to extract from ```yaml blocks first
424        if let Some(yaml_start) = content.find("```yaml") {
425            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
426                return yaml_content.trim().to_string();
427            }
428        }
429
430        // Try to extract from generic ``` blocks
431        if let Some(code_start) = content.find("```") {
432            if let Some(code_content) = content[code_start + 3..].split("```").next() {
433                let potential_yaml = code_content.trim();
434                // Check if it looks like YAML (starts with expected structure)
435                if potential_yaml.starts_with("checks:") {
436                    return potential_yaml.to_string();
437                }
438            }
439        }
440
441        // If no markdown blocks found or extraction failed, return trimmed content
442        content.to_string()
443    }
444
445    /// Extract YAML content from Claude response, handling markdown wrappers
446    fn extract_yaml_from_response(&self, content: &str) -> String {
447        let content = content.trim();
448
449        // If content already starts with "amendments:", it's pure YAML - return as-is
450        if content.starts_with("amendments:") {
451            return content.to_string();
452        }
453
454        // Try to extract from ```yaml blocks first
455        if let Some(yaml_start) = content.find("```yaml") {
456            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
457                return yaml_content.trim().to_string();
458            }
459        }
460
461        // Try to extract from generic ``` blocks
462        if let Some(code_start) = content.find("```") {
463            if let Some(code_content) = content[code_start + 3..].split("```").next() {
464                let potential_yaml = code_content.trim();
465                // Check if it looks like YAML (starts with expected structure)
466                if potential_yaml.starts_with("amendments:") {
467                    return potential_yaml.to_string();
468                }
469            }
470        }
471
472        // If no markdown blocks found or extraction failed, return trimmed content
473        content.to_string()
474    }
475}
476
477/// Create a default Claude client using environment variables and settings
478pub fn create_default_claude_client(model: Option<String>) -> Result<ClaudeClient> {
479    use crate::claude::ai::openai::OpenAiAiClient;
480    use crate::utils::settings::{get_env_var, get_env_vars};
481
482    // Check if we should use OpenAI-compatible API (OpenAI or Ollama)
483    let use_openai = get_env_var("USE_OPENAI")
484        .map(|val| val == "true")
485        .unwrap_or(false);
486
487    let use_ollama = get_env_var("USE_OLLAMA")
488        .map(|val| val == "true")
489        .unwrap_or(false);
490
491    // Check if we should use Bedrock
492    let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK")
493        .map(|val| val == "true")
494        .unwrap_or(false);
495
496    debug!(
497        use_openai = use_openai,
498        use_ollama = use_ollama,
499        use_bedrock = use_bedrock,
500        "Client selection flags"
501    );
502
503    // Handle Ollama configuration
504    if use_ollama {
505        let ollama_model = model
506            .or_else(|| get_env_var("OLLAMA_MODEL").ok())
507            .unwrap_or_else(|| "llama2".to_string());
508        let base_url = get_env_var("OLLAMA_BASE_URL").ok();
509        let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url);
510        return Ok(ClaudeClient::new(Box::new(ai_client)));
511    }
512
513    // Handle OpenAI configuration
514    if use_openai {
515        debug!("Creating OpenAI client");
516        let openai_model = model
517            .or_else(|| get_env_var("OPENAI_MODEL").ok())
518            .unwrap_or_else(|| "gpt-5".to_string());
519        debug!(openai_model = %openai_model, "Selected OpenAI model");
520
521        let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
522            debug!(error = ?e, "Failed to get OpenAI API key");
523            ClaudeError::ApiKeyNotFound
524        })?;
525        debug!("OpenAI API key found");
526
527        let ai_client = OpenAiAiClient::new_openai(openai_model, api_key);
528        debug!("OpenAI client created successfully");
529        return Ok(ClaudeClient::new(Box::new(ai_client)));
530    }
531
532    // For Claude clients, try to get model from env vars or use default
533    let claude_model = model
534        .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
535        .unwrap_or_else(|| "claude-opus-4-1-20250805".to_string());
536
537    if use_bedrock {
538        // Use Bedrock AI client
539        let auth_token =
540            get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
541
542        let base_url =
543            get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
544
545        let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url);
546        return Ok(ClaudeClient::new(Box::new(ai_client)));
547    }
548
549    // Default: use standard Claude AI client
550    debug!("Falling back to Claude client");
551    let api_key = get_env_vars(&[
552        "CLAUDE_API_KEY",
553        "ANTHROPIC_API_KEY",
554        "ANTHROPIC_AUTH_TOKEN",
555    ])
556    .map_err(|_| ClaudeError::ApiKeyNotFound)?;
557
558    let ai_client = ClaudeAiClient::new(claude_model, api_key);
559    debug!("Claude client created successfully");
560    Ok(ClaudeClient::new(Box::new(ai_client)))
561}