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").is_ok_and(|val| val == "true");
1504
1505 let use_ollama = get_env_var("USE_OLLAMA").is_ok_and(|val| val == "true");
1506
1507 let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK").is_ok_and(|val| val == "true");
1509
1510 debug!(
1511 use_openai = use_openai,
1512 use_ollama = use_ollama,
1513 use_bedrock = use_bedrock,
1514 "Client selection flags"
1515 );
1516
1517 let registry = crate::claude::model_config::get_model_registry();
1518
1519 if use_ollama {
1521 let ollama_model = model
1522 .or_else(|| get_env_var("OLLAMA_MODEL").ok())
1523 .unwrap_or_else(|| "llama2".to_string());
1524 validate_beta_header(&ollama_model, &beta_header)?;
1525 let base_url = get_env_var("OLLAMA_BASE_URL").ok();
1526 let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1527 return Ok(ClaudeClient::new(Box::new(ai_client)));
1528 }
1529
1530 if use_openai {
1532 debug!("Creating OpenAI client");
1533 let openai_model = model
1534 .or_else(|| get_env_var("OPENAI_MODEL").ok())
1535 .unwrap_or_else(|| {
1536 registry
1537 .get_default_model("openai")
1538 .unwrap_or("gpt-5")
1539 .to_string()
1540 });
1541 debug!(openai_model = %openai_model, "Selected OpenAI model");
1542 validate_beta_header(&openai_model, &beta_header)?;
1543
1544 let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
1545 debug!(error = ?e, "Failed to get OpenAI API key");
1546 ClaudeError::ApiKeyNotFound
1547 })?;
1548 debug!("OpenAI API key found");
1549
1550 let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1551 debug!("OpenAI client created successfully");
1552 return Ok(ClaudeClient::new(Box::new(ai_client)));
1553 }
1554
1555 let claude_model = model
1557 .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1558 .unwrap_or_else(|| {
1559 registry
1560 .get_default_model("claude")
1561 .unwrap_or("claude-sonnet-4-6")
1562 .to_string()
1563 });
1564 validate_beta_header(&claude_model, &beta_header)?;
1565
1566 if use_bedrock {
1567 let auth_token =
1569 get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1570
1571 let base_url =
1572 get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1573
1574 let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1575 return Ok(ClaudeClient::new(Box::new(ai_client)));
1576 }
1577
1578 debug!("Falling back to Claude client");
1580 let api_key = get_env_vars(&[
1581 "CLAUDE_API_KEY",
1582 "ANTHROPIC_API_KEY",
1583 "ANTHROPIC_AUTH_TOKEN",
1584 ])
1585 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
1586
1587 let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1588 debug!("Claude client created successfully");
1589 Ok(ClaudeClient::new(Box::new(ai_client)))
1590}
1591
1592#[cfg(test)]
1593#[allow(clippy::unwrap_used, clippy::expect_used)]
1594mod tests {
1595 use super::*;
1596 use crate::claude::ai::{AiClient, AiClientMetadata};
1597 use std::future::Future;
1598 use std::pin::Pin;
1599
1600 struct MockAiClient;
1602
1603 impl AiClient for MockAiClient {
1604 fn send_request<'a>(
1605 &'a self,
1606 _system_prompt: &'a str,
1607 _user_prompt: &'a str,
1608 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1609 Box::pin(async { Ok(String::new()) })
1610 }
1611
1612 fn get_metadata(&self) -> AiClientMetadata {
1613 AiClientMetadata {
1614 provider: "Mock".to_string(),
1615 model: "mock-model".to_string(),
1616 max_context_length: 200_000,
1617 max_response_length: 8_192,
1618 active_beta: None,
1619 }
1620 }
1621 }
1622
1623 fn make_client() -> ClaudeClient {
1624 ClaudeClient::new(Box::new(MockAiClient))
1625 }
1626
1627 #[test]
1630 fn extract_yaml_pure_amendments() {
1631 let client = make_client();
1632 let content = "amendments:\n - commit: abc123\n message: test";
1633 let result = client.extract_yaml_from_response(content);
1634 assert!(result.starts_with("amendments:"));
1635 }
1636
1637 #[test]
1638 fn extract_yaml_with_markdown_yaml_block() {
1639 let client = make_client();
1640 let content = "Here is the result:\n```yaml\namendments:\n - commit: abc\n```\n";
1641 let result = client.extract_yaml_from_response(content);
1642 assert!(result.starts_with("amendments:"));
1643 }
1644
1645 #[test]
1646 fn extract_yaml_with_generic_code_block() {
1647 let client = make_client();
1648 let content = "```\namendments:\n - commit: abc\n```";
1649 let result = client.extract_yaml_from_response(content);
1650 assert!(result.starts_with("amendments:"));
1651 }
1652
1653 #[test]
1654 fn extract_yaml_with_whitespace() {
1655 let client = make_client();
1656 let content = " \n amendments:\n - commit: abc\n ";
1657 let result = client.extract_yaml_from_response(content);
1658 assert!(result.starts_with("amendments:"));
1659 }
1660
1661 #[test]
1662 fn extract_yaml_fallback_returns_trimmed() {
1663 let client = make_client();
1664 let content = " some random text ";
1665 let result = client.extract_yaml_from_response(content);
1666 assert_eq!(result, "some random text");
1667 }
1668
1669 #[test]
1672 fn extract_check_yaml_pure() {
1673 let client = make_client();
1674 let content = "checks:\n - commit: abc123";
1675 let result = client.extract_yaml_from_check_response(content);
1676 assert!(result.starts_with("checks:"));
1677 }
1678
1679 #[test]
1680 fn extract_check_yaml_markdown_block() {
1681 let client = make_client();
1682 let content = "```yaml\nchecks:\n - commit: abc\n```";
1683 let result = client.extract_yaml_from_check_response(content);
1684 assert!(result.starts_with("checks:"));
1685 }
1686
1687 #[test]
1688 fn extract_check_yaml_generic_block() {
1689 let client = make_client();
1690 let content = "```\nchecks:\n - commit: abc\n```";
1691 let result = client.extract_yaml_from_check_response(content);
1692 assert!(result.starts_with("checks:"));
1693 }
1694
1695 #[test]
1696 fn extract_check_yaml_fallback() {
1697 let client = make_client();
1698 let content = " unexpected content ";
1699 let result = client.extract_yaml_from_check_response(content);
1700 assert_eq!(result, "unexpected content");
1701 }
1702
1703 #[test]
1706 fn parse_amendment_response_valid() {
1707 let client = make_client();
1708 let yaml = format!(
1709 "amendments:\n - commit: \"{}\"\n message: \"test message\"",
1710 "a".repeat(40)
1711 );
1712 let result = client.parse_amendment_response(&yaml);
1713 assert!(result.is_ok());
1714 assert_eq!(result.unwrap().amendments.len(), 1);
1715 }
1716
1717 #[test]
1718 fn parse_amendment_response_invalid_yaml() {
1719 let client = make_client();
1720 let result = client.parse_amendment_response("not: valid: yaml: [{{");
1721 assert!(result.is_err());
1722 }
1723
1724 #[test]
1725 fn parse_amendment_response_invalid_hash() {
1726 let client = make_client();
1727 let yaml = "amendments:\n - commit: \"short\"\n message: \"test\"";
1728 let result = client.parse_amendment_response(yaml);
1729 assert!(result.is_err());
1730 }
1731
1732 #[test]
1735 fn validate_beta_header_none_passes() {
1736 let result = validate_beta_header("claude-opus-4-1-20250805", &None);
1737 assert!(result.is_ok());
1738 }
1739
1740 #[test]
1741 fn validate_beta_header_unsupported_fails() {
1742 let header = Some(("fake-key".to_string(), "fake-value".to_string()));
1743 let result = validate_beta_header("claude-opus-4-1-20250805", &header);
1744 assert!(result.is_err());
1745 }
1746
1747 #[test]
1750 fn client_metadata() {
1751 let client = make_client();
1752 let metadata = client.get_ai_client_metadata();
1753 assert_eq!(metadata.provider, "Mock");
1754 assert_eq!(metadata.model, "mock-model");
1755 }
1756
1757 mod prop {
1760 use super::*;
1761 use proptest::prelude::*;
1762
1763 proptest! {
1764 #[test]
1765 fn yaml_response_output_trimmed(s in ".*") {
1766 let client = make_client();
1767 let result = client.extract_yaml_from_response(&s);
1768 prop_assert_eq!(&result, result.trim());
1769 }
1770
1771 #[test]
1772 fn yaml_response_amendments_prefix_preserved(tail in ".*") {
1773 let client = make_client();
1774 let input = format!("amendments:{tail}");
1775 let result = client.extract_yaml_from_response(&input);
1776 prop_assert!(result.starts_with("amendments:"));
1777 }
1778
1779 #[test]
1780 fn check_response_checks_prefix_preserved(tail in ".*") {
1781 let client = make_client();
1782 let input = format!("checks:{tail}");
1783 let result = client.extract_yaml_from_check_response(&input);
1784 prop_assert!(result.starts_with("checks:"));
1785 }
1786
1787 #[test]
1788 fn yaml_fenced_block_strips_fences(
1789 content in "[a-zA-Z0-9: _\\-\n]{1,100}",
1790 ) {
1791 let client = make_client();
1792 let input = format!("```yaml\n{content}\n```");
1793 let result = client.extract_yaml_from_response(&input);
1794 prop_assert!(!result.contains("```"));
1795 }
1796 }
1797 }
1798
1799 fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
1802 ClaudeClient::new(Box::new(
1803 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
1804 ))
1805 }
1806
1807 fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
1808 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
1809 use crate::git::commit::FileChanges;
1810 use crate::git::{CommitAnalysis, CommitInfo};
1811
1812 let diff_path = dir.path().join("0.diff");
1813 std::fs::write(&diff_path, "+added line\n").unwrap();
1814
1815 crate::data::RepositoryView {
1816 versions: None,
1817 explanation: FieldExplanation::default(),
1818 working_directory: WorkingDirectoryInfo {
1819 clean: true,
1820 untracked_changes: Vec::new(),
1821 },
1822 remotes: Vec::new(),
1823 ai: AiInfo {
1824 scratch: String::new(),
1825 },
1826 branch_info: None,
1827 pr_template: None,
1828 pr_template_location: None,
1829 branch_prs: None,
1830 commits: vec![CommitInfo {
1831 hash: format!("{:0>40}", 0),
1832 author: "Test <test@test.com>".to_string(),
1833 date: chrono::Utc::now().fixed_offset(),
1834 original_message: "feat(test): add something".to_string(),
1835 in_main_branches: Vec::new(),
1836 analysis: CommitAnalysis {
1837 detected_type: "feat".to_string(),
1838 detected_scope: "test".to_string(),
1839 proposed_message: "feat(test): add something".to_string(),
1840 file_changes: FileChanges {
1841 total_files: 1,
1842 files_added: 1,
1843 files_deleted: 0,
1844 file_list: Vec::new(),
1845 },
1846 diff_summary: "file.rs | 1 +".to_string(),
1847 diff_file: diff_path.to_string_lossy().to_string(),
1848 file_diffs: Vec::new(),
1849 },
1850 }],
1851 }
1852 }
1853
1854 fn valid_check_yaml() -> String {
1855 format!(
1856 "checks:\n - commit: \"{hash}\"\n passes: true\n issues: []\n",
1857 hash = format!("{:0>40}", 0)
1858 )
1859 }
1860
1861 #[tokio::test]
1862 async fn send_message_propagates_ai_error() {
1863 let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
1864 let result = client.send_message("sys", "usr").await;
1865 assert!(result.is_err());
1866 assert!(result.unwrap_err().to_string().contains("mock error"));
1867 }
1868
1869 #[tokio::test]
1870 async fn check_commits_succeeds_after_request_error() {
1871 let dir = tempfile::tempdir().unwrap();
1872 let repo_view = make_test_repo_view(&dir);
1873 let client = make_configurable_client(vec![
1875 Err(anyhow::anyhow!("rate limit")),
1876 Ok(valid_check_yaml()),
1877 Ok(valid_check_yaml()),
1878 ]);
1879 let result = client
1880 .check_commits_with_scopes(&repo_view, None, &[], false)
1881 .await;
1882 assert!(result.is_ok());
1883 }
1884
1885 #[tokio::test]
1886 async fn check_commits_succeeds_after_parse_error() {
1887 let dir = tempfile::tempdir().unwrap();
1888 let repo_view = make_test_repo_view(&dir);
1889 let client = make_configurable_client(vec![
1891 Ok("not: valid: yaml: [[".to_string()),
1892 Ok(valid_check_yaml()),
1893 Ok(valid_check_yaml()),
1894 ]);
1895 let result = client
1896 .check_commits_with_scopes(&repo_view, None, &[], false)
1897 .await;
1898 assert!(result.is_ok());
1899 }
1900
1901 #[tokio::test]
1902 async fn check_commits_fails_after_all_retries_exhausted() {
1903 let dir = tempfile::tempdir().unwrap();
1904 let repo_view = make_test_repo_view(&dir);
1905 let client = make_configurable_client(vec![
1906 Err(anyhow::anyhow!("first failure")),
1907 Err(anyhow::anyhow!("second failure")),
1908 Err(anyhow::anyhow!("final failure")),
1909 ]);
1910 let result = client
1911 .check_commits_with_scopes(&repo_view, None, &[], false)
1912 .await;
1913 assert!(result.is_err());
1914 }
1915
1916 #[tokio::test]
1917 async fn check_commits_fails_when_all_parses_fail() {
1918 let dir = tempfile::tempdir().unwrap();
1919 let repo_view = make_test_repo_view(&dir);
1920 let client = make_configurable_client(vec![
1921 Ok("bad yaml [[".to_string()),
1922 Ok("bad yaml [[".to_string()),
1923 Ok("bad yaml [[".to_string()),
1924 ]);
1925 let result = client
1926 .check_commits_with_scopes(&repo_view, None, &[], false)
1927 .await;
1928 assert!(result.is_err());
1929 }
1930
1931 fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
1938 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
1942 .with_context_length(50_000);
1943 ClaudeClient::new(Box::new(mock))
1944 }
1945
1946 fn make_small_context_client_tracked(
1949 responses: Vec<Result<String>>,
1950 ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
1951 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
1952 .with_context_length(50_000);
1953 let handle = mock.response_handle();
1954 (ClaudeClient::new(Box::new(mock)), handle)
1955 }
1956
1957 fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
1960 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
1961 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
1962 use crate::git::{CommitAnalysis, CommitInfo};
1963
1964 let hash = "a".repeat(40);
1965
1966 let full_diff = "x".repeat(120_000);
1970 let flat_diff_path = dir.path().join("full.diff");
1971 std::fs::write(&flat_diff_path, &full_diff).unwrap();
1972
1973 let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
1976 let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
1977
1978 let path_a = dir.path().join("0000.diff");
1979 let path_b = dir.path().join("0001.diff");
1980 std::fs::write(&path_a, &diff_a).unwrap();
1981 std::fs::write(&path_b, &diff_b).unwrap();
1982
1983 crate::data::RepositoryView {
1984 versions: None,
1985 explanation: FieldExplanation::default(),
1986 working_directory: WorkingDirectoryInfo {
1987 clean: true,
1988 untracked_changes: Vec::new(),
1989 },
1990 remotes: Vec::new(),
1991 ai: AiInfo {
1992 scratch: String::new(),
1993 },
1994 branch_info: None,
1995 pr_template: None,
1996 pr_template_location: None,
1997 branch_prs: None,
1998 commits: vec![CommitInfo {
1999 hash,
2000 author: "Test <test@test.com>".to_string(),
2001 date: chrono::Utc::now().fixed_offset(),
2002 original_message: "feat(test): large commit".to_string(),
2003 in_main_branches: Vec::new(),
2004 analysis: CommitAnalysis {
2005 detected_type: "feat".to_string(),
2006 detected_scope: "test".to_string(),
2007 proposed_message: "feat(test): large commit".to_string(),
2008 file_changes: FileChanges {
2009 total_files: 2,
2010 files_added: 2,
2011 files_deleted: 0,
2012 file_list: vec![
2013 FileChange {
2014 status: "A".to_string(),
2015 file: "src/a.rs".to_string(),
2016 },
2017 FileChange {
2018 status: "A".to_string(),
2019 file: "src/b.rs".to_string(),
2020 },
2021 ],
2022 },
2023 diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2024 diff_file: flat_diff_path.to_string_lossy().to_string(),
2025 file_diffs: vec![
2026 FileDiffRef {
2027 path: "src/a.rs".to_string(),
2028 diff_file: path_a.to_string_lossy().to_string(),
2029 byte_len: diff_a.len(),
2030 },
2031 FileDiffRef {
2032 path: "src/b.rs".to_string(),
2033 diff_file: path_b.to_string_lossy().to_string(),
2034 byte_len: diff_b.len(),
2035 },
2036 ],
2037 },
2038 }],
2039 }
2040 }
2041
2042 fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2043 format!("amendments:\n - commit: \"{hash}\"\n message: \"{message}\"")
2044 }
2045
2046 #[tokio::test]
2047 async fn generate_amendments_split_dispatch() {
2048 let dir = tempfile::tempdir().unwrap();
2049 let repo_view = make_large_diff_repo_view(&dir);
2050 let hash = "a".repeat(40);
2051
2052 let client = make_small_context_client(vec![
2054 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2055 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2056 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2057 ]);
2058
2059 let result = client
2060 .generate_amendments_with_options(&repo_view, false)
2061 .await;
2062
2063 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2064 let amendments = result.unwrap();
2065 assert_eq!(amendments.amendments.len(), 1);
2066 assert_eq!(amendments.amendments[0].commit, hash);
2067 assert!(amendments.amendments[0]
2068 .message
2069 .contains("add a.rs and b.rs"));
2070 }
2071
2072 #[tokio::test]
2073 async fn generate_amendments_split_chunk_failure() {
2074 let dir = tempfile::tempdir().unwrap();
2075 let repo_view = make_large_diff_repo_view(&dir);
2076 let hash = "a".repeat(40);
2077
2078 let client = make_small_context_client(vec![
2080 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2081 Err(anyhow::anyhow!("rate limit exceeded")),
2082 ]);
2083
2084 let result = client
2085 .generate_amendments_with_options(&repo_view, false)
2086 .await;
2087
2088 assert!(result.is_err());
2089 }
2090
2091 #[tokio::test]
2092 async fn generate_amendments_no_split_when_fits() {
2093 let dir = tempfile::tempdir().unwrap();
2094 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2096
2097 let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2099 &hash,
2100 "feat(test): improved message",
2101 ))]);
2102
2103 let result = client
2104 .generate_amendments_with_options(&repo_view, false)
2105 .await;
2106
2107 assert!(result.is_ok());
2108 assert_eq!(result.unwrap().amendments.len(), 1);
2109 }
2110
2111 fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2114 format!(
2115 "checks:\n - commit: \"{hash}\"\n passes: {passes}\n issues: []\n summary: \"test summary\"\n"
2116 )
2117 }
2118
2119 fn valid_check_yaml_with_issues(hash: &str) -> String {
2120 format!(
2121 concat!(
2122 "checks:\n",
2123 " - commit: \"{hash}\"\n",
2124 " passes: false\n",
2125 " issues:\n",
2126 " - severity: error\n",
2127 " section: \"Subject Line\"\n",
2128 " rule: \"subject-too-long\"\n",
2129 " explanation: \"Subject exceeds 72 characters\"\n",
2130 " suggestion:\n",
2131 " message: \"feat(test): shorter subject\"\n",
2132 " explanation: \"Shortened subject line\"\n",
2133 " summary: \"Large commit with issues\"\n",
2134 ),
2135 hash = hash,
2136 )
2137 }
2138
2139 fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2140 format!(
2141 concat!(
2142 "checks:\n",
2143 " - commit: \"{hash}\"\n",
2144 " passes: true\n",
2145 " issues: []\n",
2146 " summary: \"chunk summary\"\n",
2147 ),
2148 hash = hash,
2149 )
2150 }
2151
2152 #[tokio::test]
2153 async fn check_commits_split_dispatch() {
2154 let dir = tempfile::tempdir().unwrap();
2155 let repo_view = make_large_diff_repo_view(&dir);
2156 let hash = "a".repeat(40);
2157
2158 let client = make_small_context_client(vec![
2160 Ok(valid_check_yaml_with_issues(&hash)),
2161 Ok(valid_check_yaml_with_issues(&hash)),
2162 Ok(valid_check_yaml_with_issues(&hash)), ]);
2164
2165 let result = client
2166 .check_commits_with_scopes(&repo_view, None, &[], true)
2167 .await;
2168
2169 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2170 let report = result.unwrap();
2171 assert_eq!(report.commits.len(), 1);
2172 assert!(!report.commits[0].passes);
2173 assert_eq!(report.commits[0].issues.len(), 1);
2175 assert_eq!(report.commits[0].issues[0].rule, "subject-too-long");
2176 }
2177
2178 #[tokio::test]
2179 async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2180 let dir = tempfile::tempdir().unwrap();
2181 let repo_view = make_large_diff_repo_view(&dir);
2182 let hash = "a".repeat(40);
2183
2184 let client = make_small_context_client(vec![
2187 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2188 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2189 ]);
2190
2191 let result = client
2192 .check_commits_with_scopes(&repo_view, None, &[], false)
2193 .await;
2194
2195 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2196 let report = result.unwrap();
2197 assert_eq!(report.commits.len(), 1);
2198 assert!(report.commits[0].passes);
2199 assert!(report.commits[0].issues.is_empty());
2200 assert!(report.commits[0].suggestion.is_none());
2201 assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2203 }
2204
2205 #[tokio::test]
2206 async fn check_commits_split_chunk_failure() {
2207 let dir = tempfile::tempdir().unwrap();
2208 let repo_view = make_large_diff_repo_view(&dir);
2209 let hash = "a".repeat(40);
2210
2211 let client = make_small_context_client(vec![
2213 Ok(valid_check_yaml_for(&hash, true)),
2214 Err(anyhow::anyhow!("rate limit exceeded")),
2215 ]);
2216
2217 let result = client
2218 .check_commits_with_scopes(&repo_view, None, &[], false)
2219 .await;
2220
2221 assert!(result.is_err());
2222 }
2223
2224 #[tokio::test]
2225 async fn check_commits_no_split_when_fits() {
2226 let dir = tempfile::tempdir().unwrap();
2227 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2229
2230 let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2232
2233 let result = client
2234 .check_commits_with_scopes(&repo_view, None, &[], false)
2235 .await;
2236
2237 assert!(result.is_ok());
2238 assert_eq!(result.unwrap().commits.len(), 1);
2239 }
2240
2241 #[tokio::test]
2242 async fn check_commits_split_dedup_across_chunks() {
2243 let dir = tempfile::tempdir().unwrap();
2244 let repo_view = make_large_diff_repo_view(&dir);
2245 let hash = "a".repeat(40);
2246
2247 let chunk1 = format!(
2249 concat!(
2250 "checks:\n",
2251 " - commit: \"{hash}\"\n",
2252 " passes: false\n",
2253 " issues:\n",
2254 " - severity: error\n",
2255 " section: \"Subject Line\"\n",
2256 " rule: \"subject-too-long\"\n",
2257 " explanation: \"Subject exceeds 72 characters\"\n",
2258 " - severity: warning\n",
2259 " section: \"Content\"\n",
2260 " rule: \"body-required\"\n",
2261 " explanation: \"Large change needs body\"\n",
2262 ),
2263 hash = hash,
2264 );
2265
2266 let chunk2 = format!(
2268 concat!(
2269 "checks:\n",
2270 " - commit: \"{hash}\"\n",
2271 " passes: false\n",
2272 " issues:\n",
2273 " - severity: error\n",
2274 " section: \"Subject Line\"\n",
2275 " rule: \"subject-too-long\"\n",
2276 " explanation: \"Subject line is too long\"\n",
2277 " - severity: info\n",
2278 " section: \"Style\"\n",
2279 " rule: \"scope-suggestion\"\n",
2280 " explanation: \"Consider more specific scope\"\n",
2281 ),
2282 hash = hash,
2283 );
2284
2285 let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2287
2288 let result = client
2289 .check_commits_with_scopes(&repo_view, None, &[], false)
2290 .await;
2291
2292 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2293 let report = result.unwrap();
2294 assert_eq!(report.commits.len(), 1);
2295 assert!(!report.commits[0].passes);
2296 assert_eq!(report.commits[0].issues.len(), 3);
2299 }
2300
2301 #[tokio::test]
2302 async fn check_commits_split_passes_only_when_all_chunks_pass() {
2303 let dir = tempfile::tempdir().unwrap();
2304 let repo_view = make_large_diff_repo_view(&dir);
2305 let hash = "a".repeat(40);
2306
2307 let client = make_small_context_client(vec![
2309 Ok(valid_check_yaml_for(&hash, true)),
2310 Ok(valid_check_yaml_for(&hash, false)),
2311 ]);
2312
2313 let result = client
2314 .check_commits_with_scopes(&repo_view, None, &[], false)
2315 .await;
2316
2317 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2318 let report = result.unwrap();
2319 assert!(
2320 !report.commits[0].passes,
2321 "should fail when any chunk fails"
2322 );
2323 }
2324
2325 fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2329 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2330 use crate::git::commit::FileChanges;
2331 use crate::git::{CommitAnalysis, CommitInfo};
2332
2333 let diff_a = dir.path().join("0.diff");
2334 let diff_b = dir.path().join("1.diff");
2335 std::fs::write(&diff_a, "+line a\n").unwrap();
2336 std::fs::write(&diff_b, "+line b\n").unwrap();
2337
2338 let hash_a = "a".repeat(40);
2339 let hash_b = "b".repeat(40);
2340
2341 crate::data::RepositoryView {
2342 versions: None,
2343 explanation: FieldExplanation::default(),
2344 working_directory: WorkingDirectoryInfo {
2345 clean: true,
2346 untracked_changes: Vec::new(),
2347 },
2348 remotes: Vec::new(),
2349 ai: AiInfo {
2350 scratch: String::new(),
2351 },
2352 branch_info: None,
2353 pr_template: None,
2354 pr_template_location: None,
2355 branch_prs: None,
2356 commits: vec![
2357 CommitInfo {
2358 hash: hash_a,
2359 author: "Test <test@test.com>".to_string(),
2360 date: chrono::Utc::now().fixed_offset(),
2361 original_message: "feat(a): add a".to_string(),
2362 in_main_branches: Vec::new(),
2363 analysis: CommitAnalysis {
2364 detected_type: "feat".to_string(),
2365 detected_scope: "a".to_string(),
2366 proposed_message: "feat(a): add a".to_string(),
2367 file_changes: FileChanges {
2368 total_files: 1,
2369 files_added: 1,
2370 files_deleted: 0,
2371 file_list: Vec::new(),
2372 },
2373 diff_summary: "a.rs | 1 +".to_string(),
2374 diff_file: diff_a.to_string_lossy().to_string(),
2375 file_diffs: Vec::new(),
2376 },
2377 },
2378 CommitInfo {
2379 hash: hash_b,
2380 author: "Test <test@test.com>".to_string(),
2381 date: chrono::Utc::now().fixed_offset(),
2382 original_message: "feat(b): add b".to_string(),
2383 in_main_branches: Vec::new(),
2384 analysis: CommitAnalysis {
2385 detected_type: "feat".to_string(),
2386 detected_scope: "b".to_string(),
2387 proposed_message: "feat(b): add b".to_string(),
2388 file_changes: FileChanges {
2389 total_files: 1,
2390 files_added: 1,
2391 files_deleted: 0,
2392 file_list: Vec::new(),
2393 },
2394 diff_summary: "b.rs | 1 +".to_string(),
2395 diff_file: diff_b.to_string_lossy().to_string(),
2396 file_diffs: Vec::new(),
2397 },
2398 },
2399 ],
2400 }
2401 }
2402
2403 #[tokio::test]
2404 async fn generate_amendments_multi_commit() {
2405 let dir = tempfile::tempdir().unwrap();
2406 let repo_view = make_multi_commit_repo_view(&dir);
2407 let hash_a = "a".repeat(40);
2408 let hash_b = "b".repeat(40);
2409
2410 let response = format!(
2411 concat!(
2412 "amendments:\n",
2413 " - commit: \"{hash_a}\"\n",
2414 " message: \"feat(a): improved a\"\n",
2415 " - commit: \"{hash_b}\"\n",
2416 " message: \"feat(b): improved b\"\n",
2417 ),
2418 hash_a = hash_a,
2419 hash_b = hash_b,
2420 );
2421 let client = make_configurable_client(vec![Ok(response)]);
2422
2423 let result = client
2424 .generate_amendments_with_options(&repo_view, false)
2425 .await;
2426
2427 assert!(
2428 result.is_ok(),
2429 "multi-commit amendment failed: {:?}",
2430 result.err()
2431 );
2432 let amendments = result.unwrap();
2433 assert_eq!(amendments.amendments.len(), 2);
2434 }
2435
2436 #[tokio::test]
2437 async fn generate_contextual_amendments_multi_commit() {
2438 let dir = tempfile::tempdir().unwrap();
2439 let repo_view = make_multi_commit_repo_view(&dir);
2440 let hash_a = "a".repeat(40);
2441 let hash_b = "b".repeat(40);
2442
2443 let response = format!(
2444 concat!(
2445 "amendments:\n",
2446 " - commit: \"{hash_a}\"\n",
2447 " message: \"feat(a): improved a\"\n",
2448 " - commit: \"{hash_b}\"\n",
2449 " message: \"feat(b): improved b\"\n",
2450 ),
2451 hash_a = hash_a,
2452 hash_b = hash_b,
2453 );
2454 let client = make_configurable_client(vec![Ok(response)]);
2455 let context = crate::data::context::CommitContext::default();
2456
2457 let result = client
2458 .generate_contextual_amendments_with_options(&repo_view, &context, false)
2459 .await;
2460
2461 assert!(
2462 result.is_ok(),
2463 "multi-commit contextual amendment failed: {:?}",
2464 result.err()
2465 );
2466 let amendments = result.unwrap();
2467 assert_eq!(amendments.amendments.len(), 2);
2468 }
2469
2470 #[tokio::test]
2471 async fn generate_pr_content_succeeds() {
2472 let dir = tempfile::tempdir().unwrap();
2473 let repo_view = make_test_repo_view(&dir);
2474
2475 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2476 let client = make_configurable_client(vec![Ok(response.to_string())]);
2477
2478 let result = client.generate_pr_content(&repo_view, "").await;
2479
2480 assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
2481 let pr = result.unwrap();
2482 assert_eq!(pr.title, "feat: add something");
2483 assert_eq!(pr.description, "Adds a new feature.");
2484 }
2485
2486 #[tokio::test]
2487 async fn generate_pr_content_with_context_succeeds() {
2488 let dir = tempfile::tempdir().unwrap();
2489 let repo_view = make_test_repo_view(&dir);
2490 let context = crate::data::context::CommitContext::default();
2491
2492 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2493 let client = make_configurable_client(vec![Ok(response.to_string())]);
2494
2495 let result = client
2496 .generate_pr_content_with_context(&repo_view, "", &context)
2497 .await;
2498
2499 assert!(
2500 result.is_ok(),
2501 "PR generation with context failed: {:?}",
2502 result.err()
2503 );
2504 let pr = result.unwrap();
2505 assert_eq!(pr.title, "feat: add something");
2506 }
2507
2508 #[tokio::test]
2509 async fn check_commits_multi_commit() {
2510 let dir = tempfile::tempdir().unwrap();
2511 let repo_view = make_multi_commit_repo_view(&dir);
2512 let hash_a = "a".repeat(40);
2513 let hash_b = "b".repeat(40);
2514
2515 let response = format!(
2516 concat!(
2517 "checks:\n",
2518 " - commit: \"{hash_a}\"\n",
2519 " passes: true\n",
2520 " issues: []\n",
2521 " - commit: \"{hash_b}\"\n",
2522 " passes: true\n",
2523 " issues: []\n",
2524 ),
2525 hash_a = hash_a,
2526 hash_b = hash_b,
2527 );
2528 let client = make_configurable_client(vec![Ok(response)]);
2529
2530 let result = client
2531 .check_commits_with_scopes(&repo_view, None, &[], false)
2532 .await;
2533
2534 assert!(
2535 result.is_ok(),
2536 "multi-commit check failed: {:?}",
2537 result.err()
2538 );
2539 let report = result.unwrap();
2540 assert_eq!(report.commits.len(), 2);
2541 assert!(report.commits[0].passes);
2542 assert!(report.commits[1].passes);
2543 }
2544
2545 fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2550 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2551 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2552 use crate::git::{CommitAnalysis, CommitInfo};
2553
2554 let hash_a = "a".repeat(40);
2555 let hash_b = "b".repeat(40);
2556
2557 let diff_content_a = "x".repeat(60_000);
2560 let diff_content_b = "y".repeat(60_000);
2561 let flat_a = dir.path().join("flat_a.diff");
2562 let flat_b = dir.path().join("flat_b.diff");
2563 std::fs::write(&flat_a, &diff_content_a).unwrap();
2564 std::fs::write(&flat_b, &diff_content_b).unwrap();
2565
2566 let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2568 let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2569 let per_file_a = dir.path().join("pf_a.diff");
2570 let per_file_b = dir.path().join("pf_b.diff");
2571 std::fs::write(&per_file_a, &file_diff_a).unwrap();
2572 std::fs::write(&per_file_b, &file_diff_b).unwrap();
2573
2574 crate::data::RepositoryView {
2575 versions: None,
2576 explanation: FieldExplanation::default(),
2577 working_directory: WorkingDirectoryInfo {
2578 clean: true,
2579 untracked_changes: Vec::new(),
2580 },
2581 remotes: Vec::new(),
2582 ai: AiInfo {
2583 scratch: String::new(),
2584 },
2585 branch_info: None,
2586 pr_template: None,
2587 pr_template_location: None,
2588 branch_prs: None,
2589 commits: vec![
2590 CommitInfo {
2591 hash: hash_a,
2592 author: "Test <test@test.com>".to_string(),
2593 date: chrono::Utc::now().fixed_offset(),
2594 original_message: "feat(a): add module a".to_string(),
2595 in_main_branches: Vec::new(),
2596 analysis: CommitAnalysis {
2597 detected_type: "feat".to_string(),
2598 detected_scope: "a".to_string(),
2599 proposed_message: "feat(a): add module a".to_string(),
2600 file_changes: FileChanges {
2601 total_files: 1,
2602 files_added: 1,
2603 files_deleted: 0,
2604 file_list: vec![FileChange {
2605 status: "A".to_string(),
2606 file: "src/a.rs".to_string(),
2607 }],
2608 },
2609 diff_summary: " src/a.rs | 100 ++++\n".to_string(),
2610 diff_file: flat_a.to_string_lossy().to_string(),
2611 file_diffs: vec![FileDiffRef {
2612 path: "src/a.rs".to_string(),
2613 diff_file: per_file_a.to_string_lossy().to_string(),
2614 byte_len: file_diff_a.len(),
2615 }],
2616 },
2617 },
2618 CommitInfo {
2619 hash: hash_b,
2620 author: "Test <test@test.com>".to_string(),
2621 date: chrono::Utc::now().fixed_offset(),
2622 original_message: "feat(b): add module b".to_string(),
2623 in_main_branches: Vec::new(),
2624 analysis: CommitAnalysis {
2625 detected_type: "feat".to_string(),
2626 detected_scope: "b".to_string(),
2627 proposed_message: "feat(b): add module b".to_string(),
2628 file_changes: FileChanges {
2629 total_files: 1,
2630 files_added: 1,
2631 files_deleted: 0,
2632 file_list: vec![FileChange {
2633 status: "A".to_string(),
2634 file: "src/b.rs".to_string(),
2635 }],
2636 },
2637 diff_summary: " src/b.rs | 100 ++++\n".to_string(),
2638 diff_file: flat_b.to_string_lossy().to_string(),
2639 file_diffs: vec![FileDiffRef {
2640 path: "src/b.rs".to_string(),
2641 diff_file: per_file_b.to_string_lossy().to_string(),
2642 byte_len: file_diff_b.len(),
2643 }],
2644 },
2645 },
2646 ],
2647 }
2648 }
2649
2650 fn valid_pr_yaml(title: &str, description: &str) -> String {
2651 format!("title: \"{title}\"\ndescription: \"{description}\"\n")
2652 }
2653
2654 #[tokio::test]
2657 async fn generate_amendments_multi_commit_split_dispatch() {
2658 let dir = tempfile::tempdir().unwrap();
2659 let repo_view = make_large_multi_commit_repo_view(&dir);
2660 let hash_a = "a".repeat(40);
2661 let hash_b = "b".repeat(40);
2662
2663 let (client, handle) = make_small_context_client_tracked(vec![
2666 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
2667 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
2668 ]);
2669
2670 let result = client
2671 .generate_amendments_with_options(&repo_view, false)
2672 .await;
2673
2674 assert!(
2675 result.is_ok(),
2676 "multi-commit split dispatch failed: {:?}",
2677 result.err()
2678 );
2679 let amendments = result.unwrap();
2680 assert_eq!(amendments.amendments.len(), 2);
2681 assert_eq!(amendments.amendments[0].commit, hash_a);
2682 assert_eq!(amendments.amendments[1].commit, hash_b);
2683 assert!(amendments.amendments[0].message.contains("improved a"));
2684 assert!(amendments.amendments[1].message.contains("improved b"));
2685 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2686 }
2687
2688 #[tokio::test]
2689 async fn generate_contextual_amendments_multi_commit_split_dispatch() {
2690 let dir = tempfile::tempdir().unwrap();
2691 let repo_view = make_large_multi_commit_repo_view(&dir);
2692 let hash_a = "a".repeat(40);
2693 let hash_b = "b".repeat(40);
2694 let context = crate::data::context::CommitContext::default();
2695
2696 let (client, handle) = make_small_context_client_tracked(vec![
2697 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
2698 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
2699 ]);
2700
2701 let result = client
2702 .generate_contextual_amendments_with_options(&repo_view, &context, false)
2703 .await;
2704
2705 assert!(
2706 result.is_ok(),
2707 "multi-commit contextual split dispatch failed: {:?}",
2708 result.err()
2709 );
2710 let amendments = result.unwrap();
2711 assert_eq!(amendments.amendments.len(), 2);
2712 assert_eq!(amendments.amendments[0].commit, hash_a);
2713 assert_eq!(amendments.amendments[1].commit, hash_b);
2714 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2715 }
2716
2717 #[tokio::test]
2720 async fn check_commits_multi_commit_split_dispatch() {
2721 let dir = tempfile::tempdir().unwrap();
2722 let repo_view = make_large_multi_commit_repo_view(&dir);
2723 let hash_a = "a".repeat(40);
2724 let hash_b = "b".repeat(40);
2725
2726 let (client, handle) = make_small_context_client_tracked(vec![
2728 Ok(valid_check_yaml_for(&hash_a, true)),
2729 Ok(valid_check_yaml_for(&hash_b, true)),
2730 ]);
2731
2732 let result = client
2733 .check_commits_with_scopes(&repo_view, None, &[], false)
2734 .await;
2735
2736 assert!(
2737 result.is_ok(),
2738 "multi-commit check split dispatch failed: {:?}",
2739 result.err()
2740 );
2741 let report = result.unwrap();
2742 assert_eq!(report.commits.len(), 2);
2743 assert!(report.commits[0].passes);
2744 assert!(report.commits[1].passes);
2745 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2746 }
2747
2748 #[tokio::test]
2751 async fn generate_pr_content_split_dispatch() {
2752 let dir = tempfile::tempdir().unwrap();
2753 let repo_view = make_large_diff_repo_view(&dir);
2754
2755 let (client, handle) = make_small_context_client_tracked(vec![
2759 Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
2760 Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
2761 Ok(valid_pr_yaml(
2762 "feat(test): add modules",
2763 "Adds a.rs and b.rs",
2764 )),
2765 ]);
2766
2767 let result = client.generate_pr_content(&repo_view, "").await;
2768
2769 assert!(
2770 result.is_ok(),
2771 "PR split dispatch failed: {:?}",
2772 result.err()
2773 );
2774 let pr = result.unwrap();
2775 assert!(pr.title.contains("add modules"));
2776 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2777 }
2778
2779 #[tokio::test]
2780 async fn generate_pr_content_multi_commit_split_dispatch() {
2781 let dir = tempfile::tempdir().unwrap();
2782 let repo_view = make_large_multi_commit_repo_view(&dir);
2783
2784 let (client, handle) = make_small_context_client_tracked(vec![
2787 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
2788 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
2789 Ok(valid_pr_yaml(
2790 "feat: add modules a and b",
2791 "Adds both modules",
2792 )),
2793 ]);
2794
2795 let result = client.generate_pr_content(&repo_view, "").await;
2796
2797 assert!(
2798 result.is_ok(),
2799 "PR multi-commit split dispatch failed: {:?}",
2800 result.err()
2801 );
2802 let pr = result.unwrap();
2803 assert!(pr.title.contains("modules"));
2804 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2805 }
2806
2807 #[tokio::test]
2808 async fn generate_pr_content_with_context_split_dispatch() {
2809 let dir = tempfile::tempdir().unwrap();
2810 let repo_view = make_large_multi_commit_repo_view(&dir);
2811 let context = crate::data::context::CommitContext::default();
2812
2813 let (client, handle) = make_small_context_client_tracked(vec![
2815 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
2816 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
2817 Ok(valid_pr_yaml(
2818 "feat: add modules a and b",
2819 "Adds both modules",
2820 )),
2821 ]);
2822
2823 let result = client
2824 .generate_pr_content_with_context(&repo_view, "", &context)
2825 .await;
2826
2827 assert!(
2828 result.is_ok(),
2829 "PR with context split dispatch failed: {:?}",
2830 result.err()
2831 );
2832 let pr = result.unwrap();
2833 assert!(pr.title.contains("modules"));
2834 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2835 }
2836
2837 fn make_small_context_client_with_prompts(
2842 responses: Vec<Result<String>>,
2843 ) -> (
2844 ClaudeClient,
2845 crate::claude::test_utils::ResponseQueueHandle,
2846 crate::claude::test_utils::PromptRecordHandle,
2847 ) {
2848 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2849 .with_context_length(50_000);
2850 let response_handle = mock.response_handle();
2851 let prompt_handle = mock.prompt_handle();
2852 (
2853 ClaudeClient::new(Box::new(mock)),
2854 response_handle,
2855 prompt_handle,
2856 )
2857 }
2858
2859 fn make_configurable_client_with_prompts(
2861 responses: Vec<Result<String>>,
2862 ) -> (
2863 ClaudeClient,
2864 crate::claude::test_utils::ResponseQueueHandle,
2865 crate::claude::test_utils::PromptRecordHandle,
2866 ) {
2867 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
2868 let response_handle = mock.response_handle();
2869 let prompt_handle = mock.prompt_handle();
2870 (
2871 ClaudeClient::new(Box::new(mock)),
2872 response_handle,
2873 prompt_handle,
2874 )
2875 }
2876
2877 fn make_single_oversized_file_repo_view(
2884 dir: &tempfile::TempDir,
2885 ) -> crate::data::RepositoryView {
2886 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2887 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2888 use crate::git::{CommitAnalysis, CommitInfo};
2889
2890 let hash = "c".repeat(40);
2891
2892 let diff_content = format!(
2895 "diff --git a/src/big.rs b/src/big.rs\n{}\n",
2896 "x".repeat(80_000)
2897 );
2898
2899 let flat_diff_path = dir.path().join("full.diff");
2900 std::fs::write(&flat_diff_path, &diff_content).unwrap();
2901
2902 let per_file_path = dir.path().join("0000.diff");
2903 std::fs::write(&per_file_path, &diff_content).unwrap();
2904
2905 crate::data::RepositoryView {
2906 versions: None,
2907 explanation: FieldExplanation::default(),
2908 working_directory: WorkingDirectoryInfo {
2909 clean: true,
2910 untracked_changes: Vec::new(),
2911 },
2912 remotes: Vec::new(),
2913 ai: AiInfo {
2914 scratch: String::new(),
2915 },
2916 branch_info: None,
2917 pr_template: None,
2918 pr_template_location: None,
2919 branch_prs: None,
2920 commits: vec![CommitInfo {
2921 hash,
2922 author: "Test <test@test.com>".to_string(),
2923 date: chrono::Utc::now().fixed_offset(),
2924 original_message: "feat(big): add large module".to_string(),
2925 in_main_branches: Vec::new(),
2926 analysis: CommitAnalysis {
2927 detected_type: "feat".to_string(),
2928 detected_scope: "big".to_string(),
2929 proposed_message: "feat(big): add large module".to_string(),
2930 file_changes: FileChanges {
2931 total_files: 1,
2932 files_added: 1,
2933 files_deleted: 0,
2934 file_list: vec![FileChange {
2935 status: "A".to_string(),
2936 file: "src/big.rs".to_string(),
2937 }],
2938 },
2939 diff_summary: " src/big.rs | 80 ++++\n".to_string(),
2940 diff_file: flat_diff_path.to_string_lossy().to_string(),
2941 file_diffs: vec![FileDiffRef {
2942 path: "src/big.rs".to_string(),
2943 diff_file: per_file_path.to_string_lossy().to_string(),
2944 byte_len: diff_content.len(),
2945 }],
2946 },
2947 }],
2948 }
2949 }
2950
2951 #[tokio::test]
2958 async fn amendment_single_file_under_budget_no_split() {
2959 let dir = tempfile::tempdir().unwrap();
2960 let repo_view = make_test_repo_view(&dir);
2961 let hash = format!("{:0>40}", 0);
2962
2963 let (client, response_handle, prompt_handle) =
2964 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
2965 &hash,
2966 "feat(test): improved message",
2967 ))]);
2968
2969 let result = client
2970 .generate_amendments_with_options(&repo_view, false)
2971 .await;
2972
2973 assert!(result.is_ok());
2974 assert_eq!(result.unwrap().amendments.len(), 1);
2975 assert_eq!(response_handle.remaining(), 0);
2976
2977 let prompts = prompt_handle.prompts();
2978 assert_eq!(
2979 prompts.len(),
2980 1,
2981 "expected exactly one AI request, no split"
2982 );
2983
2984 let (_, user_prompt) = &prompts[0];
2985 assert!(
2986 user_prompt.contains("added line"),
2987 "user prompt should contain the diff content"
2988 );
2989 }
2990
2991 #[tokio::test]
3002 async fn amendment_two_chunks_prompt_content() {
3003 let dir = tempfile::tempdir().unwrap();
3004 let repo_view = make_large_diff_repo_view(&dir);
3005 let hash = "a".repeat(40);
3006
3007 let (client, response_handle, prompt_handle) =
3008 make_small_context_client_with_prompts(vec![
3009 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3010 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3011 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3012 ]);
3013
3014 let result = client
3015 .generate_amendments_with_options(&repo_view, false)
3016 .await;
3017
3018 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3019 let amendments = result.unwrap();
3020 assert_eq!(amendments.amendments.len(), 1);
3021 assert!(amendments.amendments[0]
3022 .message
3023 .contains("add a.rs and b.rs"));
3024 assert_eq!(response_handle.remaining(), 0);
3025
3026 let prompts = prompt_handle.prompts();
3027 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3028
3029 let (_, chunk1_user) = &prompts[0];
3031 assert!(
3032 chunk1_user.contains("aaa"),
3033 "chunk 1 prompt should contain file-a diff content"
3034 );
3035
3036 let (_, chunk2_user) = &prompts[1];
3038 assert!(
3039 chunk2_user.contains("bbb"),
3040 "chunk 2 prompt should contain file-b diff content"
3041 );
3042
3043 let (merge_sys, merge_user) = &prompts[2];
3045 assert!(
3046 merge_sys.contains("synthesiz"),
3047 "merge system prompt should contain synthesis instructions"
3048 );
3049 assert!(
3051 merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
3052 "merge user prompt should contain both partial amendment messages"
3053 );
3054 }
3055
3056 #[tokio::test]
3068 async fn amendment_single_oversized_file_gets_placeholder() {
3069 let dir = tempfile::tempdir().unwrap();
3070 let repo_view = make_single_oversized_file_repo_view(&dir);
3071 let hash = "c".repeat(40);
3072
3073 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3078 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3079 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3080 ]);
3081
3082 let result = client
3083 .generate_amendments_with_options(&repo_view, false)
3084 .await;
3085
3086 assert!(
3088 result.is_ok(),
3089 "expected success with placeholder, got: {result:?}"
3090 );
3091
3092 assert!(
3094 prompt_handle.request_count() >= 1,
3095 "expected at least 1 request, got {}",
3096 prompt_handle.request_count()
3097 );
3098 }
3099
3100 #[tokio::test]
3109 async fn amendment_chunk_failure_stops_dispatch() {
3110 let dir = tempfile::tempdir().unwrap();
3111 let repo_view = make_large_diff_repo_view(&dir);
3112 let hash = "a".repeat(40);
3113
3114 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3116 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3117 Err(anyhow::anyhow!("rate limit exceeded")),
3118 ]);
3119
3120 let result = client
3121 .generate_amendments_with_options(&repo_view, false)
3122 .await;
3123
3124 assert!(result.is_err());
3125
3126 let prompts = prompt_handle.prompts();
3128 assert_eq!(
3129 prompts.len(),
3130 2,
3131 "should stop after the failing chunk, got {} requests",
3132 prompts.len()
3133 );
3134
3135 let (_, first_user) = &prompts[0];
3137 assert!(
3138 first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
3139 "first chunk prompt should reference a file"
3140 );
3141 }
3142
3143 #[tokio::test]
3154 async fn amendment_reduce_pass_prompt_content() {
3155 let dir = tempfile::tempdir().unwrap();
3156 let repo_view = make_large_diff_repo_view(&dir);
3157 let hash = "a".repeat(40);
3158
3159 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3160 Ok(valid_amendment_yaml(
3161 &hash,
3162 "feat(a): add module a implementation",
3163 )),
3164 Ok(valid_amendment_yaml(
3165 &hash,
3166 "feat(b): add module b implementation",
3167 )),
3168 Ok(valid_amendment_yaml(
3169 &hash,
3170 "feat(test): add modules a and b",
3171 )),
3172 ]);
3173
3174 let result = client
3175 .generate_amendments_with_options(&repo_view, false)
3176 .await;
3177
3178 assert!(result.is_ok());
3179
3180 let prompts = prompt_handle.prompts();
3181 assert_eq!(prompts.len(), 3);
3182
3183 let (merge_system, merge_user) = &prompts[2];
3185
3186 assert!(
3188 merge_system.contains("synthesiz"),
3189 "merge system prompt should contain synthesis instructions"
3190 );
3191
3192 assert!(
3194 merge_user.contains("feat(a): add module a implementation"),
3195 "merge user prompt should contain chunk 1's partial message"
3196 );
3197 assert!(
3198 merge_user.contains("feat(b): add module b implementation"),
3199 "merge user prompt should contain chunk 2's partial message"
3200 );
3201
3202 assert!(
3204 merge_user.contains("feat(test): large commit"),
3205 "merge user prompt should contain the original commit message"
3206 );
3207
3208 assert!(
3210 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3211 "merge user prompt should contain the diff_summary"
3212 );
3213
3214 assert!(
3216 merge_user.contains(&hash),
3217 "merge user prompt should reference the commit hash"
3218 );
3219 }
3220
3221 #[tokio::test]
3238 async fn check_split_dedup_and_merge_prompt() {
3239 let dir = tempfile::tempdir().unwrap();
3240 let repo_view = make_large_diff_repo_view(&dir);
3241 let hash = "a".repeat(40);
3242
3243 let chunk1_yaml = format!(
3245 concat!(
3246 "checks:\n",
3247 " - commit: \"{hash}\"\n",
3248 " passes: false\n",
3249 " issues:\n",
3250 " - severity: error\n",
3251 " section: \"Subject Line\"\n",
3252 " rule: \"subject-too-long\"\n",
3253 " explanation: \"Subject exceeds 72 characters\"\n",
3254 " - severity: warning\n",
3255 " section: \"Content\"\n",
3256 " rule: \"body-required\"\n",
3257 " explanation: \"Large change needs body\"\n",
3258 " suggestion:\n",
3259 " message: \"feat(a): shorter subject for a\"\n",
3260 " explanation: \"Shortened subject for file a\"\n",
3261 " summary: \"Adds module a\"\n",
3262 ),
3263 hash = hash,
3264 );
3265
3266 let chunk2_yaml = format!(
3268 concat!(
3269 "checks:\n",
3270 " - commit: \"{hash}\"\n",
3271 " passes: false\n",
3272 " issues:\n",
3273 " - severity: error\n",
3274 " section: \"Subject Line\"\n",
3275 " rule: \"subject-too-long\"\n",
3276 " explanation: \"Subject line is way too long\"\n",
3277 " - severity: info\n",
3278 " section: \"Style\"\n",
3279 " rule: \"scope-suggestion\"\n",
3280 " explanation: \"Consider more specific scope\"\n",
3281 " suggestion:\n",
3282 " message: \"feat(b): shorter subject for b\"\n",
3283 " explanation: \"Shortened subject for file b\"\n",
3284 " summary: \"Adds module b\"\n",
3285 ),
3286 hash = hash,
3287 );
3288
3289 let merge_yaml = format!(
3291 concat!(
3292 "checks:\n",
3293 " - commit: \"{hash}\"\n",
3294 " passes: false\n",
3295 " issues: []\n",
3296 " suggestion:\n",
3297 " message: \"feat(test): add modules a and b\"\n",
3298 " explanation: \"Combined suggestion\"\n",
3299 " summary: \"Adds modules a and b\"\n",
3300 ),
3301 hash = hash,
3302 );
3303
3304 let (client, response_handle, prompt_handle) =
3305 make_small_context_client_with_prompts(vec![
3306 Ok(chunk1_yaml),
3307 Ok(chunk2_yaml),
3308 Ok(merge_yaml),
3309 ]);
3310
3311 let result = client
3312 .check_commits_with_scopes(&repo_view, None, &[], true)
3313 .await;
3314
3315 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3316 let report = result.unwrap();
3317 assert_eq!(report.commits.len(), 1);
3318 assert!(!report.commits[0].passes);
3319 assert_eq!(response_handle.remaining(), 0);
3320
3321 assert_eq!(
3326 report.commits[0].issues.len(),
3327 3,
3328 "expected 3 unique issues after dedup, got {:?}",
3329 report.commits[0]
3330 .issues
3331 .iter()
3332 .map(|i| &i.rule)
3333 .collect::<Vec<_>>()
3334 );
3335
3336 assert!(report.commits[0].suggestion.is_some());
3338 assert!(
3339 report.commits[0]
3340 .suggestion
3341 .as_ref()
3342 .unwrap()
3343 .message
3344 .contains("add modules a and b"),
3345 "suggestion should come from the merge pass"
3346 );
3347
3348 let prompts = prompt_handle.prompts();
3350 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
3351
3352 let (_, chunk1_user) = &prompts[0];
3354 let (_, chunk2_user) = &prompts[1];
3355 let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
3356 assert!(
3357 combined_chunk_prompts.contains("src/a.rs")
3358 && combined_chunk_prompts.contains("src/b.rs"),
3359 "chunk prompts should collectively cover both files"
3360 );
3361
3362 let (merge_sys, merge_user) = &prompts[2];
3364 assert!(
3365 merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
3366 "merge system prompt should be the check chunk merge prompt"
3367 );
3368 assert!(
3369 merge_user.contains("feat(a): shorter subject for a")
3370 && merge_user.contains("feat(b): shorter subject for b"),
3371 "merge user prompt should contain both partial suggestions"
3372 );
3373 assert!(
3375 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3376 "merge user prompt should contain the diff_summary"
3377 );
3378 }
3379
3380 #[tokio::test]
3383 async fn amendment_retry_parse_failure_then_success() {
3384 let dir = tempfile::tempdir().unwrap();
3385 let repo_view = make_test_repo_view(&dir);
3386 let hash = format!("{:0>40}", 0);
3387
3388 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3389 Ok("not valid yaml {{[".to_string()),
3390 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3391 ]);
3392
3393 let result = client
3394 .generate_amendments_with_options(&repo_view, false)
3395 .await;
3396
3397 assert!(
3398 result.is_ok(),
3399 "should succeed after retry: {:?}",
3400 result.err()
3401 );
3402 assert_eq!(result.unwrap().amendments.len(), 1);
3403 assert_eq!(response_handle.remaining(), 0, "both responses consumed");
3404 assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
3405 }
3406
3407 #[tokio::test]
3408 async fn amendment_retry_request_failure_then_success() {
3409 let dir = tempfile::tempdir().unwrap();
3410 let repo_view = make_test_repo_view(&dir);
3411 let hash = format!("{:0>40}", 0);
3412
3413 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3414 Err(anyhow::anyhow!("rate limit")),
3415 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3416 ]);
3417
3418 let result = client
3419 .generate_amendments_with_options(&repo_view, false)
3420 .await;
3421
3422 assert!(
3423 result.is_ok(),
3424 "should succeed after retry: {:?}",
3425 result.err()
3426 );
3427 assert_eq!(result.unwrap().amendments.len(), 1);
3428 assert_eq!(response_handle.remaining(), 0);
3429 assert_eq!(prompt_handle.request_count(), 2);
3430 }
3431
3432 #[tokio::test]
3433 async fn amendment_retry_all_attempts_exhausted() {
3434 let dir = tempfile::tempdir().unwrap();
3435 let repo_view = make_test_repo_view(&dir);
3436
3437 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3438 Ok("bad yaml 1".to_string()),
3439 Ok("bad yaml 2".to_string()),
3440 Ok("bad yaml 3".to_string()),
3441 ]);
3442
3443 let result = client
3444 .generate_amendments_with_options(&repo_view, false)
3445 .await;
3446
3447 assert!(result.is_err(), "should fail after all retries exhausted");
3448 assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
3449 assert_eq!(
3450 prompt_handle.request_count(),
3451 3,
3452 "exactly 3 AI requests (1 + 2 retries)"
3453 );
3454 }
3455
3456 #[tokio::test]
3457 async fn amendment_retry_success_first_attempt() {
3458 let dir = tempfile::tempdir().unwrap();
3459 let repo_view = make_test_repo_view(&dir);
3460 let hash = format!("{:0>40}", 0);
3461
3462 let (client, response_handle, prompt_handle) =
3463 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3464 &hash,
3465 "feat(test): works first time",
3466 ))]);
3467
3468 let result = client
3469 .generate_amendments_with_options(&repo_view, false)
3470 .await;
3471
3472 assert!(result.is_ok());
3473 assert_eq!(response_handle.remaining(), 0);
3474 assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
3475 }
3476
3477 #[tokio::test]
3478 async fn amendment_retry_mixed_request_and_parse_failures() {
3479 let dir = tempfile::tempdir().unwrap();
3480 let repo_view = make_test_repo_view(&dir);
3481 let hash = format!("{:0>40}", 0);
3482
3483 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3484 Err(anyhow::anyhow!("network error")),
3485 Ok("invalid yaml {{".to_string()),
3486 Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
3487 ]);
3488
3489 let result = client
3490 .generate_amendments_with_options(&repo_view, false)
3491 .await;
3492
3493 assert!(
3494 result.is_ok(),
3495 "should succeed on third attempt: {:?}",
3496 result.err()
3497 );
3498 assert_eq!(result.unwrap().amendments.len(), 1);
3499 assert_eq!(response_handle.remaining(), 0);
3500 assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
3501 }
3502}