1use anyhow::{Context, Result};
4use tracing::{debug, info, warn};
5
6use crate::claude::token_budget::TokenBudget;
7use crate::claude::{ai::bedrock::BedrockAiClient, ai::claude::ClaudeAiClient};
8use crate::claude::{
9 ai::{AiClient, RequestOptions, ResponseFormat},
10 error::ClaudeError,
11 prompts, response_schema,
12};
13use crate::data::{
14 amendments::{Amendment, AmendmentFile},
15 context::CommitContext,
16 RepositoryView, RepositoryViewForAI,
17};
18
19struct BudgetExceeded {
24 available_input_tokens: usize,
26}
27
28const AMENDMENT_PARSE_MAX_RETRIES: u32 = 2;
30
31pub struct ClaudeClient {
33 ai_client: Box<dyn AiClient>,
35}
36
37impl ClaudeClient {
38 pub fn new(ai_client: Box<dyn AiClient>) -> Self {
40 Self { ai_client }
41 }
42
43 pub fn get_ai_client_metadata(&self) -> crate::claude::ai::AiClientMetadata {
45 self.ai_client.get_metadata()
46 }
47
48 fn adjusted_system_prompt(&self, system_prompt: String) -> String {
59 let format = ResponseFormat::from_capabilities(&self.ai_client.capabilities());
60 prompts::apply_response_format_to_system_prompt(system_prompt, format)
61 }
62
63 fn schema_if_supported<'a>(
70 &self,
71 schema: &'a serde_json::Value,
72 ) -> Option<&'a serde_json::Value> {
73 if self.ai_client.capabilities().supports_response_schema {
74 Some(schema)
75 } else {
76 None
77 }
78 }
79
80 async fn send_with_optional_schema(
91 &self,
92 system_prompt: &str,
93 user_prompt: &str,
94 schema: Option<&serde_json::Value>,
95 ) -> Result<String> {
96 match schema {
97 Some(s) => {
98 let opts = RequestOptions::default().with_response_schema(s.clone());
99 self.ai_client
100 .send_request_with_options(system_prompt, user_prompt, opts)
101 .await
102 }
103 None => {
104 self.ai_client
105 .send_request(system_prompt, user_prompt)
106 .await
107 }
108 }
109 }
110
111 fn validate_prompt_budget(&self, system_prompt: &str, user_prompt: &str) -> Result<()> {
116 let metadata = self.ai_client.get_metadata();
117 let budget = TokenBudget::from_metadata(&metadata);
118 let estimate = budget.validate_prompt(system_prompt, user_prompt)?;
119
120 debug!(
121 model = %metadata.model,
122 estimated_tokens = estimate.estimated_tokens,
123 available_tokens = estimate.available_tokens,
124 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
125 "Token budget check passed"
126 );
127
128 Ok(())
129 }
130
131 fn build_prompt_fitting_budget(
137 &self,
138 ai_view: &RepositoryViewForAI,
139 system_prompt: &str,
140 build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
141 ) -> Result<String> {
142 let metadata = self.ai_client.get_metadata();
143 let budget = TokenBudget::from_metadata(&metadata);
144
145 let yaml =
146 crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
147 let user_prompt = build_user_prompt(&yaml);
148
149 let estimate = budget.validate_prompt(system_prompt, &user_prompt)?;
150 debug!(
151 model = %metadata.model,
152 estimated_tokens = estimate.estimated_tokens,
153 available_tokens = estimate.available_tokens,
154 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
155 "Token budget check passed"
156 );
157
158 Ok(user_prompt)
159 }
160
161 fn try_full_diff_budget(
167 &self,
168 ai_view: &RepositoryViewForAI,
169 system_prompt: &str,
170 build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
171 ) -> Result<std::result::Result<String, BudgetExceeded>> {
172 let metadata = self.ai_client.get_metadata();
173 let budget = TokenBudget::from_metadata(&metadata);
174
175 let yaml =
176 crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
177 let user_prompt = build_user_prompt(&yaml);
178
179 if let Ok(estimate) = budget.validate_prompt(system_prompt, &user_prompt) {
180 debug!(
181 model = %metadata.model,
182 estimated_tokens = estimate.estimated_tokens,
183 available_tokens = estimate.available_tokens,
184 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
185 "Token budget check passed"
186 );
187 return Ok(Ok(user_prompt));
188 }
189
190 Ok(Err(BudgetExceeded {
191 available_input_tokens: budget.available_input_tokens(),
192 }))
193 }
194
195 async fn generate_amendment_split(
202 &self,
203 commit: &crate::git::CommitInfo,
204 repo_view_for_ai: &RepositoryViewForAI,
205 system_prompt: &str,
206 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
207 available_input_tokens: usize,
208 fresh: bool,
209 ) -> Result<Amendment> {
210 use crate::claude::batch::{
211 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
212 VIEW_ENVELOPE_OVERHEAD_TOKENS,
213 };
214 use crate::claude::diff_pack::pack_file_diffs;
215 use crate::claude::token_budget;
216 use crate::git::commit::CommitInfoForAI;
217
218 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
226 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
227 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
228 let chunk_capacity = available_input_tokens
229 .saturating_sub(system_prompt_tokens)
230 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
231 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
232 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
233 .saturating_sub(commit_text_tokens);
234
235 debug!(
236 commit = %&commit.hash[..8],
237 available_input_tokens,
238 system_prompt_tokens,
239 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
240 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
241 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
242 commit_text_tokens,
243 chunk_capacity,
244 "Split dispatch: computed chunk capacity"
245 );
246
247 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
248 .with_context(|| {
249 format!(
250 "Failed to plan diff chunks for commit {}",
251 &commit.hash[..8]
252 )
253 })?;
254
255 let total_chunks = plan.chunks.len();
256 debug!(
257 commit = %&commit.hash[..8],
258 chunks = total_chunks,
259 chunk_capacity,
260 "Split dispatch: processing commit in chunks"
261 );
262
263 let mut chunk_amendments = Vec::with_capacity(total_chunks);
264 for (i, chunk) in plan.chunks.iter().enumerate() {
265 let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
266 commit.clone(),
267 &chunk.file_paths,
268 &chunk.diff_overrides,
269 )
270 .with_context(|| {
271 format!(
272 "Failed to build partial view for chunk {}/{} of commit {}",
273 i + 1,
274 total_chunks,
275 &commit.hash[..8]
276 )
277 })?;
278
279 if fresh {
280 partial.base.original_message =
281 "(Original message hidden - generate fresh message from diff)".to_string();
282 }
283
284 let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
285
286 let diff_content_len = partial.base.analysis.diff_content.len();
288 let diff_content_tokens =
289 token_budget::estimate_tokens_from_char_count(diff_content_len);
290 debug!(
291 commit = %&commit.hash[..8],
292 chunk_index = i,
293 diff_content_len,
294 diff_content_tokens,
295 "Split dispatch: chunk diff content size"
296 );
297
298 let user_prompt =
299 self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
300
301 info!(
302 commit = %&commit.hash[..8],
303 chunk = i + 1,
304 total_chunks,
305 user_prompt_len = user_prompt.len(),
306 "Split dispatch: sending chunk to AI"
307 );
308
309 let content = match self
310 .send_with_optional_schema(
311 system_prompt,
312 &user_prompt,
313 self.schema_if_supported(response_schema::amendment_file_schema()),
314 )
315 .await
316 {
317 Ok(content) => content,
318 Err(e) => {
319 tracing::error!(
321 commit = %&commit.hash[..8],
322 chunk = i + 1,
323 error = %e,
324 error_debug = ?e,
325 "Split dispatch: AI request failed"
326 );
327 return Err(e).with_context(|| {
328 format!(
329 "Chunk {}/{} failed for commit {}",
330 i + 1,
331 total_chunks,
332 &commit.hash[..8]
333 )
334 });
335 }
336 };
337
338 info!(
339 commit = %&commit.hash[..8],
340 chunk = i + 1,
341 response_len = content.len(),
342 "Split dispatch: received chunk response"
343 );
344
345 let amendment_file = self.parse_amendment_response(&content).with_context(|| {
346 format!(
347 "Failed to parse chunk {}/{} response for commit {}",
348 i + 1,
349 total_chunks,
350 &commit.hash[..8]
351 )
352 })?;
353
354 if let Some(amendment) = amendment_file.amendments.into_iter().next() {
355 chunk_amendments.push(amendment);
356 }
357 }
358
359 self.merge_amendment_chunks(
360 &commit.hash,
361 &commit.original_message,
362 &commit.analysis.diff_summary,
363 &chunk_amendments,
364 )
365 .await
366 }
367
368 async fn merge_amendment_chunks(
374 &self,
375 commit_hash: &str,
376 original_message: &str,
377 diff_summary: &str,
378 chunk_amendments: &[Amendment],
379 ) -> Result<Amendment> {
380 let system_prompt =
381 self.adjusted_system_prompt(prompts::AMENDMENT_CHUNK_MERGE_SYSTEM_PROMPT.to_string());
382 let user_prompt = prompts::generate_chunk_merge_user_prompt(
383 commit_hash,
384 original_message,
385 diff_summary,
386 chunk_amendments,
387 );
388
389 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
390
391 let content = self
392 .send_with_optional_schema(
393 &system_prompt,
394 &user_prompt,
395 self.schema_if_supported(response_schema::amendment_file_schema()),
396 )
397 .await
398 .context("Merge pass failed for chunk amendments")?;
399
400 let amendment_file = self
401 .parse_amendment_response(&content)
402 .context("Failed to parse merge pass response")?;
403
404 amendment_file
405 .amendments
406 .into_iter()
407 .next()
408 .context("Merge pass returned no amendments")
409 }
410
411 async fn generate_amendment_for_commit(
418 &self,
419 commit: &crate::git::CommitInfo,
420 repo_view_for_ai: &RepositoryViewForAI,
421 system_prompt: &str,
422 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
423 fresh: bool,
424 ) -> Result<Amendment> {
425 let mut ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
426 if fresh {
427 ai_commit.base.original_message =
428 "(Original message hidden - generate fresh message from diff)".to_string();
429 }
430 let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
431
432 match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
433 Ok(user_prompt) => {
434 let amendment_file = self
435 .send_and_parse_amendment_with_retry(system_prompt, &user_prompt)
436 .await?;
437 amendment_file
438 .amendments
439 .into_iter()
440 .next()
441 .context("AI returned no amendments for commit")
442 }
443 Err(exceeded) => {
444 if commit.analysis.file_diffs.is_empty() {
445 anyhow::bail!(
446 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
447 &commit.hash[..8]
448 );
449 }
450 self.generate_amendment_split(
451 commit,
452 repo_view_for_ai,
453 system_prompt,
454 build_user_prompt,
455 exceeded.available_input_tokens,
456 fresh,
457 )
458 .await
459 }
460 }
461 }
462
463 async fn check_commit_split(
471 &self,
472 commit: &crate::git::CommitInfo,
473 repo_view: &RepositoryView,
474 system_prompt: &str,
475 valid_scopes: &[crate::data::context::ScopeDefinition],
476 include_suggestions: bool,
477 available_input_tokens: usize,
478 ) -> Result<crate::data::check::CheckReport> {
479 use crate::claude::batch::{
480 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
481 VIEW_ENVELOPE_OVERHEAD_TOKENS,
482 };
483 use crate::claude::diff_pack::pack_file_diffs;
484 use crate::claude::token_budget;
485 use crate::data::check::{CommitCheckResult, CommitIssue, IssueSeverity};
486 use crate::git::commit::CommitInfoForAI;
487
488 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
496 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
497 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
498 let chunk_capacity = available_input_tokens
499 .saturating_sub(system_prompt_tokens)
500 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
501 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
502 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
503 .saturating_sub(commit_text_tokens);
504
505 debug!(
506 commit = %&commit.hash[..8],
507 available_input_tokens,
508 system_prompt_tokens,
509 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
510 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
511 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
512 commit_text_tokens,
513 chunk_capacity,
514 "Check split dispatch: computed chunk capacity"
515 );
516
517 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
518 .with_context(|| {
519 format!(
520 "Failed to plan diff chunks for commit {}",
521 &commit.hash[..8]
522 )
523 })?;
524
525 let total_chunks = plan.chunks.len();
526 debug!(
527 commit = %&commit.hash[..8],
528 chunks = total_chunks,
529 chunk_capacity,
530 "Check split dispatch: processing commit in chunks"
531 );
532
533 let build_user_prompt =
534 |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
535
536 let mut chunk_results = Vec::with_capacity(total_chunks);
537 for (i, chunk) in plan.chunks.iter().enumerate() {
538 let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
539 commit.clone(),
540 &chunk.file_paths,
541 &chunk.diff_overrides,
542 )
543 .with_context(|| {
544 format!(
545 "Failed to build partial view for chunk {}/{} of commit {}",
546 i + 1,
547 total_chunks,
548 &commit.hash[..8]
549 )
550 })?;
551
552 partial.run_pre_validation_checks(valid_scopes);
553
554 let partial_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
555 .context("Failed to enhance repository view with diff content")?
556 .single_commit_view_for_ai(&partial);
557
558 let user_prompt =
559 self.build_prompt_fitting_budget(&partial_view, system_prompt, &build_user_prompt)?;
560
561 let content = self
562 .send_with_optional_schema(
563 system_prompt,
564 &user_prompt,
565 self.schema_if_supported(response_schema::check_response_schema()),
566 )
567 .await
568 .with_context(|| {
569 format!(
570 "Check chunk {}/{} failed for commit {}",
571 i + 1,
572 total_chunks,
573 &commit.hash[..8]
574 )
575 })?;
576
577 let report = self
578 .parse_check_response(&content, repo_view)
579 .with_context(|| {
580 format!(
581 "Failed to parse check chunk {}/{} response for commit {}",
582 i + 1,
583 total_chunks,
584 &commit.hash[..8]
585 )
586 })?;
587
588 if let Some(result) = report.commits.into_iter().next() {
589 chunk_results.push(result);
590 }
591 }
592
593 let mut seen = std::collections::HashSet::new();
595 let mut merged_issues: Vec<CommitIssue> = Vec::new();
596 for result in &chunk_results {
597 for issue in &result.issues {
598 let key: (String, IssueSeverity, String) =
599 (issue.rule.clone(), issue.severity, issue.section.clone());
600 if seen.insert(key) {
601 merged_issues.push(issue.clone());
602 }
603 }
604 }
605
606 let passes = chunk_results.iter().all(|r| r.passes);
607
608 let has_suggestions = chunk_results.iter().any(|r| r.suggestion.is_some());
610
611 let (merged_suggestion, merged_summary) = if has_suggestions {
612 self.merge_check_chunks(
613 &commit.hash,
614 &commit.original_message,
615 &commit.analysis.diff_summary,
616 passes,
617 &chunk_results,
618 repo_view,
619 )
620 .await?
621 } else {
622 let summary = chunk_results.iter().find_map(|r| r.summary.clone());
624 (None, summary)
625 };
626
627 let original_message = commit
628 .original_message
629 .lines()
630 .next()
631 .unwrap_or("")
632 .to_string();
633
634 let merged_result = CommitCheckResult {
635 hash: commit.hash.clone(),
636 message: original_message,
637 issues: merged_issues,
638 suggestion: merged_suggestion,
639 passes,
640 summary: merged_summary,
641 };
642
643 Ok(crate::data::check::CheckReport::new(vec![merged_result]))
644 }
645
646 async fn merge_check_chunks(
651 &self,
652 commit_hash: &str,
653 original_message: &str,
654 diff_summary: &str,
655 passes: bool,
656 chunk_results: &[crate::data::check::CommitCheckResult],
657 repo_view: &RepositoryView,
658 ) -> Result<(Option<crate::data::check::CommitSuggestion>, Option<String>)> {
659 let suggestions: Vec<&crate::data::check::CommitSuggestion> = chunk_results
660 .iter()
661 .filter_map(|r| r.suggestion.as_ref())
662 .collect();
663
664 let summaries: Vec<Option<&str>> =
665 chunk_results.iter().map(|r| r.summary.as_deref()).collect();
666
667 let system_prompt =
668 self.adjusted_system_prompt(prompts::CHECK_CHUNK_MERGE_SYSTEM_PROMPT.to_string());
669 let user_prompt = prompts::generate_check_chunk_merge_user_prompt(
670 commit_hash,
671 original_message,
672 diff_summary,
673 passes,
674 &suggestions,
675 &summaries,
676 );
677
678 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
679
680 let content = self
681 .send_with_optional_schema(
682 &system_prompt,
683 &user_prompt,
684 self.schema_if_supported(response_schema::check_response_schema()),
685 )
686 .await
687 .context("Merge pass failed for check chunk suggestions")?;
688
689 let report = self
690 .parse_check_response(&content, repo_view)
691 .context("Failed to parse check merge pass response")?;
692
693 let result = report.commits.into_iter().next();
694 Ok(match result {
695 Some(r) => (r.suggestion, r.summary),
696 None => (None, None),
697 })
698 }
699
700 pub async fn send_message(&self, system_prompt: &str, user_prompt: &str) -> Result<String> {
702 self.validate_prompt_budget(system_prompt, user_prompt)?;
703 self.ai_client
704 .send_request(system_prompt, user_prompt)
705 .await
706 }
707
708 pub fn from_env(model: String) -> Result<Self> {
710 let api_key = std::env::var("CLAUDE_API_KEY")
712 .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
713 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
714
715 let ai_client = ClaudeAiClient::new(model, api_key, None)?;
716 Ok(Self::new(Box::new(ai_client)))
717 }
718
719 pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
721 self.generate_amendments_with_options(repo_view, false)
722 .await
723 }
724
725 pub async fn generate_amendments_with_options(
736 &self,
737 repo_view: &RepositoryView,
738 fresh: bool,
739 ) -> Result<AmendmentFile> {
740 let ai_repo_view =
742 RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
743 .context("Failed to enhance repository view with diff content")?;
744
745 let system_prompt = self.adjusted_system_prompt(prompts::SYSTEM_PROMPT.to_string());
746 let build_user_prompt = |yaml: &str| prompts::generate_user_prompt(yaml);
747
748 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
750 Ok(user_prompt) => {
751 self.send_and_parse_amendment_with_retry(&system_prompt, &user_prompt)
752 .await
753 }
754 Err(_exceeded) => {
755 let mut amendments = Vec::new();
756 for commit in &repo_view.commits {
757 let amendment = self
758 .generate_amendment_for_commit(
759 commit,
760 &ai_repo_view,
761 &system_prompt,
762 &build_user_prompt,
763 fresh,
764 )
765 .await?;
766 amendments.push(amendment);
767 }
768 Ok(AmendmentFile { amendments })
769 }
770 }
771 }
772
773 pub async fn generate_contextual_amendments(
775 &self,
776 repo_view: &RepositoryView,
777 context: &CommitContext,
778 ) -> Result<AmendmentFile> {
779 self.generate_contextual_amendments_with_options(repo_view, context, false)
780 .await
781 }
782
783 pub async fn generate_contextual_amendments_with_options(
793 &self,
794 repo_view: &RepositoryView,
795 context: &CommitContext,
796 fresh: bool,
797 ) -> Result<AmendmentFile> {
798 let ai_repo_view =
800 RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
801 .context("Failed to enhance repository view with diff content")?;
802
803 let prompt_style = self.ai_client.get_metadata().prompt_style();
805 let system_prompt = self.adjusted_system_prompt(
806 prompts::generate_contextual_system_prompt_for_provider(context, prompt_style),
807 );
808
809 match &context.project.commit_guidelines {
811 Some(guidelines) => {
812 debug!(length = guidelines.len(), "Project commit guidelines found");
813 debug!(guidelines = %guidelines, "Commit guidelines content");
814 }
815 None => {
816 debug!("No project commit guidelines found");
817 }
818 }
819
820 let build_user_prompt =
821 |yaml: &str| prompts::generate_contextual_user_prompt(yaml, context);
822
823 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
825 Ok(user_prompt) => {
826 self.send_and_parse_amendment_with_retry(&system_prompt, &user_prompt)
827 .await
828 }
829 Err(_exceeded) => {
830 let mut amendments = Vec::new();
831 for commit in &repo_view.commits {
832 let amendment = self
833 .generate_amendment_for_commit(
834 commit,
835 &ai_repo_view,
836 &system_prompt,
837 &build_user_prompt,
838 fresh,
839 )
840 .await?;
841 amendments.push(amendment);
842 }
843 Ok(AmendmentFile { amendments })
844 }
845 }
846 }
847
848 fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
850 let yaml_content = self.extract_yaml_from_response(content);
852
853 let amendment_file: AmendmentFile = crate::data::from_yaml(&yaml_content).map_err(|e| {
855 debug!(
856 error = %e,
857 content_length = content.len(),
858 yaml_length = yaml_content.len(),
859 "YAML parsing failed"
860 );
861 debug!(content = %content, "Raw Claude response");
862 debug!(yaml = %yaml_content, "Extracted YAML content");
863
864 if yaml_content.lines().any(|line| line.contains('\t')) {
866 ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
867 } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
868 ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
869 } else {
870 ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {e}"))
871 }
872 })?;
873
874 amendment_file
876 .validate()
877 .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {e}")))?;
878
879 Ok(amendment_file)
880 }
881
882 async fn send_and_parse_amendment_with_retry(
890 &self,
891 system_prompt: &str,
892 user_prompt: &str,
893 ) -> Result<AmendmentFile> {
894 let mut last_error = None;
895 for attempt in 0..=AMENDMENT_PARSE_MAX_RETRIES {
896 match self
897 .send_with_optional_schema(
898 system_prompt,
899 user_prompt,
900 self.schema_if_supported(response_schema::amendment_file_schema()),
901 )
902 .await
903 {
904 Ok(content) => match self.parse_amendment_response(&content) {
905 Ok(amendment_file) => return Ok(amendment_file),
906 Err(e) => {
907 if attempt < AMENDMENT_PARSE_MAX_RETRIES {
908 eprintln!(
909 "warning: failed to parse amendment response (attempt {}), retrying...",
910 attempt + 1
911 );
912 debug!(error = %e, attempt = attempt + 1, "Amendment response parse failed, retrying");
913 }
914 last_error = Some(e);
915 }
916 },
917 Err(e) => {
918 if attempt < AMENDMENT_PARSE_MAX_RETRIES {
919 eprintln!(
920 "warning: AI request failed (attempt {}), retrying...",
921 attempt + 1
922 );
923 debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
924 }
925 last_error = Some(e);
926 }
927 }
928 }
929 Err(last_error
930 .unwrap_or_else(|| anyhow::anyhow!("Amendment generation failed after retries")))
931 }
932
933 fn parse_pr_response(&self, content: &str) -> Result<crate::cli::git::PrContent> {
935 let yaml_content = content.trim();
936 crate::data::from_yaml(yaml_content)
937 .context("Failed to parse AI response as YAML. AI may have returned malformed output.")
938 }
939
940 async fn generate_pr_content_split(
947 &self,
948 commit: &crate::git::CommitInfo,
949 repo_view_for_ai: &RepositoryViewForAI,
950 system_prompt: &str,
951 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
952 available_input_tokens: usize,
953 pr_template: &str,
954 ) -> Result<crate::cli::git::PrContent> {
955 use crate::claude::batch::{
956 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
957 VIEW_ENVELOPE_OVERHEAD_TOKENS,
958 };
959 use crate::claude::diff_pack::pack_file_diffs;
960 use crate::claude::token_budget;
961 use crate::git::commit::CommitInfoForAI;
962
963 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
971 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
972 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
973 let chunk_capacity = available_input_tokens
974 .saturating_sub(system_prompt_tokens)
975 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
976 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
977 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
978 .saturating_sub(commit_text_tokens);
979
980 debug!(
981 commit = %&commit.hash[..8],
982 available_input_tokens,
983 system_prompt_tokens,
984 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
985 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
986 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
987 commit_text_tokens,
988 chunk_capacity,
989 "PR split dispatch: computed chunk capacity"
990 );
991
992 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
993 .with_context(|| {
994 format!(
995 "Failed to plan diff chunks for commit {}",
996 &commit.hash[..8]
997 )
998 })?;
999
1000 let total_chunks = plan.chunks.len();
1001 debug!(
1002 commit = %&commit.hash[..8],
1003 chunks = total_chunks,
1004 chunk_capacity,
1005 "PR split dispatch: processing commit in chunks"
1006 );
1007
1008 let mut chunk_contents = Vec::with_capacity(total_chunks);
1009 for (i, chunk) in plan.chunks.iter().enumerate() {
1010 let partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
1011 commit.clone(),
1012 &chunk.file_paths,
1013 &chunk.diff_overrides,
1014 )
1015 .with_context(|| {
1016 format!(
1017 "Failed to build partial view for chunk {}/{} of commit {}",
1018 i + 1,
1019 total_chunks,
1020 &commit.hash[..8]
1021 )
1022 })?;
1023
1024 let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
1025
1026 let user_prompt =
1027 self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
1028
1029 let content = self
1030 .send_with_optional_schema(
1031 system_prompt,
1032 &user_prompt,
1033 self.schema_if_supported(response_schema::pr_content_schema()),
1034 )
1035 .await
1036 .with_context(|| {
1037 format!(
1038 "PR chunk {}/{} failed for commit {}",
1039 i + 1,
1040 total_chunks,
1041 &commit.hash[..8]
1042 )
1043 })?;
1044
1045 let pr_content = self.parse_pr_response(&content).with_context(|| {
1046 format!(
1047 "Failed to parse PR chunk {}/{} response for commit {}",
1048 i + 1,
1049 total_chunks,
1050 &commit.hash[..8]
1051 )
1052 })?;
1053
1054 chunk_contents.push(pr_content);
1055 }
1056
1057 self.merge_pr_content_chunks(&chunk_contents, pr_template)
1058 .await
1059 }
1060
1061 async fn merge_pr_content_chunks(
1064 &self,
1065 partial_contents: &[crate::cli::git::PrContent],
1066 pr_template: &str,
1067 ) -> Result<crate::cli::git::PrContent> {
1068 let system_prompt =
1069 self.adjusted_system_prompt(prompts::PR_CONTENT_MERGE_SYSTEM_PROMPT.to_string());
1070 let user_prompt =
1071 prompts::generate_pr_content_merge_user_prompt(partial_contents, pr_template);
1072
1073 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1074
1075 let content = self
1076 .send_with_optional_schema(
1077 &system_prompt,
1078 &user_prompt,
1079 self.schema_if_supported(response_schema::pr_content_schema()),
1080 )
1081 .await
1082 .context("Merge pass failed for PR content chunks")?;
1083
1084 self.parse_pr_response(&content)
1085 .context("Failed to parse PR content merge pass response")
1086 }
1087
1088 async fn generate_pr_content_for_commit(
1090 &self,
1091 commit: &crate::git::CommitInfo,
1092 repo_view_for_ai: &RepositoryViewForAI,
1093 system_prompt: &str,
1094 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
1095 pr_template: &str,
1096 ) -> Result<crate::cli::git::PrContent> {
1097 let ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
1098 let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
1099
1100 match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
1101 Ok(user_prompt) => {
1102 let content = self
1103 .send_with_optional_schema(
1104 system_prompt,
1105 &user_prompt,
1106 self.schema_if_supported(response_schema::pr_content_schema()),
1107 )
1108 .await?;
1109 self.parse_pr_response(&content)
1110 }
1111 Err(exceeded) => {
1112 if commit.analysis.file_diffs.is_empty() {
1113 anyhow::bail!(
1114 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1115 &commit.hash[..8]
1116 );
1117 }
1118 self.generate_pr_content_split(
1119 commit,
1120 repo_view_for_ai,
1121 system_prompt,
1122 build_user_prompt,
1123 exceeded.available_input_tokens,
1124 pr_template,
1125 )
1126 .await
1127 }
1128 }
1129 }
1130
1131 pub async fn generate_pr_content(
1133 &self,
1134 repo_view: &RepositoryView,
1135 pr_template: &str,
1136 ) -> Result<crate::cli::git::PrContent> {
1137 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1139 .context("Failed to enhance repository view with diff content")?;
1140
1141 let system_prompt =
1142 self.adjusted_system_prompt(prompts::PR_GENERATION_SYSTEM_PROMPT.to_string());
1143
1144 let build_user_prompt =
1145 |yaml: &str| prompts::generate_pr_description_prompt(yaml, pr_template);
1146
1147 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1149 Ok(user_prompt) => {
1150 let content = self
1151 .send_with_optional_schema(
1152 &system_prompt,
1153 &user_prompt,
1154 self.schema_if_supported(response_schema::pr_content_schema()),
1155 )
1156 .await?;
1157 self.parse_pr_response(&content)
1158 }
1159 Err(_exceeded) => {
1160 let mut per_commit_contents = Vec::new();
1161 for commit in &repo_view.commits {
1162 let pr = self
1163 .generate_pr_content_for_commit(
1164 commit,
1165 &ai_repo_view,
1166 &system_prompt,
1167 &build_user_prompt,
1168 pr_template,
1169 )
1170 .await?;
1171 per_commit_contents.push(pr);
1172 }
1173 if per_commit_contents.len() == 1 {
1174 return per_commit_contents
1175 .into_iter()
1176 .next()
1177 .context("Per-commit PR contents unexpectedly empty");
1178 }
1179 self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1180 .await
1181 }
1182 }
1183 }
1184
1185 pub async fn generate_pr_content_with_context(
1187 &self,
1188 repo_view: &RepositoryView,
1189 pr_template: &str,
1190 context: &crate::data::context::CommitContext,
1191 ) -> Result<crate::cli::git::PrContent> {
1192 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1194 .context("Failed to enhance repository view with diff content")?;
1195
1196 let prompt_style = self.ai_client.get_metadata().prompt_style();
1198 let system_prompt = self.adjusted_system_prompt(
1199 prompts::generate_pr_system_prompt_with_context_for_provider(context, prompt_style),
1200 );
1201
1202 let build_user_prompt = |yaml: &str| {
1203 prompts::generate_pr_description_prompt_with_context(yaml, pr_template, context)
1204 };
1205
1206 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1208 Ok(user_prompt) => {
1209 let content = self
1210 .send_with_optional_schema(
1211 &system_prompt,
1212 &user_prompt,
1213 self.schema_if_supported(response_schema::pr_content_schema()),
1214 )
1215 .await?;
1216
1217 debug!(
1218 content_length = content.len(),
1219 "Received AI response for PR content"
1220 );
1221
1222 let pr_content = self.parse_pr_response(&content)?;
1223
1224 debug!(
1225 parsed_title = %pr_content.title,
1226 parsed_description_length = pr_content.description.len(),
1227 parsed_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1228 "Successfully parsed PR content from YAML"
1229 );
1230
1231 Ok(pr_content)
1232 }
1233 Err(_exceeded) => {
1234 let mut per_commit_contents = Vec::new();
1235 for commit in &repo_view.commits {
1236 let pr = self
1237 .generate_pr_content_for_commit(
1238 commit,
1239 &ai_repo_view,
1240 &system_prompt,
1241 &build_user_prompt,
1242 pr_template,
1243 )
1244 .await?;
1245 per_commit_contents.push(pr);
1246 }
1247 if per_commit_contents.len() == 1 {
1248 return per_commit_contents
1249 .into_iter()
1250 .next()
1251 .context("Per-commit PR contents unexpectedly empty");
1252 }
1253 self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1254 .await
1255 }
1256 }
1257 }
1258
1259 pub async fn check_commits(
1264 &self,
1265 repo_view: &RepositoryView,
1266 guidelines: Option<&str>,
1267 include_suggestions: bool,
1268 ) -> Result<crate::data::check::CheckReport> {
1269 self.check_commits_with_scopes(repo_view, guidelines, &[], include_suggestions)
1270 .await
1271 }
1272
1273 pub async fn check_commits_with_scopes(
1278 &self,
1279 repo_view: &RepositoryView,
1280 guidelines: Option<&str>,
1281 valid_scopes: &[crate::data::context::ScopeDefinition],
1282 include_suggestions: bool,
1283 ) -> Result<crate::data::check::CheckReport> {
1284 self.check_commits_with_retry(repo_view, guidelines, valid_scopes, include_suggestions, 2)
1285 .await
1286 }
1287
1288 async fn check_commits_with_retry(
1296 &self,
1297 repo_view: &RepositoryView,
1298 guidelines: Option<&str>,
1299 valid_scopes: &[crate::data::context::ScopeDefinition],
1300 include_suggestions: bool,
1301 max_retries: u32,
1302 ) -> Result<crate::data::check::CheckReport> {
1303 let system_prompt = self.adjusted_system_prompt(
1305 prompts::generate_check_system_prompt_with_scopes(guidelines, valid_scopes),
1306 );
1307
1308 let build_user_prompt =
1309 |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
1310
1311 let mut ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1312 .context("Failed to enhance repository view with diff content")?;
1313 for commit in &mut ai_repo_view.commits {
1314 commit.run_pre_validation_checks(valid_scopes);
1315 }
1316
1317 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1319 Ok(user_prompt) => {
1320 let mut last_error = None;
1322 for attempt in 0..=max_retries {
1323 match self
1324 .send_with_optional_schema(
1325 &system_prompt,
1326 &user_prompt,
1327 self.schema_if_supported(response_schema::check_response_schema()),
1328 )
1329 .await
1330 {
1331 Ok(content) => match self.parse_check_response(&content, repo_view) {
1332 Ok(report) => return Ok(report),
1333 Err(e) => {
1334 if attempt < max_retries {
1335 eprintln!(
1336 "warning: failed to parse AI response (attempt {}), retrying...",
1337 attempt + 1
1338 );
1339 debug!(error = %e, attempt = attempt + 1, "Check response parse failed, retrying");
1340 }
1341 last_error = Some(e);
1342 }
1343 },
1344 Err(e) => {
1345 if attempt < max_retries {
1346 eprintln!(
1347 "warning: AI request failed (attempt {}), retrying...",
1348 attempt + 1
1349 );
1350 debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
1351 }
1352 last_error = Some(e);
1353 }
1354 }
1355 }
1356 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Check failed after retries")))
1357 }
1358 Err(_exceeded) => {
1359 let mut all_results = Vec::new();
1361 for commit in &repo_view.commits {
1362 let single_view = repo_view.single_commit_view(commit);
1363 let mut single_ai_view =
1364 RepositoryViewForAI::from_repository_view(single_view.clone())
1365 .context("Failed to enhance single-commit view with diff content")?;
1366 for c in &mut single_ai_view.commits {
1367 c.run_pre_validation_checks(valid_scopes);
1368 }
1369
1370 match self.try_full_diff_budget(
1371 &single_ai_view,
1372 &system_prompt,
1373 &build_user_prompt,
1374 )? {
1375 Ok(user_prompt) => {
1376 let content = self
1377 .send_with_optional_schema(
1378 &system_prompt,
1379 &user_prompt,
1380 self.schema_if_supported(
1381 response_schema::check_response_schema(),
1382 ),
1383 )
1384 .await?;
1385 let report = self.parse_check_response(&content, &single_view)?;
1386 all_results.extend(report.commits);
1387 }
1388 Err(exceeded) => {
1389 if commit.analysis.file_diffs.is_empty() {
1390 anyhow::bail!(
1391 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1392 &commit.hash[..8]
1393 );
1394 }
1395 let report = self
1396 .check_commit_split(
1397 commit,
1398 &single_view,
1399 &system_prompt,
1400 valid_scopes,
1401 include_suggestions,
1402 exceeded.available_input_tokens,
1403 )
1404 .await?;
1405 all_results.extend(report.commits);
1406 }
1407 }
1408 }
1409 Ok(crate::data::check::CheckReport::new(all_results))
1410 }
1411 }
1412 }
1413
1414 fn parse_check_response(
1416 &self,
1417 content: &str,
1418 repo_view: &RepositoryView,
1419 ) -> Result<crate::data::check::CheckReport> {
1420 use crate::data::check::{
1421 AiCheckResponse, CheckReport, CommitCheckResult as CheckResultType,
1422 };
1423
1424 let yaml_content = self.extract_yaml_from_check_response(content);
1426
1427 let ai_response: AiCheckResponse = crate::data::from_yaml(&yaml_content).map_err(|e| {
1429 debug!(
1430 error = %e,
1431 content_length = content.len(),
1432 yaml_length = yaml_content.len(),
1433 "Check YAML parsing failed"
1434 );
1435 debug!(content = %content, "Raw AI response");
1436 debug!(yaml = %yaml_content, "Extracted YAML content");
1437 ClaudeError::AmendmentParsingFailed(format!("Check response parsing error: {e}"))
1438 })?;
1439
1440 let commit_messages: std::collections::HashMap<&str, &str> = repo_view
1442 .commits
1443 .iter()
1444 .map(|c| (c.hash.as_str(), c.original_message.as_str()))
1445 .collect();
1446
1447 let results: Vec<CheckResultType> = ai_response
1449 .checks
1450 .into_iter()
1451 .map(|check| {
1452 let mut result: CheckResultType = check.into();
1453 if let Some(msg) = commit_messages.get(result.hash.as_str()) {
1455 result.message = msg.lines().next().unwrap_or("").to_string();
1456 } else {
1457 for (hash, msg) in &commit_messages {
1459 if hash.starts_with(&result.hash) || result.hash.starts_with(*hash) {
1460 result.message = msg.lines().next().unwrap_or("").to_string();
1461 break;
1462 }
1463 }
1464 }
1465 result
1466 })
1467 .collect();
1468
1469 Ok(CheckReport::new(results))
1470 }
1471
1472 fn extract_yaml_from_check_response(&self, content: &str) -> String {
1474 let content = content.trim();
1475
1476 if content.starts_with("checks:") {
1478 return content.to_string();
1479 }
1480
1481 if let Some(yaml_start) = content.find("```yaml") {
1483 if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1484 return yaml_content.trim().to_string();
1485 }
1486 }
1487
1488 if let Some(code_start) = content.find("```") {
1490 if let Some(code_content) = content[code_start + 3..].split("```").next() {
1491 let potential_yaml = code_content.trim();
1492 if potential_yaml.starts_with("checks:") {
1494 return potential_yaml.to_string();
1495 }
1496 }
1497 }
1498
1499 content.to_string()
1501 }
1502
1503 pub async fn refine_amendments_coherence(
1508 &self,
1509 items: &[(crate::data::amendments::Amendment, String)],
1510 ) -> Result<AmendmentFile> {
1511 let system_prompt =
1512 self.adjusted_system_prompt(prompts::AMENDMENT_COHERENCE_SYSTEM_PROMPT.to_string());
1513 let user_prompt = prompts::generate_amendment_coherence_user_prompt(items);
1514
1515 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1516
1517 let content = self
1518 .send_with_optional_schema(
1519 &system_prompt,
1520 &user_prompt,
1521 self.schema_if_supported(response_schema::amendment_file_schema()),
1522 )
1523 .await?;
1524
1525 self.parse_amendment_response(&content)
1526 }
1527
1528 pub async fn refine_checks_coherence(
1534 &self,
1535 items: &[(crate::data::check::CommitCheckResult, String)],
1536 repo_view: &RepositoryView,
1537 ) -> Result<crate::data::check::CheckReport> {
1538 let system_prompt =
1539 self.adjusted_system_prompt(prompts::CHECK_COHERENCE_SYSTEM_PROMPT.to_string());
1540 let user_prompt = prompts::generate_check_coherence_user_prompt(items);
1541
1542 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1543
1544 let content = self
1545 .send_with_optional_schema(
1546 &system_prompt,
1547 &user_prompt,
1548 self.schema_if_supported(response_schema::check_response_schema()),
1549 )
1550 .await?;
1551
1552 self.parse_check_response(&content, repo_view)
1553 }
1554
1555 fn extract_yaml_from_response(&self, content: &str) -> String {
1557 let content = content.trim();
1558
1559 if content.starts_with("amendments:") {
1561 return content.to_string();
1562 }
1563
1564 if let Some(yaml_start) = content.find("```yaml") {
1566 if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1567 return yaml_content.trim().to_string();
1568 }
1569 }
1570
1571 if let Some(code_start) = content.find("```") {
1573 if let Some(code_content) = content[code_start + 3..].split("```").next() {
1574 let potential_yaml = code_content.trim();
1575 if potential_yaml.starts_with("amendments:") {
1577 return potential_yaml.to_string();
1578 }
1579 }
1580 }
1581
1582 content.to_string()
1584 }
1585}
1586
1587fn validate_beta_header(model: &str, beta_header: &Option<(String, String)>) -> Result<()> {
1589 if let Some((ref key, ref value)) = beta_header {
1590 let registry = crate::claude::model_config::get_model_registry();
1591 let supported = registry.get_beta_headers(model);
1592 if !supported
1593 .iter()
1594 .any(|bh| bh.key == *key && bh.value == *value)
1595 {
1596 let available: Vec<String> = supported
1597 .iter()
1598 .map(|bh| format!("{}:{}", bh.key, bh.value))
1599 .collect();
1600 if available.is_empty() {
1601 anyhow::bail!("Model '{model}' does not support any beta headers");
1602 }
1603 anyhow::bail!(
1604 "Beta header '{key}:{value}' is not supported for model '{model}'. Supported: {}",
1605 available.join(", ")
1606 );
1607 }
1608 }
1609 Ok(())
1610}
1611
1612pub fn create_default_claude_client(
1614 model: Option<String>,
1615 beta_header: Option<(String, String)>,
1616) -> Result<ClaudeClient> {
1617 use crate::claude::ai::claude_cli::ClaudeCliAiClient;
1618 use crate::claude::ai::openai::OpenAiAiClient;
1619 use crate::utils::settings::{get_env_var, get_env_vars};
1620
1621 let ai_backend = get_env_var("OMNI_DEV_AI_BACKEND").ok();
1626 let use_claude_cli = ai_backend
1627 .as_deref()
1628 .is_some_and(|v| matches!(v, "claude-cli" | "claude_cli"));
1629
1630 if use_claude_cli {
1631 if beta_header.is_some() {
1632 warn!(
1633 "--beta-header is ignored when OMNI_DEV_AI_BACKEND=claude-cli \
1634 (the CLI's --betas flag has different semantics and is not forwarded)"
1635 );
1636 }
1637 let registry = crate::claude::model_config::get_model_registry();
1638 let cli_model = model
1639 .or_else(|| get_env_var("CLAUDE_MODEL").ok())
1640 .or_else(|| get_env_var("CLAUDE_CODE_MODEL").ok())
1641 .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1642 .unwrap_or_else(|| {
1643 registry
1644 .get_default_model("claude")
1645 .unwrap_or("claude-sonnet-4-6")
1646 .to_string()
1647 });
1648 debug!(model = %cli_model, "Creating claude -p subprocess client");
1649 let ai_client = ClaudeCliAiClient::new(cli_model);
1650 return Ok(ClaudeClient::new(Box::new(ai_client)));
1651 }
1652
1653 let use_openai = get_env_var("USE_OPENAI").is_ok_and(|val| val == "true");
1655
1656 let use_ollama = get_env_var("USE_OLLAMA").is_ok_and(|val| val == "true");
1657
1658 let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK").is_ok_and(|val| val == "true");
1660
1661 debug!(
1662 use_openai = use_openai,
1663 use_ollama = use_ollama,
1664 use_bedrock = use_bedrock,
1665 "Client selection flags"
1666 );
1667
1668 let registry = crate::claude::model_config::get_model_registry();
1669
1670 if use_ollama {
1672 let ollama_model = model
1673 .or_else(|| get_env_var("OLLAMA_MODEL").ok())
1674 .unwrap_or_else(|| "llama2".to_string());
1675 validate_beta_header(&ollama_model, &beta_header)?;
1676 let base_url = get_env_var("OLLAMA_BASE_URL").ok();
1677 let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1678 return Ok(ClaudeClient::new(Box::new(ai_client)));
1679 }
1680
1681 if use_openai {
1683 debug!("Creating OpenAI client");
1684 let openai_model = model
1685 .or_else(|| get_env_var("OPENAI_MODEL").ok())
1686 .unwrap_or_else(|| {
1687 registry
1688 .get_default_model("openai")
1689 .unwrap_or("gpt-5")
1690 .to_string()
1691 });
1692 debug!(openai_model = %openai_model, "Selected OpenAI model");
1693 validate_beta_header(&openai_model, &beta_header)?;
1694
1695 let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
1696 debug!(error = ?e, "Failed to get OpenAI API key");
1697 ClaudeError::ApiKeyNotFound
1698 })?;
1699 debug!("OpenAI API key found");
1700
1701 let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1702 debug!("OpenAI client created successfully");
1703 return Ok(ClaudeClient::new(Box::new(ai_client)));
1704 }
1705
1706 let claude_model = model
1708 .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1709 .unwrap_or_else(|| {
1710 registry
1711 .get_default_model("claude")
1712 .unwrap_or("claude-sonnet-4-6")
1713 .to_string()
1714 });
1715 validate_beta_header(&claude_model, &beta_header)?;
1716
1717 if use_bedrock {
1718 let auth_token =
1720 get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1721
1722 let base_url =
1723 get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1724
1725 let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1726 return Ok(ClaudeClient::new(Box::new(ai_client)));
1727 }
1728
1729 debug!("Falling back to Claude client");
1731 let api_key = get_env_vars(&[
1732 "CLAUDE_API_KEY",
1733 "ANTHROPIC_API_KEY",
1734 "ANTHROPIC_AUTH_TOKEN",
1735 ])
1736 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
1737
1738 let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1739 debug!("Claude client created successfully");
1740 Ok(ClaudeClient::new(Box::new(ai_client)))
1741}
1742
1743#[cfg(test)]
1744#[allow(clippy::unwrap_used, clippy::expect_used)]
1745mod tests {
1746 use super::*;
1747 use crate::claude::ai::{AiClient, AiClientCapabilities, AiClientMetadata};
1748 use std::future::Future;
1749 use std::pin::Pin;
1750 use std::sync::{Arc, Mutex};
1751
1752 struct MockAiClient;
1754
1755 impl AiClient for MockAiClient {
1756 fn send_request<'a>(
1757 &'a self,
1758 _system_prompt: &'a str,
1759 _user_prompt: &'a str,
1760 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1761 Box::pin(async { Ok(String::new()) })
1762 }
1763
1764 fn get_metadata(&self) -> AiClientMetadata {
1765 AiClientMetadata {
1766 provider: "Mock".to_string(),
1767 model: "mock-model".to_string(),
1768 max_context_length: 200_000,
1769 max_response_length: 8_192,
1770 active_beta: None,
1771 }
1772 }
1773 }
1774
1775 fn make_client() -> ClaudeClient {
1776 ClaudeClient::new(Box::new(MockAiClient))
1777 }
1778
1779 struct SchemaRecordingMockAiClient {
1790 capabilities: AiClientCapabilities,
1791 response: String,
1792 recorded_options: Arc<Mutex<Vec<RequestOptions>>>,
1793 recorded_plain: Arc<Mutex<Vec<(String, String)>>>,
1794 }
1795 impl SchemaRecordingMockAiClient {
1796 fn new(supports_response_schema: bool) -> Self {
1797 Self::with_response(supports_response_schema, String::new())
1798 }
1799
1800 fn with_response(supports_response_schema: bool, response: String) -> Self {
1801 Self {
1802 capabilities: AiClientCapabilities {
1803 supports_response_schema,
1804 },
1805 response,
1806 recorded_options: Arc::new(Mutex::new(Vec::new())),
1807 recorded_plain: Arc::new(Mutex::new(Vec::new())),
1808 }
1809 }
1810 }
1811
1812 impl AiClient for SchemaRecordingMockAiClient {
1813 fn send_request<'a>(
1814 &'a self,
1815 system_prompt: &'a str,
1816 user_prompt: &'a str,
1817 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1818 let plain = self.recorded_plain.clone();
1819 let sys = system_prompt.to_string();
1820 let usr = user_prompt.to_string();
1821 let response = self.response.clone();
1822 Box::pin(async move {
1823 plain.lock().unwrap().push((sys, usr));
1824 Ok(response)
1825 })
1826 }
1827
1828 fn capabilities(&self) -> AiClientCapabilities {
1829 self.capabilities
1830 }
1831
1832 fn send_request_with_options<'a>(
1833 &'a self,
1834 _system_prompt: &'a str,
1835 _user_prompt: &'a str,
1836 options: RequestOptions,
1837 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1838 let recorded = self.recorded_options.clone();
1839 let response = self.response.clone();
1840 Box::pin(async move {
1841 recorded.lock().unwrap().push(options);
1842 Ok(response)
1843 })
1844 }
1845
1846 fn get_metadata(&self) -> AiClientMetadata {
1847 AiClientMetadata {
1848 provider: "SchemaMock".to_string(),
1849 model: "schema-mock".to_string(),
1850 max_context_length: 200_000,
1851 max_response_length: 8_192,
1852 active_beta: None,
1853 }
1854 }
1855 }
1856
1857 #[tokio::test]
1863 async fn send_with_optional_schema_without_caps_uses_plain_send() {
1864 let inner = SchemaRecordingMockAiClient::new(false);
1865 let plain_log = inner.recorded_plain.clone();
1866 let opts_log = inner.recorded_options.clone();
1867 let client = ClaudeClient::new(Box::new(inner));
1868
1869 let schema = serde_json::json!({"type": "object"});
1870 client
1871 .send_with_optional_schema(
1872 "sys",
1873 "usr",
1874 client.schema_if_supported(&schema), )
1876 .await
1877 .unwrap();
1878
1879 assert_eq!(plain_log.lock().unwrap().len(), 1);
1880 assert!(opts_log.lock().unwrap().is_empty());
1881 }
1882
1883 #[tokio::test]
1887 async fn send_with_optional_schema_with_caps_uses_options_send() {
1888 let inner = SchemaRecordingMockAiClient::new(true);
1889 let plain_log = inner.recorded_plain.clone();
1890 let opts_log = inner.recorded_options.clone();
1891 let client = ClaudeClient::new(Box::new(inner));
1892
1893 let schema = serde_json::json!({"type": "object", "additionalProperties": false});
1894 client
1895 .send_with_optional_schema(
1896 "sys",
1897 "usr",
1898 client.schema_if_supported(&schema), )
1900 .await
1901 .unwrap();
1902
1903 let recorded = opts_log.lock().unwrap();
1904 assert_eq!(recorded.len(), 1);
1905 assert_eq!(recorded[0].response_schema.as_ref(), Some(&schema));
1906 assert!(plain_log.lock().unwrap().is_empty());
1907 }
1908
1909 #[test]
1912 fn adjusted_system_prompt_adds_suffix_when_supported() {
1913 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
1914 let result = client.adjusted_system_prompt("body".to_string());
1915 assert!(result.starts_with("body"));
1916 assert!(result.contains("STRUCTURED OUTPUT OVERRIDE"));
1917 }
1918
1919 #[test]
1920 fn adjusted_system_prompt_passes_through_when_not_supported() {
1921 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
1922 let result = client.adjusted_system_prompt("body".to_string());
1923 assert_eq!(result, "body");
1924 }
1925
1926 #[test]
1927 fn schema_if_supported_returns_some_when_supported() {
1928 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
1929 let schema = serde_json::json!({"type": "object"});
1930 let returned = client.schema_if_supported(&schema);
1931 assert!(returned.is_some());
1932 assert!(std::ptr::eq(
1933 returned.unwrap() as *const _,
1934 &schema as *const _
1935 ));
1936 }
1937
1938 #[test]
1939 fn schema_if_supported_returns_none_when_not_supported() {
1940 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
1941 let schema = serde_json::json!({"type": "object"});
1942 assert!(client.schema_if_supported(&schema).is_none());
1943 }
1944
1945 #[tokio::test]
1952 async fn refine_amendments_coherence_round_trip() {
1953 let mock = SchemaRecordingMockAiClient::with_response(
1954 true, "amendments: []".to_string(),
1956 );
1957 let recorded_opts = mock.recorded_options.clone();
1958 let client = ClaudeClient::new(Box::new(mock));
1959
1960 let amendment = crate::data::amendments::Amendment {
1961 commit: "abc123".to_string(),
1962 message: "feat: do thing".to_string(),
1963 summary: Some("did the thing".to_string()),
1964 };
1965 let items = vec![(amendment, "summary text".to_string())];
1966
1967 let result = client
1968 .refine_amendments_coherence(&items)
1969 .await
1970 .expect("coherence refinement should succeed");
1971 assert!(result.amendments.is_empty());
1972
1973 let recorded = recorded_opts.lock().unwrap();
1976 assert_eq!(recorded.len(), 1);
1977 let attached = recorded[0]
1978 .response_schema
1979 .as_ref()
1980 .expect("schema must be attached when capability is true");
1981 assert_eq!(
1982 attached,
1983 response_schema::amendment_file_schema(),
1984 "refine_amendments_coherence should attach the AmendmentFile schema"
1985 );
1986 }
1987
1988 #[tokio::test]
1993 async fn refine_checks_coherence_round_trip() {
1994 let mock = SchemaRecordingMockAiClient::with_response(
1995 true, "checks: []".to_string(),
1997 );
1998 let recorded_opts = mock.recorded_options.clone();
1999 let client = ClaudeClient::new(Box::new(mock));
2000
2001 let check = crate::data::check::CommitCheckResult {
2002 hash: "abc123".to_string(),
2003 message: "feat: do thing".to_string(),
2004 issues: Vec::new(),
2005 suggestion: None,
2006 passes: true,
2007 summary: Some("summary".to_string()),
2008 };
2009 let items = vec![(check, "summary text".to_string())];
2010 let dir = tempfile::TempDir::new().unwrap();
2011 let repo_view = make_test_repo_view(&dir);
2012
2013 let result = client
2014 .refine_checks_coherence(&items, &repo_view)
2015 .await
2016 .expect("coherence refinement should succeed");
2017 assert_eq!(result.summary.total_commits, 0);
2018
2019 let recorded = recorded_opts.lock().unwrap();
2020 assert_eq!(recorded.len(), 1);
2021 let attached = recorded[0]
2022 .response_schema
2023 .as_ref()
2024 .expect("schema must be attached when capability is true");
2025 assert_eq!(
2026 attached,
2027 response_schema::check_response_schema(),
2028 "refine_checks_coherence should attach the AiCheckResponse schema"
2029 );
2030 }
2031
2032 #[tokio::test]
2036 async fn refine_amendments_coherence_without_schema_capability() {
2037 let mock = SchemaRecordingMockAiClient::with_response(
2038 false, "amendments: []".to_string(),
2040 );
2041 let recorded_plain = mock.recorded_plain.clone();
2042 let recorded_opts = mock.recorded_options.clone();
2043 let client = ClaudeClient::new(Box::new(mock));
2044
2045 let amendment = crate::data::amendments::Amendment {
2046 commit: "abc123".to_string(),
2047 message: "feat: do thing".to_string(),
2048 summary: None,
2049 };
2050 let items = vec![(amendment, "summary".to_string())];
2051
2052 client
2053 .refine_amendments_coherence(&items)
2054 .await
2055 .expect("coherence refinement should succeed without schema support");
2056
2057 assert_eq!(recorded_plain.lock().unwrap().len(), 1);
2058 assert!(
2059 recorded_opts.lock().unwrap().is_empty(),
2060 "no-schema backend must not be reached via the options path"
2061 );
2062 }
2063
2064 #[test]
2067 fn extract_yaml_pure_amendments() {
2068 let client = make_client();
2069 let content = "amendments:\n - commit: abc123\n message: test";
2070 let result = client.extract_yaml_from_response(content);
2071 assert!(result.starts_with("amendments:"));
2072 }
2073
2074 #[test]
2075 fn extract_yaml_with_markdown_yaml_block() {
2076 let client = make_client();
2077 let content = "Here is the result:\n```yaml\namendments:\n - commit: abc\n```\n";
2078 let result = client.extract_yaml_from_response(content);
2079 assert!(result.starts_with("amendments:"));
2080 }
2081
2082 #[test]
2083 fn extract_yaml_with_generic_code_block() {
2084 let client = make_client();
2085 let content = "```\namendments:\n - commit: abc\n```";
2086 let result = client.extract_yaml_from_response(content);
2087 assert!(result.starts_with("amendments:"));
2088 }
2089
2090 #[test]
2091 fn extract_yaml_with_whitespace() {
2092 let client = make_client();
2093 let content = " \n amendments:\n - commit: abc\n ";
2094 let result = client.extract_yaml_from_response(content);
2095 assert!(result.starts_with("amendments:"));
2096 }
2097
2098 #[test]
2099 fn extract_yaml_fallback_returns_trimmed() {
2100 let client = make_client();
2101 let content = " some random text ";
2102 let result = client.extract_yaml_from_response(content);
2103 assert_eq!(result, "some random text");
2104 }
2105
2106 #[test]
2109 fn extract_check_yaml_pure() {
2110 let client = make_client();
2111 let content = "checks:\n - commit: abc123";
2112 let result = client.extract_yaml_from_check_response(content);
2113 assert!(result.starts_with("checks:"));
2114 }
2115
2116 #[test]
2117 fn extract_check_yaml_markdown_block() {
2118 let client = make_client();
2119 let content = "```yaml\nchecks:\n - commit: abc\n```";
2120 let result = client.extract_yaml_from_check_response(content);
2121 assert!(result.starts_with("checks:"));
2122 }
2123
2124 #[test]
2125 fn extract_check_yaml_generic_block() {
2126 let client = make_client();
2127 let content = "```\nchecks:\n - commit: abc\n```";
2128 let result = client.extract_yaml_from_check_response(content);
2129 assert!(result.starts_with("checks:"));
2130 }
2131
2132 #[test]
2133 fn extract_check_yaml_fallback() {
2134 let client = make_client();
2135 let content = " unexpected content ";
2136 let result = client.extract_yaml_from_check_response(content);
2137 assert_eq!(result, "unexpected content");
2138 }
2139
2140 #[test]
2143 fn parse_amendment_response_valid() {
2144 let client = make_client();
2145 let yaml = format!(
2146 "amendments:\n - commit: \"{}\"\n message: \"test message\"",
2147 "a".repeat(40)
2148 );
2149 let result = client.parse_amendment_response(&yaml);
2150 assert!(result.is_ok());
2151 assert_eq!(result.unwrap().amendments.len(), 1);
2152 }
2153
2154 #[test]
2155 fn parse_amendment_response_invalid_yaml() {
2156 let client = make_client();
2157 let result = client.parse_amendment_response("not: valid: yaml: [{{");
2158 assert!(result.is_err());
2159 }
2160
2161 #[test]
2162 fn parse_amendment_response_invalid_hash() {
2163 let client = make_client();
2164 let yaml = "amendments:\n - commit: \"short\"\n message: \"test\"";
2165 let result = client.parse_amendment_response(yaml);
2166 assert!(result.is_err());
2167 }
2168
2169 #[test]
2172 fn validate_beta_header_none_passes() {
2173 let result = validate_beta_header("claude-opus-4-1-20250805", &None);
2174 assert!(result.is_ok());
2175 }
2176
2177 #[test]
2178 fn validate_beta_header_unsupported_fails() {
2179 let header = Some(("fake-key".to_string(), "fake-value".to_string()));
2180 let result = validate_beta_header("claude-opus-4-1-20250805", &header);
2181 assert!(result.is_err());
2182 }
2183
2184 #[test]
2187 fn client_metadata() {
2188 let client = make_client();
2189 let metadata = client.get_ai_client_metadata();
2190 assert_eq!(metadata.provider, "Mock");
2191 assert_eq!(metadata.model, "mock-model");
2192 }
2193
2194 mod prop {
2197 use super::*;
2198 use proptest::prelude::*;
2199
2200 proptest! {
2201 #[test]
2202 fn yaml_response_output_trimmed(s in ".*") {
2203 let client = make_client();
2204 let result = client.extract_yaml_from_response(&s);
2205 prop_assert_eq!(&result, result.trim());
2206 }
2207
2208 #[test]
2209 fn yaml_response_amendments_prefix_preserved(tail in ".*") {
2210 let client = make_client();
2211 let input = format!("amendments:{tail}");
2212 let result = client.extract_yaml_from_response(&input);
2213 prop_assert!(result.starts_with("amendments:"));
2214 }
2215
2216 #[test]
2217 fn check_response_checks_prefix_preserved(tail in ".*") {
2218 let client = make_client();
2219 let input = format!("checks:{tail}");
2220 let result = client.extract_yaml_from_check_response(&input);
2221 prop_assert!(result.starts_with("checks:"));
2222 }
2223
2224 #[test]
2225 fn yaml_fenced_block_strips_fences(
2226 content in "[a-zA-Z0-9: _\\-\n]{1,100}",
2227 ) {
2228 let client = make_client();
2229 let input = format!("```yaml\n{content}\n```");
2230 let result = client.extract_yaml_from_response(&input);
2231 prop_assert!(!result.contains("```"));
2232 }
2233 }
2234 }
2235
2236 fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
2239 ClaudeClient::new(Box::new(
2240 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2241 ))
2242 }
2243
2244 fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2245 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2246 use crate::git::commit::FileChanges;
2247 use crate::git::{CommitAnalysis, CommitInfo};
2248
2249 let diff_path = dir.path().join("0.diff");
2250 std::fs::write(&diff_path, "+added line\n").unwrap();
2251
2252 crate::data::RepositoryView {
2253 versions: None,
2254 explanation: FieldExplanation::default(),
2255 working_directory: WorkingDirectoryInfo {
2256 clean: true,
2257 untracked_changes: Vec::new(),
2258 },
2259 remotes: Vec::new(),
2260 ai: AiInfo {
2261 scratch: String::new(),
2262 },
2263 branch_info: None,
2264 pr_template: None,
2265 pr_template_location: None,
2266 branch_prs: None,
2267 commits: vec![CommitInfo {
2268 hash: format!("{:0>40}", 0),
2269 author: "Test <test@test.com>".to_string(),
2270 date: chrono::Utc::now().fixed_offset(),
2271 original_message: "feat(test): add something".to_string(),
2272 in_main_branches: Vec::new(),
2273 analysis: CommitAnalysis {
2274 detected_type: "feat".to_string(),
2275 detected_scope: "test".to_string(),
2276 proposed_message: "feat(test): add something".to_string(),
2277 file_changes: FileChanges {
2278 total_files: 1,
2279 files_added: 1,
2280 files_deleted: 0,
2281 file_list: Vec::new(),
2282 },
2283 diff_summary: "file.rs | 1 +".to_string(),
2284 diff_file: diff_path.to_string_lossy().to_string(),
2285 file_diffs: Vec::new(),
2286 },
2287 }],
2288 }
2289 }
2290
2291 fn valid_check_yaml() -> String {
2292 format!(
2293 "checks:\n - commit: \"{hash}\"\n passes: true\n issues: []\n",
2294 hash = format!("{:0>40}", 0)
2295 )
2296 }
2297
2298 #[tokio::test]
2299 async fn send_message_propagates_ai_error() {
2300 let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
2301 let result = client.send_message("sys", "usr").await;
2302 assert!(result.is_err());
2303 assert!(result.unwrap_err().to_string().contains("mock error"));
2304 }
2305
2306 #[tokio::test]
2307 async fn check_commits_succeeds_after_request_error() {
2308 let dir = tempfile::tempdir().unwrap();
2309 let repo_view = make_test_repo_view(&dir);
2310 let client = make_configurable_client(vec![
2312 Err(anyhow::anyhow!("rate limit")),
2313 Ok(valid_check_yaml()),
2314 Ok(valid_check_yaml()),
2315 ]);
2316 let result = client
2317 .check_commits_with_scopes(&repo_view, None, &[], false)
2318 .await;
2319 assert!(result.is_ok());
2320 }
2321
2322 #[tokio::test]
2323 async fn check_commits_succeeds_after_parse_error() {
2324 let dir = tempfile::tempdir().unwrap();
2325 let repo_view = make_test_repo_view(&dir);
2326 let client = make_configurable_client(vec![
2328 Ok("not: valid: yaml: [[".to_string()),
2329 Ok(valid_check_yaml()),
2330 Ok(valid_check_yaml()),
2331 ]);
2332 let result = client
2333 .check_commits_with_scopes(&repo_view, None, &[], false)
2334 .await;
2335 assert!(result.is_ok());
2336 }
2337
2338 #[tokio::test]
2339 async fn check_commits_fails_after_all_retries_exhausted() {
2340 let dir = tempfile::tempdir().unwrap();
2341 let repo_view = make_test_repo_view(&dir);
2342 let client = make_configurable_client(vec![
2343 Err(anyhow::anyhow!("first failure")),
2344 Err(anyhow::anyhow!("second failure")),
2345 Err(anyhow::anyhow!("final failure")),
2346 ]);
2347 let result = client
2348 .check_commits_with_scopes(&repo_view, None, &[], false)
2349 .await;
2350 assert!(result.is_err());
2351 }
2352
2353 #[tokio::test]
2354 async fn check_commits_fails_when_all_parses_fail() {
2355 let dir = tempfile::tempdir().unwrap();
2356 let repo_view = make_test_repo_view(&dir);
2357 let client = make_configurable_client(vec![
2358 Ok("bad yaml [[".to_string()),
2359 Ok("bad yaml [[".to_string()),
2360 Ok("bad yaml [[".to_string()),
2361 ]);
2362 let result = client
2363 .check_commits_with_scopes(&repo_view, None, &[], false)
2364 .await;
2365 assert!(result.is_err());
2366 }
2367
2368 fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
2375 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2379 .with_context_length(50_000);
2380 ClaudeClient::new(Box::new(mock))
2381 }
2382
2383 fn make_small_context_client_tracked(
2386 responses: Vec<Result<String>>,
2387 ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
2388 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2389 .with_context_length(50_000);
2390 let handle = mock.response_handle();
2391 (ClaudeClient::new(Box::new(mock)), handle)
2392 }
2393
2394 fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2397 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2398 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2399 use crate::git::{CommitAnalysis, CommitInfo};
2400
2401 let hash = "a".repeat(40);
2402
2403 let full_diff = "x".repeat(120_000);
2407 let flat_diff_path = dir.path().join("full.diff");
2408 std::fs::write(&flat_diff_path, &full_diff).unwrap();
2409
2410 let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2413 let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2414
2415 let path_a = dir.path().join("0000.diff");
2416 let path_b = dir.path().join("0001.diff");
2417 std::fs::write(&path_a, &diff_a).unwrap();
2418 std::fs::write(&path_b, &diff_b).unwrap();
2419
2420 crate::data::RepositoryView {
2421 versions: None,
2422 explanation: FieldExplanation::default(),
2423 working_directory: WorkingDirectoryInfo {
2424 clean: true,
2425 untracked_changes: Vec::new(),
2426 },
2427 remotes: Vec::new(),
2428 ai: AiInfo {
2429 scratch: String::new(),
2430 },
2431 branch_info: None,
2432 pr_template: None,
2433 pr_template_location: None,
2434 branch_prs: None,
2435 commits: vec![CommitInfo {
2436 hash,
2437 author: "Test <test@test.com>".to_string(),
2438 date: chrono::Utc::now().fixed_offset(),
2439 original_message: "feat(test): large commit".to_string(),
2440 in_main_branches: Vec::new(),
2441 analysis: CommitAnalysis {
2442 detected_type: "feat".to_string(),
2443 detected_scope: "test".to_string(),
2444 proposed_message: "feat(test): large commit".to_string(),
2445 file_changes: FileChanges {
2446 total_files: 2,
2447 files_added: 2,
2448 files_deleted: 0,
2449 file_list: vec![
2450 FileChange {
2451 status: "A".to_string(),
2452 file: "src/a.rs".to_string(),
2453 },
2454 FileChange {
2455 status: "A".to_string(),
2456 file: "src/b.rs".to_string(),
2457 },
2458 ],
2459 },
2460 diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2461 diff_file: flat_diff_path.to_string_lossy().to_string(),
2462 file_diffs: vec![
2463 FileDiffRef {
2464 path: "src/a.rs".to_string(),
2465 diff_file: path_a.to_string_lossy().to_string(),
2466 byte_len: diff_a.len(),
2467 },
2468 FileDiffRef {
2469 path: "src/b.rs".to_string(),
2470 diff_file: path_b.to_string_lossy().to_string(),
2471 byte_len: diff_b.len(),
2472 },
2473 ],
2474 },
2475 }],
2476 }
2477 }
2478
2479 fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2480 format!("amendments:\n - commit: \"{hash}\"\n message: \"{message}\"")
2481 }
2482
2483 #[tokio::test]
2484 async fn generate_amendments_split_dispatch() {
2485 let dir = tempfile::tempdir().unwrap();
2486 let repo_view = make_large_diff_repo_view(&dir);
2487 let hash = "a".repeat(40);
2488
2489 let client = make_small_context_client(vec![
2491 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2492 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2493 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2494 ]);
2495
2496 let result = client
2497 .generate_amendments_with_options(&repo_view, false)
2498 .await;
2499
2500 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2501 let amendments = result.unwrap();
2502 assert_eq!(amendments.amendments.len(), 1);
2503 assert_eq!(amendments.amendments[0].commit, hash);
2504 assert!(amendments.amendments[0]
2505 .message
2506 .contains("add a.rs and b.rs"));
2507 }
2508
2509 #[tokio::test]
2510 async fn generate_amendments_split_chunk_failure() {
2511 let dir = tempfile::tempdir().unwrap();
2512 let repo_view = make_large_diff_repo_view(&dir);
2513 let hash = "a".repeat(40);
2514
2515 let client = make_small_context_client(vec![
2517 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2518 Err(anyhow::anyhow!("rate limit exceeded")),
2519 ]);
2520
2521 let result = client
2522 .generate_amendments_with_options(&repo_view, false)
2523 .await;
2524
2525 assert!(result.is_err());
2526 }
2527
2528 #[tokio::test]
2529 async fn generate_amendments_no_split_when_fits() {
2530 let dir = tempfile::tempdir().unwrap();
2531 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2533
2534 let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2536 &hash,
2537 "feat(test): improved message",
2538 ))]);
2539
2540 let result = client
2541 .generate_amendments_with_options(&repo_view, false)
2542 .await;
2543
2544 assert!(result.is_ok());
2545 assert_eq!(result.unwrap().amendments.len(), 1);
2546 }
2547
2548 fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2551 format!(
2552 "checks:\n - commit: \"{hash}\"\n passes: {passes}\n issues: []\n summary: \"test summary\"\n"
2553 )
2554 }
2555
2556 fn valid_check_yaml_with_issues(hash: &str) -> String {
2557 format!(
2558 concat!(
2559 "checks:\n",
2560 " - commit: \"{hash}\"\n",
2561 " passes: false\n",
2562 " issues:\n",
2563 " - severity: error\n",
2564 " section: \"Subject Line\"\n",
2565 " rule: \"subject-too-long\"\n",
2566 " explanation: \"Subject exceeds 72 characters\"\n",
2567 " suggestion:\n",
2568 " message: \"feat(test): shorter subject\"\n",
2569 " explanation: \"Shortened subject line\"\n",
2570 " summary: \"Large commit with issues\"\n",
2571 ),
2572 hash = hash,
2573 )
2574 }
2575
2576 fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2577 format!(
2578 concat!(
2579 "checks:\n",
2580 " - commit: \"{hash}\"\n",
2581 " passes: true\n",
2582 " issues: []\n",
2583 " summary: \"chunk summary\"\n",
2584 ),
2585 hash = hash,
2586 )
2587 }
2588
2589 #[tokio::test]
2590 async fn check_commits_split_dispatch() {
2591 let dir = tempfile::tempdir().unwrap();
2592 let repo_view = make_large_diff_repo_view(&dir);
2593 let hash = "a".repeat(40);
2594
2595 let client = make_small_context_client(vec![
2597 Ok(valid_check_yaml_with_issues(&hash)),
2598 Ok(valid_check_yaml_with_issues(&hash)),
2599 Ok(valid_check_yaml_with_issues(&hash)), ]);
2601
2602 let result = client
2603 .check_commits_with_scopes(&repo_view, None, &[], true)
2604 .await;
2605
2606 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2607 let report = result.unwrap();
2608 assert_eq!(report.commits.len(), 1);
2609 assert!(!report.commits[0].passes);
2610 assert_eq!(report.commits[0].issues.len(), 1);
2612 assert_eq!(report.commits[0].issues[0].rule, "subject-too-long");
2613 }
2614
2615 #[tokio::test]
2616 async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2617 let dir = tempfile::tempdir().unwrap();
2618 let repo_view = make_large_diff_repo_view(&dir);
2619 let hash = "a".repeat(40);
2620
2621 let client = make_small_context_client(vec![
2624 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2625 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2626 ]);
2627
2628 let result = client
2629 .check_commits_with_scopes(&repo_view, None, &[], false)
2630 .await;
2631
2632 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2633 let report = result.unwrap();
2634 assert_eq!(report.commits.len(), 1);
2635 assert!(report.commits[0].passes);
2636 assert!(report.commits[0].issues.is_empty());
2637 assert!(report.commits[0].suggestion.is_none());
2638 assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2640 }
2641
2642 #[tokio::test]
2643 async fn check_commits_split_chunk_failure() {
2644 let dir = tempfile::tempdir().unwrap();
2645 let repo_view = make_large_diff_repo_view(&dir);
2646 let hash = "a".repeat(40);
2647
2648 let client = make_small_context_client(vec![
2650 Ok(valid_check_yaml_for(&hash, true)),
2651 Err(anyhow::anyhow!("rate limit exceeded")),
2652 ]);
2653
2654 let result = client
2655 .check_commits_with_scopes(&repo_view, None, &[], false)
2656 .await;
2657
2658 assert!(result.is_err());
2659 }
2660
2661 #[tokio::test]
2662 async fn check_commits_no_split_when_fits() {
2663 let dir = tempfile::tempdir().unwrap();
2664 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2666
2667 let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2669
2670 let result = client
2671 .check_commits_with_scopes(&repo_view, None, &[], false)
2672 .await;
2673
2674 assert!(result.is_ok());
2675 assert_eq!(result.unwrap().commits.len(), 1);
2676 }
2677
2678 #[tokio::test]
2679 async fn check_commits_split_dedup_across_chunks() {
2680 let dir = tempfile::tempdir().unwrap();
2681 let repo_view = make_large_diff_repo_view(&dir);
2682 let hash = "a".repeat(40);
2683
2684 let chunk1 = format!(
2686 concat!(
2687 "checks:\n",
2688 " - commit: \"{hash}\"\n",
2689 " passes: false\n",
2690 " issues:\n",
2691 " - severity: error\n",
2692 " section: \"Subject Line\"\n",
2693 " rule: \"subject-too-long\"\n",
2694 " explanation: \"Subject exceeds 72 characters\"\n",
2695 " - severity: warning\n",
2696 " section: \"Content\"\n",
2697 " rule: \"body-required\"\n",
2698 " explanation: \"Large change needs body\"\n",
2699 ),
2700 hash = hash,
2701 );
2702
2703 let chunk2 = format!(
2705 concat!(
2706 "checks:\n",
2707 " - commit: \"{hash}\"\n",
2708 " passes: false\n",
2709 " issues:\n",
2710 " - severity: error\n",
2711 " section: \"Subject Line\"\n",
2712 " rule: \"subject-too-long\"\n",
2713 " explanation: \"Subject line is too long\"\n",
2714 " - severity: info\n",
2715 " section: \"Style\"\n",
2716 " rule: \"scope-suggestion\"\n",
2717 " explanation: \"Consider more specific scope\"\n",
2718 ),
2719 hash = hash,
2720 );
2721
2722 let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2724
2725 let result = client
2726 .check_commits_with_scopes(&repo_view, None, &[], false)
2727 .await;
2728
2729 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2730 let report = result.unwrap();
2731 assert_eq!(report.commits.len(), 1);
2732 assert!(!report.commits[0].passes);
2733 assert_eq!(report.commits[0].issues.len(), 3);
2736 }
2737
2738 #[tokio::test]
2739 async fn check_commits_split_passes_only_when_all_chunks_pass() {
2740 let dir = tempfile::tempdir().unwrap();
2741 let repo_view = make_large_diff_repo_view(&dir);
2742 let hash = "a".repeat(40);
2743
2744 let client = make_small_context_client(vec![
2746 Ok(valid_check_yaml_for(&hash, true)),
2747 Ok(valid_check_yaml_for(&hash, false)),
2748 ]);
2749
2750 let result = client
2751 .check_commits_with_scopes(&repo_view, None, &[], false)
2752 .await;
2753
2754 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2755 let report = result.unwrap();
2756 assert!(
2757 !report.commits[0].passes,
2758 "should fail when any chunk fails"
2759 );
2760 }
2761
2762 fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2766 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2767 use crate::git::commit::FileChanges;
2768 use crate::git::{CommitAnalysis, CommitInfo};
2769
2770 let diff_a = dir.path().join("0.diff");
2771 let diff_b = dir.path().join("1.diff");
2772 std::fs::write(&diff_a, "+line a\n").unwrap();
2773 std::fs::write(&diff_b, "+line b\n").unwrap();
2774
2775 let hash_a = "a".repeat(40);
2776 let hash_b = "b".repeat(40);
2777
2778 crate::data::RepositoryView {
2779 versions: None,
2780 explanation: FieldExplanation::default(),
2781 working_directory: WorkingDirectoryInfo {
2782 clean: true,
2783 untracked_changes: Vec::new(),
2784 },
2785 remotes: Vec::new(),
2786 ai: AiInfo {
2787 scratch: String::new(),
2788 },
2789 branch_info: None,
2790 pr_template: None,
2791 pr_template_location: None,
2792 branch_prs: None,
2793 commits: vec![
2794 CommitInfo {
2795 hash: hash_a,
2796 author: "Test <test@test.com>".to_string(),
2797 date: chrono::Utc::now().fixed_offset(),
2798 original_message: "feat(a): add a".to_string(),
2799 in_main_branches: Vec::new(),
2800 analysis: CommitAnalysis {
2801 detected_type: "feat".to_string(),
2802 detected_scope: "a".to_string(),
2803 proposed_message: "feat(a): add a".to_string(),
2804 file_changes: FileChanges {
2805 total_files: 1,
2806 files_added: 1,
2807 files_deleted: 0,
2808 file_list: Vec::new(),
2809 },
2810 diff_summary: "a.rs | 1 +".to_string(),
2811 diff_file: diff_a.to_string_lossy().to_string(),
2812 file_diffs: Vec::new(),
2813 },
2814 },
2815 CommitInfo {
2816 hash: hash_b,
2817 author: "Test <test@test.com>".to_string(),
2818 date: chrono::Utc::now().fixed_offset(),
2819 original_message: "feat(b): add b".to_string(),
2820 in_main_branches: Vec::new(),
2821 analysis: CommitAnalysis {
2822 detected_type: "feat".to_string(),
2823 detected_scope: "b".to_string(),
2824 proposed_message: "feat(b): add b".to_string(),
2825 file_changes: FileChanges {
2826 total_files: 1,
2827 files_added: 1,
2828 files_deleted: 0,
2829 file_list: Vec::new(),
2830 },
2831 diff_summary: "b.rs | 1 +".to_string(),
2832 diff_file: diff_b.to_string_lossy().to_string(),
2833 file_diffs: Vec::new(),
2834 },
2835 },
2836 ],
2837 }
2838 }
2839
2840 #[tokio::test]
2841 async fn generate_amendments_multi_commit() {
2842 let dir = tempfile::tempdir().unwrap();
2843 let repo_view = make_multi_commit_repo_view(&dir);
2844 let hash_a = "a".repeat(40);
2845 let hash_b = "b".repeat(40);
2846
2847 let response = format!(
2848 concat!(
2849 "amendments:\n",
2850 " - commit: \"{hash_a}\"\n",
2851 " message: \"feat(a): improved a\"\n",
2852 " - commit: \"{hash_b}\"\n",
2853 " message: \"feat(b): improved b\"\n",
2854 ),
2855 hash_a = hash_a,
2856 hash_b = hash_b,
2857 );
2858 let client = make_configurable_client(vec![Ok(response)]);
2859
2860 let result = client
2861 .generate_amendments_with_options(&repo_view, false)
2862 .await;
2863
2864 assert!(
2865 result.is_ok(),
2866 "multi-commit amendment failed: {:?}",
2867 result.err()
2868 );
2869 let amendments = result.unwrap();
2870 assert_eq!(amendments.amendments.len(), 2);
2871 }
2872
2873 #[tokio::test]
2874 async fn generate_contextual_amendments_multi_commit() {
2875 let dir = tempfile::tempdir().unwrap();
2876 let repo_view = make_multi_commit_repo_view(&dir);
2877 let hash_a = "a".repeat(40);
2878 let hash_b = "b".repeat(40);
2879
2880 let response = format!(
2881 concat!(
2882 "amendments:\n",
2883 " - commit: \"{hash_a}\"\n",
2884 " message: \"feat(a): improved a\"\n",
2885 " - commit: \"{hash_b}\"\n",
2886 " message: \"feat(b): improved b\"\n",
2887 ),
2888 hash_a = hash_a,
2889 hash_b = hash_b,
2890 );
2891 let client = make_configurable_client(vec![Ok(response)]);
2892 let context = crate::data::context::CommitContext::default();
2893
2894 let result = client
2895 .generate_contextual_amendments_with_options(&repo_view, &context, false)
2896 .await;
2897
2898 assert!(
2899 result.is_ok(),
2900 "multi-commit contextual amendment failed: {:?}",
2901 result.err()
2902 );
2903 let amendments = result.unwrap();
2904 assert_eq!(amendments.amendments.len(), 2);
2905 }
2906
2907 #[tokio::test]
2908 async fn generate_pr_content_succeeds() {
2909 let dir = tempfile::tempdir().unwrap();
2910 let repo_view = make_test_repo_view(&dir);
2911
2912 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2913 let client = make_configurable_client(vec![Ok(response.to_string())]);
2914
2915 let result = client.generate_pr_content(&repo_view, "").await;
2916
2917 assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
2918 let pr = result.unwrap();
2919 assert_eq!(pr.title, "feat: add something");
2920 assert_eq!(pr.description, "Adds a new feature.");
2921 }
2922
2923 #[tokio::test]
2924 async fn generate_pr_content_with_context_succeeds() {
2925 let dir = tempfile::tempdir().unwrap();
2926 let repo_view = make_test_repo_view(&dir);
2927 let context = crate::data::context::CommitContext::default();
2928
2929 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2930 let client = make_configurable_client(vec![Ok(response.to_string())]);
2931
2932 let result = client
2933 .generate_pr_content_with_context(&repo_view, "", &context)
2934 .await;
2935
2936 assert!(
2937 result.is_ok(),
2938 "PR generation with context failed: {:?}",
2939 result.err()
2940 );
2941 let pr = result.unwrap();
2942 assert_eq!(pr.title, "feat: add something");
2943 }
2944
2945 #[tokio::test]
2946 async fn check_commits_multi_commit() {
2947 let dir = tempfile::tempdir().unwrap();
2948 let repo_view = make_multi_commit_repo_view(&dir);
2949 let hash_a = "a".repeat(40);
2950 let hash_b = "b".repeat(40);
2951
2952 let response = format!(
2953 concat!(
2954 "checks:\n",
2955 " - commit: \"{hash_a}\"\n",
2956 " passes: true\n",
2957 " issues: []\n",
2958 " - commit: \"{hash_b}\"\n",
2959 " passes: true\n",
2960 " issues: []\n",
2961 ),
2962 hash_a = hash_a,
2963 hash_b = hash_b,
2964 );
2965 let client = make_configurable_client(vec![Ok(response)]);
2966
2967 let result = client
2968 .check_commits_with_scopes(&repo_view, None, &[], false)
2969 .await;
2970
2971 assert!(
2972 result.is_ok(),
2973 "multi-commit check failed: {:?}",
2974 result.err()
2975 );
2976 let report = result.unwrap();
2977 assert_eq!(report.commits.len(), 2);
2978 assert!(report.commits[0].passes);
2979 assert!(report.commits[1].passes);
2980 }
2981
2982 fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2987 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2988 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2989 use crate::git::{CommitAnalysis, CommitInfo};
2990
2991 let hash_a = "a".repeat(40);
2992 let hash_b = "b".repeat(40);
2993
2994 let diff_content_a = "x".repeat(60_000);
2997 let diff_content_b = "y".repeat(60_000);
2998 let flat_a = dir.path().join("flat_a.diff");
2999 let flat_b = dir.path().join("flat_b.diff");
3000 std::fs::write(&flat_a, &diff_content_a).unwrap();
3001 std::fs::write(&flat_b, &diff_content_b).unwrap();
3002
3003 let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
3005 let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
3006 let per_file_a = dir.path().join("pf_a.diff");
3007 let per_file_b = dir.path().join("pf_b.diff");
3008 std::fs::write(&per_file_a, &file_diff_a).unwrap();
3009 std::fs::write(&per_file_b, &file_diff_b).unwrap();
3010
3011 crate::data::RepositoryView {
3012 versions: None,
3013 explanation: FieldExplanation::default(),
3014 working_directory: WorkingDirectoryInfo {
3015 clean: true,
3016 untracked_changes: Vec::new(),
3017 },
3018 remotes: Vec::new(),
3019 ai: AiInfo {
3020 scratch: String::new(),
3021 },
3022 branch_info: None,
3023 pr_template: None,
3024 pr_template_location: None,
3025 branch_prs: None,
3026 commits: vec![
3027 CommitInfo {
3028 hash: hash_a,
3029 author: "Test <test@test.com>".to_string(),
3030 date: chrono::Utc::now().fixed_offset(),
3031 original_message: "feat(a): add module a".to_string(),
3032 in_main_branches: Vec::new(),
3033 analysis: CommitAnalysis {
3034 detected_type: "feat".to_string(),
3035 detected_scope: "a".to_string(),
3036 proposed_message: "feat(a): add module a".to_string(),
3037 file_changes: FileChanges {
3038 total_files: 1,
3039 files_added: 1,
3040 files_deleted: 0,
3041 file_list: vec![FileChange {
3042 status: "A".to_string(),
3043 file: "src/a.rs".to_string(),
3044 }],
3045 },
3046 diff_summary: " src/a.rs | 100 ++++\n".to_string(),
3047 diff_file: flat_a.to_string_lossy().to_string(),
3048 file_diffs: vec![FileDiffRef {
3049 path: "src/a.rs".to_string(),
3050 diff_file: per_file_a.to_string_lossy().to_string(),
3051 byte_len: file_diff_a.len(),
3052 }],
3053 },
3054 },
3055 CommitInfo {
3056 hash: hash_b,
3057 author: "Test <test@test.com>".to_string(),
3058 date: chrono::Utc::now().fixed_offset(),
3059 original_message: "feat(b): add module b".to_string(),
3060 in_main_branches: Vec::new(),
3061 analysis: CommitAnalysis {
3062 detected_type: "feat".to_string(),
3063 detected_scope: "b".to_string(),
3064 proposed_message: "feat(b): add module b".to_string(),
3065 file_changes: FileChanges {
3066 total_files: 1,
3067 files_added: 1,
3068 files_deleted: 0,
3069 file_list: vec![FileChange {
3070 status: "A".to_string(),
3071 file: "src/b.rs".to_string(),
3072 }],
3073 },
3074 diff_summary: " src/b.rs | 100 ++++\n".to_string(),
3075 diff_file: flat_b.to_string_lossy().to_string(),
3076 file_diffs: vec![FileDiffRef {
3077 path: "src/b.rs".to_string(),
3078 diff_file: per_file_b.to_string_lossy().to_string(),
3079 byte_len: file_diff_b.len(),
3080 }],
3081 },
3082 },
3083 ],
3084 }
3085 }
3086
3087 fn valid_pr_yaml(title: &str, description: &str) -> String {
3088 format!("title: \"{title}\"\ndescription: \"{description}\"\n")
3089 }
3090
3091 #[tokio::test]
3094 async fn generate_amendments_multi_commit_split_dispatch() {
3095 let dir = tempfile::tempdir().unwrap();
3096 let repo_view = make_large_multi_commit_repo_view(&dir);
3097 let hash_a = "a".repeat(40);
3098 let hash_b = "b".repeat(40);
3099
3100 let (client, handle) = make_small_context_client_tracked(vec![
3103 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3104 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3105 ]);
3106
3107 let result = client
3108 .generate_amendments_with_options(&repo_view, false)
3109 .await;
3110
3111 assert!(
3112 result.is_ok(),
3113 "multi-commit split dispatch failed: {:?}",
3114 result.err()
3115 );
3116 let amendments = result.unwrap();
3117 assert_eq!(amendments.amendments.len(), 2);
3118 assert_eq!(amendments.amendments[0].commit, hash_a);
3119 assert_eq!(amendments.amendments[1].commit, hash_b);
3120 assert!(amendments.amendments[0].message.contains("improved a"));
3121 assert!(amendments.amendments[1].message.contains("improved b"));
3122 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3123 }
3124
3125 #[tokio::test]
3126 async fn generate_contextual_amendments_multi_commit_split_dispatch() {
3127 let dir = tempfile::tempdir().unwrap();
3128 let repo_view = make_large_multi_commit_repo_view(&dir);
3129 let hash_a = "a".repeat(40);
3130 let hash_b = "b".repeat(40);
3131 let context = crate::data::context::CommitContext::default();
3132
3133 let (client, handle) = make_small_context_client_tracked(vec![
3134 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3135 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3136 ]);
3137
3138 let result = client
3139 .generate_contextual_amendments_with_options(&repo_view, &context, false)
3140 .await;
3141
3142 assert!(
3143 result.is_ok(),
3144 "multi-commit contextual split dispatch failed: {:?}",
3145 result.err()
3146 );
3147 let amendments = result.unwrap();
3148 assert_eq!(amendments.amendments.len(), 2);
3149 assert_eq!(amendments.amendments[0].commit, hash_a);
3150 assert_eq!(amendments.amendments[1].commit, hash_b);
3151 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3152 }
3153
3154 #[tokio::test]
3157 async fn check_commits_multi_commit_split_dispatch() {
3158 let dir = tempfile::tempdir().unwrap();
3159 let repo_view = make_large_multi_commit_repo_view(&dir);
3160 let hash_a = "a".repeat(40);
3161 let hash_b = "b".repeat(40);
3162
3163 let (client, handle) = make_small_context_client_tracked(vec![
3165 Ok(valid_check_yaml_for(&hash_a, true)),
3166 Ok(valid_check_yaml_for(&hash_b, true)),
3167 ]);
3168
3169 let result = client
3170 .check_commits_with_scopes(&repo_view, None, &[], false)
3171 .await;
3172
3173 assert!(
3174 result.is_ok(),
3175 "multi-commit check split dispatch failed: {:?}",
3176 result.err()
3177 );
3178 let report = result.unwrap();
3179 assert_eq!(report.commits.len(), 2);
3180 assert!(report.commits[0].passes);
3181 assert!(report.commits[1].passes);
3182 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3183 }
3184
3185 #[tokio::test]
3188 async fn generate_pr_content_split_dispatch() {
3189 let dir = tempfile::tempdir().unwrap();
3190 let repo_view = make_large_diff_repo_view(&dir);
3191
3192 let (client, handle) = make_small_context_client_tracked(vec![
3196 Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
3197 Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
3198 Ok(valid_pr_yaml(
3199 "feat(test): add modules",
3200 "Adds a.rs and b.rs",
3201 )),
3202 ]);
3203
3204 let result = client.generate_pr_content(&repo_view, "").await;
3205
3206 assert!(
3207 result.is_ok(),
3208 "PR split dispatch failed: {:?}",
3209 result.err()
3210 );
3211 let pr = result.unwrap();
3212 assert!(pr.title.contains("add modules"));
3213 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3214 }
3215
3216 #[tokio::test]
3217 async fn generate_pr_content_multi_commit_split_dispatch() {
3218 let dir = tempfile::tempdir().unwrap();
3219 let repo_view = make_large_multi_commit_repo_view(&dir);
3220
3221 let (client, handle) = make_small_context_client_tracked(vec![
3224 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3225 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3226 Ok(valid_pr_yaml(
3227 "feat: add modules a and b",
3228 "Adds both modules",
3229 )),
3230 ]);
3231
3232 let result = client.generate_pr_content(&repo_view, "").await;
3233
3234 assert!(
3235 result.is_ok(),
3236 "PR multi-commit split dispatch failed: {:?}",
3237 result.err()
3238 );
3239 let pr = result.unwrap();
3240 assert!(pr.title.contains("modules"));
3241 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3242 }
3243
3244 #[tokio::test]
3245 async fn generate_pr_content_with_context_split_dispatch() {
3246 let dir = tempfile::tempdir().unwrap();
3247 let repo_view = make_large_multi_commit_repo_view(&dir);
3248 let context = crate::data::context::CommitContext::default();
3249
3250 let (client, handle) = make_small_context_client_tracked(vec![
3252 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3253 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3254 Ok(valid_pr_yaml(
3255 "feat: add modules a and b",
3256 "Adds both modules",
3257 )),
3258 ]);
3259
3260 let result = client
3261 .generate_pr_content_with_context(&repo_view, "", &context)
3262 .await;
3263
3264 assert!(
3265 result.is_ok(),
3266 "PR with context split dispatch failed: {:?}",
3267 result.err()
3268 );
3269 let pr = result.unwrap();
3270 assert!(pr.title.contains("modules"));
3271 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3272 }
3273
3274 fn make_small_context_client_with_prompts(
3279 responses: Vec<Result<String>>,
3280 ) -> (
3281 ClaudeClient,
3282 crate::claude::test_utils::ResponseQueueHandle,
3283 crate::claude::test_utils::PromptRecordHandle,
3284 ) {
3285 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
3286 .with_context_length(50_000);
3287 let response_handle = mock.response_handle();
3288 let prompt_handle = mock.prompt_handle();
3289 (
3290 ClaudeClient::new(Box::new(mock)),
3291 response_handle,
3292 prompt_handle,
3293 )
3294 }
3295
3296 fn make_configurable_client_with_prompts(
3298 responses: Vec<Result<String>>,
3299 ) -> (
3300 ClaudeClient,
3301 crate::claude::test_utils::ResponseQueueHandle,
3302 crate::claude::test_utils::PromptRecordHandle,
3303 ) {
3304 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
3305 let response_handle = mock.response_handle();
3306 let prompt_handle = mock.prompt_handle();
3307 (
3308 ClaudeClient::new(Box::new(mock)),
3309 response_handle,
3310 prompt_handle,
3311 )
3312 }
3313
3314 fn make_single_oversized_file_repo_view(
3321 dir: &tempfile::TempDir,
3322 ) -> crate::data::RepositoryView {
3323 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3324 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3325 use crate::git::{CommitAnalysis, CommitInfo};
3326
3327 let hash = "c".repeat(40);
3328
3329 let diff_content = format!(
3332 "diff --git a/src/big.rs b/src/big.rs\n{}\n",
3333 "x".repeat(80_000)
3334 );
3335
3336 let flat_diff_path = dir.path().join("full.diff");
3337 std::fs::write(&flat_diff_path, &diff_content).unwrap();
3338
3339 let per_file_path = dir.path().join("0000.diff");
3340 std::fs::write(&per_file_path, &diff_content).unwrap();
3341
3342 crate::data::RepositoryView {
3343 versions: None,
3344 explanation: FieldExplanation::default(),
3345 working_directory: WorkingDirectoryInfo {
3346 clean: true,
3347 untracked_changes: Vec::new(),
3348 },
3349 remotes: Vec::new(),
3350 ai: AiInfo {
3351 scratch: String::new(),
3352 },
3353 branch_info: None,
3354 pr_template: None,
3355 pr_template_location: None,
3356 branch_prs: None,
3357 commits: vec![CommitInfo {
3358 hash,
3359 author: "Test <test@test.com>".to_string(),
3360 date: chrono::Utc::now().fixed_offset(),
3361 original_message: "feat(big): add large module".to_string(),
3362 in_main_branches: Vec::new(),
3363 analysis: CommitAnalysis {
3364 detected_type: "feat".to_string(),
3365 detected_scope: "big".to_string(),
3366 proposed_message: "feat(big): add large module".to_string(),
3367 file_changes: FileChanges {
3368 total_files: 1,
3369 files_added: 1,
3370 files_deleted: 0,
3371 file_list: vec![FileChange {
3372 status: "A".to_string(),
3373 file: "src/big.rs".to_string(),
3374 }],
3375 },
3376 diff_summary: " src/big.rs | 80 ++++\n".to_string(),
3377 diff_file: flat_diff_path.to_string_lossy().to_string(),
3378 file_diffs: vec![FileDiffRef {
3379 path: "src/big.rs".to_string(),
3380 diff_file: per_file_path.to_string_lossy().to_string(),
3381 byte_len: diff_content.len(),
3382 }],
3383 },
3384 }],
3385 }
3386 }
3387
3388 #[tokio::test]
3395 async fn amendment_single_file_under_budget_no_split() {
3396 let dir = tempfile::tempdir().unwrap();
3397 let repo_view = make_test_repo_view(&dir);
3398 let hash = format!("{:0>40}", 0);
3399
3400 let (client, response_handle, prompt_handle) =
3401 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3402 &hash,
3403 "feat(test): improved message",
3404 ))]);
3405
3406 let result = client
3407 .generate_amendments_with_options(&repo_view, false)
3408 .await;
3409
3410 assert!(result.is_ok());
3411 assert_eq!(result.unwrap().amendments.len(), 1);
3412 assert_eq!(response_handle.remaining(), 0);
3413
3414 let prompts = prompt_handle.prompts();
3415 assert_eq!(
3416 prompts.len(),
3417 1,
3418 "expected exactly one AI request, no split"
3419 );
3420
3421 let (_, user_prompt) = &prompts[0];
3422 assert!(
3423 user_prompt.contains("added line"),
3424 "user prompt should contain the diff content"
3425 );
3426 }
3427
3428 #[tokio::test]
3439 async fn amendment_two_chunks_prompt_content() {
3440 let dir = tempfile::tempdir().unwrap();
3441 let repo_view = make_large_diff_repo_view(&dir);
3442 let hash = "a".repeat(40);
3443
3444 let (client, response_handle, prompt_handle) =
3445 make_small_context_client_with_prompts(vec![
3446 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3447 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3448 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3449 ]);
3450
3451 let result = client
3452 .generate_amendments_with_options(&repo_view, false)
3453 .await;
3454
3455 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3456 let amendments = result.unwrap();
3457 assert_eq!(amendments.amendments.len(), 1);
3458 assert!(amendments.amendments[0]
3459 .message
3460 .contains("add a.rs and b.rs"));
3461 assert_eq!(response_handle.remaining(), 0);
3462
3463 let prompts = prompt_handle.prompts();
3464 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3465
3466 let (_, chunk1_user) = &prompts[0];
3468 assert!(
3469 chunk1_user.contains("aaa"),
3470 "chunk 1 prompt should contain file-a diff content"
3471 );
3472
3473 let (_, chunk2_user) = &prompts[1];
3475 assert!(
3476 chunk2_user.contains("bbb"),
3477 "chunk 2 prompt should contain file-b diff content"
3478 );
3479
3480 let (merge_sys, merge_user) = &prompts[2];
3482 assert!(
3483 merge_sys.contains("synthesiz"),
3484 "merge system prompt should contain synthesis instructions"
3485 );
3486 assert!(
3488 merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
3489 "merge user prompt should contain both partial amendment messages"
3490 );
3491 }
3492
3493 #[tokio::test]
3505 async fn amendment_single_oversized_file_gets_placeholder() {
3506 let dir = tempfile::tempdir().unwrap();
3507 let repo_view = make_single_oversized_file_repo_view(&dir);
3508 let hash = "c".repeat(40);
3509
3510 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3515 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3516 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3517 ]);
3518
3519 let result = client
3520 .generate_amendments_with_options(&repo_view, false)
3521 .await;
3522
3523 assert!(
3525 result.is_ok(),
3526 "expected success with placeholder, got: {result:?}"
3527 );
3528
3529 assert!(
3531 prompt_handle.request_count() >= 1,
3532 "expected at least 1 request, got {}",
3533 prompt_handle.request_count()
3534 );
3535 }
3536
3537 #[tokio::test]
3546 async fn amendment_chunk_failure_stops_dispatch() {
3547 let dir = tempfile::tempdir().unwrap();
3548 let repo_view = make_large_diff_repo_view(&dir);
3549 let hash = "a".repeat(40);
3550
3551 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3553 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3554 Err(anyhow::anyhow!("rate limit exceeded")),
3555 ]);
3556
3557 let result = client
3558 .generate_amendments_with_options(&repo_view, false)
3559 .await;
3560
3561 assert!(result.is_err());
3562
3563 let prompts = prompt_handle.prompts();
3565 assert_eq!(
3566 prompts.len(),
3567 2,
3568 "should stop after the failing chunk, got {} requests",
3569 prompts.len()
3570 );
3571
3572 let (_, first_user) = &prompts[0];
3574 assert!(
3575 first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
3576 "first chunk prompt should reference a file"
3577 );
3578 }
3579
3580 #[tokio::test]
3591 async fn amendment_reduce_pass_prompt_content() {
3592 let dir = tempfile::tempdir().unwrap();
3593 let repo_view = make_large_diff_repo_view(&dir);
3594 let hash = "a".repeat(40);
3595
3596 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3597 Ok(valid_amendment_yaml(
3598 &hash,
3599 "feat(a): add module a implementation",
3600 )),
3601 Ok(valid_amendment_yaml(
3602 &hash,
3603 "feat(b): add module b implementation",
3604 )),
3605 Ok(valid_amendment_yaml(
3606 &hash,
3607 "feat(test): add modules a and b",
3608 )),
3609 ]);
3610
3611 let result = client
3612 .generate_amendments_with_options(&repo_view, false)
3613 .await;
3614
3615 assert!(result.is_ok());
3616
3617 let prompts = prompt_handle.prompts();
3618 assert_eq!(prompts.len(), 3);
3619
3620 let (merge_system, merge_user) = &prompts[2];
3622
3623 assert!(
3625 merge_system.contains("synthesiz"),
3626 "merge system prompt should contain synthesis instructions"
3627 );
3628
3629 assert!(
3631 merge_user.contains("feat(a): add module a implementation"),
3632 "merge user prompt should contain chunk 1's partial message"
3633 );
3634 assert!(
3635 merge_user.contains("feat(b): add module b implementation"),
3636 "merge user prompt should contain chunk 2's partial message"
3637 );
3638
3639 assert!(
3641 merge_user.contains("feat(test): large commit"),
3642 "merge user prompt should contain the original commit message"
3643 );
3644
3645 assert!(
3647 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3648 "merge user prompt should contain the diff_summary"
3649 );
3650
3651 assert!(
3653 merge_user.contains(&hash),
3654 "merge user prompt should reference the commit hash"
3655 );
3656 }
3657
3658 #[tokio::test]
3675 async fn check_split_dedup_and_merge_prompt() {
3676 let dir = tempfile::tempdir().unwrap();
3677 let repo_view = make_large_diff_repo_view(&dir);
3678 let hash = "a".repeat(40);
3679
3680 let chunk1_yaml = format!(
3682 concat!(
3683 "checks:\n",
3684 " - commit: \"{hash}\"\n",
3685 " passes: false\n",
3686 " issues:\n",
3687 " - severity: error\n",
3688 " section: \"Subject Line\"\n",
3689 " rule: \"subject-too-long\"\n",
3690 " explanation: \"Subject exceeds 72 characters\"\n",
3691 " - severity: warning\n",
3692 " section: \"Content\"\n",
3693 " rule: \"body-required\"\n",
3694 " explanation: \"Large change needs body\"\n",
3695 " suggestion:\n",
3696 " message: \"feat(a): shorter subject for a\"\n",
3697 " explanation: \"Shortened subject for file a\"\n",
3698 " summary: \"Adds module a\"\n",
3699 ),
3700 hash = hash,
3701 );
3702
3703 let chunk2_yaml = format!(
3705 concat!(
3706 "checks:\n",
3707 " - commit: \"{hash}\"\n",
3708 " passes: false\n",
3709 " issues:\n",
3710 " - severity: error\n",
3711 " section: \"Subject Line\"\n",
3712 " rule: \"subject-too-long\"\n",
3713 " explanation: \"Subject line is way too long\"\n",
3714 " - severity: info\n",
3715 " section: \"Style\"\n",
3716 " rule: \"scope-suggestion\"\n",
3717 " explanation: \"Consider more specific scope\"\n",
3718 " suggestion:\n",
3719 " message: \"feat(b): shorter subject for b\"\n",
3720 " explanation: \"Shortened subject for file b\"\n",
3721 " summary: \"Adds module b\"\n",
3722 ),
3723 hash = hash,
3724 );
3725
3726 let merge_yaml = format!(
3728 concat!(
3729 "checks:\n",
3730 " - commit: \"{hash}\"\n",
3731 " passes: false\n",
3732 " issues: []\n",
3733 " suggestion:\n",
3734 " message: \"feat(test): add modules a and b\"\n",
3735 " explanation: \"Combined suggestion\"\n",
3736 " summary: \"Adds modules a and b\"\n",
3737 ),
3738 hash = hash,
3739 );
3740
3741 let (client, response_handle, prompt_handle) =
3742 make_small_context_client_with_prompts(vec![
3743 Ok(chunk1_yaml),
3744 Ok(chunk2_yaml),
3745 Ok(merge_yaml),
3746 ]);
3747
3748 let result = client
3749 .check_commits_with_scopes(&repo_view, None, &[], true)
3750 .await;
3751
3752 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3753 let report = result.unwrap();
3754 assert_eq!(report.commits.len(), 1);
3755 assert!(!report.commits[0].passes);
3756 assert_eq!(response_handle.remaining(), 0);
3757
3758 assert_eq!(
3763 report.commits[0].issues.len(),
3764 3,
3765 "expected 3 unique issues after dedup, got {:?}",
3766 report.commits[0]
3767 .issues
3768 .iter()
3769 .map(|i| &i.rule)
3770 .collect::<Vec<_>>()
3771 );
3772
3773 assert!(report.commits[0].suggestion.is_some());
3775 assert!(
3776 report.commits[0]
3777 .suggestion
3778 .as_ref()
3779 .unwrap()
3780 .message
3781 .contains("add modules a and b"),
3782 "suggestion should come from the merge pass"
3783 );
3784
3785 let prompts = prompt_handle.prompts();
3787 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
3788
3789 let (_, chunk1_user) = &prompts[0];
3791 let (_, chunk2_user) = &prompts[1];
3792 let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
3793 assert!(
3794 combined_chunk_prompts.contains("src/a.rs")
3795 && combined_chunk_prompts.contains("src/b.rs"),
3796 "chunk prompts should collectively cover both files"
3797 );
3798
3799 let (merge_sys, merge_user) = &prompts[2];
3801 assert!(
3802 merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
3803 "merge system prompt should be the check chunk merge prompt"
3804 );
3805 assert!(
3806 merge_user.contains("feat(a): shorter subject for a")
3807 && merge_user.contains("feat(b): shorter subject for b"),
3808 "merge user prompt should contain both partial suggestions"
3809 );
3810 assert!(
3812 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3813 "merge user prompt should contain the diff_summary"
3814 );
3815 }
3816
3817 #[tokio::test]
3820 async fn amendment_retry_parse_failure_then_success() {
3821 let dir = tempfile::tempdir().unwrap();
3822 let repo_view = make_test_repo_view(&dir);
3823 let hash = format!("{:0>40}", 0);
3824
3825 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3826 Ok("not valid yaml {{[".to_string()),
3827 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3828 ]);
3829
3830 let result = client
3831 .generate_amendments_with_options(&repo_view, false)
3832 .await;
3833
3834 assert!(
3835 result.is_ok(),
3836 "should succeed after retry: {:?}",
3837 result.err()
3838 );
3839 assert_eq!(result.unwrap().amendments.len(), 1);
3840 assert_eq!(response_handle.remaining(), 0, "both responses consumed");
3841 assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
3842 }
3843
3844 #[tokio::test]
3845 async fn amendment_retry_request_failure_then_success() {
3846 let dir = tempfile::tempdir().unwrap();
3847 let repo_view = make_test_repo_view(&dir);
3848 let hash = format!("{:0>40}", 0);
3849
3850 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3851 Err(anyhow::anyhow!("rate limit")),
3852 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3853 ]);
3854
3855 let result = client
3856 .generate_amendments_with_options(&repo_view, false)
3857 .await;
3858
3859 assert!(
3860 result.is_ok(),
3861 "should succeed after retry: {:?}",
3862 result.err()
3863 );
3864 assert_eq!(result.unwrap().amendments.len(), 1);
3865 assert_eq!(response_handle.remaining(), 0);
3866 assert_eq!(prompt_handle.request_count(), 2);
3867 }
3868
3869 #[tokio::test]
3870 async fn amendment_retry_all_attempts_exhausted() {
3871 let dir = tempfile::tempdir().unwrap();
3872 let repo_view = make_test_repo_view(&dir);
3873
3874 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3875 Ok("bad yaml 1".to_string()),
3876 Ok("bad yaml 2".to_string()),
3877 Ok("bad yaml 3".to_string()),
3878 ]);
3879
3880 let result = client
3881 .generate_amendments_with_options(&repo_view, false)
3882 .await;
3883
3884 assert!(result.is_err(), "should fail after all retries exhausted");
3885 assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
3886 assert_eq!(
3887 prompt_handle.request_count(),
3888 3,
3889 "exactly 3 AI requests (1 + 2 retries)"
3890 );
3891 }
3892
3893 #[tokio::test]
3894 async fn amendment_retry_success_first_attempt() {
3895 let dir = tempfile::tempdir().unwrap();
3896 let repo_view = make_test_repo_view(&dir);
3897 let hash = format!("{:0>40}", 0);
3898
3899 let (client, response_handle, prompt_handle) =
3900 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3901 &hash,
3902 "feat(test): works first time",
3903 ))]);
3904
3905 let result = client
3906 .generate_amendments_with_options(&repo_view, false)
3907 .await;
3908
3909 assert!(result.is_ok());
3910 assert_eq!(response_handle.remaining(), 0);
3911 assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
3912 }
3913
3914 #[tokio::test]
3915 async fn amendment_retry_mixed_request_and_parse_failures() {
3916 let dir = tempfile::tempdir().unwrap();
3917 let repo_view = make_test_repo_view(&dir);
3918 let hash = format!("{:0>40}", 0);
3919
3920 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3921 Err(anyhow::anyhow!("network error")),
3922 Ok("invalid yaml {{".to_string()),
3923 Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
3924 ]);
3925
3926 let result = client
3927 .generate_amendments_with_options(&repo_view, false)
3928 .await;
3929
3930 assert!(
3931 result.is_ok(),
3932 "should succeed on third attempt: {:?}",
3933 result.err()
3934 );
3935 assert_eq!(result.unwrap().amendments.len(), 1);
3936 assert_eq!(response_handle.remaining(), 0);
3937 assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
3938 }
3939
3940 static FACTORY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3944
3945 struct FactoryEnvGuard {
3946 _lock: std::sync::MutexGuard<'static, ()>,
3947 saved: Vec<(&'static str, Option<String>)>,
3948 }
3949
3950 impl FactoryEnvGuard {
3951 fn new(keys: &[&'static str]) -> Self {
3952 let lock = FACTORY_ENV_LOCK
3953 .lock()
3954 .unwrap_or_else(std::sync::PoisonError::into_inner);
3955 let saved = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
3956 for k in keys {
3957 std::env::remove_var(k);
3958 }
3959 Self { _lock: lock, saved }
3960 }
3961
3962 fn set(&self, key: &str, value: &str) {
3963 std::env::set_var(key, value);
3964 }
3965 }
3966
3967 impl Drop for FactoryEnvGuard {
3968 fn drop(&mut self) {
3969 for (k, v) in self.saved.drain(..) {
3970 match v {
3971 Some(val) => std::env::set_var(k, val),
3972 None => std::env::remove_var(k),
3973 }
3974 }
3975 }
3976 }
3977
3978 #[test]
3979 fn factory_claude_cli_backend_dispatches_to_claude_cli_client() {
3980 let guard = FactoryEnvGuard::new(&[
3981 "OMNI_DEV_AI_BACKEND",
3982 "USE_OPENAI",
3983 "USE_OLLAMA",
3984 "CLAUDE_CODE_USE_BEDROCK",
3985 "CLAUDE_MODEL",
3986 "CLAUDE_CODE_MODEL",
3987 "ANTHROPIC_MODEL",
3988 ]);
3989 guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
3990
3991 let client = create_default_claude_client(None, None).expect("factory should succeed");
3992 let metadata = client.get_ai_client_metadata();
3993 assert_eq!(metadata.provider, "Claude CLI");
3994 assert_eq!(metadata.model, "claude-sonnet-4-6");
3996 }
3997
3998 #[test]
3999 fn factory_claude_cli_backend_honours_model_precedence() {
4000 let guard = FactoryEnvGuard::new(&[
4001 "OMNI_DEV_AI_BACKEND",
4002 "USE_OPENAI",
4003 "USE_OLLAMA",
4004 "CLAUDE_CODE_USE_BEDROCK",
4005 "CLAUDE_MODEL",
4006 "CLAUDE_CODE_MODEL",
4007 "ANTHROPIC_MODEL",
4008 ]);
4009 guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4010 guard.set("CLAUDE_CODE_MODEL", "opus");
4011 guard.set("CLAUDE_MODEL", "haiku");
4013
4014 let client = create_default_claude_client(None, None).expect("factory should succeed");
4015 let metadata = client.get_ai_client_metadata();
4016 assert_eq!(metadata.provider, "Claude CLI");
4017 assert_eq!(metadata.model, "haiku");
4018 }
4019
4020 #[test]
4021 fn factory_claude_cli_backend_explicit_model_wins_over_env() {
4022 let guard = FactoryEnvGuard::new(&[
4023 "OMNI_DEV_AI_BACKEND",
4024 "USE_OPENAI",
4025 "USE_OLLAMA",
4026 "CLAUDE_CODE_USE_BEDROCK",
4027 "CLAUDE_MODEL",
4028 "CLAUDE_CODE_MODEL",
4029 "ANTHROPIC_MODEL",
4030 ]);
4031 guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4032 guard.set("CLAUDE_MODEL", "haiku");
4033
4034 let client = create_default_claude_client(Some("opus".to_string()), None)
4035 .expect("factory should succeed");
4036 let metadata = client.get_ai_client_metadata();
4037 assert_eq!(metadata.model, "opus");
4038 }
4039
4040 #[test]
4041 fn factory_claude_cli_backend_accepts_underscore_alias() {
4042 let guard = FactoryEnvGuard::new(&[
4043 "OMNI_DEV_AI_BACKEND",
4044 "USE_OPENAI",
4045 "USE_OLLAMA",
4046 "CLAUDE_CODE_USE_BEDROCK",
4047 "CLAUDE_MODEL",
4048 "CLAUDE_CODE_MODEL",
4049 "ANTHROPIC_MODEL",
4050 ]);
4051 guard.set("OMNI_DEV_AI_BACKEND", "claude_cli");
4052
4053 let client = create_default_claude_client(None, None).expect("factory should succeed");
4054 let metadata = client.get_ai_client_metadata();
4055 assert_eq!(metadata.provider, "Claude CLI");
4056 }
4057}