1use anyhow::{Context, Result};
4use tracing::{debug, info};
5
6use crate::claude::token_budget::TokenBudget;
7use crate::claude::{ai::bedrock::BedrockAiClient, ai::claude::ClaudeAiClient};
8use crate::claude::{ai::AiClient, error::ClaudeError, prompts};
9use crate::data::{
10 amendments::{Amendment, AmendmentFile},
11 context::CommitContext,
12 RepositoryView, RepositoryViewForAI,
13};
14
15struct BudgetExceeded {
20 available_input_tokens: usize,
22}
23
24const AMENDMENT_PARSE_MAX_RETRIES: u32 = 2;
26
27pub struct ClaudeClient {
29 ai_client: Box<dyn AiClient>,
31}
32
33impl ClaudeClient {
34 pub fn new(ai_client: Box<dyn AiClient>) -> Self {
36 Self { ai_client }
37 }
38
39 pub fn get_ai_client_metadata(&self) -> crate::claude::ai::AiClientMetadata {
41 self.ai_client.get_metadata()
42 }
43
44 fn validate_prompt_budget(&self, system_prompt: &str, user_prompt: &str) -> Result<()> {
49 let metadata = self.ai_client.get_metadata();
50 let budget = TokenBudget::from_metadata(&metadata);
51 let estimate = budget.validate_prompt(system_prompt, user_prompt)?;
52
53 debug!(
54 model = %metadata.model,
55 estimated_tokens = estimate.estimated_tokens,
56 available_tokens = estimate.available_tokens,
57 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
58 "Token budget check passed"
59 );
60
61 Ok(())
62 }
63
64 fn build_prompt_fitting_budget(
70 &self,
71 ai_view: &RepositoryViewForAI,
72 system_prompt: &str,
73 build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
74 ) -> Result<String> {
75 let metadata = self.ai_client.get_metadata();
76 let budget = TokenBudget::from_metadata(&metadata);
77
78 let yaml =
79 crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
80 let user_prompt = build_user_prompt(&yaml);
81
82 let estimate = budget.validate_prompt(system_prompt, &user_prompt)?;
83 debug!(
84 model = %metadata.model,
85 estimated_tokens = estimate.estimated_tokens,
86 available_tokens = estimate.available_tokens,
87 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
88 "Token budget check passed"
89 );
90
91 Ok(user_prompt)
92 }
93
94 fn try_full_diff_budget(
100 &self,
101 ai_view: &RepositoryViewForAI,
102 system_prompt: &str,
103 build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
104 ) -> Result<std::result::Result<String, BudgetExceeded>> {
105 let metadata = self.ai_client.get_metadata();
106 let budget = TokenBudget::from_metadata(&metadata);
107
108 let yaml =
109 crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
110 let user_prompt = build_user_prompt(&yaml);
111
112 if let Ok(estimate) = budget.validate_prompt(system_prompt, &user_prompt) {
113 debug!(
114 model = %metadata.model,
115 estimated_tokens = estimate.estimated_tokens,
116 available_tokens = estimate.available_tokens,
117 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
118 "Token budget check passed"
119 );
120 return Ok(Ok(user_prompt));
121 }
122
123 Ok(Err(BudgetExceeded {
124 available_input_tokens: budget.available_input_tokens(),
125 }))
126 }
127
128 async fn generate_amendment_split(
135 &self,
136 commit: &crate::git::CommitInfo,
137 repo_view_for_ai: &RepositoryViewForAI,
138 system_prompt: &str,
139 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
140 available_input_tokens: usize,
141 fresh: bool,
142 ) -> Result<Amendment> {
143 use crate::claude::batch::{
144 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
145 VIEW_ENVELOPE_OVERHEAD_TOKENS,
146 };
147 use crate::claude::diff_pack::pack_file_diffs;
148 use crate::claude::token_budget;
149 use crate::git::commit::CommitInfoForAI;
150
151 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
159 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
160 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
161 let chunk_capacity = available_input_tokens
162 .saturating_sub(system_prompt_tokens)
163 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
164 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
165 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
166 .saturating_sub(commit_text_tokens);
167
168 debug!(
169 commit = %&commit.hash[..8],
170 available_input_tokens,
171 system_prompt_tokens,
172 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
173 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
174 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
175 commit_text_tokens,
176 chunk_capacity,
177 "Split dispatch: computed chunk capacity"
178 );
179
180 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
181 .with_context(|| {
182 format!(
183 "Failed to plan diff chunks for commit {}",
184 &commit.hash[..8]
185 )
186 })?;
187
188 let total_chunks = plan.chunks.len();
189 debug!(
190 commit = %&commit.hash[..8],
191 chunks = total_chunks,
192 chunk_capacity,
193 "Split dispatch: processing commit in chunks"
194 );
195
196 let mut chunk_amendments = Vec::with_capacity(total_chunks);
197 for (i, chunk) in plan.chunks.iter().enumerate() {
198 let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
199 commit.clone(),
200 &chunk.file_paths,
201 &chunk.diff_overrides,
202 )
203 .with_context(|| {
204 format!(
205 "Failed to build partial view for chunk {}/{} of commit {}",
206 i + 1,
207 total_chunks,
208 &commit.hash[..8]
209 )
210 })?;
211
212 if fresh {
213 partial.base.original_message =
214 "(Original message hidden - generate fresh message from diff)".to_string();
215 }
216
217 let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
218
219 let diff_content_len = partial.base.analysis.diff_content.len();
221 let diff_content_tokens =
222 token_budget::estimate_tokens_from_char_count(diff_content_len);
223 debug!(
224 commit = %&commit.hash[..8],
225 chunk_index = i,
226 diff_content_len,
227 diff_content_tokens,
228 "Split dispatch: chunk diff content size"
229 );
230
231 let user_prompt =
232 self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
233
234 info!(
235 commit = %&commit.hash[..8],
236 chunk = i + 1,
237 total_chunks,
238 user_prompt_len = user_prompt.len(),
239 "Split dispatch: sending chunk to AI"
240 );
241
242 let content = match self
243 .ai_client
244 .send_request(system_prompt, &user_prompt)
245 .await
246 {
247 Ok(content) => content,
248 Err(e) => {
249 tracing::error!(
251 commit = %&commit.hash[..8],
252 chunk = i + 1,
253 error = %e,
254 error_debug = ?e,
255 "Split dispatch: AI request failed"
256 );
257 return Err(e).with_context(|| {
258 format!(
259 "Chunk {}/{} failed for commit {}",
260 i + 1,
261 total_chunks,
262 &commit.hash[..8]
263 )
264 });
265 }
266 };
267
268 info!(
269 commit = %&commit.hash[..8],
270 chunk = i + 1,
271 response_len = content.len(),
272 "Split dispatch: received chunk response"
273 );
274
275 let amendment_file = self.parse_amendment_response(&content).with_context(|| {
276 format!(
277 "Failed to parse chunk {}/{} response for commit {}",
278 i + 1,
279 total_chunks,
280 &commit.hash[..8]
281 )
282 })?;
283
284 if let Some(amendment) = amendment_file.amendments.into_iter().next() {
285 chunk_amendments.push(amendment);
286 }
287 }
288
289 self.merge_amendment_chunks(
290 &commit.hash,
291 &commit.original_message,
292 &commit.analysis.diff_summary,
293 &chunk_amendments,
294 )
295 .await
296 }
297
298 async fn merge_amendment_chunks(
304 &self,
305 commit_hash: &str,
306 original_message: &str,
307 diff_summary: &str,
308 chunk_amendments: &[Amendment],
309 ) -> Result<Amendment> {
310 let system_prompt = prompts::AMENDMENT_CHUNK_MERGE_SYSTEM_PROMPT;
311 let user_prompt = prompts::generate_chunk_merge_user_prompt(
312 commit_hash,
313 original_message,
314 diff_summary,
315 chunk_amendments,
316 );
317
318 self.validate_prompt_budget(system_prompt, &user_prompt)?;
319
320 let content = self
321 .ai_client
322 .send_request(system_prompt, &user_prompt)
323 .await
324 .context("Merge pass failed for chunk amendments")?;
325
326 let amendment_file = self
327 .parse_amendment_response(&content)
328 .context("Failed to parse merge pass response")?;
329
330 amendment_file
331 .amendments
332 .into_iter()
333 .next()
334 .context("Merge pass returned no amendments")
335 }
336
337 async fn generate_amendment_for_commit(
344 &self,
345 commit: &crate::git::CommitInfo,
346 repo_view_for_ai: &RepositoryViewForAI,
347 system_prompt: &str,
348 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
349 fresh: bool,
350 ) -> Result<Amendment> {
351 let mut ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
352 if fresh {
353 ai_commit.base.original_message =
354 "(Original message hidden - generate fresh message from diff)".to_string();
355 }
356 let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
357
358 match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
359 Ok(user_prompt) => {
360 let amendment_file = self
361 .send_and_parse_amendment_with_retry(system_prompt, &user_prompt)
362 .await?;
363 amendment_file
364 .amendments
365 .into_iter()
366 .next()
367 .context("AI returned no amendments for commit")
368 }
369 Err(exceeded) => {
370 if commit.analysis.file_diffs.is_empty() {
371 anyhow::bail!(
372 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
373 &commit.hash[..8]
374 );
375 }
376 self.generate_amendment_split(
377 commit,
378 repo_view_for_ai,
379 system_prompt,
380 build_user_prompt,
381 exceeded.available_input_tokens,
382 fresh,
383 )
384 .await
385 }
386 }
387 }
388
389 async fn check_commit_split(
397 &self,
398 commit: &crate::git::CommitInfo,
399 repo_view: &RepositoryView,
400 system_prompt: &str,
401 valid_scopes: &[crate::data::context::ScopeDefinition],
402 include_suggestions: bool,
403 available_input_tokens: usize,
404 ) -> Result<crate::data::check::CheckReport> {
405 use crate::claude::batch::{
406 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
407 VIEW_ENVELOPE_OVERHEAD_TOKENS,
408 };
409 use crate::claude::diff_pack::pack_file_diffs;
410 use crate::claude::token_budget;
411 use crate::data::check::{CommitCheckResult, CommitIssue, IssueSeverity};
412 use crate::git::commit::CommitInfoForAI;
413
414 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
422 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
423 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
424 let chunk_capacity = available_input_tokens
425 .saturating_sub(system_prompt_tokens)
426 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
427 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
428 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
429 .saturating_sub(commit_text_tokens);
430
431 debug!(
432 commit = %&commit.hash[..8],
433 available_input_tokens,
434 system_prompt_tokens,
435 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
436 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
437 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
438 commit_text_tokens,
439 chunk_capacity,
440 "Check split dispatch: computed chunk capacity"
441 );
442
443 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
444 .with_context(|| {
445 format!(
446 "Failed to plan diff chunks for commit {}",
447 &commit.hash[..8]
448 )
449 })?;
450
451 let total_chunks = plan.chunks.len();
452 debug!(
453 commit = %&commit.hash[..8],
454 chunks = total_chunks,
455 chunk_capacity,
456 "Check split dispatch: processing commit in chunks"
457 );
458
459 let build_user_prompt =
460 |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
461
462 let mut chunk_results = Vec::with_capacity(total_chunks);
463 for (i, chunk) in plan.chunks.iter().enumerate() {
464 let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
465 commit.clone(),
466 &chunk.file_paths,
467 &chunk.diff_overrides,
468 )
469 .with_context(|| {
470 format!(
471 "Failed to build partial view for chunk {}/{} of commit {}",
472 i + 1,
473 total_chunks,
474 &commit.hash[..8]
475 )
476 })?;
477
478 partial.run_pre_validation_checks(valid_scopes);
479
480 let partial_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
481 .context("Failed to enhance repository view with diff content")?
482 .single_commit_view_for_ai(&partial);
483
484 let user_prompt =
485 self.build_prompt_fitting_budget(&partial_view, system_prompt, &build_user_prompt)?;
486
487 let content = self
488 .ai_client
489 .send_request(system_prompt, &user_prompt)
490 .await
491 .with_context(|| {
492 format!(
493 "Check chunk {}/{} failed for commit {}",
494 i + 1,
495 total_chunks,
496 &commit.hash[..8]
497 )
498 })?;
499
500 let report = self
501 .parse_check_response(&content, repo_view)
502 .with_context(|| {
503 format!(
504 "Failed to parse check chunk {}/{} response for commit {}",
505 i + 1,
506 total_chunks,
507 &commit.hash[..8]
508 )
509 })?;
510
511 if let Some(result) = report.commits.into_iter().next() {
512 chunk_results.push(result);
513 }
514 }
515
516 let mut seen = std::collections::HashSet::new();
518 let mut merged_issues: Vec<CommitIssue> = Vec::new();
519 for result in &chunk_results {
520 for issue in &result.issues {
521 let key: (String, IssueSeverity, String) =
522 (issue.rule.clone(), issue.severity, issue.section.clone());
523 if seen.insert(key) {
524 merged_issues.push(issue.clone());
525 }
526 }
527 }
528
529 let passes = chunk_results.iter().all(|r| r.passes);
530
531 let has_suggestions = chunk_results.iter().any(|r| r.suggestion.is_some());
533
534 let (merged_suggestion, merged_summary) = if has_suggestions {
535 self.merge_check_chunks(
536 &commit.hash,
537 &commit.original_message,
538 &commit.analysis.diff_summary,
539 passes,
540 &chunk_results,
541 repo_view,
542 )
543 .await?
544 } else {
545 let summary = chunk_results.iter().find_map(|r| r.summary.clone());
547 (None, summary)
548 };
549
550 let original_message = commit
551 .original_message
552 .lines()
553 .next()
554 .unwrap_or("")
555 .to_string();
556
557 let merged_result = CommitCheckResult {
558 hash: commit.hash.clone(),
559 message: original_message,
560 issues: merged_issues,
561 suggestion: merged_suggestion,
562 passes,
563 summary: merged_summary,
564 };
565
566 Ok(crate::data::check::CheckReport::new(vec![merged_result]))
567 }
568
569 async fn merge_check_chunks(
574 &self,
575 commit_hash: &str,
576 original_message: &str,
577 diff_summary: &str,
578 passes: bool,
579 chunk_results: &[crate::data::check::CommitCheckResult],
580 repo_view: &RepositoryView,
581 ) -> Result<(Option<crate::data::check::CommitSuggestion>, Option<String>)> {
582 let suggestions: Vec<&crate::data::check::CommitSuggestion> = chunk_results
583 .iter()
584 .filter_map(|r| r.suggestion.as_ref())
585 .collect();
586
587 let summaries: Vec<Option<&str>> =
588 chunk_results.iter().map(|r| r.summary.as_deref()).collect();
589
590 let system_prompt = prompts::CHECK_CHUNK_MERGE_SYSTEM_PROMPT;
591 let user_prompt = prompts::generate_check_chunk_merge_user_prompt(
592 commit_hash,
593 original_message,
594 diff_summary,
595 passes,
596 &suggestions,
597 &summaries,
598 );
599
600 self.validate_prompt_budget(system_prompt, &user_prompt)?;
601
602 let content = self
603 .ai_client
604 .send_request(system_prompt, &user_prompt)
605 .await
606 .context("Merge pass failed for check chunk suggestions")?;
607
608 let report = self
609 .parse_check_response(&content, repo_view)
610 .context("Failed to parse check merge pass response")?;
611
612 let result = report.commits.into_iter().next();
613 Ok(match result {
614 Some(r) => (r.suggestion, r.summary),
615 None => (None, None),
616 })
617 }
618
619 pub async fn send_message(&self, system_prompt: &str, user_prompt: &str) -> Result<String> {
621 self.validate_prompt_budget(system_prompt, user_prompt)?;
622 self.ai_client
623 .send_request(system_prompt, user_prompt)
624 .await
625 }
626
627 pub fn from_env(model: String) -> Result<Self> {
629 let api_key = std::env::var("CLAUDE_API_KEY")
631 .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
632 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
633
634 let ai_client = ClaudeAiClient::new(model, api_key, None)?;
635 Ok(Self::new(Box::new(ai_client)))
636 }
637
638 pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
640 self.generate_amendments_with_options(repo_view, false)
641 .await
642 }
643
644 pub async fn generate_amendments_with_options(
655 &self,
656 repo_view: &RepositoryView,
657 fresh: bool,
658 ) -> Result<AmendmentFile> {
659 let ai_repo_view =
661 RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
662 .context("Failed to enhance repository view with diff content")?;
663
664 let system_prompt = prompts::SYSTEM_PROMPT;
665 let build_user_prompt = |yaml: &str| prompts::generate_user_prompt(yaml);
666
667 match self.try_full_diff_budget(&ai_repo_view, system_prompt, &build_user_prompt)? {
669 Ok(user_prompt) => {
670 self.send_and_parse_amendment_with_retry(system_prompt, &user_prompt)
671 .await
672 }
673 Err(_exceeded) => {
674 let mut amendments = Vec::new();
675 for commit in &repo_view.commits {
676 let amendment = self
677 .generate_amendment_for_commit(
678 commit,
679 &ai_repo_view,
680 system_prompt,
681 &build_user_prompt,
682 fresh,
683 )
684 .await?;
685 amendments.push(amendment);
686 }
687 Ok(AmendmentFile { amendments })
688 }
689 }
690 }
691
692 pub async fn generate_contextual_amendments(
694 &self,
695 repo_view: &RepositoryView,
696 context: &CommitContext,
697 ) -> Result<AmendmentFile> {
698 self.generate_contextual_amendments_with_options(repo_view, context, false)
699 .await
700 }
701
702 pub async fn generate_contextual_amendments_with_options(
712 &self,
713 repo_view: &RepositoryView,
714 context: &CommitContext,
715 fresh: bool,
716 ) -> Result<AmendmentFile> {
717 let ai_repo_view =
719 RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
720 .context("Failed to enhance repository view with diff content")?;
721
722 let prompt_style = self.ai_client.get_metadata().prompt_style();
724 let system_prompt =
725 prompts::generate_contextual_system_prompt_for_provider(context, prompt_style);
726
727 match &context.project.commit_guidelines {
729 Some(guidelines) => {
730 debug!(length = guidelines.len(), "Project commit guidelines found");
731 debug!(guidelines = %guidelines, "Commit guidelines content");
732 }
733 None => {
734 debug!("No project commit guidelines found");
735 }
736 }
737
738 let build_user_prompt =
739 |yaml: &str| prompts::generate_contextual_user_prompt(yaml, context);
740
741 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
743 Ok(user_prompt) => {
744 self.send_and_parse_amendment_with_retry(&system_prompt, &user_prompt)
745 .await
746 }
747 Err(_exceeded) => {
748 let mut amendments = Vec::new();
749 for commit in &repo_view.commits {
750 let amendment = self
751 .generate_amendment_for_commit(
752 commit,
753 &ai_repo_view,
754 &system_prompt,
755 &build_user_prompt,
756 fresh,
757 )
758 .await?;
759 amendments.push(amendment);
760 }
761 Ok(AmendmentFile { amendments })
762 }
763 }
764 }
765
766 fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
768 let yaml_content = self.extract_yaml_from_response(content);
770
771 let amendment_file: AmendmentFile = crate::data::from_yaml(&yaml_content).map_err(|e| {
773 debug!(
774 error = %e,
775 content_length = content.len(),
776 yaml_length = yaml_content.len(),
777 "YAML parsing failed"
778 );
779 debug!(content = %content, "Raw Claude response");
780 debug!(yaml = %yaml_content, "Extracted YAML content");
781
782 if yaml_content.lines().any(|line| line.contains('\t')) {
784 ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
785 } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
786 ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
787 } else {
788 ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {e}"))
789 }
790 })?;
791
792 amendment_file
794 .validate()
795 .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {e}")))?;
796
797 Ok(amendment_file)
798 }
799
800 async fn send_and_parse_amendment_with_retry(
808 &self,
809 system_prompt: &str,
810 user_prompt: &str,
811 ) -> Result<AmendmentFile> {
812 let mut last_error = None;
813 for attempt in 0..=AMENDMENT_PARSE_MAX_RETRIES {
814 match self
815 .ai_client
816 .send_request(system_prompt, user_prompt)
817 .await
818 {
819 Ok(content) => match self.parse_amendment_response(&content) {
820 Ok(amendment_file) => return Ok(amendment_file),
821 Err(e) => {
822 if attempt < AMENDMENT_PARSE_MAX_RETRIES {
823 eprintln!(
824 "warning: failed to parse amendment response (attempt {}), retrying...",
825 attempt + 1
826 );
827 debug!(error = %e, attempt = attempt + 1, "Amendment response parse failed, retrying");
828 }
829 last_error = Some(e);
830 }
831 },
832 Err(e) => {
833 if attempt < AMENDMENT_PARSE_MAX_RETRIES {
834 eprintln!(
835 "warning: AI request failed (attempt {}), retrying...",
836 attempt + 1
837 );
838 debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
839 }
840 last_error = Some(e);
841 }
842 }
843 }
844 Err(last_error
845 .unwrap_or_else(|| anyhow::anyhow!("Amendment generation failed after retries")))
846 }
847
848 fn parse_pr_response(&self, content: &str) -> Result<crate::cli::git::PrContent> {
850 let yaml_content = content.trim();
851 crate::data::from_yaml(yaml_content)
852 .context("Failed to parse AI response as YAML. AI may have returned malformed output.")
853 }
854
855 async fn generate_pr_content_split(
862 &self,
863 commit: &crate::git::CommitInfo,
864 repo_view_for_ai: &RepositoryViewForAI,
865 system_prompt: &str,
866 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
867 available_input_tokens: usize,
868 pr_template: &str,
869 ) -> Result<crate::cli::git::PrContent> {
870 use crate::claude::batch::{
871 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
872 VIEW_ENVELOPE_OVERHEAD_TOKENS,
873 };
874 use crate::claude::diff_pack::pack_file_diffs;
875 use crate::claude::token_budget;
876 use crate::git::commit::CommitInfoForAI;
877
878 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
886 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
887 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
888 let chunk_capacity = available_input_tokens
889 .saturating_sub(system_prompt_tokens)
890 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
891 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
892 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
893 .saturating_sub(commit_text_tokens);
894
895 debug!(
896 commit = %&commit.hash[..8],
897 available_input_tokens,
898 system_prompt_tokens,
899 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
900 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
901 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
902 commit_text_tokens,
903 chunk_capacity,
904 "PR split dispatch: computed chunk capacity"
905 );
906
907 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
908 .with_context(|| {
909 format!(
910 "Failed to plan diff chunks for commit {}",
911 &commit.hash[..8]
912 )
913 })?;
914
915 let total_chunks = plan.chunks.len();
916 debug!(
917 commit = %&commit.hash[..8],
918 chunks = total_chunks,
919 chunk_capacity,
920 "PR split dispatch: processing commit in chunks"
921 );
922
923 let mut chunk_contents = Vec::with_capacity(total_chunks);
924 for (i, chunk) in plan.chunks.iter().enumerate() {
925 let partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
926 commit.clone(),
927 &chunk.file_paths,
928 &chunk.diff_overrides,
929 )
930 .with_context(|| {
931 format!(
932 "Failed to build partial view for chunk {}/{} of commit {}",
933 i + 1,
934 total_chunks,
935 &commit.hash[..8]
936 )
937 })?;
938
939 let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
940
941 let user_prompt =
942 self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
943
944 let content = self
945 .ai_client
946 .send_request(system_prompt, &user_prompt)
947 .await
948 .with_context(|| {
949 format!(
950 "PR chunk {}/{} failed for commit {}",
951 i + 1,
952 total_chunks,
953 &commit.hash[..8]
954 )
955 })?;
956
957 let pr_content = self.parse_pr_response(&content).with_context(|| {
958 format!(
959 "Failed to parse PR chunk {}/{} response for commit {}",
960 i + 1,
961 total_chunks,
962 &commit.hash[..8]
963 )
964 })?;
965
966 chunk_contents.push(pr_content);
967 }
968
969 self.merge_pr_content_chunks(&chunk_contents, pr_template)
970 .await
971 }
972
973 async fn merge_pr_content_chunks(
976 &self,
977 partial_contents: &[crate::cli::git::PrContent],
978 pr_template: &str,
979 ) -> Result<crate::cli::git::PrContent> {
980 let system_prompt = prompts::PR_CONTENT_MERGE_SYSTEM_PROMPT;
981 let user_prompt =
982 prompts::generate_pr_content_merge_user_prompt(partial_contents, pr_template);
983
984 self.validate_prompt_budget(system_prompt, &user_prompt)?;
985
986 let content = self
987 .ai_client
988 .send_request(system_prompt, &user_prompt)
989 .await
990 .context("Merge pass failed for PR content chunks")?;
991
992 self.parse_pr_response(&content)
993 .context("Failed to parse PR content merge pass response")
994 }
995
996 async fn generate_pr_content_for_commit(
998 &self,
999 commit: &crate::git::CommitInfo,
1000 repo_view_for_ai: &RepositoryViewForAI,
1001 system_prompt: &str,
1002 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
1003 pr_template: &str,
1004 ) -> Result<crate::cli::git::PrContent> {
1005 let ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
1006 let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
1007
1008 match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
1009 Ok(user_prompt) => {
1010 let content = self
1011 .ai_client
1012 .send_request(system_prompt, &user_prompt)
1013 .await?;
1014 self.parse_pr_response(&content)
1015 }
1016 Err(exceeded) => {
1017 if commit.analysis.file_diffs.is_empty() {
1018 anyhow::bail!(
1019 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1020 &commit.hash[..8]
1021 );
1022 }
1023 self.generate_pr_content_split(
1024 commit,
1025 repo_view_for_ai,
1026 system_prompt,
1027 build_user_prompt,
1028 exceeded.available_input_tokens,
1029 pr_template,
1030 )
1031 .await
1032 }
1033 }
1034 }
1035
1036 pub async fn generate_pr_content(
1038 &self,
1039 repo_view: &RepositoryView,
1040 pr_template: &str,
1041 ) -> Result<crate::cli::git::PrContent> {
1042 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1044 .context("Failed to enhance repository view with diff content")?;
1045
1046 let build_user_prompt =
1047 |yaml: &str| prompts::generate_pr_description_prompt(yaml, pr_template);
1048
1049 match self.try_full_diff_budget(
1051 &ai_repo_view,
1052 prompts::PR_GENERATION_SYSTEM_PROMPT,
1053 &build_user_prompt,
1054 )? {
1055 Ok(user_prompt) => {
1056 let content = self
1057 .ai_client
1058 .send_request(prompts::PR_GENERATION_SYSTEM_PROMPT, &user_prompt)
1059 .await?;
1060 self.parse_pr_response(&content)
1061 }
1062 Err(_exceeded) => {
1063 let mut per_commit_contents = Vec::new();
1064 for commit in &repo_view.commits {
1065 let pr = self
1066 .generate_pr_content_for_commit(
1067 commit,
1068 &ai_repo_view,
1069 prompts::PR_GENERATION_SYSTEM_PROMPT,
1070 &build_user_prompt,
1071 pr_template,
1072 )
1073 .await?;
1074 per_commit_contents.push(pr);
1075 }
1076 if per_commit_contents.len() == 1 {
1077 return per_commit_contents
1078 .into_iter()
1079 .next()
1080 .context("Per-commit PR contents unexpectedly empty");
1081 }
1082 self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1083 .await
1084 }
1085 }
1086 }
1087
1088 pub async fn generate_pr_content_with_context(
1090 &self,
1091 repo_view: &RepositoryView,
1092 pr_template: &str,
1093 context: &crate::data::context::CommitContext,
1094 ) -> Result<crate::cli::git::PrContent> {
1095 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1097 .context("Failed to enhance repository view with diff content")?;
1098
1099 let prompt_style = self.ai_client.get_metadata().prompt_style();
1101 let system_prompt =
1102 prompts::generate_pr_system_prompt_with_context_for_provider(context, prompt_style);
1103
1104 let build_user_prompt = |yaml: &str| {
1105 prompts::generate_pr_description_prompt_with_context(yaml, pr_template, context)
1106 };
1107
1108 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1110 Ok(user_prompt) => {
1111 let content = self
1112 .ai_client
1113 .send_request(&system_prompt, &user_prompt)
1114 .await?;
1115
1116 debug!(
1117 content_length = content.len(),
1118 "Received AI response for PR content"
1119 );
1120
1121 let pr_content = self.parse_pr_response(&content)?;
1122
1123 debug!(
1124 parsed_title = %pr_content.title,
1125 parsed_description_length = pr_content.description.len(),
1126 parsed_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1127 "Successfully parsed PR content from YAML"
1128 );
1129
1130 Ok(pr_content)
1131 }
1132 Err(_exceeded) => {
1133 let mut per_commit_contents = Vec::new();
1134 for commit in &repo_view.commits {
1135 let pr = self
1136 .generate_pr_content_for_commit(
1137 commit,
1138 &ai_repo_view,
1139 &system_prompt,
1140 &build_user_prompt,
1141 pr_template,
1142 )
1143 .await?;
1144 per_commit_contents.push(pr);
1145 }
1146 if per_commit_contents.len() == 1 {
1147 return per_commit_contents
1148 .into_iter()
1149 .next()
1150 .context("Per-commit PR contents unexpectedly empty");
1151 }
1152 self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1153 .await
1154 }
1155 }
1156 }
1157
1158 pub async fn check_commits(
1163 &self,
1164 repo_view: &RepositoryView,
1165 guidelines: Option<&str>,
1166 include_suggestions: bool,
1167 ) -> Result<crate::data::check::CheckReport> {
1168 self.check_commits_with_scopes(repo_view, guidelines, &[], include_suggestions)
1169 .await
1170 }
1171
1172 pub async fn check_commits_with_scopes(
1177 &self,
1178 repo_view: &RepositoryView,
1179 guidelines: Option<&str>,
1180 valid_scopes: &[crate::data::context::ScopeDefinition],
1181 include_suggestions: bool,
1182 ) -> Result<crate::data::check::CheckReport> {
1183 self.check_commits_with_retry(repo_view, guidelines, valid_scopes, include_suggestions, 2)
1184 .await
1185 }
1186
1187 async fn check_commits_with_retry(
1195 &self,
1196 repo_view: &RepositoryView,
1197 guidelines: Option<&str>,
1198 valid_scopes: &[crate::data::context::ScopeDefinition],
1199 include_suggestions: bool,
1200 max_retries: u32,
1201 ) -> Result<crate::data::check::CheckReport> {
1202 let system_prompt =
1204 prompts::generate_check_system_prompt_with_scopes(guidelines, valid_scopes);
1205
1206 let build_user_prompt =
1207 |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
1208
1209 let mut ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1210 .context("Failed to enhance repository view with diff content")?;
1211 for commit in &mut ai_repo_view.commits {
1212 commit.run_pre_validation_checks(valid_scopes);
1213 }
1214
1215 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1217 Ok(user_prompt) => {
1218 let mut last_error = None;
1220 for attempt in 0..=max_retries {
1221 match self
1222 .ai_client
1223 .send_request(&system_prompt, &user_prompt)
1224 .await
1225 {
1226 Ok(content) => match self.parse_check_response(&content, repo_view) {
1227 Ok(report) => return Ok(report),
1228 Err(e) => {
1229 if attempt < max_retries {
1230 eprintln!(
1231 "warning: failed to parse AI response (attempt {}), retrying...",
1232 attempt + 1
1233 );
1234 debug!(error = %e, attempt = attempt + 1, "Check response parse failed, retrying");
1235 }
1236 last_error = Some(e);
1237 }
1238 },
1239 Err(e) => {
1240 if attempt < max_retries {
1241 eprintln!(
1242 "warning: AI request failed (attempt {}), retrying...",
1243 attempt + 1
1244 );
1245 debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
1246 }
1247 last_error = Some(e);
1248 }
1249 }
1250 }
1251 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Check failed after retries")))
1252 }
1253 Err(_exceeded) => {
1254 let mut all_results = Vec::new();
1256 for commit in &repo_view.commits {
1257 let single_view = repo_view.single_commit_view(commit);
1258 let mut single_ai_view =
1259 RepositoryViewForAI::from_repository_view(single_view.clone())
1260 .context("Failed to enhance single-commit view with diff content")?;
1261 for c in &mut single_ai_view.commits {
1262 c.run_pre_validation_checks(valid_scopes);
1263 }
1264
1265 match self.try_full_diff_budget(
1266 &single_ai_view,
1267 &system_prompt,
1268 &build_user_prompt,
1269 )? {
1270 Ok(user_prompt) => {
1271 let content = self
1272 .ai_client
1273 .send_request(&system_prompt, &user_prompt)
1274 .await?;
1275 let report = self.parse_check_response(&content, &single_view)?;
1276 all_results.extend(report.commits);
1277 }
1278 Err(exceeded) => {
1279 if commit.analysis.file_diffs.is_empty() {
1280 anyhow::bail!(
1281 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1282 &commit.hash[..8]
1283 );
1284 }
1285 let report = self
1286 .check_commit_split(
1287 commit,
1288 &single_view,
1289 &system_prompt,
1290 valid_scopes,
1291 include_suggestions,
1292 exceeded.available_input_tokens,
1293 )
1294 .await?;
1295 all_results.extend(report.commits);
1296 }
1297 }
1298 }
1299 Ok(crate::data::check::CheckReport::new(all_results))
1300 }
1301 }
1302 }
1303
1304 fn parse_check_response(
1306 &self,
1307 content: &str,
1308 repo_view: &RepositoryView,
1309 ) -> Result<crate::data::check::CheckReport> {
1310 use crate::data::check::{
1311 AiCheckResponse, CheckReport, CommitCheckResult as CheckResultType,
1312 };
1313
1314 let yaml_content = self.extract_yaml_from_check_response(content);
1316
1317 let ai_response: AiCheckResponse = crate::data::from_yaml(&yaml_content).map_err(|e| {
1319 debug!(
1320 error = %e,
1321 content_length = content.len(),
1322 yaml_length = yaml_content.len(),
1323 "Check YAML parsing failed"
1324 );
1325 debug!(content = %content, "Raw AI response");
1326 debug!(yaml = %yaml_content, "Extracted YAML content");
1327 ClaudeError::AmendmentParsingFailed(format!("Check response parsing error: {e}"))
1328 })?;
1329
1330 let commit_messages: std::collections::HashMap<&str, &str> = repo_view
1332 .commits
1333 .iter()
1334 .map(|c| (c.hash.as_str(), c.original_message.as_str()))
1335 .collect();
1336
1337 let results: Vec<CheckResultType> = ai_response
1339 .checks
1340 .into_iter()
1341 .map(|check| {
1342 let mut result: CheckResultType = check.into();
1343 if let Some(msg) = commit_messages.get(result.hash.as_str()) {
1345 result.message = msg.lines().next().unwrap_or("").to_string();
1346 } else {
1347 for (hash, msg) in &commit_messages {
1349 if hash.starts_with(&result.hash) || result.hash.starts_with(*hash) {
1350 result.message = msg.lines().next().unwrap_or("").to_string();
1351 break;
1352 }
1353 }
1354 }
1355 result
1356 })
1357 .collect();
1358
1359 Ok(CheckReport::new(results))
1360 }
1361
1362 fn extract_yaml_from_check_response(&self, content: &str) -> String {
1364 let content = content.trim();
1365
1366 if content.starts_with("checks:") {
1368 return content.to_string();
1369 }
1370
1371 if let Some(yaml_start) = content.find("```yaml") {
1373 if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1374 return yaml_content.trim().to_string();
1375 }
1376 }
1377
1378 if let Some(code_start) = content.find("```") {
1380 if let Some(code_content) = content[code_start + 3..].split("```").next() {
1381 let potential_yaml = code_content.trim();
1382 if potential_yaml.starts_with("checks:") {
1384 return potential_yaml.to_string();
1385 }
1386 }
1387 }
1388
1389 content.to_string()
1391 }
1392
1393 pub async fn refine_amendments_coherence(
1398 &self,
1399 items: &[(crate::data::amendments::Amendment, String)],
1400 ) -> Result<AmendmentFile> {
1401 let system_prompt = prompts::AMENDMENT_COHERENCE_SYSTEM_PROMPT;
1402 let user_prompt = prompts::generate_amendment_coherence_user_prompt(items);
1403
1404 self.validate_prompt_budget(system_prompt, &user_prompt)?;
1405
1406 let content = self
1407 .ai_client
1408 .send_request(system_prompt, &user_prompt)
1409 .await?;
1410
1411 self.parse_amendment_response(&content)
1412 }
1413
1414 pub async fn refine_checks_coherence(
1420 &self,
1421 items: &[(crate::data::check::CommitCheckResult, String)],
1422 repo_view: &RepositoryView,
1423 ) -> Result<crate::data::check::CheckReport> {
1424 let system_prompt = prompts::CHECK_COHERENCE_SYSTEM_PROMPT;
1425 let user_prompt = prompts::generate_check_coherence_user_prompt(items);
1426
1427 self.validate_prompt_budget(system_prompt, &user_prompt)?;
1428
1429 let content = self
1430 .ai_client
1431 .send_request(system_prompt, &user_prompt)
1432 .await?;
1433
1434 self.parse_check_response(&content, repo_view)
1435 }
1436
1437 fn extract_yaml_from_response(&self, content: &str) -> String {
1439 let content = content.trim();
1440
1441 if content.starts_with("amendments:") {
1443 return content.to_string();
1444 }
1445
1446 if let Some(yaml_start) = content.find("```yaml") {
1448 if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1449 return yaml_content.trim().to_string();
1450 }
1451 }
1452
1453 if let Some(code_start) = content.find("```") {
1455 if let Some(code_content) = content[code_start + 3..].split("```").next() {
1456 let potential_yaml = code_content.trim();
1457 if potential_yaml.starts_with("amendments:") {
1459 return potential_yaml.to_string();
1460 }
1461 }
1462 }
1463
1464 content.to_string()
1466 }
1467}
1468
1469fn validate_beta_header(model: &str, beta_header: &Option<(String, String)>) -> Result<()> {
1471 if let Some((ref key, ref value)) = beta_header {
1472 let registry = crate::claude::model_config::get_model_registry();
1473 let supported = registry.get_beta_headers(model);
1474 if !supported
1475 .iter()
1476 .any(|bh| bh.key == *key && bh.value == *value)
1477 {
1478 let available: Vec<String> = supported
1479 .iter()
1480 .map(|bh| format!("{}:{}", bh.key, bh.value))
1481 .collect();
1482 if available.is_empty() {
1483 anyhow::bail!("Model '{model}' does not support any beta headers");
1484 }
1485 anyhow::bail!(
1486 "Beta header '{key}:{value}' is not supported for model '{model}'. Supported: {}",
1487 available.join(", ")
1488 );
1489 }
1490 }
1491 Ok(())
1492}
1493
1494pub fn create_default_claude_client(
1496 model: Option<String>,
1497 beta_header: Option<(String, String)>,
1498) -> Result<ClaudeClient> {
1499 use crate::claude::ai::openai::OpenAiAiClient;
1500 use crate::utils::settings::{get_env_var, get_env_vars};
1501
1502 let use_openai = get_env_var("USE_OPENAI")
1504 .map(|val| val == "true")
1505 .unwrap_or(false);
1506
1507 let use_ollama = get_env_var("USE_OLLAMA")
1508 .map(|val| val == "true")
1509 .unwrap_or(false);
1510
1511 let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK")
1513 .map(|val| val == "true")
1514 .unwrap_or(false);
1515
1516 debug!(
1517 use_openai = use_openai,
1518 use_ollama = use_ollama,
1519 use_bedrock = use_bedrock,
1520 "Client selection flags"
1521 );
1522
1523 let registry = crate::claude::model_config::get_model_registry();
1524
1525 if use_ollama {
1527 let ollama_model = model
1528 .or_else(|| get_env_var("OLLAMA_MODEL").ok())
1529 .unwrap_or_else(|| "llama2".to_string());
1530 validate_beta_header(&ollama_model, &beta_header)?;
1531 let base_url = get_env_var("OLLAMA_BASE_URL").ok();
1532 let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1533 return Ok(ClaudeClient::new(Box::new(ai_client)));
1534 }
1535
1536 if use_openai {
1538 debug!("Creating OpenAI client");
1539 let openai_model = model
1540 .or_else(|| get_env_var("OPENAI_MODEL").ok())
1541 .unwrap_or_else(|| {
1542 registry
1543 .get_default_model("openai")
1544 .unwrap_or("gpt-5")
1545 .to_string()
1546 });
1547 debug!(openai_model = %openai_model, "Selected OpenAI model");
1548 validate_beta_header(&openai_model, &beta_header)?;
1549
1550 let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
1551 debug!(error = ?e, "Failed to get OpenAI API key");
1552 ClaudeError::ApiKeyNotFound
1553 })?;
1554 debug!("OpenAI API key found");
1555
1556 let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1557 debug!("OpenAI client created successfully");
1558 return Ok(ClaudeClient::new(Box::new(ai_client)));
1559 }
1560
1561 let claude_model = model
1563 .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1564 .unwrap_or_else(|| {
1565 registry
1566 .get_default_model("claude")
1567 .unwrap_or("claude-sonnet-4-6")
1568 .to_string()
1569 });
1570 validate_beta_header(&claude_model, &beta_header)?;
1571
1572 if use_bedrock {
1573 let auth_token =
1575 get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1576
1577 let base_url =
1578 get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1579
1580 let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1581 return Ok(ClaudeClient::new(Box::new(ai_client)));
1582 }
1583
1584 debug!("Falling back to Claude client");
1586 let api_key = get_env_vars(&[
1587 "CLAUDE_API_KEY",
1588 "ANTHROPIC_API_KEY",
1589 "ANTHROPIC_AUTH_TOKEN",
1590 ])
1591 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
1592
1593 let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1594 debug!("Claude client created successfully");
1595 Ok(ClaudeClient::new(Box::new(ai_client)))
1596}
1597
1598#[cfg(test)]
1599#[allow(clippy::unwrap_used, clippy::expect_used)]
1600mod tests {
1601 use super::*;
1602 use crate::claude::ai::{AiClient, AiClientMetadata};
1603 use std::future::Future;
1604 use std::pin::Pin;
1605
1606 struct MockAiClient;
1608
1609 impl AiClient for MockAiClient {
1610 fn send_request<'a>(
1611 &'a self,
1612 _system_prompt: &'a str,
1613 _user_prompt: &'a str,
1614 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1615 Box::pin(async { Ok(String::new()) })
1616 }
1617
1618 fn get_metadata(&self) -> AiClientMetadata {
1619 AiClientMetadata {
1620 provider: "Mock".to_string(),
1621 model: "mock-model".to_string(),
1622 max_context_length: 200_000,
1623 max_response_length: 8_192,
1624 active_beta: None,
1625 }
1626 }
1627 }
1628
1629 fn make_client() -> ClaudeClient {
1630 ClaudeClient::new(Box::new(MockAiClient))
1631 }
1632
1633 #[test]
1636 fn extract_yaml_pure_amendments() {
1637 let client = make_client();
1638 let content = "amendments:\n - commit: abc123\n message: test";
1639 let result = client.extract_yaml_from_response(content);
1640 assert!(result.starts_with("amendments:"));
1641 }
1642
1643 #[test]
1644 fn extract_yaml_with_markdown_yaml_block() {
1645 let client = make_client();
1646 let content = "Here is the result:\n```yaml\namendments:\n - commit: abc\n```\n";
1647 let result = client.extract_yaml_from_response(content);
1648 assert!(result.starts_with("amendments:"));
1649 }
1650
1651 #[test]
1652 fn extract_yaml_with_generic_code_block() {
1653 let client = make_client();
1654 let content = "```\namendments:\n - commit: abc\n```";
1655 let result = client.extract_yaml_from_response(content);
1656 assert!(result.starts_with("amendments:"));
1657 }
1658
1659 #[test]
1660 fn extract_yaml_with_whitespace() {
1661 let client = make_client();
1662 let content = " \n amendments:\n - commit: abc\n ";
1663 let result = client.extract_yaml_from_response(content);
1664 assert!(result.starts_with("amendments:"));
1665 }
1666
1667 #[test]
1668 fn extract_yaml_fallback_returns_trimmed() {
1669 let client = make_client();
1670 let content = " some random text ";
1671 let result = client.extract_yaml_from_response(content);
1672 assert_eq!(result, "some random text");
1673 }
1674
1675 #[test]
1678 fn extract_check_yaml_pure() {
1679 let client = make_client();
1680 let content = "checks:\n - commit: abc123";
1681 let result = client.extract_yaml_from_check_response(content);
1682 assert!(result.starts_with("checks:"));
1683 }
1684
1685 #[test]
1686 fn extract_check_yaml_markdown_block() {
1687 let client = make_client();
1688 let content = "```yaml\nchecks:\n - commit: abc\n```";
1689 let result = client.extract_yaml_from_check_response(content);
1690 assert!(result.starts_with("checks:"));
1691 }
1692
1693 #[test]
1694 fn extract_check_yaml_generic_block() {
1695 let client = make_client();
1696 let content = "```\nchecks:\n - commit: abc\n```";
1697 let result = client.extract_yaml_from_check_response(content);
1698 assert!(result.starts_with("checks:"));
1699 }
1700
1701 #[test]
1702 fn extract_check_yaml_fallback() {
1703 let client = make_client();
1704 let content = " unexpected content ";
1705 let result = client.extract_yaml_from_check_response(content);
1706 assert_eq!(result, "unexpected content");
1707 }
1708
1709 #[test]
1712 fn parse_amendment_response_valid() {
1713 let client = make_client();
1714 let yaml = format!(
1715 "amendments:\n - commit: \"{}\"\n message: \"test message\"",
1716 "a".repeat(40)
1717 );
1718 let result = client.parse_amendment_response(&yaml);
1719 assert!(result.is_ok());
1720 assert_eq!(result.unwrap().amendments.len(), 1);
1721 }
1722
1723 #[test]
1724 fn parse_amendment_response_invalid_yaml() {
1725 let client = make_client();
1726 let result = client.parse_amendment_response("not: valid: yaml: [{{");
1727 assert!(result.is_err());
1728 }
1729
1730 #[test]
1731 fn parse_amendment_response_invalid_hash() {
1732 let client = make_client();
1733 let yaml = "amendments:\n - commit: \"short\"\n message: \"test\"";
1734 let result = client.parse_amendment_response(yaml);
1735 assert!(result.is_err());
1736 }
1737
1738 #[test]
1741 fn validate_beta_header_none_passes() {
1742 let result = validate_beta_header("claude-opus-4-1-20250805", &None);
1743 assert!(result.is_ok());
1744 }
1745
1746 #[test]
1747 fn validate_beta_header_unsupported_fails() {
1748 let header = Some(("fake-key".to_string(), "fake-value".to_string()));
1749 let result = validate_beta_header("claude-opus-4-1-20250805", &header);
1750 assert!(result.is_err());
1751 }
1752
1753 #[test]
1756 fn client_metadata() {
1757 let client = make_client();
1758 let metadata = client.get_ai_client_metadata();
1759 assert_eq!(metadata.provider, "Mock");
1760 assert_eq!(metadata.model, "mock-model");
1761 }
1762
1763 mod prop {
1766 use super::*;
1767 use proptest::prelude::*;
1768
1769 proptest! {
1770 #[test]
1771 fn yaml_response_output_trimmed(s in ".*") {
1772 let client = make_client();
1773 let result = client.extract_yaml_from_response(&s);
1774 prop_assert_eq!(&result, result.trim());
1775 }
1776
1777 #[test]
1778 fn yaml_response_amendments_prefix_preserved(tail in ".*") {
1779 let client = make_client();
1780 let input = format!("amendments:{tail}");
1781 let result = client.extract_yaml_from_response(&input);
1782 prop_assert!(result.starts_with("amendments:"));
1783 }
1784
1785 #[test]
1786 fn check_response_checks_prefix_preserved(tail in ".*") {
1787 let client = make_client();
1788 let input = format!("checks:{tail}");
1789 let result = client.extract_yaml_from_check_response(&input);
1790 prop_assert!(result.starts_with("checks:"));
1791 }
1792
1793 #[test]
1794 fn yaml_fenced_block_strips_fences(
1795 content in "[a-zA-Z0-9: _\\-\n]{1,100}",
1796 ) {
1797 let client = make_client();
1798 let input = format!("```yaml\n{content}\n```");
1799 let result = client.extract_yaml_from_response(&input);
1800 prop_assert!(!result.contains("```"));
1801 }
1802 }
1803 }
1804
1805 fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
1808 ClaudeClient::new(Box::new(
1809 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
1810 ))
1811 }
1812
1813 fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
1814 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
1815 use crate::git::commit::FileChanges;
1816 use crate::git::{CommitAnalysis, CommitInfo};
1817
1818 let diff_path = dir.path().join("0.diff");
1819 std::fs::write(&diff_path, "+added line\n").unwrap();
1820
1821 crate::data::RepositoryView {
1822 versions: None,
1823 explanation: FieldExplanation::default(),
1824 working_directory: WorkingDirectoryInfo {
1825 clean: true,
1826 untracked_changes: Vec::new(),
1827 },
1828 remotes: Vec::new(),
1829 ai: AiInfo {
1830 scratch: String::new(),
1831 },
1832 branch_info: None,
1833 pr_template: None,
1834 pr_template_location: None,
1835 branch_prs: None,
1836 commits: vec![CommitInfo {
1837 hash: format!("{:0>40}", 0),
1838 author: "Test <test@test.com>".to_string(),
1839 date: chrono::Utc::now().fixed_offset(),
1840 original_message: "feat(test): add something".to_string(),
1841 in_main_branches: Vec::new(),
1842 analysis: CommitAnalysis {
1843 detected_type: "feat".to_string(),
1844 detected_scope: "test".to_string(),
1845 proposed_message: "feat(test): add something".to_string(),
1846 file_changes: FileChanges {
1847 total_files: 1,
1848 files_added: 1,
1849 files_deleted: 0,
1850 file_list: Vec::new(),
1851 },
1852 diff_summary: "file.rs | 1 +".to_string(),
1853 diff_file: diff_path.to_string_lossy().to_string(),
1854 file_diffs: Vec::new(),
1855 },
1856 }],
1857 }
1858 }
1859
1860 fn valid_check_yaml() -> String {
1861 format!(
1862 "checks:\n - commit: \"{hash}\"\n passes: true\n issues: []\n",
1863 hash = format!("{:0>40}", 0)
1864 )
1865 }
1866
1867 #[tokio::test]
1868 async fn send_message_propagates_ai_error() {
1869 let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
1870 let result = client.send_message("sys", "usr").await;
1871 assert!(result.is_err());
1872 assert!(result.unwrap_err().to_string().contains("mock error"));
1873 }
1874
1875 #[tokio::test]
1876 async fn check_commits_succeeds_after_request_error() {
1877 let dir = tempfile::tempdir().unwrap();
1878 let repo_view = make_test_repo_view(&dir);
1879 let client = make_configurable_client(vec![
1881 Err(anyhow::anyhow!("rate limit")),
1882 Ok(valid_check_yaml()),
1883 Ok(valid_check_yaml()),
1884 ]);
1885 let result = client
1886 .check_commits_with_scopes(&repo_view, None, &[], false)
1887 .await;
1888 assert!(result.is_ok());
1889 }
1890
1891 #[tokio::test]
1892 async fn check_commits_succeeds_after_parse_error() {
1893 let dir = tempfile::tempdir().unwrap();
1894 let repo_view = make_test_repo_view(&dir);
1895 let client = make_configurable_client(vec![
1897 Ok("not: valid: yaml: [[".to_string()),
1898 Ok(valid_check_yaml()),
1899 Ok(valid_check_yaml()),
1900 ]);
1901 let result = client
1902 .check_commits_with_scopes(&repo_view, None, &[], false)
1903 .await;
1904 assert!(result.is_ok());
1905 }
1906
1907 #[tokio::test]
1908 async fn check_commits_fails_after_all_retries_exhausted() {
1909 let dir = tempfile::tempdir().unwrap();
1910 let repo_view = make_test_repo_view(&dir);
1911 let client = make_configurable_client(vec![
1912 Err(anyhow::anyhow!("first failure")),
1913 Err(anyhow::anyhow!("second failure")),
1914 Err(anyhow::anyhow!("final failure")),
1915 ]);
1916 let result = client
1917 .check_commits_with_scopes(&repo_view, None, &[], false)
1918 .await;
1919 assert!(result.is_err());
1920 }
1921
1922 #[tokio::test]
1923 async fn check_commits_fails_when_all_parses_fail() {
1924 let dir = tempfile::tempdir().unwrap();
1925 let repo_view = make_test_repo_view(&dir);
1926 let client = make_configurable_client(vec![
1927 Ok("bad yaml [[".to_string()),
1928 Ok("bad yaml [[".to_string()),
1929 Ok("bad yaml [[".to_string()),
1930 ]);
1931 let result = client
1932 .check_commits_with_scopes(&repo_view, None, &[], false)
1933 .await;
1934 assert!(result.is_err());
1935 }
1936
1937 fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
1944 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
1948 .with_context_length(50_000);
1949 ClaudeClient::new(Box::new(mock))
1950 }
1951
1952 fn make_small_context_client_tracked(
1955 responses: Vec<Result<String>>,
1956 ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
1957 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
1958 .with_context_length(50_000);
1959 let handle = mock.response_handle();
1960 (ClaudeClient::new(Box::new(mock)), handle)
1961 }
1962
1963 fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
1966 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
1967 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
1968 use crate::git::{CommitAnalysis, CommitInfo};
1969
1970 let hash = "a".repeat(40);
1971
1972 let full_diff = "x".repeat(120_000);
1976 let flat_diff_path = dir.path().join("full.diff");
1977 std::fs::write(&flat_diff_path, &full_diff).unwrap();
1978
1979 let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
1982 let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
1983
1984 let path_a = dir.path().join("0000.diff");
1985 let path_b = dir.path().join("0001.diff");
1986 std::fs::write(&path_a, &diff_a).unwrap();
1987 std::fs::write(&path_b, &diff_b).unwrap();
1988
1989 crate::data::RepositoryView {
1990 versions: None,
1991 explanation: FieldExplanation::default(),
1992 working_directory: WorkingDirectoryInfo {
1993 clean: true,
1994 untracked_changes: Vec::new(),
1995 },
1996 remotes: Vec::new(),
1997 ai: AiInfo {
1998 scratch: String::new(),
1999 },
2000 branch_info: None,
2001 pr_template: None,
2002 pr_template_location: None,
2003 branch_prs: None,
2004 commits: vec![CommitInfo {
2005 hash,
2006 author: "Test <test@test.com>".to_string(),
2007 date: chrono::Utc::now().fixed_offset(),
2008 original_message: "feat(test): large commit".to_string(),
2009 in_main_branches: Vec::new(),
2010 analysis: CommitAnalysis {
2011 detected_type: "feat".to_string(),
2012 detected_scope: "test".to_string(),
2013 proposed_message: "feat(test): large commit".to_string(),
2014 file_changes: FileChanges {
2015 total_files: 2,
2016 files_added: 2,
2017 files_deleted: 0,
2018 file_list: vec![
2019 FileChange {
2020 status: "A".to_string(),
2021 file: "src/a.rs".to_string(),
2022 },
2023 FileChange {
2024 status: "A".to_string(),
2025 file: "src/b.rs".to_string(),
2026 },
2027 ],
2028 },
2029 diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2030 diff_file: flat_diff_path.to_string_lossy().to_string(),
2031 file_diffs: vec![
2032 FileDiffRef {
2033 path: "src/a.rs".to_string(),
2034 diff_file: path_a.to_string_lossy().to_string(),
2035 byte_len: diff_a.len(),
2036 },
2037 FileDiffRef {
2038 path: "src/b.rs".to_string(),
2039 diff_file: path_b.to_string_lossy().to_string(),
2040 byte_len: diff_b.len(),
2041 },
2042 ],
2043 },
2044 }],
2045 }
2046 }
2047
2048 fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2049 format!("amendments:\n - commit: \"{hash}\"\n message: \"{message}\"")
2050 }
2051
2052 #[tokio::test]
2053 async fn generate_amendments_split_dispatch() {
2054 let dir = tempfile::tempdir().unwrap();
2055 let repo_view = make_large_diff_repo_view(&dir);
2056 let hash = "a".repeat(40);
2057
2058 let client = make_small_context_client(vec![
2060 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2061 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2062 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2063 ]);
2064
2065 let result = client
2066 .generate_amendments_with_options(&repo_view, false)
2067 .await;
2068
2069 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2070 let amendments = result.unwrap();
2071 assert_eq!(amendments.amendments.len(), 1);
2072 assert_eq!(amendments.amendments[0].commit, hash);
2073 assert!(amendments.amendments[0]
2074 .message
2075 .contains("add a.rs and b.rs"));
2076 }
2077
2078 #[tokio::test]
2079 async fn generate_amendments_split_chunk_failure() {
2080 let dir = tempfile::tempdir().unwrap();
2081 let repo_view = make_large_diff_repo_view(&dir);
2082 let hash = "a".repeat(40);
2083
2084 let client = make_small_context_client(vec![
2086 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2087 Err(anyhow::anyhow!("rate limit exceeded")),
2088 ]);
2089
2090 let result = client
2091 .generate_amendments_with_options(&repo_view, false)
2092 .await;
2093
2094 assert!(result.is_err());
2095 }
2096
2097 #[tokio::test]
2098 async fn generate_amendments_no_split_when_fits() {
2099 let dir = tempfile::tempdir().unwrap();
2100 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2102
2103 let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2105 &hash,
2106 "feat(test): improved message",
2107 ))]);
2108
2109 let result = client
2110 .generate_amendments_with_options(&repo_view, false)
2111 .await;
2112
2113 assert!(result.is_ok());
2114 assert_eq!(result.unwrap().amendments.len(), 1);
2115 }
2116
2117 fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2120 format!(
2121 "checks:\n - commit: \"{hash}\"\n passes: {passes}\n issues: []\n summary: \"test summary\"\n"
2122 )
2123 }
2124
2125 fn valid_check_yaml_with_issues(hash: &str) -> String {
2126 format!(
2127 concat!(
2128 "checks:\n",
2129 " - commit: \"{hash}\"\n",
2130 " passes: false\n",
2131 " issues:\n",
2132 " - severity: error\n",
2133 " section: \"Subject Line\"\n",
2134 " rule: \"subject-too-long\"\n",
2135 " explanation: \"Subject exceeds 72 characters\"\n",
2136 " suggestion:\n",
2137 " message: \"feat(test): shorter subject\"\n",
2138 " explanation: \"Shortened subject line\"\n",
2139 " summary: \"Large commit with issues\"\n",
2140 ),
2141 hash = hash,
2142 )
2143 }
2144
2145 fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2146 format!(
2147 concat!(
2148 "checks:\n",
2149 " - commit: \"{hash}\"\n",
2150 " passes: true\n",
2151 " issues: []\n",
2152 " summary: \"chunk summary\"\n",
2153 ),
2154 hash = hash,
2155 )
2156 }
2157
2158 #[tokio::test]
2159 async fn check_commits_split_dispatch() {
2160 let dir = tempfile::tempdir().unwrap();
2161 let repo_view = make_large_diff_repo_view(&dir);
2162 let hash = "a".repeat(40);
2163
2164 let client = make_small_context_client(vec![
2166 Ok(valid_check_yaml_with_issues(&hash)),
2167 Ok(valid_check_yaml_with_issues(&hash)),
2168 Ok(valid_check_yaml_with_issues(&hash)), ]);
2170
2171 let result = client
2172 .check_commits_with_scopes(&repo_view, None, &[], true)
2173 .await;
2174
2175 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2176 let report = result.unwrap();
2177 assert_eq!(report.commits.len(), 1);
2178 assert!(!report.commits[0].passes);
2179 assert_eq!(report.commits[0].issues.len(), 1);
2181 assert_eq!(report.commits[0].issues[0].rule, "subject-too-long");
2182 }
2183
2184 #[tokio::test]
2185 async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2186 let dir = tempfile::tempdir().unwrap();
2187 let repo_view = make_large_diff_repo_view(&dir);
2188 let hash = "a".repeat(40);
2189
2190 let client = make_small_context_client(vec![
2193 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2194 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2195 ]);
2196
2197 let result = client
2198 .check_commits_with_scopes(&repo_view, None, &[], false)
2199 .await;
2200
2201 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2202 let report = result.unwrap();
2203 assert_eq!(report.commits.len(), 1);
2204 assert!(report.commits[0].passes);
2205 assert!(report.commits[0].issues.is_empty());
2206 assert!(report.commits[0].suggestion.is_none());
2207 assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2209 }
2210
2211 #[tokio::test]
2212 async fn check_commits_split_chunk_failure() {
2213 let dir = tempfile::tempdir().unwrap();
2214 let repo_view = make_large_diff_repo_view(&dir);
2215 let hash = "a".repeat(40);
2216
2217 let client = make_small_context_client(vec![
2219 Ok(valid_check_yaml_for(&hash, true)),
2220 Err(anyhow::anyhow!("rate limit exceeded")),
2221 ]);
2222
2223 let result = client
2224 .check_commits_with_scopes(&repo_view, None, &[], false)
2225 .await;
2226
2227 assert!(result.is_err());
2228 }
2229
2230 #[tokio::test]
2231 async fn check_commits_no_split_when_fits() {
2232 let dir = tempfile::tempdir().unwrap();
2233 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2235
2236 let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2238
2239 let result = client
2240 .check_commits_with_scopes(&repo_view, None, &[], false)
2241 .await;
2242
2243 assert!(result.is_ok());
2244 assert_eq!(result.unwrap().commits.len(), 1);
2245 }
2246
2247 #[tokio::test]
2248 async fn check_commits_split_dedup_across_chunks() {
2249 let dir = tempfile::tempdir().unwrap();
2250 let repo_view = make_large_diff_repo_view(&dir);
2251 let hash = "a".repeat(40);
2252
2253 let chunk1 = format!(
2255 concat!(
2256 "checks:\n",
2257 " - commit: \"{hash}\"\n",
2258 " passes: false\n",
2259 " issues:\n",
2260 " - severity: error\n",
2261 " section: \"Subject Line\"\n",
2262 " rule: \"subject-too-long\"\n",
2263 " explanation: \"Subject exceeds 72 characters\"\n",
2264 " - severity: warning\n",
2265 " section: \"Content\"\n",
2266 " rule: \"body-required\"\n",
2267 " explanation: \"Large change needs body\"\n",
2268 ),
2269 hash = hash,
2270 );
2271
2272 let chunk2 = format!(
2274 concat!(
2275 "checks:\n",
2276 " - commit: \"{hash}\"\n",
2277 " passes: false\n",
2278 " issues:\n",
2279 " - severity: error\n",
2280 " section: \"Subject Line\"\n",
2281 " rule: \"subject-too-long\"\n",
2282 " explanation: \"Subject line is too long\"\n",
2283 " - severity: info\n",
2284 " section: \"Style\"\n",
2285 " rule: \"scope-suggestion\"\n",
2286 " explanation: \"Consider more specific scope\"\n",
2287 ),
2288 hash = hash,
2289 );
2290
2291 let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2293
2294 let result = client
2295 .check_commits_with_scopes(&repo_view, None, &[], false)
2296 .await;
2297
2298 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2299 let report = result.unwrap();
2300 assert_eq!(report.commits.len(), 1);
2301 assert!(!report.commits[0].passes);
2302 assert_eq!(report.commits[0].issues.len(), 3);
2305 }
2306
2307 #[tokio::test]
2308 async fn check_commits_split_passes_only_when_all_chunks_pass() {
2309 let dir = tempfile::tempdir().unwrap();
2310 let repo_view = make_large_diff_repo_view(&dir);
2311 let hash = "a".repeat(40);
2312
2313 let client = make_small_context_client(vec![
2315 Ok(valid_check_yaml_for(&hash, true)),
2316 Ok(valid_check_yaml_for(&hash, false)),
2317 ]);
2318
2319 let result = client
2320 .check_commits_with_scopes(&repo_view, None, &[], false)
2321 .await;
2322
2323 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2324 let report = result.unwrap();
2325 assert!(
2326 !report.commits[0].passes,
2327 "should fail when any chunk fails"
2328 );
2329 }
2330
2331 fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2335 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2336 use crate::git::commit::FileChanges;
2337 use crate::git::{CommitAnalysis, CommitInfo};
2338
2339 let diff_a = dir.path().join("0.diff");
2340 let diff_b = dir.path().join("1.diff");
2341 std::fs::write(&diff_a, "+line a\n").unwrap();
2342 std::fs::write(&diff_b, "+line b\n").unwrap();
2343
2344 let hash_a = "a".repeat(40);
2345 let hash_b = "b".repeat(40);
2346
2347 crate::data::RepositoryView {
2348 versions: None,
2349 explanation: FieldExplanation::default(),
2350 working_directory: WorkingDirectoryInfo {
2351 clean: true,
2352 untracked_changes: Vec::new(),
2353 },
2354 remotes: Vec::new(),
2355 ai: AiInfo {
2356 scratch: String::new(),
2357 },
2358 branch_info: None,
2359 pr_template: None,
2360 pr_template_location: None,
2361 branch_prs: None,
2362 commits: vec![
2363 CommitInfo {
2364 hash: hash_a,
2365 author: "Test <test@test.com>".to_string(),
2366 date: chrono::Utc::now().fixed_offset(),
2367 original_message: "feat(a): add a".to_string(),
2368 in_main_branches: Vec::new(),
2369 analysis: CommitAnalysis {
2370 detected_type: "feat".to_string(),
2371 detected_scope: "a".to_string(),
2372 proposed_message: "feat(a): add a".to_string(),
2373 file_changes: FileChanges {
2374 total_files: 1,
2375 files_added: 1,
2376 files_deleted: 0,
2377 file_list: Vec::new(),
2378 },
2379 diff_summary: "a.rs | 1 +".to_string(),
2380 diff_file: diff_a.to_string_lossy().to_string(),
2381 file_diffs: Vec::new(),
2382 },
2383 },
2384 CommitInfo {
2385 hash: hash_b,
2386 author: "Test <test@test.com>".to_string(),
2387 date: chrono::Utc::now().fixed_offset(),
2388 original_message: "feat(b): add b".to_string(),
2389 in_main_branches: Vec::new(),
2390 analysis: CommitAnalysis {
2391 detected_type: "feat".to_string(),
2392 detected_scope: "b".to_string(),
2393 proposed_message: "feat(b): add b".to_string(),
2394 file_changes: FileChanges {
2395 total_files: 1,
2396 files_added: 1,
2397 files_deleted: 0,
2398 file_list: Vec::new(),
2399 },
2400 diff_summary: "b.rs | 1 +".to_string(),
2401 diff_file: diff_b.to_string_lossy().to_string(),
2402 file_diffs: Vec::new(),
2403 },
2404 },
2405 ],
2406 }
2407 }
2408
2409 #[tokio::test]
2410 async fn generate_amendments_multi_commit() {
2411 let dir = tempfile::tempdir().unwrap();
2412 let repo_view = make_multi_commit_repo_view(&dir);
2413 let hash_a = "a".repeat(40);
2414 let hash_b = "b".repeat(40);
2415
2416 let response = format!(
2417 concat!(
2418 "amendments:\n",
2419 " - commit: \"{hash_a}\"\n",
2420 " message: \"feat(a): improved a\"\n",
2421 " - commit: \"{hash_b}\"\n",
2422 " message: \"feat(b): improved b\"\n",
2423 ),
2424 hash_a = hash_a,
2425 hash_b = hash_b,
2426 );
2427 let client = make_configurable_client(vec![Ok(response)]);
2428
2429 let result = client
2430 .generate_amendments_with_options(&repo_view, false)
2431 .await;
2432
2433 assert!(
2434 result.is_ok(),
2435 "multi-commit amendment failed: {:?}",
2436 result.err()
2437 );
2438 let amendments = result.unwrap();
2439 assert_eq!(amendments.amendments.len(), 2);
2440 }
2441
2442 #[tokio::test]
2443 async fn generate_contextual_amendments_multi_commit() {
2444 let dir = tempfile::tempdir().unwrap();
2445 let repo_view = make_multi_commit_repo_view(&dir);
2446 let hash_a = "a".repeat(40);
2447 let hash_b = "b".repeat(40);
2448
2449 let response = format!(
2450 concat!(
2451 "amendments:\n",
2452 " - commit: \"{hash_a}\"\n",
2453 " message: \"feat(a): improved a\"\n",
2454 " - commit: \"{hash_b}\"\n",
2455 " message: \"feat(b): improved b\"\n",
2456 ),
2457 hash_a = hash_a,
2458 hash_b = hash_b,
2459 );
2460 let client = make_configurable_client(vec![Ok(response)]);
2461 let context = crate::data::context::CommitContext::default();
2462
2463 let result = client
2464 .generate_contextual_amendments_with_options(&repo_view, &context, false)
2465 .await;
2466
2467 assert!(
2468 result.is_ok(),
2469 "multi-commit contextual amendment failed: {:?}",
2470 result.err()
2471 );
2472 let amendments = result.unwrap();
2473 assert_eq!(amendments.amendments.len(), 2);
2474 }
2475
2476 #[tokio::test]
2477 async fn generate_pr_content_succeeds() {
2478 let dir = tempfile::tempdir().unwrap();
2479 let repo_view = make_test_repo_view(&dir);
2480
2481 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2482 let client = make_configurable_client(vec![Ok(response.to_string())]);
2483
2484 let result = client.generate_pr_content(&repo_view, "").await;
2485
2486 assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
2487 let pr = result.unwrap();
2488 assert_eq!(pr.title, "feat: add something");
2489 assert_eq!(pr.description, "Adds a new feature.");
2490 }
2491
2492 #[tokio::test]
2493 async fn generate_pr_content_with_context_succeeds() {
2494 let dir = tempfile::tempdir().unwrap();
2495 let repo_view = make_test_repo_view(&dir);
2496 let context = crate::data::context::CommitContext::default();
2497
2498 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2499 let client = make_configurable_client(vec![Ok(response.to_string())]);
2500
2501 let result = client
2502 .generate_pr_content_with_context(&repo_view, "", &context)
2503 .await;
2504
2505 assert!(
2506 result.is_ok(),
2507 "PR generation with context failed: {:?}",
2508 result.err()
2509 );
2510 let pr = result.unwrap();
2511 assert_eq!(pr.title, "feat: add something");
2512 }
2513
2514 #[tokio::test]
2515 async fn check_commits_multi_commit() {
2516 let dir = tempfile::tempdir().unwrap();
2517 let repo_view = make_multi_commit_repo_view(&dir);
2518 let hash_a = "a".repeat(40);
2519 let hash_b = "b".repeat(40);
2520
2521 let response = format!(
2522 concat!(
2523 "checks:\n",
2524 " - commit: \"{hash_a}\"\n",
2525 " passes: true\n",
2526 " issues: []\n",
2527 " - commit: \"{hash_b}\"\n",
2528 " passes: true\n",
2529 " issues: []\n",
2530 ),
2531 hash_a = hash_a,
2532 hash_b = hash_b,
2533 );
2534 let client = make_configurable_client(vec![Ok(response)]);
2535
2536 let result = client
2537 .check_commits_with_scopes(&repo_view, None, &[], false)
2538 .await;
2539
2540 assert!(
2541 result.is_ok(),
2542 "multi-commit check failed: {:?}",
2543 result.err()
2544 );
2545 let report = result.unwrap();
2546 assert_eq!(report.commits.len(), 2);
2547 assert!(report.commits[0].passes);
2548 assert!(report.commits[1].passes);
2549 }
2550
2551 fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2556 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2557 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2558 use crate::git::{CommitAnalysis, CommitInfo};
2559
2560 let hash_a = "a".repeat(40);
2561 let hash_b = "b".repeat(40);
2562
2563 let diff_content_a = "x".repeat(60_000);
2566 let diff_content_b = "y".repeat(60_000);
2567 let flat_a = dir.path().join("flat_a.diff");
2568 let flat_b = dir.path().join("flat_b.diff");
2569 std::fs::write(&flat_a, &diff_content_a).unwrap();
2570 std::fs::write(&flat_b, &diff_content_b).unwrap();
2571
2572 let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2574 let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2575 let per_file_a = dir.path().join("pf_a.diff");
2576 let per_file_b = dir.path().join("pf_b.diff");
2577 std::fs::write(&per_file_a, &file_diff_a).unwrap();
2578 std::fs::write(&per_file_b, &file_diff_b).unwrap();
2579
2580 crate::data::RepositoryView {
2581 versions: None,
2582 explanation: FieldExplanation::default(),
2583 working_directory: WorkingDirectoryInfo {
2584 clean: true,
2585 untracked_changes: Vec::new(),
2586 },
2587 remotes: Vec::new(),
2588 ai: AiInfo {
2589 scratch: String::new(),
2590 },
2591 branch_info: None,
2592 pr_template: None,
2593 pr_template_location: None,
2594 branch_prs: None,
2595 commits: vec![
2596 CommitInfo {
2597 hash: hash_a,
2598 author: "Test <test@test.com>".to_string(),
2599 date: chrono::Utc::now().fixed_offset(),
2600 original_message: "feat(a): add module a".to_string(),
2601 in_main_branches: Vec::new(),
2602 analysis: CommitAnalysis {
2603 detected_type: "feat".to_string(),
2604 detected_scope: "a".to_string(),
2605 proposed_message: "feat(a): add module a".to_string(),
2606 file_changes: FileChanges {
2607 total_files: 1,
2608 files_added: 1,
2609 files_deleted: 0,
2610 file_list: vec![FileChange {
2611 status: "A".to_string(),
2612 file: "src/a.rs".to_string(),
2613 }],
2614 },
2615 diff_summary: " src/a.rs | 100 ++++\n".to_string(),
2616 diff_file: flat_a.to_string_lossy().to_string(),
2617 file_diffs: vec![FileDiffRef {
2618 path: "src/a.rs".to_string(),
2619 diff_file: per_file_a.to_string_lossy().to_string(),
2620 byte_len: file_diff_a.len(),
2621 }],
2622 },
2623 },
2624 CommitInfo {
2625 hash: hash_b,
2626 author: "Test <test@test.com>".to_string(),
2627 date: chrono::Utc::now().fixed_offset(),
2628 original_message: "feat(b): add module b".to_string(),
2629 in_main_branches: Vec::new(),
2630 analysis: CommitAnalysis {
2631 detected_type: "feat".to_string(),
2632 detected_scope: "b".to_string(),
2633 proposed_message: "feat(b): add module b".to_string(),
2634 file_changes: FileChanges {
2635 total_files: 1,
2636 files_added: 1,
2637 files_deleted: 0,
2638 file_list: vec![FileChange {
2639 status: "A".to_string(),
2640 file: "src/b.rs".to_string(),
2641 }],
2642 },
2643 diff_summary: " src/b.rs | 100 ++++\n".to_string(),
2644 diff_file: flat_b.to_string_lossy().to_string(),
2645 file_diffs: vec![FileDiffRef {
2646 path: "src/b.rs".to_string(),
2647 diff_file: per_file_b.to_string_lossy().to_string(),
2648 byte_len: file_diff_b.len(),
2649 }],
2650 },
2651 },
2652 ],
2653 }
2654 }
2655
2656 fn valid_pr_yaml(title: &str, description: &str) -> String {
2657 format!("title: \"{title}\"\ndescription: \"{description}\"\n")
2658 }
2659
2660 #[tokio::test]
2663 async fn generate_amendments_multi_commit_split_dispatch() {
2664 let dir = tempfile::tempdir().unwrap();
2665 let repo_view = make_large_multi_commit_repo_view(&dir);
2666 let hash_a = "a".repeat(40);
2667 let hash_b = "b".repeat(40);
2668
2669 let (client, handle) = make_small_context_client_tracked(vec![
2672 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
2673 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
2674 ]);
2675
2676 let result = client
2677 .generate_amendments_with_options(&repo_view, false)
2678 .await;
2679
2680 assert!(
2681 result.is_ok(),
2682 "multi-commit split dispatch failed: {:?}",
2683 result.err()
2684 );
2685 let amendments = result.unwrap();
2686 assert_eq!(amendments.amendments.len(), 2);
2687 assert_eq!(amendments.amendments[0].commit, hash_a);
2688 assert_eq!(amendments.amendments[1].commit, hash_b);
2689 assert!(amendments.amendments[0].message.contains("improved a"));
2690 assert!(amendments.amendments[1].message.contains("improved b"));
2691 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2692 }
2693
2694 #[tokio::test]
2695 async fn generate_contextual_amendments_multi_commit_split_dispatch() {
2696 let dir = tempfile::tempdir().unwrap();
2697 let repo_view = make_large_multi_commit_repo_view(&dir);
2698 let hash_a = "a".repeat(40);
2699 let hash_b = "b".repeat(40);
2700 let context = crate::data::context::CommitContext::default();
2701
2702 let (client, handle) = make_small_context_client_tracked(vec![
2703 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
2704 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
2705 ]);
2706
2707 let result = client
2708 .generate_contextual_amendments_with_options(&repo_view, &context, false)
2709 .await;
2710
2711 assert!(
2712 result.is_ok(),
2713 "multi-commit contextual split dispatch failed: {:?}",
2714 result.err()
2715 );
2716 let amendments = result.unwrap();
2717 assert_eq!(amendments.amendments.len(), 2);
2718 assert_eq!(amendments.amendments[0].commit, hash_a);
2719 assert_eq!(amendments.amendments[1].commit, hash_b);
2720 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2721 }
2722
2723 #[tokio::test]
2726 async fn check_commits_multi_commit_split_dispatch() {
2727 let dir = tempfile::tempdir().unwrap();
2728 let repo_view = make_large_multi_commit_repo_view(&dir);
2729 let hash_a = "a".repeat(40);
2730 let hash_b = "b".repeat(40);
2731
2732 let (client, handle) = make_small_context_client_tracked(vec![
2734 Ok(valid_check_yaml_for(&hash_a, true)),
2735 Ok(valid_check_yaml_for(&hash_b, true)),
2736 ]);
2737
2738 let result = client
2739 .check_commits_with_scopes(&repo_view, None, &[], false)
2740 .await;
2741
2742 assert!(
2743 result.is_ok(),
2744 "multi-commit check split dispatch failed: {:?}",
2745 result.err()
2746 );
2747 let report = result.unwrap();
2748 assert_eq!(report.commits.len(), 2);
2749 assert!(report.commits[0].passes);
2750 assert!(report.commits[1].passes);
2751 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2752 }
2753
2754 #[tokio::test]
2757 async fn generate_pr_content_split_dispatch() {
2758 let dir = tempfile::tempdir().unwrap();
2759 let repo_view = make_large_diff_repo_view(&dir);
2760
2761 let (client, handle) = make_small_context_client_tracked(vec![
2765 Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
2766 Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
2767 Ok(valid_pr_yaml(
2768 "feat(test): add modules",
2769 "Adds a.rs and b.rs",
2770 )),
2771 ]);
2772
2773 let result = client.generate_pr_content(&repo_view, "").await;
2774
2775 assert!(
2776 result.is_ok(),
2777 "PR split dispatch failed: {:?}",
2778 result.err()
2779 );
2780 let pr = result.unwrap();
2781 assert!(pr.title.contains("add modules"));
2782 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2783 }
2784
2785 #[tokio::test]
2786 async fn generate_pr_content_multi_commit_split_dispatch() {
2787 let dir = tempfile::tempdir().unwrap();
2788 let repo_view = make_large_multi_commit_repo_view(&dir);
2789
2790 let (client, handle) = make_small_context_client_tracked(vec![
2793 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
2794 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
2795 Ok(valid_pr_yaml(
2796 "feat: add modules a and b",
2797 "Adds both modules",
2798 )),
2799 ]);
2800
2801 let result = client.generate_pr_content(&repo_view, "").await;
2802
2803 assert!(
2804 result.is_ok(),
2805 "PR multi-commit split dispatch failed: {:?}",
2806 result.err()
2807 );
2808 let pr = result.unwrap();
2809 assert!(pr.title.contains("modules"));
2810 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2811 }
2812
2813 #[tokio::test]
2814 async fn generate_pr_content_with_context_split_dispatch() {
2815 let dir = tempfile::tempdir().unwrap();
2816 let repo_view = make_large_multi_commit_repo_view(&dir);
2817 let context = crate::data::context::CommitContext::default();
2818
2819 let (client, handle) = make_small_context_client_tracked(vec![
2821 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
2822 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
2823 Ok(valid_pr_yaml(
2824 "feat: add modules a and b",
2825 "Adds both modules",
2826 )),
2827 ]);
2828
2829 let result = client
2830 .generate_pr_content_with_context(&repo_view, "", &context)
2831 .await;
2832
2833 assert!(
2834 result.is_ok(),
2835 "PR with context split dispatch failed: {:?}",
2836 result.err()
2837 );
2838 let pr = result.unwrap();
2839 assert!(pr.title.contains("modules"));
2840 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2841 }
2842
2843 fn make_small_context_client_with_prompts(
2848 responses: Vec<Result<String>>,
2849 ) -> (
2850 ClaudeClient,
2851 crate::claude::test_utils::ResponseQueueHandle,
2852 crate::claude::test_utils::PromptRecordHandle,
2853 ) {
2854 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2855 .with_context_length(50_000);
2856 let response_handle = mock.response_handle();
2857 let prompt_handle = mock.prompt_handle();
2858 (
2859 ClaudeClient::new(Box::new(mock)),
2860 response_handle,
2861 prompt_handle,
2862 )
2863 }
2864
2865 fn make_configurable_client_with_prompts(
2867 responses: Vec<Result<String>>,
2868 ) -> (
2869 ClaudeClient,
2870 crate::claude::test_utils::ResponseQueueHandle,
2871 crate::claude::test_utils::PromptRecordHandle,
2872 ) {
2873 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
2874 let response_handle = mock.response_handle();
2875 let prompt_handle = mock.prompt_handle();
2876 (
2877 ClaudeClient::new(Box::new(mock)),
2878 response_handle,
2879 prompt_handle,
2880 )
2881 }
2882
2883 fn make_single_oversized_file_repo_view(
2890 dir: &tempfile::TempDir,
2891 ) -> crate::data::RepositoryView {
2892 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2893 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2894 use crate::git::{CommitAnalysis, CommitInfo};
2895
2896 let hash = "c".repeat(40);
2897
2898 let diff_content = format!(
2901 "diff --git a/src/big.rs b/src/big.rs\n{}\n",
2902 "x".repeat(80_000)
2903 );
2904
2905 let flat_diff_path = dir.path().join("full.diff");
2906 std::fs::write(&flat_diff_path, &diff_content).unwrap();
2907
2908 let per_file_path = dir.path().join("0000.diff");
2909 std::fs::write(&per_file_path, &diff_content).unwrap();
2910
2911 crate::data::RepositoryView {
2912 versions: None,
2913 explanation: FieldExplanation::default(),
2914 working_directory: WorkingDirectoryInfo {
2915 clean: true,
2916 untracked_changes: Vec::new(),
2917 },
2918 remotes: Vec::new(),
2919 ai: AiInfo {
2920 scratch: String::new(),
2921 },
2922 branch_info: None,
2923 pr_template: None,
2924 pr_template_location: None,
2925 branch_prs: None,
2926 commits: vec![CommitInfo {
2927 hash,
2928 author: "Test <test@test.com>".to_string(),
2929 date: chrono::Utc::now().fixed_offset(),
2930 original_message: "feat(big): add large module".to_string(),
2931 in_main_branches: Vec::new(),
2932 analysis: CommitAnalysis {
2933 detected_type: "feat".to_string(),
2934 detected_scope: "big".to_string(),
2935 proposed_message: "feat(big): add large module".to_string(),
2936 file_changes: FileChanges {
2937 total_files: 1,
2938 files_added: 1,
2939 files_deleted: 0,
2940 file_list: vec![FileChange {
2941 status: "A".to_string(),
2942 file: "src/big.rs".to_string(),
2943 }],
2944 },
2945 diff_summary: " src/big.rs | 80 ++++\n".to_string(),
2946 diff_file: flat_diff_path.to_string_lossy().to_string(),
2947 file_diffs: vec![FileDiffRef {
2948 path: "src/big.rs".to_string(),
2949 diff_file: per_file_path.to_string_lossy().to_string(),
2950 byte_len: diff_content.len(),
2951 }],
2952 },
2953 }],
2954 }
2955 }
2956
2957 #[tokio::test]
2964 async fn amendment_single_file_under_budget_no_split() {
2965 let dir = tempfile::tempdir().unwrap();
2966 let repo_view = make_test_repo_view(&dir);
2967 let hash = format!("{:0>40}", 0);
2968
2969 let (client, response_handle, prompt_handle) =
2970 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
2971 &hash,
2972 "feat(test): improved message",
2973 ))]);
2974
2975 let result = client
2976 .generate_amendments_with_options(&repo_view, false)
2977 .await;
2978
2979 assert!(result.is_ok());
2980 assert_eq!(result.unwrap().amendments.len(), 1);
2981 assert_eq!(response_handle.remaining(), 0);
2982
2983 let prompts = prompt_handle.prompts();
2984 assert_eq!(
2985 prompts.len(),
2986 1,
2987 "expected exactly one AI request, no split"
2988 );
2989
2990 let (_, user_prompt) = &prompts[0];
2991 assert!(
2992 user_prompt.contains("added line"),
2993 "user prompt should contain the diff content"
2994 );
2995 }
2996
2997 #[tokio::test]
3008 async fn amendment_two_chunks_prompt_content() {
3009 let dir = tempfile::tempdir().unwrap();
3010 let repo_view = make_large_diff_repo_view(&dir);
3011 let hash = "a".repeat(40);
3012
3013 let (client, response_handle, prompt_handle) =
3014 make_small_context_client_with_prompts(vec![
3015 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3016 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3017 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3018 ]);
3019
3020 let result = client
3021 .generate_amendments_with_options(&repo_view, false)
3022 .await;
3023
3024 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3025 let amendments = result.unwrap();
3026 assert_eq!(amendments.amendments.len(), 1);
3027 assert!(amendments.amendments[0]
3028 .message
3029 .contains("add a.rs and b.rs"));
3030 assert_eq!(response_handle.remaining(), 0);
3031
3032 let prompts = prompt_handle.prompts();
3033 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3034
3035 let (_, chunk1_user) = &prompts[0];
3037 assert!(
3038 chunk1_user.contains("aaa"),
3039 "chunk 1 prompt should contain file-a diff content"
3040 );
3041
3042 let (_, chunk2_user) = &prompts[1];
3044 assert!(
3045 chunk2_user.contains("bbb"),
3046 "chunk 2 prompt should contain file-b diff content"
3047 );
3048
3049 let (merge_sys, merge_user) = &prompts[2];
3051 assert!(
3052 merge_sys.contains("synthesiz"),
3053 "merge system prompt should contain synthesis instructions"
3054 );
3055 assert!(
3057 merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
3058 "merge user prompt should contain both partial amendment messages"
3059 );
3060 }
3061
3062 #[tokio::test]
3074 async fn amendment_single_oversized_file_gets_placeholder() {
3075 let dir = tempfile::tempdir().unwrap();
3076 let repo_view = make_single_oversized_file_repo_view(&dir);
3077 let hash = "c".repeat(40);
3078
3079 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![Ok(
3083 valid_amendment_yaml(&hash, "feat(big): add large module"),
3084 )]);
3085
3086 let result = client
3087 .generate_amendments_with_options(&repo_view, false)
3088 .await;
3089
3090 assert!(
3092 result.is_ok(),
3093 "expected success with placeholder, got: {result:?}"
3094 );
3095
3096 assert!(
3098 prompt_handle.request_count() >= 1,
3099 "expected at least 1 request, got {}",
3100 prompt_handle.request_count()
3101 );
3102 }
3103
3104 #[tokio::test]
3113 async fn amendment_chunk_failure_stops_dispatch() {
3114 let dir = tempfile::tempdir().unwrap();
3115 let repo_view = make_large_diff_repo_view(&dir);
3116 let hash = "a".repeat(40);
3117
3118 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3120 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3121 Err(anyhow::anyhow!("rate limit exceeded")),
3122 ]);
3123
3124 let result = client
3125 .generate_amendments_with_options(&repo_view, false)
3126 .await;
3127
3128 assert!(result.is_err());
3129
3130 let prompts = prompt_handle.prompts();
3132 assert_eq!(
3133 prompts.len(),
3134 2,
3135 "should stop after the failing chunk, got {} requests",
3136 prompts.len()
3137 );
3138
3139 let (_, first_user) = &prompts[0];
3141 assert!(
3142 first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
3143 "first chunk prompt should reference a file"
3144 );
3145 }
3146
3147 #[tokio::test]
3158 async fn amendment_reduce_pass_prompt_content() {
3159 let dir = tempfile::tempdir().unwrap();
3160 let repo_view = make_large_diff_repo_view(&dir);
3161 let hash = "a".repeat(40);
3162
3163 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3164 Ok(valid_amendment_yaml(
3165 &hash,
3166 "feat(a): add module a implementation",
3167 )),
3168 Ok(valid_amendment_yaml(
3169 &hash,
3170 "feat(b): add module b implementation",
3171 )),
3172 Ok(valid_amendment_yaml(
3173 &hash,
3174 "feat(test): add modules a and b",
3175 )),
3176 ]);
3177
3178 let result = client
3179 .generate_amendments_with_options(&repo_view, false)
3180 .await;
3181
3182 assert!(result.is_ok());
3183
3184 let prompts = prompt_handle.prompts();
3185 assert_eq!(prompts.len(), 3);
3186
3187 let (merge_system, merge_user) = &prompts[2];
3189
3190 assert!(
3192 merge_system.contains("synthesiz"),
3193 "merge system prompt should contain synthesis instructions"
3194 );
3195
3196 assert!(
3198 merge_user.contains("feat(a): add module a implementation"),
3199 "merge user prompt should contain chunk 1's partial message"
3200 );
3201 assert!(
3202 merge_user.contains("feat(b): add module b implementation"),
3203 "merge user prompt should contain chunk 2's partial message"
3204 );
3205
3206 assert!(
3208 merge_user.contains("feat(test): large commit"),
3209 "merge user prompt should contain the original commit message"
3210 );
3211
3212 assert!(
3214 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3215 "merge user prompt should contain the diff_summary"
3216 );
3217
3218 assert!(
3220 merge_user.contains(&hash),
3221 "merge user prompt should reference the commit hash"
3222 );
3223 }
3224
3225 #[tokio::test]
3242 async fn check_split_dedup_and_merge_prompt() {
3243 let dir = tempfile::tempdir().unwrap();
3244 let repo_view = make_large_diff_repo_view(&dir);
3245 let hash = "a".repeat(40);
3246
3247 let chunk1_yaml = format!(
3249 concat!(
3250 "checks:\n",
3251 " - commit: \"{hash}\"\n",
3252 " passes: false\n",
3253 " issues:\n",
3254 " - severity: error\n",
3255 " section: \"Subject Line\"\n",
3256 " rule: \"subject-too-long\"\n",
3257 " explanation: \"Subject exceeds 72 characters\"\n",
3258 " - severity: warning\n",
3259 " section: \"Content\"\n",
3260 " rule: \"body-required\"\n",
3261 " explanation: \"Large change needs body\"\n",
3262 " suggestion:\n",
3263 " message: \"feat(a): shorter subject for a\"\n",
3264 " explanation: \"Shortened subject for file a\"\n",
3265 " summary: \"Adds module a\"\n",
3266 ),
3267 hash = hash,
3268 );
3269
3270 let chunk2_yaml = format!(
3272 concat!(
3273 "checks:\n",
3274 " - commit: \"{hash}\"\n",
3275 " passes: false\n",
3276 " issues:\n",
3277 " - severity: error\n",
3278 " section: \"Subject Line\"\n",
3279 " rule: \"subject-too-long\"\n",
3280 " explanation: \"Subject line is way too long\"\n",
3281 " - severity: info\n",
3282 " section: \"Style\"\n",
3283 " rule: \"scope-suggestion\"\n",
3284 " explanation: \"Consider more specific scope\"\n",
3285 " suggestion:\n",
3286 " message: \"feat(b): shorter subject for b\"\n",
3287 " explanation: \"Shortened subject for file b\"\n",
3288 " summary: \"Adds module b\"\n",
3289 ),
3290 hash = hash,
3291 );
3292
3293 let merge_yaml = format!(
3295 concat!(
3296 "checks:\n",
3297 " - commit: \"{hash}\"\n",
3298 " passes: false\n",
3299 " issues: []\n",
3300 " suggestion:\n",
3301 " message: \"feat(test): add modules a and b\"\n",
3302 " explanation: \"Combined suggestion\"\n",
3303 " summary: \"Adds modules a and b\"\n",
3304 ),
3305 hash = hash,
3306 );
3307
3308 let (client, response_handle, prompt_handle) =
3309 make_small_context_client_with_prompts(vec![
3310 Ok(chunk1_yaml),
3311 Ok(chunk2_yaml),
3312 Ok(merge_yaml),
3313 ]);
3314
3315 let result = client
3316 .check_commits_with_scopes(&repo_view, None, &[], true)
3317 .await;
3318
3319 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3320 let report = result.unwrap();
3321 assert_eq!(report.commits.len(), 1);
3322 assert!(!report.commits[0].passes);
3323 assert_eq!(response_handle.remaining(), 0);
3324
3325 assert_eq!(
3330 report.commits[0].issues.len(),
3331 3,
3332 "expected 3 unique issues after dedup, got {:?}",
3333 report.commits[0]
3334 .issues
3335 .iter()
3336 .map(|i| &i.rule)
3337 .collect::<Vec<_>>()
3338 );
3339
3340 assert!(report.commits[0].suggestion.is_some());
3342 assert!(
3343 report.commits[0]
3344 .suggestion
3345 .as_ref()
3346 .unwrap()
3347 .message
3348 .contains("add modules a and b"),
3349 "suggestion should come from the merge pass"
3350 );
3351
3352 let prompts = prompt_handle.prompts();
3354 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
3355
3356 let (_, chunk1_user) = &prompts[0];
3358 let (_, chunk2_user) = &prompts[1];
3359 let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
3360 assert!(
3361 combined_chunk_prompts.contains("src/a.rs")
3362 && combined_chunk_prompts.contains("src/b.rs"),
3363 "chunk prompts should collectively cover both files"
3364 );
3365
3366 let (merge_sys, merge_user) = &prompts[2];
3368 assert!(
3369 merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
3370 "merge system prompt should be the check chunk merge prompt"
3371 );
3372 assert!(
3373 merge_user.contains("feat(a): shorter subject for a")
3374 && merge_user.contains("feat(b): shorter subject for b"),
3375 "merge user prompt should contain both partial suggestions"
3376 );
3377 assert!(
3379 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3380 "merge user prompt should contain the diff_summary"
3381 );
3382 }
3383
3384 #[tokio::test]
3387 async fn amendment_retry_parse_failure_then_success() {
3388 let dir = tempfile::tempdir().unwrap();
3389 let repo_view = make_test_repo_view(&dir);
3390 let hash = format!("{:0>40}", 0);
3391
3392 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3393 Ok("not valid yaml {{[".to_string()),
3394 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3395 ]);
3396
3397 let result = client
3398 .generate_amendments_with_options(&repo_view, false)
3399 .await;
3400
3401 assert!(
3402 result.is_ok(),
3403 "should succeed after retry: {:?}",
3404 result.err()
3405 );
3406 assert_eq!(result.unwrap().amendments.len(), 1);
3407 assert_eq!(response_handle.remaining(), 0, "both responses consumed");
3408 assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
3409 }
3410
3411 #[tokio::test]
3412 async fn amendment_retry_request_failure_then_success() {
3413 let dir = tempfile::tempdir().unwrap();
3414 let repo_view = make_test_repo_view(&dir);
3415 let hash = format!("{:0>40}", 0);
3416
3417 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3418 Err(anyhow::anyhow!("rate limit")),
3419 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3420 ]);
3421
3422 let result = client
3423 .generate_amendments_with_options(&repo_view, false)
3424 .await;
3425
3426 assert!(
3427 result.is_ok(),
3428 "should succeed after retry: {:?}",
3429 result.err()
3430 );
3431 assert_eq!(result.unwrap().amendments.len(), 1);
3432 assert_eq!(response_handle.remaining(), 0);
3433 assert_eq!(prompt_handle.request_count(), 2);
3434 }
3435
3436 #[tokio::test]
3437 async fn amendment_retry_all_attempts_exhausted() {
3438 let dir = tempfile::tempdir().unwrap();
3439 let repo_view = make_test_repo_view(&dir);
3440
3441 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3442 Ok("bad yaml 1".to_string()),
3443 Ok("bad yaml 2".to_string()),
3444 Ok("bad yaml 3".to_string()),
3445 ]);
3446
3447 let result = client
3448 .generate_amendments_with_options(&repo_view, false)
3449 .await;
3450
3451 assert!(result.is_err(), "should fail after all retries exhausted");
3452 assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
3453 assert_eq!(
3454 prompt_handle.request_count(),
3455 3,
3456 "exactly 3 AI requests (1 + 2 retries)"
3457 );
3458 }
3459
3460 #[tokio::test]
3461 async fn amendment_retry_success_first_attempt() {
3462 let dir = tempfile::tempdir().unwrap();
3463 let repo_view = make_test_repo_view(&dir);
3464 let hash = format!("{:0>40}", 0);
3465
3466 let (client, response_handle, prompt_handle) =
3467 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3468 &hash,
3469 "feat(test): works first time",
3470 ))]);
3471
3472 let result = client
3473 .generate_amendments_with_options(&repo_view, false)
3474 .await;
3475
3476 assert!(result.is_ok());
3477 assert_eq!(response_handle.remaining(), 0);
3478 assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
3479 }
3480
3481 #[tokio::test]
3482 async fn amendment_retry_mixed_request_and_parse_failures() {
3483 let dir = tempfile::tempdir().unwrap();
3484 let repo_view = make_test_repo_view(&dir);
3485 let hash = format!("{:0>40}", 0);
3486
3487 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3488 Err(anyhow::anyhow!("network error")),
3489 Ok("invalid yaml {{".to_string()),
3490 Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
3491 ]);
3492
3493 let result = client
3494 .generate_amendments_with_options(&repo_view, false)
3495 .await;
3496
3497 assert!(
3498 result.is_ok(),
3499 "should succeed on third attempt: {:?}",
3500 result.err()
3501 );
3502 assert_eq!(result.unwrap().amendments.len(), 1);
3503 assert_eq!(response_handle.remaining(), 0);
3504 assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
3505 }
3506}