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 fn from_env(model: String) -> Result<Self> {
30 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 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 pub async fn generate_amendments_with_options(
50 &self,
51 repo_view: &RepositoryView,
52 fresh: bool,
53 ) -> Result<AmendmentFile> {
54 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 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
61 .context("Failed to serialize repository view to YAML")?;
62
63 let user_prompt = prompts::generate_user_prompt(&repo_yaml);
65
66 let content = self
68 .ai_client
69 .send_request(prompts::SYSTEM_PROMPT, &user_prompt)
70 .await?;
71
72 self.parse_amendment_response(&content)
74 }
75
76 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 pub async fn generate_contextual_amendments_with_options(
91 &self,
92 repo_view: &RepositoryView,
93 context: &CommitContext,
94 fresh: bool,
95 ) -> Result<AmendmentFile> {
96 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 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
103 .context("Failed to serialize repository view to YAML")?;
104
105 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 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 let content = self
131 .ai_client
132 .send_request(&system_prompt, &user_prompt)
133 .await?;
134
135 self.parse_amendment_response(&content)
137 }
138
139 fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
141 let yaml_content = self.extract_yaml_from_response(content);
143
144 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 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 amendment_file
167 .validate()
168 .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {}", e)))?;
169
170 Ok(amendment_file)
171 }
172
173 pub async fn generate_pr_content(
175 &self,
176 repo_view: &RepositoryView,
177 pr_template: &str,
178 ) -> Result<crate::cli::git::PrContent> {
179 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
181 .context("Failed to enhance repository view with diff content")?;
182
183 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
185 .context("Failed to serialize repository view to YAML")?;
186
187 let user_prompt = prompts::generate_pr_description_prompt(&repo_yaml, pr_template);
189
190 let content = self
192 .ai_client
193 .send_request(prompts::PR_GENERATION_SYSTEM_PROMPT, &user_prompt)
194 .await?;
195
196 let yaml_content = content.trim();
198
199 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 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 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
216 .context("Failed to enhance repository view with diff content")?;
217
218 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
220 .context("Failed to serialize repository view to YAML")?;
221
222 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 let content = self
238 .ai_client
239 .send_request(&system_prompt, &user_prompt)
240 .await?;
241
242 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 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 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 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 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 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
307 .context("Failed to enhance repository view with diff content")?;
308
309 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
311 .context("Failed to serialize repository view to YAML")?;
312
313 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 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 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 let yaml_content = self.extract_yaml_from_check_response(content);
368
369 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 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 let results: Vec<CheckResultType> = ai_response
391 .checks
392 .into_iter()
393 .map(|check| {
394 let mut result: CheckResultType = check.into();
395 if let Some(msg) = commit_messages.get(result.hash.as_str()) {
397 result.message = msg.lines().next().unwrap_or("").to_string();
398 } else {
399 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 fn extract_yaml_from_check_response(&self, content: &str) -> String {
416 let content = content.trim();
417
418 if content.starts_with("checks:") {
420 return content.to_string();
421 }
422
423 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 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 if potential_yaml.starts_with("checks:") {
436 return potential_yaml.to_string();
437 }
438 }
439 }
440
441 content.to_string()
443 }
444
445 fn extract_yaml_from_response(&self, content: &str) -> String {
447 let content = content.trim();
448
449 if content.starts_with("amendments:") {
451 return content.to_string();
452 }
453
454 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 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 if potential_yaml.starts_with("amendments:") {
467 return potential_yaml.to_string();
468 }
469 }
470 }
471
472 content.to_string()
474 }
475}
476
477pub 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 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 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 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 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 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 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 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}