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_retry(repo_view, guidelines, include_suggestions, 2)
278 .await
279 }
280
281 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 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
291 .context("Failed to enhance repository view with diff content")?;
292
293 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
295 .context("Failed to serialize repository view to YAML")?;
296
297 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 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 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 let yaml_content = self.extract_yaml_from_check_response(content);
351
352 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 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 let results: Vec<CheckResultType> = ai_response
374 .checks
375 .into_iter()
376 .map(|check| {
377 let mut result: CheckResultType = check.into();
378 if let Some(msg) = commit_messages.get(result.hash.as_str()) {
380 result.message = msg.lines().next().unwrap_or("").to_string();
381 } else {
382 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 fn extract_yaml_from_check_response(&self, content: &str) -> String {
399 let content = content.trim();
400
401 if content.starts_with("checks:") {
403 return content.to_string();
404 }
405
406 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 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 if potential_yaml.starts_with("checks:") {
419 return potential_yaml.to_string();
420 }
421 }
422 }
423
424 content.to_string()
426 }
427
428 fn extract_yaml_from_response(&self, content: &str) -> String {
430 let content = content.trim();
431
432 if content.starts_with("amendments:") {
434 return content.to_string();
435 }
436
437 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 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 if potential_yaml.starts_with("amendments:") {
450 return potential_yaml.to_string();
451 }
452 }
453 }
454
455 content.to_string()
457 }
458}
459
460pub 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 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 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 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 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 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 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 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 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}