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 async fn create_default_claude_client(
1619 model: Option<String>,
1620 beta_header: Option<(String, String)>,
1621) -> Result<ClaudeClient> {
1622 use crate::claude::ai::claude_cli::ClaudeCliAiClient;
1623 use crate::claude::ai::openai::OpenAiAiClient;
1624 use crate::utils::settings::{get_env_var, get_env_vars};
1625
1626 let ai_backend = get_env_var("OMNI_DEV_AI_BACKEND").ok();
1631 let use_claude_cli = ai_backend
1632 .as_deref()
1633 .is_some_and(|v| matches!(v, "claude-cli" | "claude_cli"));
1634
1635 if use_claude_cli {
1636 if beta_header.is_some() {
1637 warn!(
1638 "--beta-header is ignored when OMNI_DEV_AI_BACKEND=claude-cli \
1639 (the CLI's --betas flag has different semantics and is not forwarded)"
1640 );
1641 }
1642 let registry = crate::claude::model_config::get_model_registry();
1643 let cli_model = model
1644 .or_else(|| get_env_var("CLAUDE_MODEL").ok())
1645 .or_else(|| get_env_var("CLAUDE_CODE_MODEL").ok())
1646 .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1647 .unwrap_or_else(|| {
1648 registry
1649 .get_default_model("claude")
1650 .unwrap_or("claude-sonnet-4-6")
1651 .to_string()
1652 });
1653 debug!(model = %cli_model, "Creating claude -p subprocess client");
1654 let ai_client = ClaudeCliAiClient::new(cli_model);
1655 return Ok(ClaudeClient::new(Box::new(ai_client)));
1656 }
1657
1658 let use_openai = get_env_var("USE_OPENAI").is_ok_and(|val| val == "true");
1660
1661 let use_ollama = get_env_var("USE_OLLAMA").is_ok_and(|val| val == "true");
1662
1663 let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK").is_ok_and(|val| val == "true");
1665
1666 debug!(
1667 use_openai = use_openai,
1668 use_ollama = use_ollama,
1669 use_bedrock = use_bedrock,
1670 "Client selection flags"
1671 );
1672
1673 let registry = crate::claude::model_config::get_model_registry();
1674
1675 if use_ollama {
1677 let ollama_model = model
1678 .or_else(|| get_env_var("OLLAMA_MODEL").ok())
1679 .unwrap_or_else(|| "llama2".to_string());
1680 validate_beta_header(&ollama_model, &beta_header)?;
1681 let base_url = get_env_var("OLLAMA_BASE_URL").ok();
1682 let mut ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1683 match ai_client.probe_loaded_context_length().await {
1684 Some(source) => {
1685 info!(
1686 loaded_context_length = ai_client.loaded_context_length(),
1687 source = source.as_str(),
1688 model = %ai_client.get_metadata().model,
1689 "Probed loaded context length from local server"
1690 );
1691 }
1692 None => {
1693 debug!(
1694 "Loaded context length probe did not return a value; \
1695 falling back to registry/default for token budget"
1696 );
1697 }
1698 }
1699 return Ok(ClaudeClient::new(Box::new(ai_client)));
1700 }
1701
1702 if use_openai {
1704 debug!("Creating OpenAI client");
1705 let openai_model = model
1706 .or_else(|| get_env_var("OPENAI_MODEL").ok())
1707 .unwrap_or_else(|| {
1708 registry
1709 .get_default_model("openai")
1710 .unwrap_or("gpt-5")
1711 .to_string()
1712 });
1713 debug!(openai_model = %openai_model, "Selected OpenAI model");
1714 validate_beta_header(&openai_model, &beta_header)?;
1715
1716 let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
1717 debug!(error = ?e, "Failed to get OpenAI API key");
1718 ClaudeError::ApiKeyNotFound
1719 })?;
1720 debug!("OpenAI API key found");
1721
1722 let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1723 debug!("OpenAI client created successfully");
1724 return Ok(ClaudeClient::new(Box::new(ai_client)));
1725 }
1726
1727 let claude_model = model
1729 .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1730 .unwrap_or_else(|| {
1731 registry
1732 .get_default_model("claude")
1733 .unwrap_or("claude-sonnet-4-6")
1734 .to_string()
1735 });
1736 validate_beta_header(&claude_model, &beta_header)?;
1737
1738 if use_bedrock {
1739 let auth_token =
1741 get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1742
1743 let base_url =
1744 get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1745
1746 let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1747 return Ok(ClaudeClient::new(Box::new(ai_client)));
1748 }
1749
1750 debug!("Falling back to Claude client");
1752 let api_key = get_env_vars(&[
1753 "CLAUDE_API_KEY",
1754 "ANTHROPIC_API_KEY",
1755 "ANTHROPIC_AUTH_TOKEN",
1756 ])
1757 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
1758
1759 let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1760 debug!("Claude client created successfully");
1761 Ok(ClaudeClient::new(Box::new(ai_client)))
1762}
1763
1764#[cfg(test)]
1765#[allow(
1766 clippy::unwrap_used,
1767 clippy::expect_used,
1768 clippy::format_in_format_args
1769)]
1770mod tests {
1771 use super::*;
1772 use crate::claude::ai::{AiClient, AiClientCapabilities, AiClientMetadata};
1773 use std::future::Future;
1774 use std::pin::Pin;
1775 use std::sync::{Arc, Mutex};
1776
1777 struct MockAiClient;
1779
1780 impl AiClient for MockAiClient {
1781 fn send_request<'a>(
1782 &'a self,
1783 _system_prompt: &'a str,
1784 _user_prompt: &'a str,
1785 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1786 Box::pin(async { Ok(String::new()) })
1787 }
1788
1789 fn get_metadata(&self) -> AiClientMetadata {
1790 AiClientMetadata {
1791 provider: "Mock".to_string(),
1792 model: "mock-model".to_string(),
1793 max_context_length: 200_000,
1794 max_response_length: 8_192,
1795 active_beta: None,
1796 }
1797 }
1798 }
1799
1800 fn make_client() -> ClaudeClient {
1801 ClaudeClient::new(Box::new(MockAiClient))
1802 }
1803
1804 struct SchemaRecordingMockAiClient {
1815 capabilities: AiClientCapabilities,
1816 response: String,
1817 recorded_options: Arc<Mutex<Vec<RequestOptions>>>,
1818 recorded_plain: Arc<Mutex<Vec<(String, String)>>>,
1819 }
1820 impl SchemaRecordingMockAiClient {
1821 fn new(supports_response_schema: bool) -> Self {
1822 Self::with_response(supports_response_schema, String::new())
1823 }
1824
1825 fn with_response(supports_response_schema: bool, response: String) -> Self {
1826 Self {
1827 capabilities: AiClientCapabilities {
1828 supports_response_schema,
1829 },
1830 response,
1831 recorded_options: Arc::new(Mutex::new(Vec::new())),
1832 recorded_plain: Arc::new(Mutex::new(Vec::new())),
1833 }
1834 }
1835 }
1836
1837 impl AiClient for SchemaRecordingMockAiClient {
1838 fn send_request<'a>(
1839 &'a self,
1840 system_prompt: &'a str,
1841 user_prompt: &'a str,
1842 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1843 let plain = self.recorded_plain.clone();
1844 let sys = system_prompt.to_string();
1845 let usr = user_prompt.to_string();
1846 let response = self.response.clone();
1847 Box::pin(async move {
1848 plain.lock().unwrap().push((sys, usr));
1849 Ok(response)
1850 })
1851 }
1852
1853 fn capabilities(&self) -> AiClientCapabilities {
1854 self.capabilities
1855 }
1856
1857 fn send_request_with_options<'a>(
1858 &'a self,
1859 _system_prompt: &'a str,
1860 _user_prompt: &'a str,
1861 options: RequestOptions,
1862 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1863 let recorded = self.recorded_options.clone();
1864 let response = self.response.clone();
1865 Box::pin(async move {
1866 recorded.lock().unwrap().push(options);
1867 Ok(response)
1868 })
1869 }
1870
1871 fn get_metadata(&self) -> AiClientMetadata {
1872 AiClientMetadata {
1873 provider: "SchemaMock".to_string(),
1874 model: "schema-mock".to_string(),
1875 max_context_length: 200_000,
1876 max_response_length: 8_192,
1877 active_beta: None,
1878 }
1879 }
1880 }
1881
1882 #[tokio::test]
1888 async fn send_with_optional_schema_without_caps_uses_plain_send() {
1889 let inner = SchemaRecordingMockAiClient::new(false);
1890 let plain_log = inner.recorded_plain.clone();
1891 let opts_log = inner.recorded_options.clone();
1892 let client = ClaudeClient::new(Box::new(inner));
1893
1894 let schema = serde_json::json!({"type": "object"});
1895 client
1896 .send_with_optional_schema(
1897 "sys",
1898 "usr",
1899 client.schema_if_supported(&schema), )
1901 .await
1902 .unwrap();
1903
1904 assert_eq!(plain_log.lock().unwrap().len(), 1);
1905 assert!(opts_log.lock().unwrap().is_empty());
1906 }
1907
1908 #[tokio::test]
1912 async fn send_with_optional_schema_with_caps_uses_options_send() {
1913 let inner = SchemaRecordingMockAiClient::new(true);
1914 let plain_log = inner.recorded_plain.clone();
1915 let opts_log = inner.recorded_options.clone();
1916 let client = ClaudeClient::new(Box::new(inner));
1917
1918 let schema = serde_json::json!({"type": "object", "additionalProperties": false});
1919 client
1920 .send_with_optional_schema(
1921 "sys",
1922 "usr",
1923 client.schema_if_supported(&schema), )
1925 .await
1926 .unwrap();
1927
1928 let recorded = opts_log.lock().unwrap();
1929 assert_eq!(recorded.len(), 1);
1930 assert_eq!(recorded[0].response_schema.as_ref(), Some(&schema));
1931 assert!(plain_log.lock().unwrap().is_empty());
1932 }
1933
1934 #[test]
1937 fn adjusted_system_prompt_adds_suffix_when_supported() {
1938 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
1939 let result = client.adjusted_system_prompt("body".to_string());
1940 assert!(result.starts_with("body"));
1941 assert!(result.contains("STRUCTURED OUTPUT OVERRIDE"));
1942 }
1943
1944 #[test]
1945 fn adjusted_system_prompt_passes_through_when_not_supported() {
1946 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
1947 let result = client.adjusted_system_prompt("body".to_string());
1948 assert_eq!(result, "body");
1949 }
1950
1951 #[test]
1952 fn schema_if_supported_returns_some_when_supported() {
1953 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
1954 let schema = serde_json::json!({"type": "object"});
1955 let returned = client.schema_if_supported(&schema);
1956 assert!(returned.is_some());
1957 assert!(std::ptr::eq(
1958 std::ptr::from_ref(returned.unwrap()),
1959 std::ptr::addr_of!(schema)
1960 ));
1961 }
1962
1963 #[test]
1964 fn schema_if_supported_returns_none_when_not_supported() {
1965 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
1966 let schema = serde_json::json!({"type": "object"});
1967 assert!(client.schema_if_supported(&schema).is_none());
1968 }
1969
1970 #[tokio::test]
1977 async fn refine_amendments_coherence_round_trip() {
1978 let mock = SchemaRecordingMockAiClient::with_response(
1979 true, "amendments: []".to_string(),
1981 );
1982 let recorded_opts = mock.recorded_options.clone();
1983 let client = ClaudeClient::new(Box::new(mock));
1984
1985 let amendment = crate::data::amendments::Amendment {
1986 commit: "abc123".to_string(),
1987 message: "feat: do thing".to_string(),
1988 summary: "did the thing".to_string(),
1989 };
1990 let items = vec![(amendment, "summary text".to_string())];
1991
1992 let result = client
1993 .refine_amendments_coherence(&items)
1994 .await
1995 .expect("coherence refinement should succeed");
1996 assert!(result.amendments.is_empty());
1997
1998 let recorded = recorded_opts.lock().unwrap();
2001 assert_eq!(recorded.len(), 1);
2002 let attached = recorded[0]
2003 .response_schema
2004 .as_ref()
2005 .expect("schema must be attached when capability is true");
2006 assert_eq!(
2007 attached,
2008 response_schema::amendment_file_schema(),
2009 "refine_amendments_coherence should attach the AmendmentFile schema"
2010 );
2011 }
2012
2013 #[tokio::test]
2018 async fn refine_checks_coherence_round_trip() {
2019 let mock = SchemaRecordingMockAiClient::with_response(
2020 true, "checks: []".to_string(),
2022 );
2023 let recorded_opts = mock.recorded_options.clone();
2024 let client = ClaudeClient::new(Box::new(mock));
2025
2026 let check = crate::data::check::CommitCheckResult {
2027 hash: "abc123".to_string(),
2028 message: "feat: do thing".to_string(),
2029 issues: Vec::new(),
2030 suggestion: None,
2031 passes: true,
2032 summary: Some("summary".to_string()),
2033 };
2034 let items = vec![(check, "summary text".to_string())];
2035 let dir = tempfile::TempDir::new().unwrap();
2036 let repo_view = make_test_repo_view(&dir);
2037
2038 let result = client
2039 .refine_checks_coherence(&items, &repo_view)
2040 .await
2041 .expect("coherence refinement should succeed");
2042 assert_eq!(result.summary.total_commits, 0);
2043
2044 let recorded = recorded_opts.lock().unwrap();
2045 assert_eq!(recorded.len(), 1);
2046 let attached = recorded[0]
2047 .response_schema
2048 .as_ref()
2049 .expect("schema must be attached when capability is true");
2050 assert_eq!(
2051 attached,
2052 response_schema::check_response_schema(),
2053 "refine_checks_coherence should attach the AiCheckResponse schema"
2054 );
2055 }
2056
2057 #[tokio::test]
2061 async fn refine_amendments_coherence_without_schema_capability() {
2062 let mock = SchemaRecordingMockAiClient::with_response(
2063 false, "amendments: []".to_string(),
2065 );
2066 let recorded_plain = mock.recorded_plain.clone();
2067 let recorded_opts = mock.recorded_options.clone();
2068 let client = ClaudeClient::new(Box::new(mock));
2069
2070 let amendment = crate::data::amendments::Amendment {
2071 commit: "abc123".to_string(),
2072 message: "feat: do thing".to_string(),
2073 summary: String::new(),
2074 };
2075 let items = vec![(amendment, "summary".to_string())];
2076
2077 client
2078 .refine_amendments_coherence(&items)
2079 .await
2080 .expect("coherence refinement should succeed without schema support");
2081
2082 assert_eq!(recorded_plain.lock().unwrap().len(), 1);
2083 assert!(
2084 recorded_opts.lock().unwrap().is_empty(),
2085 "no-schema backend must not be reached via the options path"
2086 );
2087 }
2088
2089 #[test]
2092 fn extract_yaml_pure_amendments() {
2093 let client = make_client();
2094 let content = "amendments:\n - commit: abc123\n message: test";
2095 let result = client.extract_yaml_from_response(content);
2096 assert!(result.starts_with("amendments:"));
2097 }
2098
2099 #[test]
2100 fn extract_yaml_with_markdown_yaml_block() {
2101 let client = make_client();
2102 let content = "Here is the result:\n```yaml\namendments:\n - commit: abc\n```\n";
2103 let result = client.extract_yaml_from_response(content);
2104 assert!(result.starts_with("amendments:"));
2105 }
2106
2107 #[test]
2108 fn extract_yaml_with_generic_code_block() {
2109 let client = make_client();
2110 let content = "```\namendments:\n - commit: abc\n```";
2111 let result = client.extract_yaml_from_response(content);
2112 assert!(result.starts_with("amendments:"));
2113 }
2114
2115 #[test]
2116 fn extract_yaml_with_whitespace() {
2117 let client = make_client();
2118 let content = " \n amendments:\n - commit: abc\n ";
2119 let result = client.extract_yaml_from_response(content);
2120 assert!(result.starts_with("amendments:"));
2121 }
2122
2123 #[test]
2124 fn extract_yaml_fallback_returns_trimmed() {
2125 let client = make_client();
2126 let content = " some random text ";
2127 let result = client.extract_yaml_from_response(content);
2128 assert_eq!(result, "some random text");
2129 }
2130
2131 #[test]
2134 fn extract_check_yaml_pure() {
2135 let client = make_client();
2136 let content = "checks:\n - commit: abc123";
2137 let result = client.extract_yaml_from_check_response(content);
2138 assert!(result.starts_with("checks:"));
2139 }
2140
2141 #[test]
2142 fn extract_check_yaml_markdown_block() {
2143 let client = make_client();
2144 let content = "```yaml\nchecks:\n - commit: abc\n```";
2145 let result = client.extract_yaml_from_check_response(content);
2146 assert!(result.starts_with("checks:"));
2147 }
2148
2149 #[test]
2150 fn extract_check_yaml_generic_block() {
2151 let client = make_client();
2152 let content = "```\nchecks:\n - commit: abc\n```";
2153 let result = client.extract_yaml_from_check_response(content);
2154 assert!(result.starts_with("checks:"));
2155 }
2156
2157 #[test]
2158 fn extract_check_yaml_fallback() {
2159 let client = make_client();
2160 let content = " unexpected content ";
2161 let result = client.extract_yaml_from_check_response(content);
2162 assert_eq!(result, "unexpected content");
2163 }
2164
2165 #[test]
2168 fn parse_amendment_response_valid() {
2169 let client = make_client();
2170 let yaml = format!(
2171 "amendments:\n - commit: \"{}\"\n message: \"test message\"",
2172 "a".repeat(40)
2173 );
2174 let result = client.parse_amendment_response(&yaml);
2175 assert!(result.is_ok());
2176 assert_eq!(result.unwrap().amendments.len(), 1);
2177 }
2178
2179 #[test]
2180 fn parse_amendment_response_invalid_yaml() {
2181 let client = make_client();
2182 let result = client.parse_amendment_response("not: valid: yaml: [{{");
2183 assert!(result.is_err());
2184 }
2185
2186 #[test]
2187 fn parse_amendment_response_invalid_hash() {
2188 let client = make_client();
2189 let yaml = "amendments:\n - commit: \"short\"\n message: \"test\"";
2190 let result = client.parse_amendment_response(yaml);
2191 assert!(result.is_err());
2192 }
2193
2194 #[test]
2197 fn validate_beta_header_none_passes() {
2198 let result = validate_beta_header("claude-opus-4-1-20250805", &None);
2199 assert!(result.is_ok());
2200 }
2201
2202 #[test]
2203 fn validate_beta_header_unsupported_fails() {
2204 let header = Some(("fake-key".to_string(), "fake-value".to_string()));
2205 let result = validate_beta_header("claude-opus-4-1-20250805", &header);
2206 assert!(result.is_err());
2207 }
2208
2209 #[test]
2212 fn client_metadata() {
2213 let client = make_client();
2214 let metadata = client.get_ai_client_metadata();
2215 assert_eq!(metadata.provider, "Mock");
2216 assert_eq!(metadata.model, "mock-model");
2217 }
2218
2219 mod prop {
2222 use super::*;
2223 use proptest::prelude::*;
2224
2225 proptest! {
2226 #[test]
2227 fn yaml_response_output_trimmed(s in ".*") {
2228 let client = make_client();
2229 let result = client.extract_yaml_from_response(&s);
2230 prop_assert_eq!(&result, result.trim());
2231 }
2232
2233 #[test]
2234 fn yaml_response_amendments_prefix_preserved(tail in ".*") {
2235 let client = make_client();
2236 let input = format!("amendments:{tail}");
2237 let result = client.extract_yaml_from_response(&input);
2238 prop_assert!(result.starts_with("amendments:"));
2239 }
2240
2241 #[test]
2242 fn check_response_checks_prefix_preserved(tail in ".*") {
2243 let client = make_client();
2244 let input = format!("checks:{tail}");
2245 let result = client.extract_yaml_from_check_response(&input);
2246 prop_assert!(result.starts_with("checks:"));
2247 }
2248
2249 #[test]
2250 fn yaml_fenced_block_strips_fences(
2251 content in "[a-zA-Z0-9: _\\-\n]{1,100}",
2252 ) {
2253 let client = make_client();
2254 let input = format!("```yaml\n{content}\n```");
2255 let result = client.extract_yaml_from_response(&input);
2256 prop_assert!(!result.contains("```"));
2257 }
2258 }
2259 }
2260
2261 fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
2264 ClaudeClient::new(Box::new(
2265 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2266 ))
2267 }
2268
2269 fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2270 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2271 use crate::git::commit::FileChanges;
2272 use crate::git::{CommitAnalysis, CommitInfo};
2273
2274 let diff_path = dir.path().join("0.diff");
2275 std::fs::write(&diff_path, "+added line\n").unwrap();
2276
2277 crate::data::RepositoryView {
2278 versions: None,
2279 explanation: FieldExplanation::default(),
2280 working_directory: WorkingDirectoryInfo {
2281 clean: true,
2282 untracked_changes: Vec::new(),
2283 },
2284 remotes: Vec::new(),
2285 ai: AiInfo {
2286 scratch: String::new(),
2287 },
2288 branch_info: None,
2289 pr_template: None,
2290 pr_template_location: None,
2291 branch_prs: None,
2292 commits: vec![CommitInfo {
2293 hash: format!("{:0>40}", 0),
2294 author: "Test <test@test.com>".to_string(),
2295 date: chrono::Utc::now().fixed_offset(),
2296 original_message: "feat(test): add something".to_string(),
2297 in_main_branches: Vec::new(),
2298 analysis: CommitAnalysis {
2299 detected_type: "feat".to_string(),
2300 detected_scope: "test".to_string(),
2301 proposed_message: "feat(test): add something".to_string(),
2302 file_changes: FileChanges {
2303 total_files: 1,
2304 files_added: 1,
2305 files_deleted: 0,
2306 file_list: Vec::new(),
2307 },
2308 diff_summary: "file.rs | 1 +".to_string(),
2309 diff_file: diff_path.to_string_lossy().to_string(),
2310 file_diffs: Vec::new(),
2311 },
2312 }],
2313 }
2314 }
2315
2316 fn valid_check_yaml() -> String {
2317 format!(
2318 "checks:\n - commit: \"{hash}\"\n passes: true\n issues: []\n",
2319 hash = format!("{:0>40}", 0)
2320 )
2321 }
2322
2323 #[tokio::test]
2324 async fn send_message_propagates_ai_error() {
2325 let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
2326 let result = client.send_message("sys", "usr").await;
2327 assert!(result.is_err());
2328 assert!(result.unwrap_err().to_string().contains("mock error"));
2329 }
2330
2331 #[tokio::test]
2332 async fn check_commits_succeeds_after_request_error() {
2333 let dir = tempfile::tempdir().unwrap();
2334 let repo_view = make_test_repo_view(&dir);
2335 let client = make_configurable_client(vec![
2337 Err(anyhow::anyhow!("rate limit")),
2338 Ok(valid_check_yaml()),
2339 Ok(valid_check_yaml()),
2340 ]);
2341 let result = client
2342 .check_commits_with_scopes(&repo_view, None, &[], false)
2343 .await;
2344 assert!(result.is_ok());
2345 }
2346
2347 #[tokio::test]
2348 async fn check_commits_succeeds_after_parse_error() {
2349 let dir = tempfile::tempdir().unwrap();
2350 let repo_view = make_test_repo_view(&dir);
2351 let client = make_configurable_client(vec![
2353 Ok("not: valid: yaml: [[".to_string()),
2354 Ok(valid_check_yaml()),
2355 Ok(valid_check_yaml()),
2356 ]);
2357 let result = client
2358 .check_commits_with_scopes(&repo_view, None, &[], false)
2359 .await;
2360 assert!(result.is_ok());
2361 }
2362
2363 #[tokio::test]
2364 async fn check_commits_fails_after_all_retries_exhausted() {
2365 let dir = tempfile::tempdir().unwrap();
2366 let repo_view = make_test_repo_view(&dir);
2367 let client = make_configurable_client(vec![
2368 Err(anyhow::anyhow!("first failure")),
2369 Err(anyhow::anyhow!("second failure")),
2370 Err(anyhow::anyhow!("final failure")),
2371 ]);
2372 let result = client
2373 .check_commits_with_scopes(&repo_view, None, &[], false)
2374 .await;
2375 assert!(result.is_err());
2376 }
2377
2378 #[tokio::test]
2379 async fn check_commits_fails_when_all_parses_fail() {
2380 let dir = tempfile::tempdir().unwrap();
2381 let repo_view = make_test_repo_view(&dir);
2382 let client = make_configurable_client(vec![
2383 Ok("bad yaml [[".to_string()),
2384 Ok("bad yaml [[".to_string()),
2385 Ok("bad yaml [[".to_string()),
2386 ]);
2387 let result = client
2388 .check_commits_with_scopes(&repo_view, None, &[], false)
2389 .await;
2390 assert!(result.is_err());
2391 }
2392
2393 fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
2400 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2404 .with_context_length(50_000);
2405 ClaudeClient::new(Box::new(mock))
2406 }
2407
2408 fn make_small_context_client_tracked(
2411 responses: Vec<Result<String>>,
2412 ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
2413 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2414 .with_context_length(50_000);
2415 let handle = mock.response_handle();
2416 (ClaudeClient::new(Box::new(mock)), handle)
2417 }
2418
2419 fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2422 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2423 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2424 use crate::git::{CommitAnalysis, CommitInfo};
2425
2426 let hash = "a".repeat(40);
2427
2428 let full_diff = "x".repeat(120_000);
2432 let flat_diff_path = dir.path().join("full.diff");
2433 std::fs::write(&flat_diff_path, &full_diff).unwrap();
2434
2435 let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2438 let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2439
2440 let path_a = dir.path().join("0000.diff");
2441 let path_b = dir.path().join("0001.diff");
2442 std::fs::write(&path_a, &diff_a).unwrap();
2443 std::fs::write(&path_b, &diff_b).unwrap();
2444
2445 crate::data::RepositoryView {
2446 versions: None,
2447 explanation: FieldExplanation::default(),
2448 working_directory: WorkingDirectoryInfo {
2449 clean: true,
2450 untracked_changes: Vec::new(),
2451 },
2452 remotes: Vec::new(),
2453 ai: AiInfo {
2454 scratch: String::new(),
2455 },
2456 branch_info: None,
2457 pr_template: None,
2458 pr_template_location: None,
2459 branch_prs: None,
2460 commits: vec![CommitInfo {
2461 hash,
2462 author: "Test <test@test.com>".to_string(),
2463 date: chrono::Utc::now().fixed_offset(),
2464 original_message: "feat(test): large commit".to_string(),
2465 in_main_branches: Vec::new(),
2466 analysis: CommitAnalysis {
2467 detected_type: "feat".to_string(),
2468 detected_scope: "test".to_string(),
2469 proposed_message: "feat(test): large commit".to_string(),
2470 file_changes: FileChanges {
2471 total_files: 2,
2472 files_added: 2,
2473 files_deleted: 0,
2474 file_list: vec![
2475 FileChange {
2476 status: "A".to_string(),
2477 file: "src/a.rs".to_string(),
2478 },
2479 FileChange {
2480 status: "A".to_string(),
2481 file: "src/b.rs".to_string(),
2482 },
2483 ],
2484 },
2485 diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2486 diff_file: flat_diff_path.to_string_lossy().to_string(),
2487 file_diffs: vec![
2488 FileDiffRef {
2489 path: "src/a.rs".to_string(),
2490 diff_file: path_a.to_string_lossy().to_string(),
2491 byte_len: diff_a.len(),
2492 },
2493 FileDiffRef {
2494 path: "src/b.rs".to_string(),
2495 diff_file: path_b.to_string_lossy().to_string(),
2496 byte_len: diff_b.len(),
2497 },
2498 ],
2499 },
2500 }],
2501 }
2502 }
2503
2504 fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2505 format!("amendments:\n - commit: \"{hash}\"\n message: \"{message}\"")
2506 }
2507
2508 #[tokio::test]
2509 async fn generate_amendments_split_dispatch() {
2510 let dir = tempfile::tempdir().unwrap();
2511 let repo_view = make_large_diff_repo_view(&dir);
2512 let hash = "a".repeat(40);
2513
2514 let client = make_small_context_client(vec![
2516 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2517 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2518 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2519 ]);
2520
2521 let result = client
2522 .generate_amendments_with_options(&repo_view, false)
2523 .await;
2524
2525 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2526 let amendments = result.unwrap();
2527 assert_eq!(amendments.amendments.len(), 1);
2528 assert_eq!(amendments.amendments[0].commit, hash);
2529 assert!(amendments.amendments[0]
2530 .message
2531 .contains("add a.rs and b.rs"));
2532 }
2533
2534 #[tokio::test]
2535 async fn generate_amendments_split_chunk_failure() {
2536 let dir = tempfile::tempdir().unwrap();
2537 let repo_view = make_large_diff_repo_view(&dir);
2538 let hash = "a".repeat(40);
2539
2540 let client = make_small_context_client(vec![
2542 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2543 Err(anyhow::anyhow!("rate limit exceeded")),
2544 ]);
2545
2546 let result = client
2547 .generate_amendments_with_options(&repo_view, false)
2548 .await;
2549
2550 assert!(result.is_err());
2551 }
2552
2553 #[tokio::test]
2554 async fn generate_amendments_no_split_when_fits() {
2555 let dir = tempfile::tempdir().unwrap();
2556 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2558
2559 let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2561 &hash,
2562 "feat(test): improved message",
2563 ))]);
2564
2565 let result = client
2566 .generate_amendments_with_options(&repo_view, false)
2567 .await;
2568
2569 assert!(result.is_ok());
2570 assert_eq!(result.unwrap().amendments.len(), 1);
2571 }
2572
2573 fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2576 format!(
2577 "checks:\n - commit: \"{hash}\"\n passes: {passes}\n issues: []\n summary: \"test summary\"\n"
2578 )
2579 }
2580
2581 fn valid_check_yaml_with_issues(hash: &str) -> String {
2582 format!(
2583 concat!(
2584 "checks:\n",
2585 " - commit: \"{hash}\"\n",
2586 " passes: false\n",
2587 " issues:\n",
2588 " - severity: error\n",
2589 " section: \"Subject Line\"\n",
2590 " rule: \"imperative-mood\"\n",
2591 " explanation: \"Subject uses past tense\"\n",
2592 " suggestion:\n",
2593 " message: \"feat(test): shorter subject\"\n",
2594 " explanation: \"Shortened subject line\"\n",
2595 " summary: \"Large commit with issues\"\n",
2596 ),
2597 hash = hash,
2598 )
2599 }
2600
2601 fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2602 format!(
2603 concat!(
2604 "checks:\n",
2605 " - commit: \"{hash}\"\n",
2606 " passes: true\n",
2607 " issues: []\n",
2608 " summary: \"chunk summary\"\n",
2609 ),
2610 hash = hash,
2611 )
2612 }
2613
2614 #[tokio::test]
2615 async fn check_commits_split_dispatch() {
2616 let dir = tempfile::tempdir().unwrap();
2617 let repo_view = make_large_diff_repo_view(&dir);
2618 let hash = "a".repeat(40);
2619
2620 let client = make_small_context_client(vec![
2622 Ok(valid_check_yaml_with_issues(&hash)),
2623 Ok(valid_check_yaml_with_issues(&hash)),
2624 Ok(valid_check_yaml_with_issues(&hash)), ]);
2626
2627 let result = client
2628 .check_commits_with_scopes(&repo_view, None, &[], true)
2629 .await;
2630
2631 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2632 let report = result.unwrap();
2633 assert_eq!(report.commits.len(), 1);
2634 assert!(!report.commits[0].passes);
2635 assert_eq!(report.commits[0].issues.len(), 1);
2637 assert_eq!(report.commits[0].issues[0].rule, "imperative-mood");
2638 }
2639
2640 #[tokio::test]
2641 async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2642 let dir = tempfile::tempdir().unwrap();
2643 let repo_view = make_large_diff_repo_view(&dir);
2644 let hash = "a".repeat(40);
2645
2646 let client = make_small_context_client(vec![
2649 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2650 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2651 ]);
2652
2653 let result = client
2654 .check_commits_with_scopes(&repo_view, None, &[], false)
2655 .await;
2656
2657 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2658 let report = result.unwrap();
2659 assert_eq!(report.commits.len(), 1);
2660 assert!(report.commits[0].passes);
2661 assert!(report.commits[0].issues.is_empty());
2662 assert!(report.commits[0].suggestion.is_none());
2663 assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2665 }
2666
2667 #[tokio::test]
2668 async fn check_commits_split_chunk_failure() {
2669 let dir = tempfile::tempdir().unwrap();
2670 let repo_view = make_large_diff_repo_view(&dir);
2671 let hash = "a".repeat(40);
2672
2673 let client = make_small_context_client(vec![
2675 Ok(valid_check_yaml_for(&hash, true)),
2676 Err(anyhow::anyhow!("rate limit exceeded")),
2677 ]);
2678
2679 let result = client
2680 .check_commits_with_scopes(&repo_view, None, &[], false)
2681 .await;
2682
2683 assert!(result.is_err());
2684 }
2685
2686 #[tokio::test]
2687 async fn check_commits_no_split_when_fits() {
2688 let dir = tempfile::tempdir().unwrap();
2689 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2691
2692 let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2694
2695 let result = client
2696 .check_commits_with_scopes(&repo_view, None, &[], false)
2697 .await;
2698
2699 assert!(result.is_ok());
2700 assert_eq!(result.unwrap().commits.len(), 1);
2701 }
2702
2703 #[tokio::test]
2704 async fn check_commits_split_dedup_across_chunks() {
2705 let dir = tempfile::tempdir().unwrap();
2706 let repo_view = make_large_diff_repo_view(&dir);
2707 let hash = "a".repeat(40);
2708
2709 let chunk1 = format!(
2711 concat!(
2712 "checks:\n",
2713 " - commit: \"{hash}\"\n",
2714 " passes: false\n",
2715 " issues:\n",
2716 " - severity: error\n",
2717 " section: \"Subject Line\"\n",
2718 " rule: \"imperative-mood\"\n",
2719 " explanation: \"Subject uses past tense\"\n",
2720 " - severity: warning\n",
2721 " section: \"Content\"\n",
2722 " rule: \"body-required\"\n",
2723 " explanation: \"Large change needs body\"\n",
2724 ),
2725 hash = hash,
2726 );
2727
2728 let chunk2 = format!(
2730 concat!(
2731 "checks:\n",
2732 " - commit: \"{hash}\"\n",
2733 " passes: false\n",
2734 " issues:\n",
2735 " - severity: error\n",
2736 " section: \"Subject Line\"\n",
2737 " rule: \"imperative-mood\"\n",
2738 " explanation: \"Subject line is too long\"\n",
2739 " - severity: info\n",
2740 " section: \"Style\"\n",
2741 " rule: \"scope-suggestion\"\n",
2742 " explanation: \"Consider more specific scope\"\n",
2743 ),
2744 hash = hash,
2745 );
2746
2747 let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
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_eq!(report.commits.len(), 1);
2757 assert!(!report.commits[0].passes);
2758 assert_eq!(report.commits[0].issues.len(), 3);
2761 }
2762
2763 #[tokio::test]
2764 async fn check_commits_split_passes_only_when_all_chunks_pass() {
2765 let dir = tempfile::tempdir().unwrap();
2766 let repo_view = make_large_diff_repo_view(&dir);
2767 let hash = "a".repeat(40);
2768
2769 let client = make_small_context_client(vec![
2771 Ok(valid_check_yaml_for(&hash, true)),
2772 Ok(valid_check_yaml_for(&hash, false)),
2773 ]);
2774
2775 let result = client
2776 .check_commits_with_scopes(&repo_view, None, &[], false)
2777 .await;
2778
2779 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2780 let report = result.unwrap();
2781 assert!(
2782 !report.commits[0].passes,
2783 "should fail when any chunk fails"
2784 );
2785 }
2786
2787 fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2791 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2792 use crate::git::commit::FileChanges;
2793 use crate::git::{CommitAnalysis, CommitInfo};
2794
2795 let diff_a = dir.path().join("0.diff");
2796 let diff_b = dir.path().join("1.diff");
2797 std::fs::write(&diff_a, "+line a\n").unwrap();
2798 std::fs::write(&diff_b, "+line b\n").unwrap();
2799
2800 let hash_a = "a".repeat(40);
2801 let hash_b = "b".repeat(40);
2802
2803 crate::data::RepositoryView {
2804 versions: None,
2805 explanation: FieldExplanation::default(),
2806 working_directory: WorkingDirectoryInfo {
2807 clean: true,
2808 untracked_changes: Vec::new(),
2809 },
2810 remotes: Vec::new(),
2811 ai: AiInfo {
2812 scratch: String::new(),
2813 },
2814 branch_info: None,
2815 pr_template: None,
2816 pr_template_location: None,
2817 branch_prs: None,
2818 commits: vec![
2819 CommitInfo {
2820 hash: hash_a,
2821 author: "Test <test@test.com>".to_string(),
2822 date: chrono::Utc::now().fixed_offset(),
2823 original_message: "feat(a): add a".to_string(),
2824 in_main_branches: Vec::new(),
2825 analysis: CommitAnalysis {
2826 detected_type: "feat".to_string(),
2827 detected_scope: "a".to_string(),
2828 proposed_message: "feat(a): add a".to_string(),
2829 file_changes: FileChanges {
2830 total_files: 1,
2831 files_added: 1,
2832 files_deleted: 0,
2833 file_list: Vec::new(),
2834 },
2835 diff_summary: "a.rs | 1 +".to_string(),
2836 diff_file: diff_a.to_string_lossy().to_string(),
2837 file_diffs: Vec::new(),
2838 },
2839 },
2840 CommitInfo {
2841 hash: hash_b,
2842 author: "Test <test@test.com>".to_string(),
2843 date: chrono::Utc::now().fixed_offset(),
2844 original_message: "feat(b): add b".to_string(),
2845 in_main_branches: Vec::new(),
2846 analysis: CommitAnalysis {
2847 detected_type: "feat".to_string(),
2848 detected_scope: "b".to_string(),
2849 proposed_message: "feat(b): add b".to_string(),
2850 file_changes: FileChanges {
2851 total_files: 1,
2852 files_added: 1,
2853 files_deleted: 0,
2854 file_list: Vec::new(),
2855 },
2856 diff_summary: "b.rs | 1 +".to_string(),
2857 diff_file: diff_b.to_string_lossy().to_string(),
2858 file_diffs: Vec::new(),
2859 },
2860 },
2861 ],
2862 }
2863 }
2864
2865 #[tokio::test]
2866 async fn generate_amendments_multi_commit() {
2867 let dir = tempfile::tempdir().unwrap();
2868 let repo_view = make_multi_commit_repo_view(&dir);
2869 let hash_a = "a".repeat(40);
2870 let hash_b = "b".repeat(40);
2871
2872 let response = format!(
2873 concat!(
2874 "amendments:\n",
2875 " - commit: \"{hash_a}\"\n",
2876 " message: \"feat(a): improved a\"\n",
2877 " - commit: \"{hash_b}\"\n",
2878 " message: \"feat(b): improved b\"\n",
2879 ),
2880 hash_a = hash_a,
2881 hash_b = hash_b,
2882 );
2883 let client = make_configurable_client(vec![Ok(response)]);
2884
2885 let result = client
2886 .generate_amendments_with_options(&repo_view, false)
2887 .await;
2888
2889 assert!(
2890 result.is_ok(),
2891 "multi-commit amendment failed: {:?}",
2892 result.err()
2893 );
2894 let amendments = result.unwrap();
2895 assert_eq!(amendments.amendments.len(), 2);
2896 }
2897
2898 #[tokio::test]
2899 async fn generate_contextual_amendments_multi_commit() {
2900 let dir = tempfile::tempdir().unwrap();
2901 let repo_view = make_multi_commit_repo_view(&dir);
2902 let hash_a = "a".repeat(40);
2903 let hash_b = "b".repeat(40);
2904
2905 let response = format!(
2906 concat!(
2907 "amendments:\n",
2908 " - commit: \"{hash_a}\"\n",
2909 " message: \"feat(a): improved a\"\n",
2910 " - commit: \"{hash_b}\"\n",
2911 " message: \"feat(b): improved b\"\n",
2912 ),
2913 hash_a = hash_a,
2914 hash_b = hash_b,
2915 );
2916 let client = make_configurable_client(vec![Ok(response)]);
2917 let context = crate::data::context::CommitContext::default();
2918
2919 let result = client
2920 .generate_contextual_amendments_with_options(&repo_view, &context, false)
2921 .await;
2922
2923 assert!(
2924 result.is_ok(),
2925 "multi-commit contextual amendment failed: {:?}",
2926 result.err()
2927 );
2928 let amendments = result.unwrap();
2929 assert_eq!(amendments.amendments.len(), 2);
2930 }
2931
2932 #[tokio::test]
2933 async fn generate_pr_content_succeeds() {
2934 let dir = tempfile::tempdir().unwrap();
2935 let repo_view = make_test_repo_view(&dir);
2936
2937 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2938 let client = make_configurable_client(vec![Ok(response.to_string())]);
2939
2940 let result = client.generate_pr_content(&repo_view, "").await;
2941
2942 assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
2943 let pr = result.unwrap();
2944 assert_eq!(pr.title, "feat: add something");
2945 assert_eq!(pr.description, "Adds a new feature.");
2946 }
2947
2948 #[tokio::test]
2949 async fn generate_pr_content_with_context_succeeds() {
2950 let dir = tempfile::tempdir().unwrap();
2951 let repo_view = make_test_repo_view(&dir);
2952 let context = crate::data::context::CommitContext::default();
2953
2954 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2955 let client = make_configurable_client(vec![Ok(response.to_string())]);
2956
2957 let result = client
2958 .generate_pr_content_with_context(&repo_view, "", &context)
2959 .await;
2960
2961 assert!(
2962 result.is_ok(),
2963 "PR generation with context failed: {:?}",
2964 result.err()
2965 );
2966 let pr = result.unwrap();
2967 assert_eq!(pr.title, "feat: add something");
2968 }
2969
2970 #[tokio::test]
2971 async fn check_commits_multi_commit() {
2972 let dir = tempfile::tempdir().unwrap();
2973 let repo_view = make_multi_commit_repo_view(&dir);
2974 let hash_a = "a".repeat(40);
2975 let hash_b = "b".repeat(40);
2976
2977 let response = format!(
2978 concat!(
2979 "checks:\n",
2980 " - commit: \"{hash_a}\"\n",
2981 " passes: true\n",
2982 " issues: []\n",
2983 " - commit: \"{hash_b}\"\n",
2984 " passes: true\n",
2985 " issues: []\n",
2986 ),
2987 hash_a = hash_a,
2988 hash_b = hash_b,
2989 );
2990 let client = make_configurable_client(vec![Ok(response)]);
2991
2992 let result = client
2993 .check_commits_with_scopes(&repo_view, None, &[], false)
2994 .await;
2995
2996 assert!(
2997 result.is_ok(),
2998 "multi-commit check failed: {:?}",
2999 result.err()
3000 );
3001 let report = result.unwrap();
3002 assert_eq!(report.commits.len(), 2);
3003 assert!(report.commits[0].passes);
3004 assert!(report.commits[1].passes);
3005 }
3006
3007 fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
3012 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3013 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3014 use crate::git::{CommitAnalysis, CommitInfo};
3015
3016 let hash_a = "a".repeat(40);
3017 let hash_b = "b".repeat(40);
3018
3019 let diff_content_a = "x".repeat(60_000);
3022 let diff_content_b = "y".repeat(60_000);
3023 let flat_a = dir.path().join("flat_a.diff");
3024 let flat_b = dir.path().join("flat_b.diff");
3025 std::fs::write(&flat_a, &diff_content_a).unwrap();
3026 std::fs::write(&flat_b, &diff_content_b).unwrap();
3027
3028 let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
3030 let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
3031 let per_file_a = dir.path().join("pf_a.diff");
3032 let per_file_b = dir.path().join("pf_b.diff");
3033 std::fs::write(&per_file_a, &file_diff_a).unwrap();
3034 std::fs::write(&per_file_b, &file_diff_b).unwrap();
3035
3036 crate::data::RepositoryView {
3037 versions: None,
3038 explanation: FieldExplanation::default(),
3039 working_directory: WorkingDirectoryInfo {
3040 clean: true,
3041 untracked_changes: Vec::new(),
3042 },
3043 remotes: Vec::new(),
3044 ai: AiInfo {
3045 scratch: String::new(),
3046 },
3047 branch_info: None,
3048 pr_template: None,
3049 pr_template_location: None,
3050 branch_prs: None,
3051 commits: vec![
3052 CommitInfo {
3053 hash: hash_a,
3054 author: "Test <test@test.com>".to_string(),
3055 date: chrono::Utc::now().fixed_offset(),
3056 original_message: "feat(a): add module a".to_string(),
3057 in_main_branches: Vec::new(),
3058 analysis: CommitAnalysis {
3059 detected_type: "feat".to_string(),
3060 detected_scope: "a".to_string(),
3061 proposed_message: "feat(a): add module a".to_string(),
3062 file_changes: FileChanges {
3063 total_files: 1,
3064 files_added: 1,
3065 files_deleted: 0,
3066 file_list: vec![FileChange {
3067 status: "A".to_string(),
3068 file: "src/a.rs".to_string(),
3069 }],
3070 },
3071 diff_summary: " src/a.rs | 100 ++++\n".to_string(),
3072 diff_file: flat_a.to_string_lossy().to_string(),
3073 file_diffs: vec![FileDiffRef {
3074 path: "src/a.rs".to_string(),
3075 diff_file: per_file_a.to_string_lossy().to_string(),
3076 byte_len: file_diff_a.len(),
3077 }],
3078 },
3079 },
3080 CommitInfo {
3081 hash: hash_b,
3082 author: "Test <test@test.com>".to_string(),
3083 date: chrono::Utc::now().fixed_offset(),
3084 original_message: "feat(b): add module b".to_string(),
3085 in_main_branches: Vec::new(),
3086 analysis: CommitAnalysis {
3087 detected_type: "feat".to_string(),
3088 detected_scope: "b".to_string(),
3089 proposed_message: "feat(b): add module b".to_string(),
3090 file_changes: FileChanges {
3091 total_files: 1,
3092 files_added: 1,
3093 files_deleted: 0,
3094 file_list: vec![FileChange {
3095 status: "A".to_string(),
3096 file: "src/b.rs".to_string(),
3097 }],
3098 },
3099 diff_summary: " src/b.rs | 100 ++++\n".to_string(),
3100 diff_file: flat_b.to_string_lossy().to_string(),
3101 file_diffs: vec![FileDiffRef {
3102 path: "src/b.rs".to_string(),
3103 diff_file: per_file_b.to_string_lossy().to_string(),
3104 byte_len: file_diff_b.len(),
3105 }],
3106 },
3107 },
3108 ],
3109 }
3110 }
3111
3112 fn valid_pr_yaml(title: &str, description: &str) -> String {
3113 format!("title: \"{title}\"\ndescription: \"{description}\"\n")
3114 }
3115
3116 #[tokio::test]
3119 async fn generate_amendments_multi_commit_split_dispatch() {
3120 let dir = tempfile::tempdir().unwrap();
3121 let repo_view = make_large_multi_commit_repo_view(&dir);
3122 let hash_a = "a".repeat(40);
3123 let hash_b = "b".repeat(40);
3124
3125 let (client, handle) = make_small_context_client_tracked(vec![
3128 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3129 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3130 ]);
3131
3132 let result = client
3133 .generate_amendments_with_options(&repo_view, false)
3134 .await;
3135
3136 assert!(
3137 result.is_ok(),
3138 "multi-commit split dispatch failed: {:?}",
3139 result.err()
3140 );
3141 let amendments = result.unwrap();
3142 assert_eq!(amendments.amendments.len(), 2);
3143 assert_eq!(amendments.amendments[0].commit, hash_a);
3144 assert_eq!(amendments.amendments[1].commit, hash_b);
3145 assert!(amendments.amendments[0].message.contains("improved a"));
3146 assert!(amendments.amendments[1].message.contains("improved b"));
3147 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3148 }
3149
3150 #[tokio::test]
3151 async fn generate_contextual_amendments_multi_commit_split_dispatch() {
3152 let dir = tempfile::tempdir().unwrap();
3153 let repo_view = make_large_multi_commit_repo_view(&dir);
3154 let hash_a = "a".repeat(40);
3155 let hash_b = "b".repeat(40);
3156 let context = crate::data::context::CommitContext::default();
3157
3158 let (client, handle) = make_small_context_client_tracked(vec![
3159 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3160 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3161 ]);
3162
3163 let result = client
3164 .generate_contextual_amendments_with_options(&repo_view, &context, false)
3165 .await;
3166
3167 assert!(
3168 result.is_ok(),
3169 "multi-commit contextual split dispatch failed: {:?}",
3170 result.err()
3171 );
3172 let amendments = result.unwrap();
3173 assert_eq!(amendments.amendments.len(), 2);
3174 assert_eq!(amendments.amendments[0].commit, hash_a);
3175 assert_eq!(amendments.amendments[1].commit, hash_b);
3176 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3177 }
3178
3179 #[tokio::test]
3182 async fn check_commits_multi_commit_split_dispatch() {
3183 let dir = tempfile::tempdir().unwrap();
3184 let repo_view = make_large_multi_commit_repo_view(&dir);
3185 let hash_a = "a".repeat(40);
3186 let hash_b = "b".repeat(40);
3187
3188 let (client, handle) = make_small_context_client_tracked(vec![
3190 Ok(valid_check_yaml_for(&hash_a, true)),
3191 Ok(valid_check_yaml_for(&hash_b, true)),
3192 ]);
3193
3194 let result = client
3195 .check_commits_with_scopes(&repo_view, None, &[], false)
3196 .await;
3197
3198 assert!(
3199 result.is_ok(),
3200 "multi-commit check split dispatch failed: {:?}",
3201 result.err()
3202 );
3203 let report = result.unwrap();
3204 assert_eq!(report.commits.len(), 2);
3205 assert!(report.commits[0].passes);
3206 assert!(report.commits[1].passes);
3207 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3208 }
3209
3210 #[tokio::test]
3213 async fn generate_pr_content_split_dispatch() {
3214 let dir = tempfile::tempdir().unwrap();
3215 let repo_view = make_large_diff_repo_view(&dir);
3216
3217 let (client, handle) = make_small_context_client_tracked(vec![
3221 Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
3222 Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
3223 Ok(valid_pr_yaml(
3224 "feat(test): add modules",
3225 "Adds a.rs and b.rs",
3226 )),
3227 ]);
3228
3229 let result = client.generate_pr_content(&repo_view, "").await;
3230
3231 assert!(
3232 result.is_ok(),
3233 "PR split dispatch failed: {:?}",
3234 result.err()
3235 );
3236 let pr = result.unwrap();
3237 assert!(pr.title.contains("add modules"));
3238 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3239 }
3240
3241 #[tokio::test]
3242 async fn generate_pr_content_multi_commit_split_dispatch() {
3243 let dir = tempfile::tempdir().unwrap();
3244 let repo_view = make_large_multi_commit_repo_view(&dir);
3245
3246 let (client, handle) = make_small_context_client_tracked(vec![
3249 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3250 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3251 Ok(valid_pr_yaml(
3252 "feat: add modules a and b",
3253 "Adds both modules",
3254 )),
3255 ]);
3256
3257 let result = client.generate_pr_content(&repo_view, "").await;
3258
3259 assert!(
3260 result.is_ok(),
3261 "PR multi-commit split dispatch failed: {:?}",
3262 result.err()
3263 );
3264 let pr = result.unwrap();
3265 assert!(pr.title.contains("modules"));
3266 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3267 }
3268
3269 #[tokio::test]
3270 async fn generate_pr_content_with_context_split_dispatch() {
3271 let dir = tempfile::tempdir().unwrap();
3272 let repo_view = make_large_multi_commit_repo_view(&dir);
3273 let context = crate::data::context::CommitContext::default();
3274
3275 let (client, handle) = make_small_context_client_tracked(vec![
3277 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3278 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3279 Ok(valid_pr_yaml(
3280 "feat: add modules a and b",
3281 "Adds both modules",
3282 )),
3283 ]);
3284
3285 let result = client
3286 .generate_pr_content_with_context(&repo_view, "", &context)
3287 .await;
3288
3289 assert!(
3290 result.is_ok(),
3291 "PR with context split dispatch failed: {:?}",
3292 result.err()
3293 );
3294 let pr = result.unwrap();
3295 assert!(pr.title.contains("modules"));
3296 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3297 }
3298
3299 fn make_small_context_client_with_prompts(
3304 responses: Vec<Result<String>>,
3305 ) -> (
3306 ClaudeClient,
3307 crate::claude::test_utils::ResponseQueueHandle,
3308 crate::claude::test_utils::PromptRecordHandle,
3309 ) {
3310 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
3311 .with_context_length(50_000);
3312 let response_handle = mock.response_handle();
3313 let prompt_handle = mock.prompt_handle();
3314 (
3315 ClaudeClient::new(Box::new(mock)),
3316 response_handle,
3317 prompt_handle,
3318 )
3319 }
3320
3321 fn make_configurable_client_with_prompts(
3323 responses: Vec<Result<String>>,
3324 ) -> (
3325 ClaudeClient,
3326 crate::claude::test_utils::ResponseQueueHandle,
3327 crate::claude::test_utils::PromptRecordHandle,
3328 ) {
3329 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
3330 let response_handle = mock.response_handle();
3331 let prompt_handle = mock.prompt_handle();
3332 (
3333 ClaudeClient::new(Box::new(mock)),
3334 response_handle,
3335 prompt_handle,
3336 )
3337 }
3338
3339 fn make_single_oversized_file_repo_view(
3346 dir: &tempfile::TempDir,
3347 ) -> crate::data::RepositoryView {
3348 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3349 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3350 use crate::git::{CommitAnalysis, CommitInfo};
3351
3352 let hash = "c".repeat(40);
3353
3354 let diff_content = format!(
3357 "diff --git a/src/big.rs b/src/big.rs\n{}\n",
3358 "x".repeat(80_000)
3359 );
3360
3361 let flat_diff_path = dir.path().join("full.diff");
3362 std::fs::write(&flat_diff_path, &diff_content).unwrap();
3363
3364 let per_file_path = dir.path().join("0000.diff");
3365 std::fs::write(&per_file_path, &diff_content).unwrap();
3366
3367 crate::data::RepositoryView {
3368 versions: None,
3369 explanation: FieldExplanation::default(),
3370 working_directory: WorkingDirectoryInfo {
3371 clean: true,
3372 untracked_changes: Vec::new(),
3373 },
3374 remotes: Vec::new(),
3375 ai: AiInfo {
3376 scratch: String::new(),
3377 },
3378 branch_info: None,
3379 pr_template: None,
3380 pr_template_location: None,
3381 branch_prs: None,
3382 commits: vec![CommitInfo {
3383 hash,
3384 author: "Test <test@test.com>".to_string(),
3385 date: chrono::Utc::now().fixed_offset(),
3386 original_message: "feat(big): add large module".to_string(),
3387 in_main_branches: Vec::new(),
3388 analysis: CommitAnalysis {
3389 detected_type: "feat".to_string(),
3390 detected_scope: "big".to_string(),
3391 proposed_message: "feat(big): add large module".to_string(),
3392 file_changes: FileChanges {
3393 total_files: 1,
3394 files_added: 1,
3395 files_deleted: 0,
3396 file_list: vec![FileChange {
3397 status: "A".to_string(),
3398 file: "src/big.rs".to_string(),
3399 }],
3400 },
3401 diff_summary: " src/big.rs | 80 ++++\n".to_string(),
3402 diff_file: flat_diff_path.to_string_lossy().to_string(),
3403 file_diffs: vec![FileDiffRef {
3404 path: "src/big.rs".to_string(),
3405 diff_file: per_file_path.to_string_lossy().to_string(),
3406 byte_len: diff_content.len(),
3407 }],
3408 },
3409 }],
3410 }
3411 }
3412
3413 #[tokio::test]
3420 async fn amendment_single_file_under_budget_no_split() {
3421 let dir = tempfile::tempdir().unwrap();
3422 let repo_view = make_test_repo_view(&dir);
3423 let hash = format!("{:0>40}", 0);
3424
3425 let (client, response_handle, prompt_handle) =
3426 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3427 &hash,
3428 "feat(test): improved message",
3429 ))]);
3430
3431 let result = client
3432 .generate_amendments_with_options(&repo_view, false)
3433 .await;
3434
3435 assert!(result.is_ok());
3436 assert_eq!(result.unwrap().amendments.len(), 1);
3437 assert_eq!(response_handle.remaining(), 0);
3438
3439 let prompts = prompt_handle.prompts();
3440 assert_eq!(
3441 prompts.len(),
3442 1,
3443 "expected exactly one AI request, no split"
3444 );
3445
3446 let (_, user_prompt) = &prompts[0];
3447 assert!(
3448 user_prompt.contains("added line"),
3449 "user prompt should contain the diff content"
3450 );
3451 }
3452
3453 #[tokio::test]
3464 async fn amendment_two_chunks_prompt_content() {
3465 let dir = tempfile::tempdir().unwrap();
3466 let repo_view = make_large_diff_repo_view(&dir);
3467 let hash = "a".repeat(40);
3468
3469 let (client, response_handle, prompt_handle) =
3470 make_small_context_client_with_prompts(vec![
3471 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3472 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3473 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3474 ]);
3475
3476 let result = client
3477 .generate_amendments_with_options(&repo_view, false)
3478 .await;
3479
3480 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3481 let amendments = result.unwrap();
3482 assert_eq!(amendments.amendments.len(), 1);
3483 assert!(amendments.amendments[0]
3484 .message
3485 .contains("add a.rs and b.rs"));
3486 assert_eq!(response_handle.remaining(), 0);
3487
3488 let prompts = prompt_handle.prompts();
3489 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3490
3491 let (_, chunk1_user) = &prompts[0];
3493 assert!(
3494 chunk1_user.contains("aaa"),
3495 "chunk 1 prompt should contain file-a diff content"
3496 );
3497
3498 let (_, chunk2_user) = &prompts[1];
3500 assert!(
3501 chunk2_user.contains("bbb"),
3502 "chunk 2 prompt should contain file-b diff content"
3503 );
3504
3505 let (merge_sys, merge_user) = &prompts[2];
3507 assert!(
3508 merge_sys.contains("synthesiz"),
3509 "merge system prompt should contain synthesis instructions"
3510 );
3511 assert!(
3513 merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
3514 "merge user prompt should contain both partial amendment messages"
3515 );
3516 }
3517
3518 #[tokio::test]
3530 async fn amendment_single_oversized_file_gets_placeholder() {
3531 let dir = tempfile::tempdir().unwrap();
3532 let repo_view = make_single_oversized_file_repo_view(&dir);
3533 let hash = "c".repeat(40);
3534
3535 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3540 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3541 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3542 ]);
3543
3544 let result = client
3545 .generate_amendments_with_options(&repo_view, false)
3546 .await;
3547
3548 assert!(
3550 result.is_ok(),
3551 "expected success with placeholder, got: {result:?}"
3552 );
3553
3554 assert!(
3556 prompt_handle.request_count() >= 1,
3557 "expected at least 1 request, got {}",
3558 prompt_handle.request_count()
3559 );
3560 }
3561
3562 #[tokio::test]
3571 async fn amendment_chunk_failure_stops_dispatch() {
3572 let dir = tempfile::tempdir().unwrap();
3573 let repo_view = make_large_diff_repo_view(&dir);
3574 let hash = "a".repeat(40);
3575
3576 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3578 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3579 Err(anyhow::anyhow!("rate limit exceeded")),
3580 ]);
3581
3582 let result = client
3583 .generate_amendments_with_options(&repo_view, false)
3584 .await;
3585
3586 assert!(result.is_err());
3587
3588 let prompts = prompt_handle.prompts();
3590 assert_eq!(
3591 prompts.len(),
3592 2,
3593 "should stop after the failing chunk, got {} requests",
3594 prompts.len()
3595 );
3596
3597 let (_, first_user) = &prompts[0];
3599 assert!(
3600 first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
3601 "first chunk prompt should reference a file"
3602 );
3603 }
3604
3605 #[tokio::test]
3616 async fn amendment_reduce_pass_prompt_content() {
3617 let dir = tempfile::tempdir().unwrap();
3618 let repo_view = make_large_diff_repo_view(&dir);
3619 let hash = "a".repeat(40);
3620
3621 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3622 Ok(valid_amendment_yaml(
3623 &hash,
3624 "feat(a): add module a implementation",
3625 )),
3626 Ok(valid_amendment_yaml(
3627 &hash,
3628 "feat(b): add module b implementation",
3629 )),
3630 Ok(valid_amendment_yaml(
3631 &hash,
3632 "feat(test): add modules a and b",
3633 )),
3634 ]);
3635
3636 let result = client
3637 .generate_amendments_with_options(&repo_view, false)
3638 .await;
3639
3640 assert!(result.is_ok());
3641
3642 let prompts = prompt_handle.prompts();
3643 assert_eq!(prompts.len(), 3);
3644
3645 let (merge_system, merge_user) = &prompts[2];
3647
3648 assert!(
3650 merge_system.contains("synthesiz"),
3651 "merge system prompt should contain synthesis instructions"
3652 );
3653
3654 assert!(
3656 merge_user.contains("feat(a): add module a implementation"),
3657 "merge user prompt should contain chunk 1's partial message"
3658 );
3659 assert!(
3660 merge_user.contains("feat(b): add module b implementation"),
3661 "merge user prompt should contain chunk 2's partial message"
3662 );
3663
3664 assert!(
3666 merge_user.contains("feat(test): large commit"),
3667 "merge user prompt should contain the original commit message"
3668 );
3669
3670 assert!(
3672 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3673 "merge user prompt should contain the diff_summary"
3674 );
3675
3676 assert!(
3678 merge_user.contains(&hash),
3679 "merge user prompt should reference the commit hash"
3680 );
3681 }
3682
3683 #[tokio::test]
3700 async fn check_split_dedup_and_merge_prompt() {
3701 let dir = tempfile::tempdir().unwrap();
3702 let repo_view = make_large_diff_repo_view(&dir);
3703 let hash = "a".repeat(40);
3704
3705 let chunk1_yaml = format!(
3707 concat!(
3708 "checks:\n",
3709 " - commit: \"{hash}\"\n",
3710 " passes: false\n",
3711 " issues:\n",
3712 " - severity: error\n",
3713 " section: \"Subject Line\"\n",
3714 " rule: \"imperative-mood\"\n",
3715 " explanation: \"Subject uses past tense\"\n",
3716 " - severity: warning\n",
3717 " section: \"Content\"\n",
3718 " rule: \"body-required\"\n",
3719 " explanation: \"Large change needs body\"\n",
3720 " suggestion:\n",
3721 " message: \"feat(a): shorter subject for a\"\n",
3722 " explanation: \"Shortened subject for file a\"\n",
3723 " summary: \"Adds module a\"\n",
3724 ),
3725 hash = hash,
3726 );
3727
3728 let chunk2_yaml = format!(
3730 concat!(
3731 "checks:\n",
3732 " - commit: \"{hash}\"\n",
3733 " passes: false\n",
3734 " issues:\n",
3735 " - severity: error\n",
3736 " section: \"Subject Line\"\n",
3737 " rule: \"imperative-mood\"\n",
3738 " explanation: \"Subject line is way too long\"\n",
3739 " - severity: info\n",
3740 " section: \"Style\"\n",
3741 " rule: \"scope-suggestion\"\n",
3742 " explanation: \"Consider more specific scope\"\n",
3743 " suggestion:\n",
3744 " message: \"feat(b): shorter subject for b\"\n",
3745 " explanation: \"Shortened subject for file b\"\n",
3746 " summary: \"Adds module b\"\n",
3747 ),
3748 hash = hash,
3749 );
3750
3751 let merge_yaml = format!(
3753 concat!(
3754 "checks:\n",
3755 " - commit: \"{hash}\"\n",
3756 " passes: false\n",
3757 " issues: []\n",
3758 " suggestion:\n",
3759 " message: \"feat(test): add modules a and b\"\n",
3760 " explanation: \"Combined suggestion\"\n",
3761 " summary: \"Adds modules a and b\"\n",
3762 ),
3763 hash = hash,
3764 );
3765
3766 let (client, response_handle, prompt_handle) =
3767 make_small_context_client_with_prompts(vec![
3768 Ok(chunk1_yaml),
3769 Ok(chunk2_yaml),
3770 Ok(merge_yaml),
3771 ]);
3772
3773 let result = client
3774 .check_commits_with_scopes(&repo_view, None, &[], true)
3775 .await;
3776
3777 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3778 let report = result.unwrap();
3779 assert_eq!(report.commits.len(), 1);
3780 assert!(!report.commits[0].passes);
3781 assert_eq!(response_handle.remaining(), 0);
3782
3783 assert_eq!(
3788 report.commits[0].issues.len(),
3789 3,
3790 "expected 3 unique issues after dedup, got {:?}",
3791 report.commits[0]
3792 .issues
3793 .iter()
3794 .map(|i| &i.rule)
3795 .collect::<Vec<_>>()
3796 );
3797
3798 assert!(report.commits[0].suggestion.is_some());
3800 assert!(
3801 report.commits[0]
3802 .suggestion
3803 .as_ref()
3804 .unwrap()
3805 .message
3806 .contains("add modules a and b"),
3807 "suggestion should come from the merge pass"
3808 );
3809
3810 let prompts = prompt_handle.prompts();
3812 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
3813
3814 let (_, chunk1_user) = &prompts[0];
3816 let (_, chunk2_user) = &prompts[1];
3817 let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
3818 assert!(
3819 combined_chunk_prompts.contains("src/a.rs")
3820 && combined_chunk_prompts.contains("src/b.rs"),
3821 "chunk prompts should collectively cover both files"
3822 );
3823
3824 let (merge_sys, merge_user) = &prompts[2];
3826 assert!(
3827 merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
3828 "merge system prompt should be the check chunk merge prompt"
3829 );
3830 assert!(
3831 merge_user.contains("feat(a): shorter subject for a")
3832 && merge_user.contains("feat(b): shorter subject for b"),
3833 "merge user prompt should contain both partial suggestions"
3834 );
3835 assert!(
3837 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3838 "merge user prompt should contain the diff_summary"
3839 );
3840 }
3841
3842 #[tokio::test]
3845 async fn amendment_retry_parse_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 Ok("not valid yaml {{[".to_string()),
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, "both responses consumed");
3866 assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
3867 }
3868
3869 #[tokio::test]
3870 async fn amendment_retry_request_failure_then_success() {
3871 let dir = tempfile::tempdir().unwrap();
3872 let repo_view = make_test_repo_view(&dir);
3873 let hash = format!("{:0>40}", 0);
3874
3875 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3876 Err(anyhow::anyhow!("rate limit")),
3877 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3878 ]);
3879
3880 let result = client
3881 .generate_amendments_with_options(&repo_view, false)
3882 .await;
3883
3884 assert!(
3885 result.is_ok(),
3886 "should succeed after retry: {:?}",
3887 result.err()
3888 );
3889 assert_eq!(result.unwrap().amendments.len(), 1);
3890 assert_eq!(response_handle.remaining(), 0);
3891 assert_eq!(prompt_handle.request_count(), 2);
3892 }
3893
3894 #[tokio::test]
3895 async fn amendment_retry_all_attempts_exhausted() {
3896 let dir = tempfile::tempdir().unwrap();
3897 let repo_view = make_test_repo_view(&dir);
3898
3899 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3900 Ok("bad yaml 1".to_string()),
3901 Ok("bad yaml 2".to_string()),
3902 Ok("bad yaml 3".to_string()),
3903 ]);
3904
3905 let result = client
3906 .generate_amendments_with_options(&repo_view, false)
3907 .await;
3908
3909 assert!(result.is_err(), "should fail after all retries exhausted");
3910 assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
3911 assert_eq!(
3912 prompt_handle.request_count(),
3913 3,
3914 "exactly 3 AI requests (1 + 2 retries)"
3915 );
3916 }
3917
3918 #[tokio::test]
3919 async fn amendment_retry_success_first_attempt() {
3920 let dir = tempfile::tempdir().unwrap();
3921 let repo_view = make_test_repo_view(&dir);
3922 let hash = format!("{:0>40}", 0);
3923
3924 let (client, response_handle, prompt_handle) =
3925 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3926 &hash,
3927 "feat(test): works first time",
3928 ))]);
3929
3930 let result = client
3931 .generate_amendments_with_options(&repo_view, false)
3932 .await;
3933
3934 assert!(result.is_ok());
3935 assert_eq!(response_handle.remaining(), 0);
3936 assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
3937 }
3938
3939 #[tokio::test]
3940 async fn amendment_retry_mixed_request_and_parse_failures() {
3941 let dir = tempfile::tempdir().unwrap();
3942 let repo_view = make_test_repo_view(&dir);
3943 let hash = format!("{:0>40}", 0);
3944
3945 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3946 Err(anyhow::anyhow!("network error")),
3947 Ok("invalid yaml {{".to_string()),
3948 Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
3949 ]);
3950
3951 let result = client
3952 .generate_amendments_with_options(&repo_view, false)
3953 .await;
3954
3955 assert!(
3956 result.is_ok(),
3957 "should succeed on third attempt: {:?}",
3958 result.err()
3959 );
3960 assert_eq!(result.unwrap().amendments.len(), 1);
3961 assert_eq!(response_handle.remaining(), 0);
3962 assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
3963 }
3964
3965 static FACTORY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3969
3970 struct FactoryEnvGuard {
3971 _lock: std::sync::MutexGuard<'static, ()>,
3972 saved: Vec<(&'static str, Option<String>)>,
3973 }
3974
3975 impl FactoryEnvGuard {
3976 fn new(keys: &[&'static str]) -> Self {
3977 let lock = FACTORY_ENV_LOCK
3978 .lock()
3979 .unwrap_or_else(std::sync::PoisonError::into_inner);
3980 let saved = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
3981 for k in keys {
3982 std::env::remove_var(k);
3983 }
3984 Self { _lock: lock, saved }
3985 }
3986
3987 fn set(&self, key: &str, value: &str) {
3988 std::env::set_var(key, value);
3989 }
3990 }
3991
3992 impl Drop for FactoryEnvGuard {
3993 fn drop(&mut self) {
3994 for (k, v) in self.saved.drain(..) {
3995 match v {
3996 Some(val) => std::env::set_var(k, val),
3997 None => std::env::remove_var(k),
3998 }
3999 }
4000 }
4001 }
4002
4003 #[tokio::test]
4004 async fn factory_claude_cli_backend_dispatches_to_claude_cli_client() {
4005 let guard = FactoryEnvGuard::new(&[
4006 "OMNI_DEV_AI_BACKEND",
4007 "USE_OPENAI",
4008 "USE_OLLAMA",
4009 "CLAUDE_CODE_USE_BEDROCK",
4010 "CLAUDE_MODEL",
4011 "CLAUDE_CODE_MODEL",
4012 "ANTHROPIC_MODEL",
4013 ]);
4014 guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4015
4016 let client = create_default_claude_client(None, None)
4017 .await
4018 .expect("factory should succeed");
4019 let metadata = client.get_ai_client_metadata();
4020 assert_eq!(metadata.provider, "Claude CLI");
4021 assert_eq!(metadata.model, "claude-sonnet-4-6");
4023 }
4024
4025 #[tokio::test]
4026 async fn factory_claude_cli_backend_honours_model_precedence() {
4027 let guard = FactoryEnvGuard::new(&[
4028 "OMNI_DEV_AI_BACKEND",
4029 "USE_OPENAI",
4030 "USE_OLLAMA",
4031 "CLAUDE_CODE_USE_BEDROCK",
4032 "CLAUDE_MODEL",
4033 "CLAUDE_CODE_MODEL",
4034 "ANTHROPIC_MODEL",
4035 ]);
4036 guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4037 guard.set("CLAUDE_CODE_MODEL", "opus");
4038 guard.set("CLAUDE_MODEL", "haiku");
4040
4041 let client = create_default_claude_client(None, None)
4042 .await
4043 .expect("factory should succeed");
4044 let metadata = client.get_ai_client_metadata();
4045 assert_eq!(metadata.provider, "Claude CLI");
4046 assert_eq!(metadata.model, "haiku");
4047 }
4048
4049 #[tokio::test]
4050 async fn factory_claude_cli_backend_explicit_model_wins_over_env() {
4051 let guard = FactoryEnvGuard::new(&[
4052 "OMNI_DEV_AI_BACKEND",
4053 "USE_OPENAI",
4054 "USE_OLLAMA",
4055 "CLAUDE_CODE_USE_BEDROCK",
4056 "CLAUDE_MODEL",
4057 "CLAUDE_CODE_MODEL",
4058 "ANTHROPIC_MODEL",
4059 ]);
4060 guard.set("OMNI_DEV_AI_BACKEND", "claude-cli");
4061 guard.set("CLAUDE_MODEL", "haiku");
4062
4063 let client = create_default_claude_client(Some("opus".to_string()), None)
4064 .await
4065 .expect("factory should succeed");
4066 let metadata = client.get_ai_client_metadata();
4067 assert_eq!(metadata.model, "opus");
4068 }
4069
4070 #[tokio::test]
4071 async fn factory_claude_cli_backend_accepts_underscore_alias() {
4072 let guard = FactoryEnvGuard::new(&[
4073 "OMNI_DEV_AI_BACKEND",
4074 "USE_OPENAI",
4075 "USE_OLLAMA",
4076 "CLAUDE_CODE_USE_BEDROCK",
4077 "CLAUDE_MODEL",
4078 "CLAUDE_CODE_MODEL",
4079 "ANTHROPIC_MODEL",
4080 ]);
4081 guard.set("OMNI_DEV_AI_BACKEND", "claude_cli");
4082
4083 let client = create_default_claude_client(None, None)
4084 .await
4085 .expect("factory should succeed");
4086 let metadata = client.get_ai_client_metadata();
4087 assert_eq!(metadata.provider, "Claude CLI");
4088 }
4089
4090 #[tokio::test]
4091 async fn factory_ollama_branch_probes_loaded_context_length() {
4092 use wiremock::matchers::{method, path};
4093 use wiremock::{Mock, MockServer, ResponseTemplate};
4094
4095 let server = MockServer::start().await;
4096 Mock::given(method("GET"))
4097 .and(path("/api/v0/models"))
4098 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4099 "data": [
4100 { "id": "lm-loaded", "state": "loaded", "loaded_context_length": 6144_u64 }
4101 ]
4102 })))
4103 .mount(&server)
4104 .await;
4105
4106 let guard = FactoryEnvGuard::new(&[
4107 "OMNI_DEV_AI_BACKEND",
4108 "USE_OPENAI",
4109 "USE_OLLAMA",
4110 "CLAUDE_CODE_USE_BEDROCK",
4111 "OLLAMA_BASE_URL",
4112 "OLLAMA_MODEL",
4113 ]);
4114 guard.set("USE_OLLAMA", "true");
4115 guard.set("OLLAMA_BASE_URL", &server.uri());
4116 guard.set("OLLAMA_MODEL", "lm-loaded");
4117
4118 let client = create_default_claude_client(None, None)
4119 .await
4120 .expect("factory should succeed");
4121 let metadata = client.get_ai_client_metadata();
4122 assert_eq!(metadata.provider, "Ollama");
4123 assert_eq!(metadata.model, "lm-loaded");
4124 assert_eq!(metadata.max_context_length, 6144);
4126 }
4127
4128 #[tokio::test]
4129 async fn factory_ollama_branch_falls_back_when_probe_fails() {
4130 use wiremock::matchers::{method, path};
4131 use wiremock::{Mock, MockServer, ResponseTemplate};
4132
4133 let server = MockServer::start().await;
4134 Mock::given(method("GET"))
4135 .and(path("/api/v0/models"))
4136 .respond_with(ResponseTemplate::new(500))
4137 .mount(&server)
4138 .await;
4139 Mock::given(method("POST"))
4140 .and(path("/api/show"))
4141 .respond_with(ResponseTemplate::new(500))
4142 .mount(&server)
4143 .await;
4144
4145 let guard = FactoryEnvGuard::new(&[
4146 "OMNI_DEV_AI_BACKEND",
4147 "USE_OPENAI",
4148 "USE_OLLAMA",
4149 "CLAUDE_CODE_USE_BEDROCK",
4150 "OLLAMA_BASE_URL",
4151 "OLLAMA_MODEL",
4152 ]);
4153 guard.set("USE_OLLAMA", "true");
4154 guard.set("OLLAMA_BASE_URL", &server.uri());
4155 guard.set("OLLAMA_MODEL", "no-such-model");
4156
4157 let client = create_default_claude_client(None, None)
4158 .await
4159 .expect("factory should succeed");
4160 let metadata = client.get_ai_client_metadata();
4161 let registry_value =
4164 crate::claude::model_config::get_model_registry().get_input_context("no-such-model");
4165 assert_eq!(metadata.max_context_length, registry_value);
4166 }
4167
4168 #[tokio::test]
4172 async fn factory_ollama_branch_probes_via_ollama_native() {
4173 use wiremock::matchers::{method, path};
4174 use wiremock::{Mock, MockServer, ResponseTemplate};
4175
4176 let server = MockServer::start().await;
4177 Mock::given(method("GET"))
4178 .and(path("/api/v0/models"))
4179 .respond_with(ResponseTemplate::new(404))
4180 .mount(&server)
4181 .await;
4182 Mock::given(method("POST"))
4183 .and(path("/api/show"))
4184 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4185 "model_info": { "llama.context_length": 12288_u64 }
4186 })))
4187 .mount(&server)
4188 .await;
4189
4190 let guard = FactoryEnvGuard::new(&[
4191 "OMNI_DEV_AI_BACKEND",
4192 "USE_OPENAI",
4193 "USE_OLLAMA",
4194 "CLAUDE_CODE_USE_BEDROCK",
4195 "OLLAMA_BASE_URL",
4196 "OLLAMA_MODEL",
4197 ]);
4198 guard.set("USE_OLLAMA", "true");
4199 guard.set("OLLAMA_BASE_URL", &server.uri());
4200 guard.set("OLLAMA_MODEL", "ollama-native-model");
4201
4202 let client = create_default_claude_client(None, None)
4203 .await
4204 .expect("factory should succeed");
4205 let metadata = client.get_ai_client_metadata();
4206 assert_eq!(metadata.max_context_length, 12288);
4207 }
4208}