1use 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
11pub struct ClaudeClient {
13 ai_client: Box<dyn AiClient>,
15}
16
17impl ClaudeClient {
18 pub fn new(ai_client: Box<dyn AiClient>) -> Self {
20 Self { ai_client }
21 }
22
23 pub fn get_ai_client_metadata(&self) -> crate::claude::ai::AiClientMetadata {
25 self.ai_client.get_metadata()
26 }
27
28 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 pub fn from_env(model: String) -> Result<Self> {
37 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 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 pub async fn generate_amendments_with_options(
57 &self,
58 repo_view: &RepositoryView,
59 fresh: bool,
60 ) -> Result<AmendmentFile> {
61 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 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
68 .context("Failed to serialize repository view to YAML")?;
69
70 let user_prompt = prompts::generate_user_prompt(&repo_yaml);
72
73 let content = self
75 .ai_client
76 .send_request(prompts::SYSTEM_PROMPT, &user_prompt)
77 .await?;
78
79 self.parse_amendment_response(&content)
81 }
82
83 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 pub async fn generate_contextual_amendments_with_options(
98 &self,
99 repo_view: &RepositoryView,
100 context: &CommitContext,
101 fresh: bool,
102 ) -> Result<AmendmentFile> {
103 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 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
110 .context("Failed to serialize repository view to YAML")?;
111
112 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 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 let content = self
138 .ai_client
139 .send_request(&system_prompt, &user_prompt)
140 .await?;
141
142 self.parse_amendment_response(&content)
144 }
145
146 fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
148 let yaml_content = self.extract_yaml_from_response(content);
150
151 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 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 amendment_file
174 .validate()
175 .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {}", e)))?;
176
177 Ok(amendment_file)
178 }
179
180 pub async fn generate_pr_content(
182 &self,
183 repo_view: &RepositoryView,
184 pr_template: &str,
185 ) -> Result<crate::cli::git::PrContent> {
186 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
188 .context("Failed to enhance repository view with diff content")?;
189
190 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
192 .context("Failed to serialize repository view to YAML")?;
193
194 let user_prompt = prompts::generate_pr_description_prompt(&repo_yaml, pr_template);
196
197 let content = self
199 .ai_client
200 .send_request(prompts::PR_GENERATION_SYSTEM_PROMPT, &user_prompt)
201 .await?;
202
203 let yaml_content = content.trim();
205
206 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 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 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
223 .context("Failed to enhance repository view with diff content")?;
224
225 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
227 .context("Failed to serialize repository view to YAML")?;
228
229 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 let content = self
245 .ai_client
246 .send_request(&system_prompt, &user_prompt)
247 .await?;
248
249 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 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 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 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 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 let mut ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
314 .context("Failed to enhance repository view with diff content")?;
315
316 for commit in &mut ai_repo_view.commits {
318 commit.run_pre_validation_checks();
319 }
320
321 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
323 .context("Failed to serialize repository view to YAML")?;
324
325 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 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 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 let yaml_content = self.extract_yaml_from_check_response(content);
380
381 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 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 let results: Vec<CheckResultType> = ai_response
403 .checks
404 .into_iter()
405 .map(|check| {
406 let mut result: CheckResultType = check.into();
407 if let Some(msg) = commit_messages.get(result.hash.as_str()) {
409 result.message = msg.lines().next().unwrap_or("").to_string();
410 } else {
411 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 fn extract_yaml_from_check_response(&self, content: &str) -> String {
428 let content = content.trim();
429
430 if content.starts_with("checks:") {
432 return content.to_string();
433 }
434
435 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 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 if potential_yaml.starts_with("checks:") {
448 return potential_yaml.to_string();
449 }
450 }
451 }
452
453 content.to_string()
455 }
456
457 fn extract_yaml_from_response(&self, content: &str) -> String {
459 let content = content.trim();
460
461 if content.starts_with("amendments:") {
463 return content.to_string();
464 }
465
466 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 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 if potential_yaml.starts_with("amendments:") {
479 return potential_yaml.to_string();
480 }
481 }
482 }
483
484 content.to_string()
486 }
487}
488
489fn 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
518pub 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 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 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 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 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 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 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 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}