Skip to main content

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