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 #[must_use]
56 pub fn into_ai_client(self) -> Box<dyn AiClient> {
57 self.ai_client
58 }
59
60 fn adjusted_system_prompt(&self, system_prompt: String) -> String {
71 let format = ResponseFormat::from_capabilities(&self.ai_client.capabilities());
72 prompts::apply_response_format_to_system_prompt(system_prompt, format)
73 }
74
75 fn schema_if_supported<'a>(
82 &self,
83 schema: &'a serde_json::Value,
84 ) -> Option<&'a serde_json::Value> {
85 if self.ai_client.capabilities().supports_response_schema {
86 Some(schema)
87 } else {
88 None
89 }
90 }
91
92 async fn send_with_optional_schema(
103 &self,
104 system_prompt: &str,
105 user_prompt: &str,
106 schema: Option<&serde_json::Value>,
107 ) -> Result<String> {
108 match schema {
109 Some(s) => {
110 let opts = RequestOptions::default().with_response_schema(s.clone());
111 self.ai_client
112 .send_request_with_options(system_prompt, user_prompt, opts)
113 .await
114 }
115 None => {
116 self.ai_client
117 .send_request(system_prompt, user_prompt)
118 .await
119 }
120 }
121 }
122
123 fn validate_prompt_budget(&self, system_prompt: &str, user_prompt: &str) -> Result<()> {
128 let metadata = self.ai_client.get_metadata();
129 let budget = TokenBudget::from_metadata(&metadata);
130 let estimate = budget.validate_prompt(system_prompt, user_prompt)?;
131
132 debug!(
133 model = %metadata.model,
134 estimated_tokens = estimate.estimated_tokens,
135 available_tokens = estimate.available_tokens,
136 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
137 "Token budget check passed"
138 );
139
140 Ok(())
141 }
142
143 fn build_prompt_fitting_budget(
149 &self,
150 ai_view: &RepositoryViewForAI,
151 system_prompt: &str,
152 build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
153 ) -> Result<String> {
154 let metadata = self.ai_client.get_metadata();
155 let budget = TokenBudget::from_metadata(&metadata);
156
157 let yaml =
158 crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
159 let user_prompt = build_user_prompt(&yaml);
160
161 let estimate = budget.validate_prompt(system_prompt, &user_prompt)?;
162 debug!(
163 model = %metadata.model,
164 estimated_tokens = estimate.estimated_tokens,
165 available_tokens = estimate.available_tokens,
166 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
167 "Token budget check passed"
168 );
169
170 Ok(user_prompt)
171 }
172
173 fn try_full_diff_budget<V: serde::Serialize>(
183 &self,
184 ai_view: &V,
185 system_prompt: &str,
186 build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
187 ) -> Result<std::result::Result<String, BudgetExceeded>> {
188 let metadata = self.ai_client.get_metadata();
189 let budget = TokenBudget::from_metadata(&metadata);
190
191 let yaml =
192 crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
193 let user_prompt = build_user_prompt(&yaml);
194
195 if let Ok(estimate) = budget.validate_prompt(system_prompt, &user_prompt) {
196 debug!(
197 model = %metadata.model,
198 estimated_tokens = estimate.estimated_tokens,
199 available_tokens = estimate.available_tokens,
200 utilization_pct = format!("{:.1}%", estimate.utilization_pct),
201 "Token budget check passed"
202 );
203 return Ok(Ok(user_prompt));
204 }
205
206 Ok(Err(BudgetExceeded {
207 available_input_tokens: budget.available_input_tokens(),
208 }))
209 }
210
211 async fn generate_amendment_split(
218 &self,
219 commit: &crate::git::CommitInfo,
220 repo_view_for_ai: &RepositoryViewForAI,
221 system_prompt: &str,
222 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
223 available_input_tokens: usize,
224 fresh: bool,
225 ) -> Result<Amendment> {
226 use crate::claude::batch::{
227 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
228 VIEW_ENVELOPE_OVERHEAD_TOKENS,
229 };
230 use crate::claude::diff_pack::pack_file_diffs;
231 use crate::claude::token_budget;
232 use crate::git::commit::CommitInfoForAI;
233
234 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
242 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
243 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
244 let chunk_capacity = available_input_tokens
245 .saturating_sub(system_prompt_tokens)
246 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
247 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
248 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
249 .saturating_sub(commit_text_tokens);
250
251 debug!(
252 commit = %&commit.hash[..8],
253 available_input_tokens,
254 system_prompt_tokens,
255 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
256 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
257 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
258 commit_text_tokens,
259 chunk_capacity,
260 "Split dispatch: computed chunk capacity"
261 );
262
263 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
264 .with_context(|| {
265 format!(
266 "Failed to plan diff chunks for commit {}",
267 &commit.hash[..8]
268 )
269 })?;
270
271 let total_chunks = plan.chunks.len();
272 debug!(
273 commit = %&commit.hash[..8],
274 chunks = total_chunks,
275 chunk_capacity,
276 "Split dispatch: processing commit in chunks"
277 );
278
279 let mut chunk_amendments = Vec::with_capacity(total_chunks);
280 for (i, chunk) in plan.chunks.iter().enumerate() {
281 let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
282 commit.clone(),
283 &chunk.file_paths,
284 &chunk.diff_overrides,
285 )
286 .with_context(|| {
287 format!(
288 "Failed to build partial view for chunk {}/{} of commit {}",
289 i + 1,
290 total_chunks,
291 &commit.hash[..8]
292 )
293 })?;
294
295 if fresh {
296 partial.base.original_message =
297 "(Original message hidden - generate fresh message from diff)".to_string();
298 }
299
300 let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
301
302 let diff_content_len = partial.base.analysis.diff_content.len();
304 let diff_content_tokens =
305 token_budget::estimate_tokens_from_char_count(diff_content_len);
306 debug!(
307 commit = %&commit.hash[..8],
308 chunk_index = i,
309 diff_content_len,
310 diff_content_tokens,
311 "Split dispatch: chunk diff content size"
312 );
313
314 let user_prompt =
315 self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
316
317 info!(
318 commit = %&commit.hash[..8],
319 chunk = i + 1,
320 total_chunks,
321 user_prompt_len = user_prompt.len(),
322 "Split dispatch: sending chunk to AI"
323 );
324
325 let content = match self
326 .send_with_optional_schema(
327 system_prompt,
328 &user_prompt,
329 self.schema_if_supported(response_schema::amendment_file_schema()),
330 )
331 .await
332 {
333 Ok(content) => content,
334 Err(e) => {
335 tracing::error!(
337 commit = %&commit.hash[..8],
338 chunk = i + 1,
339 error = %e,
340 error_debug = ?e,
341 "Split dispatch: AI request failed"
342 );
343 return Err(e).with_context(|| {
344 format!(
345 "Chunk {}/{} failed for commit {}",
346 i + 1,
347 total_chunks,
348 &commit.hash[..8]
349 )
350 });
351 }
352 };
353
354 info!(
355 commit = %&commit.hash[..8],
356 chunk = i + 1,
357 response_len = content.len(),
358 "Split dispatch: received chunk response"
359 );
360
361 let amendment_file = self.parse_amendment_response(&content).with_context(|| {
362 format!(
363 "Failed to parse chunk {}/{} response for commit {}",
364 i + 1,
365 total_chunks,
366 &commit.hash[..8]
367 )
368 })?;
369
370 if let Some(amendment) = amendment_file.amendments.into_iter().next() {
371 chunk_amendments.push(amendment);
372 }
373 }
374
375 self.merge_amendment_chunks(
376 &commit.hash,
377 &commit.original_message,
378 &commit.analysis.diff_summary,
379 &chunk_amendments,
380 )
381 .await
382 }
383
384 async fn merge_amendment_chunks(
390 &self,
391 commit_hash: &str,
392 original_message: &str,
393 diff_summary: &str,
394 chunk_amendments: &[Amendment],
395 ) -> Result<Amendment> {
396 let system_prompt =
397 self.adjusted_system_prompt(prompts::AMENDMENT_CHUNK_MERGE_SYSTEM_PROMPT.to_string());
398 let user_prompt = prompts::generate_chunk_merge_user_prompt(
399 commit_hash,
400 original_message,
401 diff_summary,
402 chunk_amendments,
403 );
404
405 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
406
407 let content = self
408 .send_with_optional_schema(
409 &system_prompt,
410 &user_prompt,
411 self.schema_if_supported(response_schema::amendment_file_schema()),
412 )
413 .await
414 .context("Merge pass failed for chunk amendments")?;
415
416 let amendment_file = self
417 .parse_amendment_response(&content)
418 .context("Failed to parse merge pass response")?;
419
420 amendment_file
421 .amendments
422 .into_iter()
423 .next()
424 .context("Merge pass returned no amendments")
425 }
426
427 async fn generate_amendment_for_commit(
434 &self,
435 commit: &crate::git::CommitInfo,
436 repo_view_for_ai: &RepositoryViewForAI,
437 system_prompt: &str,
438 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
439 fresh: bool,
440 ) -> Result<Amendment> {
441 let mut ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
442 if fresh {
443 ai_commit.base.original_message =
444 "(Original message hidden - generate fresh message from diff)".to_string();
445 }
446 let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
447
448 match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
449 Ok(user_prompt) => {
450 let amendment_file = self
451 .send_and_parse_amendment_with_retry(system_prompt, &user_prompt)
452 .await?;
453 amendment_file
454 .amendments
455 .into_iter()
456 .next()
457 .context("AI returned no amendments for commit")
458 }
459 Err(exceeded) => {
460 if commit.analysis.file_diffs.is_empty() {
461 anyhow::bail!(
462 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
463 &commit.hash[..8]
464 );
465 }
466 self.generate_amendment_split(
467 commit,
468 repo_view_for_ai,
469 system_prompt,
470 build_user_prompt,
471 exceeded.available_input_tokens,
472 fresh,
473 )
474 .await
475 }
476 }
477 }
478
479 async fn check_commit_split(
487 &self,
488 commit: &crate::git::CommitInfo,
489 repo_view: &RepositoryView,
490 system_prompt: &str,
491 valid_scopes: &[crate::data::context::ScopeDefinition],
492 include_suggestions: bool,
493 available_input_tokens: usize,
494 ) -> Result<crate::data::check::CheckReport> {
495 use crate::claude::batch::{
496 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
497 VIEW_ENVELOPE_OVERHEAD_TOKENS,
498 };
499 use crate::claude::diff_pack::pack_file_diffs;
500 use crate::claude::token_budget;
501 use crate::data::check::{CommitCheckResult, CommitIssue, IssueSeverity};
502 use crate::git::commit::CommitInfoForAI;
503
504 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
512 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
513 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
514 let chunk_capacity = available_input_tokens
515 .saturating_sub(system_prompt_tokens)
516 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
517 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
518 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
519 .saturating_sub(commit_text_tokens);
520
521 debug!(
522 commit = %&commit.hash[..8],
523 available_input_tokens,
524 system_prompt_tokens,
525 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
526 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
527 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
528 commit_text_tokens,
529 chunk_capacity,
530 "Check split dispatch: computed chunk capacity"
531 );
532
533 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
534 .with_context(|| {
535 format!(
536 "Failed to plan diff chunks for commit {}",
537 &commit.hash[..8]
538 )
539 })?;
540
541 let total_chunks = plan.chunks.len();
542 debug!(
543 commit = %&commit.hash[..8],
544 chunks = total_chunks,
545 chunk_capacity,
546 "Check split dispatch: processing commit in chunks"
547 );
548
549 let build_user_prompt =
550 |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
551
552 let mut chunk_results = Vec::with_capacity(total_chunks);
553 for (i, chunk) in plan.chunks.iter().enumerate() {
554 let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
555 commit.clone(),
556 &chunk.file_paths,
557 &chunk.diff_overrides,
558 )
559 .with_context(|| {
560 format!(
561 "Failed to build partial view for chunk {}/{} of commit {}",
562 i + 1,
563 total_chunks,
564 &commit.hash[..8]
565 )
566 })?;
567
568 partial.run_pre_validation_checks(valid_scopes);
569
570 let partial_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
571 .context("Failed to enhance repository view with diff content")?
572 .single_commit_view_for_ai(&partial);
573
574 let user_prompt =
575 self.build_prompt_fitting_budget(&partial_view, system_prompt, &build_user_prompt)?;
576
577 let content = self
578 .send_with_optional_schema(
579 system_prompt,
580 &user_prompt,
581 self.schema_if_supported(response_schema::check_response_schema()),
582 )
583 .await
584 .with_context(|| {
585 format!(
586 "Check chunk {}/{} failed for commit {}",
587 i + 1,
588 total_chunks,
589 &commit.hash[..8]
590 )
591 })?;
592
593 let report = self
594 .parse_check_response(&content, repo_view)
595 .with_context(|| {
596 format!(
597 "Failed to parse check chunk {}/{} response for commit {}",
598 i + 1,
599 total_chunks,
600 &commit.hash[..8]
601 )
602 })?;
603
604 if let Some(result) = report.commits.into_iter().next() {
605 chunk_results.push(result);
606 }
607 }
608
609 let mut seen = std::collections::HashSet::new();
611 let mut merged_issues: Vec<CommitIssue> = Vec::new();
612 for result in &chunk_results {
613 for issue in &result.issues {
614 let key: (String, IssueSeverity, String) =
615 (issue.rule.clone(), issue.severity, issue.section.clone());
616 if seen.insert(key) {
617 merged_issues.push(issue.clone());
618 }
619 }
620 }
621
622 let passes = chunk_results.iter().all(|r| r.passes);
623
624 let has_suggestions = chunk_results.iter().any(|r| r.suggestion.is_some());
626
627 let (merged_suggestion, merged_summary) = if has_suggestions {
628 self.merge_check_chunks(
629 &commit.hash,
630 &commit.original_message,
631 &commit.analysis.diff_summary,
632 passes,
633 &chunk_results,
634 repo_view,
635 )
636 .await?
637 } else {
638 let summary = chunk_results.iter().find_map(|r| r.summary.clone());
640 (None, summary)
641 };
642
643 let original_message = commit
644 .original_message
645 .lines()
646 .next()
647 .unwrap_or("")
648 .to_string();
649
650 let merged_result = CommitCheckResult {
651 hash: commit.hash.clone(),
652 message: original_message,
653 issues: merged_issues,
654 suggestion: merged_suggestion,
655 passes,
656 summary: merged_summary,
657 };
658
659 Ok(crate::data::check::CheckReport::new(vec![merged_result]))
660 }
661
662 async fn merge_check_chunks(
667 &self,
668 commit_hash: &str,
669 original_message: &str,
670 diff_summary: &str,
671 passes: bool,
672 chunk_results: &[crate::data::check::CommitCheckResult],
673 repo_view: &RepositoryView,
674 ) -> Result<(Option<crate::data::check::CommitSuggestion>, Option<String>)> {
675 let suggestions: Vec<&crate::data::check::CommitSuggestion> = chunk_results
676 .iter()
677 .filter_map(|r| r.suggestion.as_ref())
678 .collect();
679
680 let summaries: Vec<Option<&str>> =
681 chunk_results.iter().map(|r| r.summary.as_deref()).collect();
682
683 let system_prompt =
684 self.adjusted_system_prompt(prompts::CHECK_CHUNK_MERGE_SYSTEM_PROMPT.to_string());
685 let user_prompt = prompts::generate_check_chunk_merge_user_prompt(
686 commit_hash,
687 original_message,
688 diff_summary,
689 passes,
690 &suggestions,
691 &summaries,
692 );
693
694 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
695
696 let content = self
697 .send_with_optional_schema(
698 &system_prompt,
699 &user_prompt,
700 self.schema_if_supported(response_schema::check_response_schema()),
701 )
702 .await
703 .context("Merge pass failed for check chunk suggestions")?;
704
705 let report = self
706 .parse_check_response(&content, repo_view)
707 .context("Failed to parse check merge pass response")?;
708
709 let result = report.commits.into_iter().next();
710 Ok(match result {
711 Some(r) => (r.suggestion, r.summary),
712 None => (None, None),
713 })
714 }
715
716 pub async fn send_message(&self, system_prompt: &str, user_prompt: &str) -> Result<String> {
718 self.validate_prompt_budget(system_prompt, user_prompt)?;
719 self.ai_client
720 .send_request(system_prompt, user_prompt)
721 .await
722 }
723
724 pub fn from_env(model: String) -> Result<Self> {
726 let api_key = std::env::var("CLAUDE_API_KEY")
728 .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
729 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
730
731 let ai_client = ClaudeAiClient::new(model, api_key, None)?;
732 Ok(Self::new(Box::new(ai_client)))
733 }
734
735 pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
737 self.generate_amendments_with_options(repo_view, false)
738 .await
739 }
740
741 pub async fn generate_amendments_with_options(
752 &self,
753 repo_view: &RepositoryView,
754 fresh: bool,
755 ) -> Result<AmendmentFile> {
756 let ai_repo_view =
758 RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
759 .context("Failed to enhance repository view with diff content")?;
760
761 let system_prompt = self.adjusted_system_prompt(prompts::SYSTEM_PROMPT.to_string());
762 let build_user_prompt = |yaml: &str| prompts::generate_user_prompt(yaml);
763
764 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
766 Ok(user_prompt) => {
767 self.send_and_parse_amendment_with_retry(&system_prompt, &user_prompt)
768 .await
769 }
770 Err(_exceeded) => {
771 let mut amendments = Vec::new();
772 for commit in &repo_view.commits {
773 let amendment = self
774 .generate_amendment_for_commit(
775 commit,
776 &ai_repo_view,
777 &system_prompt,
778 &build_user_prompt,
779 fresh,
780 )
781 .await?;
782 amendments.push(amendment);
783 }
784 Ok(AmendmentFile { amendments })
785 }
786 }
787 }
788
789 pub async fn generate_contextual_amendments(
791 &self,
792 repo_view: &RepositoryView,
793 context: &CommitContext,
794 ) -> Result<AmendmentFile> {
795 self.generate_contextual_amendments_with_options(repo_view, context, false)
796 .await
797 }
798
799 pub async fn generate_contextual_amendments_with_options(
809 &self,
810 repo_view: &RepositoryView,
811 context: &CommitContext,
812 fresh: bool,
813 ) -> Result<AmendmentFile> {
814 let ai_repo_view =
816 RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
817 .context("Failed to enhance repository view with diff content")?;
818
819 let prompt_style = self.ai_client.get_metadata().prompt_style();
821 let system_prompt = self.adjusted_system_prompt(
822 prompts::generate_contextual_system_prompt_for_provider(context, prompt_style),
823 );
824
825 match &context.project.commit_guidelines {
827 Some(guidelines) => {
828 debug!(length = guidelines.len(), "Project commit guidelines found");
829 debug!(guidelines = %guidelines, "Commit guidelines content");
830 }
831 None => {
832 debug!("No project commit guidelines found");
833 }
834 }
835
836 let build_user_prompt =
837 |yaml: &str| prompts::generate_contextual_user_prompt(yaml, context);
838
839 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
841 Ok(user_prompt) => {
842 self.send_and_parse_amendment_with_retry(&system_prompt, &user_prompt)
843 .await
844 }
845 Err(_exceeded) => {
846 let mut amendments = Vec::new();
847 for commit in &repo_view.commits {
848 let amendment = self
849 .generate_amendment_for_commit(
850 commit,
851 &ai_repo_view,
852 &system_prompt,
853 &build_user_prompt,
854 fresh,
855 )
856 .await?;
857 amendments.push(amendment);
858 }
859 Ok(AmendmentFile { amendments })
860 }
861 }
862 }
863
864 fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
866 let yaml_content = self.extract_yaml_from_response(content);
868
869 let amendment_file: AmendmentFile = crate::data::from_yaml(&yaml_content).map_err(|e| {
871 debug!(
872 error = %e,
873 content_length = content.len(),
874 yaml_length = yaml_content.len(),
875 "YAML parsing failed"
876 );
877 debug!(content = %content, "Raw Claude response");
878 debug!(yaml = %yaml_content, "Extracted YAML content");
879
880 if yaml_content.lines().any(|line| line.contains('\t')) {
882 ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
883 } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
884 ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
885 } else {
886 ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {e}"))
887 }
888 })?;
889
890 amendment_file
892 .validate()
893 .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {e}")))?;
894
895 Ok(amendment_file)
896 }
897
898 async fn send_and_parse_amendment_with_retry(
906 &self,
907 system_prompt: &str,
908 user_prompt: &str,
909 ) -> Result<AmendmentFile> {
910 let mut last_error = None;
911 for attempt in 0..=AMENDMENT_PARSE_MAX_RETRIES {
912 match self
913 .send_with_optional_schema(
914 system_prompt,
915 user_prompt,
916 self.schema_if_supported(response_schema::amendment_file_schema()),
917 )
918 .await
919 {
920 Ok(content) => match self.parse_amendment_response(&content) {
921 Ok(amendment_file) => return Ok(amendment_file),
922 Err(e) => {
923 if attempt < AMENDMENT_PARSE_MAX_RETRIES {
924 eprintln!(
925 "warning: failed to parse amendment response (attempt {}), retrying...",
926 attempt + 1
927 );
928 debug!(error = %e, attempt = attempt + 1, "Amendment response parse failed, retrying");
929 }
930 last_error = Some(e);
931 }
932 },
933 Err(e) => {
934 if attempt < AMENDMENT_PARSE_MAX_RETRIES {
935 eprintln!(
936 "warning: AI request failed (attempt {}), retrying...",
937 attempt + 1
938 );
939 debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
940 }
941 last_error = Some(e);
942 }
943 }
944 }
945 Err(last_error
946 .unwrap_or_else(|| anyhow::anyhow!("Amendment generation failed after retries")))
947 }
948
949 fn parse_pr_response(&self, content: &str) -> Result<crate::cli::git::PrContent> {
951 let yaml_content = content.trim();
952 crate::data::from_yaml(yaml_content)
953 .context("Failed to parse AI response as YAML. AI may have returned malformed output.")
954 }
955
956 async fn generate_pr_content_split(
963 &self,
964 commit: &crate::git::CommitInfo,
965 repo_view_for_ai: &RepositoryViewForAI,
966 system_prompt: &str,
967 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
968 available_input_tokens: usize,
969 pr_template: &str,
970 ) -> Result<crate::cli::git::PrContent> {
971 use crate::claude::batch::{
972 PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
973 VIEW_ENVELOPE_OVERHEAD_TOKENS,
974 };
975 use crate::claude::diff_pack::pack_file_diffs;
976 use crate::claude::token_budget;
977 use crate::git::commit::CommitInfoForAI;
978
979 let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
987 let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
988 + token_budget::estimate_tokens(&commit.analysis.diff_summary);
989 let chunk_capacity = available_input_tokens
990 .saturating_sub(system_prompt_tokens)
991 .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
992 .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
993 .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
994 .saturating_sub(commit_text_tokens);
995
996 debug!(
997 commit = %&commit.hash[..8],
998 available_input_tokens,
999 system_prompt_tokens,
1000 envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
1001 metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
1002 template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
1003 commit_text_tokens,
1004 chunk_capacity,
1005 "PR split dispatch: computed chunk capacity"
1006 );
1007
1008 let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
1009 .with_context(|| {
1010 format!(
1011 "Failed to plan diff chunks for commit {}",
1012 &commit.hash[..8]
1013 )
1014 })?;
1015
1016 let total_chunks = plan.chunks.len();
1017 debug!(
1018 commit = %&commit.hash[..8],
1019 chunks = total_chunks,
1020 chunk_capacity,
1021 "PR split dispatch: processing commit in chunks"
1022 );
1023
1024 let mut chunk_contents = Vec::with_capacity(total_chunks);
1025 for (i, chunk) in plan.chunks.iter().enumerate() {
1026 let partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
1027 commit.clone(),
1028 &chunk.file_paths,
1029 &chunk.diff_overrides,
1030 )
1031 .with_context(|| {
1032 format!(
1033 "Failed to build partial view for chunk {}/{} of commit {}",
1034 i + 1,
1035 total_chunks,
1036 &commit.hash[..8]
1037 )
1038 })?;
1039
1040 let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
1041
1042 let user_prompt =
1043 self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
1044
1045 let content = self
1046 .send_with_optional_schema(
1047 system_prompt,
1048 &user_prompt,
1049 self.schema_if_supported(response_schema::pr_content_schema()),
1050 )
1051 .await
1052 .with_context(|| {
1053 format!(
1054 "PR chunk {}/{} failed for commit {}",
1055 i + 1,
1056 total_chunks,
1057 &commit.hash[..8]
1058 )
1059 })?;
1060
1061 let pr_content = self.parse_pr_response(&content).with_context(|| {
1062 format!(
1063 "Failed to parse PR chunk {}/{} response for commit {}",
1064 i + 1,
1065 total_chunks,
1066 &commit.hash[..8]
1067 )
1068 })?;
1069
1070 chunk_contents.push(pr_content);
1071 }
1072
1073 self.merge_pr_content_chunks(&chunk_contents, pr_template)
1074 .await
1075 }
1076
1077 async fn merge_pr_content_chunks(
1080 &self,
1081 partial_contents: &[crate::cli::git::PrContent],
1082 pr_template: &str,
1083 ) -> Result<crate::cli::git::PrContent> {
1084 let system_prompt =
1085 self.adjusted_system_prompt(prompts::PR_CONTENT_MERGE_SYSTEM_PROMPT.to_string());
1086 let user_prompt =
1087 prompts::generate_pr_content_merge_user_prompt(partial_contents, pr_template);
1088
1089 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1090
1091 let content = self
1092 .send_with_optional_schema(
1093 &system_prompt,
1094 &user_prompt,
1095 self.schema_if_supported(response_schema::pr_content_schema()),
1096 )
1097 .await
1098 .context("Merge pass failed for PR content chunks")?;
1099
1100 self.parse_pr_response(&content)
1101 .context("Failed to parse PR content merge pass response")
1102 }
1103
1104 async fn generate_pr_content_for_commit(
1106 &self,
1107 commit: &crate::git::CommitInfo,
1108 repo_view_for_ai: &RepositoryViewForAI,
1109 system_prompt: &str,
1110 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
1111 pr_template: &str,
1112 ) -> Result<crate::cli::git::PrContent> {
1113 let ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
1114 let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
1115
1116 match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
1117 Ok(user_prompt) => {
1118 let content = self
1119 .send_with_optional_schema(
1120 system_prompt,
1121 &user_prompt,
1122 self.schema_if_supported(response_schema::pr_content_schema()),
1123 )
1124 .await?;
1125 self.parse_pr_response(&content)
1126 }
1127 Err(exceeded) => {
1128 if commit.analysis.file_diffs.is_empty() {
1129 anyhow::bail!(
1130 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1131 &commit.hash[..8]
1132 );
1133 }
1134 self.generate_pr_content_split(
1135 commit,
1136 repo_view_for_ai,
1137 system_prompt,
1138 build_user_prompt,
1139 exceeded.available_input_tokens,
1140 pr_template,
1141 )
1142 .await
1143 }
1144 }
1145 }
1146
1147 pub async fn generate_pr_content(
1149 &self,
1150 repo_view: &RepositoryView,
1151 pr_template: &str,
1152 ) -> Result<crate::cli::git::PrContent> {
1153 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1155 .context("Failed to enhance repository view with diff content")?;
1156
1157 let system_prompt =
1158 self.adjusted_system_prompt(prompts::PR_GENERATION_SYSTEM_PROMPT.to_string());
1159
1160 let build_user_prompt =
1161 |yaml: &str| prompts::generate_pr_description_prompt(yaml, pr_template);
1162
1163 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1165 Ok(user_prompt) => {
1166 let content = self
1167 .send_with_optional_schema(
1168 &system_prompt,
1169 &user_prompt,
1170 self.schema_if_supported(response_schema::pr_content_schema()),
1171 )
1172 .await?;
1173 self.parse_pr_response(&content)
1174 }
1175 Err(_exceeded) => {
1176 let mut per_commit_contents = Vec::new();
1177 for commit in &repo_view.commits {
1178 let pr = self
1179 .generate_pr_content_for_commit(
1180 commit,
1181 &ai_repo_view,
1182 &system_prompt,
1183 &build_user_prompt,
1184 pr_template,
1185 )
1186 .await?;
1187 per_commit_contents.push(pr);
1188 }
1189 if per_commit_contents.len() == 1 {
1190 return per_commit_contents
1191 .into_iter()
1192 .next()
1193 .context("Per-commit PR contents unexpectedly empty");
1194 }
1195 self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1196 .await
1197 }
1198 }
1199 }
1200
1201 pub async fn generate_pr_content_with_context(
1203 &self,
1204 repo_view: &RepositoryView,
1205 pr_template: &str,
1206 context: &crate::data::context::CommitContext,
1207 ) -> Result<crate::cli::git::PrContent> {
1208 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1210 .context("Failed to enhance repository view with diff content")?;
1211
1212 let prompt_style = self.ai_client.get_metadata().prompt_style();
1214 let system_prompt = self.adjusted_system_prompt(
1215 prompts::generate_pr_system_prompt_with_context_for_provider(context, prompt_style),
1216 );
1217
1218 let build_user_prompt = |yaml: &str| {
1219 prompts::generate_pr_description_prompt_with_context(yaml, pr_template, context)
1220 };
1221
1222 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1224 Ok(user_prompt) => {
1225 let content = self
1226 .send_with_optional_schema(
1227 &system_prompt,
1228 &user_prompt,
1229 self.schema_if_supported(response_schema::pr_content_schema()),
1230 )
1231 .await?;
1232
1233 debug!(
1234 content_length = content.len(),
1235 "Received AI response for PR content"
1236 );
1237
1238 let pr_content = self.parse_pr_response(&content)?;
1239
1240 debug!(
1241 parsed_title = %pr_content.title,
1242 parsed_description_length = pr_content.description.len(),
1243 parsed_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1244 "Successfully parsed PR content from YAML"
1245 );
1246
1247 Ok(pr_content)
1248 }
1249 Err(_exceeded) => {
1250 let mut per_commit_contents = Vec::new();
1251 for commit in &repo_view.commits {
1252 let pr = self
1253 .generate_pr_content_for_commit(
1254 commit,
1255 &ai_repo_view,
1256 &system_prompt,
1257 &build_user_prompt,
1258 pr_template,
1259 )
1260 .await?;
1261 per_commit_contents.push(pr);
1262 }
1263 if per_commit_contents.len() == 1 {
1264 return per_commit_contents
1265 .into_iter()
1266 .next()
1267 .context("Per-commit PR contents unexpectedly empty");
1268 }
1269 self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1270 .await
1271 }
1272 }
1273 }
1274
1275 pub async fn generate_pr_content_with_context_from_commits(
1284 &self,
1285 repo_view: &RepositoryView,
1286 pr_template: &str,
1287 context: &crate::data::context::CommitContext,
1288 ) -> Result<crate::cli::git::PrContent> {
1289 use crate::data::RepositoryViewForAiFromCommits;
1290
1291 let commits_view = RepositoryViewForAiFromCommits::from_repository_view(repo_view.clone());
1292
1293 let prompt_style = self.ai_client.get_metadata().prompt_style();
1294 let system_prompt = self.adjusted_system_prompt(
1295 prompts::generate_pr_system_prompt_from_commits_with_context_for_provider(
1296 context,
1297 prompt_style,
1298 ),
1299 );
1300
1301 let build_user_prompt = |yaml: &str| {
1302 prompts::generate_pr_description_prompt_from_commits_with_context(
1303 yaml,
1304 pr_template,
1305 context,
1306 )
1307 };
1308
1309 match self.try_full_diff_budget(&commits_view, &system_prompt, &build_user_prompt)? {
1310 Ok(user_prompt) => {
1311 let content = self
1312 .send_with_optional_schema(
1313 &system_prompt,
1314 &user_prompt,
1315 self.schema_if_supported(response_schema::pr_content_schema()),
1316 )
1317 .await?;
1318
1319 debug!(
1320 content_length = content.len(),
1321 "Received AI response for from-commits PR content"
1322 );
1323
1324 self.parse_pr_response(&content)
1325 }
1326 Err(_exceeded) => {
1327 let mut per_commit_contents = Vec::new();
1328 for commit in &commits_view.commits {
1329 let pr = self
1330 .generate_pr_content_for_commit_from_commits(
1331 commit,
1332 &commits_view,
1333 &system_prompt,
1334 &build_user_prompt,
1335 )
1336 .await?;
1337 per_commit_contents.push(pr);
1338 }
1339 if per_commit_contents.len() == 1 {
1340 return per_commit_contents
1341 .into_iter()
1342 .next()
1343 .context("Per-commit PR contents unexpectedly empty");
1344 }
1345 self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1346 .await
1347 }
1348 }
1349 }
1350
1351 async fn generate_pr_content_for_commit_from_commits(
1359 &self,
1360 commit: &crate::data::CommitInfoFromCommits,
1361 commits_view: &crate::data::RepositoryViewForAiFromCommits,
1362 system_prompt: &str,
1363 build_user_prompt: &(dyn Fn(&str) -> String + Sync),
1364 ) -> Result<crate::cli::git::PrContent> {
1365 let single_view = commits_view.single_commit_view_from_commits(commit);
1366
1367 match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
1368 Ok(user_prompt) => {
1369 let content = self
1370 .send_with_optional_schema(
1371 system_prompt,
1372 &user_prompt,
1373 self.schema_if_supported(response_schema::pr_content_schema()),
1374 )
1375 .await?;
1376 self.parse_pr_response(&content)
1377 }
1378 Err(_exceeded) => {
1379 anyhow::bail!(
1380 "Token budget exceeded for commit {} in --from-commits mode; commit message is too large to fit",
1381 &commit.hash[..8.min(commit.hash.len())]
1382 )
1383 }
1384 }
1385 }
1386
1387 pub async fn check_commits(
1392 &self,
1393 repo_view: &RepositoryView,
1394 guidelines: Option<&str>,
1395 include_suggestions: bool,
1396 ) -> Result<crate::data::check::CheckReport> {
1397 self.check_commits_with_scopes(repo_view, guidelines, &[], include_suggestions)
1398 .await
1399 }
1400
1401 pub async fn check_commits_with_scopes(
1406 &self,
1407 repo_view: &RepositoryView,
1408 guidelines: Option<&str>,
1409 valid_scopes: &[crate::data::context::ScopeDefinition],
1410 include_suggestions: bool,
1411 ) -> Result<crate::data::check::CheckReport> {
1412 self.check_commits_with_retry(repo_view, guidelines, valid_scopes, include_suggestions, 2)
1413 .await
1414 }
1415
1416 async fn check_commits_with_retry(
1424 &self,
1425 repo_view: &RepositoryView,
1426 guidelines: Option<&str>,
1427 valid_scopes: &[crate::data::context::ScopeDefinition],
1428 include_suggestions: bool,
1429 max_retries: u32,
1430 ) -> Result<crate::data::check::CheckReport> {
1431 let system_prompt = self.adjusted_system_prompt(
1433 prompts::generate_check_system_prompt_with_scopes(guidelines, valid_scopes),
1434 );
1435
1436 let build_user_prompt =
1437 |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
1438
1439 let mut ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1440 .context("Failed to enhance repository view with diff content")?;
1441 for commit in &mut ai_repo_view.commits {
1442 commit.run_pre_validation_checks(valid_scopes);
1443 }
1444
1445 match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1447 Ok(user_prompt) => {
1448 let mut last_error = None;
1450 for attempt in 0..=max_retries {
1451 match self
1452 .send_with_optional_schema(
1453 &system_prompt,
1454 &user_prompt,
1455 self.schema_if_supported(response_schema::check_response_schema()),
1456 )
1457 .await
1458 {
1459 Ok(content) => match self.parse_check_response(&content, repo_view) {
1460 Ok(report) => return Ok(report),
1461 Err(e) => {
1462 if attempt < max_retries {
1463 eprintln!(
1464 "warning: failed to parse AI response (attempt {}), retrying...",
1465 attempt + 1
1466 );
1467 debug!(error = %e, attempt = attempt + 1, "Check response parse failed, retrying");
1468 }
1469 last_error = Some(e);
1470 }
1471 },
1472 Err(e) => {
1473 if attempt < max_retries {
1474 eprintln!(
1475 "warning: AI request failed (attempt {}), retrying...",
1476 attempt + 1
1477 );
1478 debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
1479 }
1480 last_error = Some(e);
1481 }
1482 }
1483 }
1484 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Check failed after retries")))
1485 }
1486 Err(_exceeded) => {
1487 let mut all_results = Vec::new();
1489 for commit in &repo_view.commits {
1490 let single_view = repo_view.single_commit_view(commit);
1491 let mut single_ai_view =
1492 RepositoryViewForAI::from_repository_view(single_view.clone())
1493 .context("Failed to enhance single-commit view with diff content")?;
1494 for c in &mut single_ai_view.commits {
1495 c.run_pre_validation_checks(valid_scopes);
1496 }
1497
1498 match self.try_full_diff_budget(
1499 &single_ai_view,
1500 &system_prompt,
1501 &build_user_prompt,
1502 )? {
1503 Ok(user_prompt) => {
1504 let content = self
1505 .send_with_optional_schema(
1506 &system_prompt,
1507 &user_prompt,
1508 self.schema_if_supported(
1509 response_schema::check_response_schema(),
1510 ),
1511 )
1512 .await?;
1513 let report = self.parse_check_response(&content, &single_view)?;
1514 all_results.extend(report.commits);
1515 }
1516 Err(exceeded) => {
1517 if commit.analysis.file_diffs.is_empty() {
1518 anyhow::bail!(
1519 "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1520 &commit.hash[..8]
1521 );
1522 }
1523 let report = self
1524 .check_commit_split(
1525 commit,
1526 &single_view,
1527 &system_prompt,
1528 valid_scopes,
1529 include_suggestions,
1530 exceeded.available_input_tokens,
1531 )
1532 .await?;
1533 all_results.extend(report.commits);
1534 }
1535 }
1536 }
1537 Ok(crate::data::check::CheckReport::new(all_results))
1538 }
1539 }
1540 }
1541
1542 fn parse_check_response(
1544 &self,
1545 content: &str,
1546 repo_view: &RepositoryView,
1547 ) -> Result<crate::data::check::CheckReport> {
1548 use crate::data::check::{
1549 AiCheckResponse, CheckReport, CommitCheckResult as CheckResultType,
1550 };
1551
1552 let yaml_content = self.extract_yaml_from_check_response(content);
1554
1555 let ai_response: AiCheckResponse = crate::data::from_yaml(&yaml_content).map_err(|e| {
1557 debug!(
1558 error = %e,
1559 content_length = content.len(),
1560 yaml_length = yaml_content.len(),
1561 "Check YAML parsing failed"
1562 );
1563 debug!(content = %content, "Raw AI response");
1564 debug!(yaml = %yaml_content, "Extracted YAML content");
1565 ClaudeError::AmendmentParsingFailed(format!("Check response parsing error: {e}"))
1566 })?;
1567
1568 let commit_messages: std::collections::HashMap<&str, &str> = repo_view
1570 .commits
1571 .iter()
1572 .map(|c| (c.hash.as_str(), c.original_message.as_str()))
1573 .collect();
1574
1575 let results: Vec<CheckResultType> = ai_response
1577 .checks
1578 .into_iter()
1579 .map(|check| {
1580 let mut result: CheckResultType = check.into();
1581 if let Some(msg) = commit_messages.get(result.hash.as_str()) {
1583 result.message = msg.lines().next().unwrap_or("").to_string();
1584 } else {
1585 for (hash, msg) in &commit_messages {
1587 if hash.starts_with(&result.hash) || result.hash.starts_with(*hash) {
1588 result.message = msg.lines().next().unwrap_or("").to_string();
1589 break;
1590 }
1591 }
1592 }
1593 result
1594 })
1595 .collect();
1596
1597 Ok(CheckReport::new(results))
1598 }
1599
1600 fn extract_yaml_from_check_response(&self, content: &str) -> String {
1602 let content = content.trim();
1603
1604 if content.starts_with("checks:") {
1606 return content.to_string();
1607 }
1608
1609 if let Some(yaml_start) = content.find("```yaml") {
1611 if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1612 return yaml_content.trim().to_string();
1613 }
1614 }
1615
1616 if let Some(code_start) = content.find("```") {
1618 if let Some(code_content) = content[code_start + 3..].split("```").next() {
1619 let potential_yaml = code_content.trim();
1620 if potential_yaml.starts_with("checks:") {
1622 return potential_yaml.to_string();
1623 }
1624 }
1625 }
1626
1627 content.to_string()
1629 }
1630
1631 pub async fn refine_amendments_coherence(
1636 &self,
1637 items: &[(crate::data::amendments::Amendment, String)],
1638 ) -> Result<AmendmentFile> {
1639 let system_prompt =
1640 self.adjusted_system_prompt(prompts::AMENDMENT_COHERENCE_SYSTEM_PROMPT.to_string());
1641 let user_prompt = prompts::generate_amendment_coherence_user_prompt(items);
1642
1643 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1644
1645 let content = self
1646 .send_with_optional_schema(
1647 &system_prompt,
1648 &user_prompt,
1649 self.schema_if_supported(response_schema::amendment_file_schema()),
1650 )
1651 .await?;
1652
1653 self.parse_amendment_response(&content)
1654 }
1655
1656 pub async fn refine_checks_coherence(
1662 &self,
1663 items: &[(crate::data::check::CommitCheckResult, String)],
1664 repo_view: &RepositoryView,
1665 ) -> Result<crate::data::check::CheckReport> {
1666 let system_prompt =
1667 self.adjusted_system_prompt(prompts::CHECK_COHERENCE_SYSTEM_PROMPT.to_string());
1668 let user_prompt = prompts::generate_check_coherence_user_prompt(items);
1669
1670 self.validate_prompt_budget(&system_prompt, &user_prompt)?;
1671
1672 let content = self
1673 .send_with_optional_schema(
1674 &system_prompt,
1675 &user_prompt,
1676 self.schema_if_supported(response_schema::check_response_schema()),
1677 )
1678 .await?;
1679
1680 self.parse_check_response(&content, repo_view)
1681 }
1682
1683 fn extract_yaml_from_response(&self, content: &str) -> String {
1685 let content = content.trim();
1686
1687 if content.starts_with("amendments:") {
1689 return content.to_string();
1690 }
1691
1692 if let Some(yaml_start) = content.find("```yaml") {
1694 if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1695 return yaml_content.trim().to_string();
1696 }
1697 }
1698
1699 if let Some(code_start) = content.find("```") {
1701 if let Some(code_content) = content[code_start + 3..].split("```").next() {
1702 let potential_yaml = code_content.trim();
1703 if potential_yaml.starts_with("amendments:") {
1705 return potential_yaml.to_string();
1706 }
1707 }
1708 }
1709
1710 content.to_string()
1712 }
1713}
1714
1715fn validate_beta_header(model: &str, beta_header: &Option<(String, String)>) -> Result<()> {
1717 if let Some((ref key, ref value)) = beta_header {
1718 let registry = crate::claude::model_config::get_model_registry();
1719 let supported = registry.get_beta_headers(model);
1720 if !supported
1721 .iter()
1722 .any(|bh| bh.key == *key && bh.value == *value)
1723 {
1724 let available: Vec<String> = supported
1725 .iter()
1726 .map(|bh| format!("{}:{}", bh.key, bh.value))
1727 .collect();
1728 if available.is_empty() {
1729 anyhow::bail!("Model '{model}' does not support any beta headers");
1730 }
1731 anyhow::bail!(
1732 "Beta header '{key}:{value}' is not supported for model '{model}'. Supported: {}",
1733 available.join(", ")
1734 );
1735 }
1736 }
1737 Ok(())
1738}
1739
1740pub async fn create_default_claude_client(
1747 model: Option<String>,
1748 beta_header: Option<(String, String)>,
1749) -> Result<ClaudeClient> {
1750 create_default_claude_client_with(
1751 &crate::utils::settings::SettingsEnv::load(),
1752 model,
1753 beta_header,
1754 )
1755 .await
1756}
1757
1758pub(crate) async fn create_default_claude_client_with(
1770 env: &(impl crate::utils::env::EnvSource + Sync),
1771 model: Option<String>,
1772 beta_header: Option<(String, String)>,
1773) -> Result<ClaudeClient> {
1774 use crate::claude::ai::claude_cli::ClaudeCliAiClient;
1775 use crate::claude::ai::openai::OpenAiAiClient;
1776 use crate::claude::backend::{self, AiBackend};
1777
1778 let ai_backend = backend::resolve_backend(env)?;
1779 let beta_header = backend::resolve_beta_header(beta_header, env)?;
1780 let registry = crate::claude::model_config::get_model_registry();
1781 let model = backend::resolve_model(ai_backend, model.as_deref(), env, registry);
1782 debug!(backend = ?ai_backend, model = %model, "Resolved AI backend");
1783
1784 match ai_backend {
1785 AiBackend::ClaudeCli => {
1786 if beta_header.is_some() {
1791 warn!(
1792 "--beta-header is ignored when OMNI_DEV_AI_BACKEND=claude-cli \
1793 (the CLI's --betas flag has different semantics and is not forwarded)"
1794 );
1795 }
1796 debug!(model = %model, "Creating claude -p subprocess client");
1797 let ai_client = ClaudeCliAiClient::new(model);
1798 Ok(ClaudeClient::new(Box::new(ai_client)))
1799 }
1800 AiBackend::Ollama => {
1801 validate_beta_header(&model, &beta_header)?;
1802 let base_url = env.var("OLLAMA_BASE_URL");
1803 let mut ai_client = OpenAiAiClient::new_ollama(model, base_url, beta_header)?;
1804 match ai_client.probe_loaded_context_length().await {
1805 Some(source) => {
1806 info!(
1807 loaded_context_length = ai_client.loaded_context_length(),
1808 source = source.as_str(),
1809 model = %ai_client.get_metadata().model,
1810 "Probed loaded context length from local server"
1811 );
1812 }
1813 None => {
1814 debug!(
1815 "Loaded context length probe did not return a value; \
1816 falling back to registry/default for token budget"
1817 );
1818 }
1819 }
1820 Ok(ClaudeClient::new(Box::new(ai_client)))
1821 }
1822 AiBackend::OpenAi => {
1823 debug!("Creating OpenAI client");
1824 validate_beta_header(&model, &beta_header)?;
1825
1826 let api_key = env
1827 .var_any(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"])
1828 .ok_or_else(|| {
1829 debug!("Failed to get OpenAI API key");
1830 ClaudeError::ApiKeyNotFound
1831 })?;
1832 debug!("OpenAI API key found");
1833
1834 let ai_client = OpenAiAiClient::new_openai(model, api_key, beta_header)?;
1835 debug!("OpenAI client created successfully");
1836 Ok(ClaudeClient::new(Box::new(ai_client)))
1837 }
1838 AiBackend::Bedrock => {
1839 validate_beta_header(&model, &beta_header)?;
1840 let auth_token = env
1841 .var("ANTHROPIC_AUTH_TOKEN")
1842 .ok_or(ClaudeError::ApiKeyNotFound)?;
1843
1844 let base_url = env
1845 .var("ANTHROPIC_BEDROCK_BASE_URL")
1846 .ok_or(ClaudeError::ApiKeyNotFound)?;
1847
1848 let ai_client = BedrockAiClient::new(model, auth_token, base_url, beta_header)?;
1849 Ok(ClaudeClient::new(Box::new(ai_client)))
1850 }
1851 AiBackend::Default => {
1852 debug!("Creating direct Claude API client");
1853 validate_beta_header(&model, &beta_header)?;
1854 let api_key = env
1855 .var_any(&[
1856 "CLAUDE_API_KEY",
1857 "ANTHROPIC_API_KEY",
1858 "ANTHROPIC_AUTH_TOKEN",
1859 ])
1860 .ok_or(ClaudeError::ApiKeyNotFound)?;
1861
1862 let ai_client = ClaudeAiClient::new(model, api_key, beta_header)?;
1863 debug!("Claude client created successfully");
1864 Ok(ClaudeClient::new(Box::new(ai_client)))
1865 }
1866 }
1867}
1868
1869#[cfg(test)]
1870#[allow(
1871 clippy::unwrap_used,
1872 clippy::expect_used,
1873 clippy::format_in_format_args
1874)]
1875mod tests {
1876 use super::*;
1877 use crate::claude::ai::{AiClient, AiClientCapabilities, AiClientMetadata};
1878 use std::future::Future;
1879 use std::pin::Pin;
1880 use std::sync::{Arc, Mutex};
1881
1882 struct MockAiClient;
1884
1885 impl AiClient for MockAiClient {
1886 fn send_request<'a>(
1887 &'a self,
1888 _system_prompt: &'a str,
1889 _user_prompt: &'a str,
1890 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1891 Box::pin(async { Ok(String::new()) })
1892 }
1893
1894 fn get_metadata(&self) -> AiClientMetadata {
1895 AiClientMetadata {
1896 provider: "Mock".to_string(),
1897 model: "mock-model".to_string(),
1898 max_context_length: 200_000,
1899 max_response_length: 8_192,
1900 active_beta: None,
1901 }
1902 }
1903 }
1904
1905 fn make_client() -> ClaudeClient {
1906 ClaudeClient::new(Box::new(MockAiClient))
1907 }
1908
1909 struct SchemaRecordingMockAiClient {
1920 capabilities: AiClientCapabilities,
1921 response: String,
1922 recorded_options: Arc<Mutex<Vec<RequestOptions>>>,
1923 recorded_plain: Arc<Mutex<Vec<(String, String)>>>,
1924 }
1925 impl SchemaRecordingMockAiClient {
1926 fn new(supports_response_schema: bool) -> Self {
1927 Self::with_response(supports_response_schema, String::new())
1928 }
1929
1930 fn with_response(supports_response_schema: bool, response: String) -> Self {
1931 Self {
1932 capabilities: AiClientCapabilities {
1933 supports_response_schema,
1934 },
1935 response,
1936 recorded_options: Arc::new(Mutex::new(Vec::new())),
1937 recorded_plain: Arc::new(Mutex::new(Vec::new())),
1938 }
1939 }
1940 }
1941
1942 impl AiClient for SchemaRecordingMockAiClient {
1943 fn send_request<'a>(
1944 &'a self,
1945 system_prompt: &'a str,
1946 user_prompt: &'a str,
1947 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1948 let plain = self.recorded_plain.clone();
1949 let sys = system_prompt.to_string();
1950 let usr = user_prompt.to_string();
1951 let response = self.response.clone();
1952 Box::pin(async move {
1953 plain.lock().unwrap().push((sys, usr));
1954 Ok(response)
1955 })
1956 }
1957
1958 fn capabilities(&self) -> AiClientCapabilities {
1959 self.capabilities
1960 }
1961
1962 fn send_request_with_options<'a>(
1963 &'a self,
1964 _system_prompt: &'a str,
1965 _user_prompt: &'a str,
1966 options: RequestOptions,
1967 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1968 let recorded = self.recorded_options.clone();
1969 let response = self.response.clone();
1970 Box::pin(async move {
1971 recorded.lock().unwrap().push(options);
1972 Ok(response)
1973 })
1974 }
1975
1976 fn get_metadata(&self) -> AiClientMetadata {
1977 AiClientMetadata {
1978 provider: "SchemaMock".to_string(),
1979 model: "schema-mock".to_string(),
1980 max_context_length: 200_000,
1981 max_response_length: 8_192,
1982 active_beta: None,
1983 }
1984 }
1985 }
1986
1987 #[tokio::test]
1993 async fn send_with_optional_schema_without_caps_uses_plain_send() {
1994 let inner = SchemaRecordingMockAiClient::new(false);
1995 let plain_log = inner.recorded_plain.clone();
1996 let opts_log = inner.recorded_options.clone();
1997 let client = ClaudeClient::new(Box::new(inner));
1998
1999 let schema = serde_json::json!({"type": "object"});
2000 client
2001 .send_with_optional_schema(
2002 "sys",
2003 "usr",
2004 client.schema_if_supported(&schema), )
2006 .await
2007 .unwrap();
2008
2009 assert_eq!(plain_log.lock().unwrap().len(), 1);
2010 assert!(opts_log.lock().unwrap().is_empty());
2011 }
2012
2013 #[tokio::test]
2017 async fn send_with_optional_schema_with_caps_uses_options_send() {
2018 let inner = SchemaRecordingMockAiClient::new(true);
2019 let plain_log = inner.recorded_plain.clone();
2020 let opts_log = inner.recorded_options.clone();
2021 let client = ClaudeClient::new(Box::new(inner));
2022
2023 let schema = serde_json::json!({"type": "object", "additionalProperties": false});
2024 client
2025 .send_with_optional_schema(
2026 "sys",
2027 "usr",
2028 client.schema_if_supported(&schema), )
2030 .await
2031 .unwrap();
2032
2033 let recorded = opts_log.lock().unwrap();
2034 assert_eq!(recorded.len(), 1);
2035 assert_eq!(recorded[0].response_schema.as_ref(), Some(&schema));
2036 assert!(plain_log.lock().unwrap().is_empty());
2037 }
2038
2039 #[test]
2042 fn adjusted_system_prompt_adds_suffix_when_supported() {
2043 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
2044 let result = client.adjusted_system_prompt("body".to_string());
2045 assert!(result.starts_with("body"));
2046 assert!(result.contains("STRUCTURED OUTPUT OVERRIDE"));
2047 }
2048
2049 #[test]
2050 fn adjusted_system_prompt_passes_through_when_not_supported() {
2051 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
2052 let result = client.adjusted_system_prompt("body".to_string());
2053 assert_eq!(result, "body");
2054 }
2055
2056 #[test]
2057 fn schema_if_supported_returns_some_when_supported() {
2058 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
2059 let schema = serde_json::json!({"type": "object"});
2060 let returned = client.schema_if_supported(&schema);
2061 assert!(returned.is_some());
2062 assert!(std::ptr::eq(
2063 std::ptr::from_ref(returned.unwrap()),
2064 std::ptr::addr_of!(schema)
2065 ));
2066 }
2067
2068 #[test]
2069 fn schema_if_supported_returns_none_when_not_supported() {
2070 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
2071 let schema = serde_json::json!({"type": "object"});
2072 assert!(client.schema_if_supported(&schema).is_none());
2073 }
2074
2075 #[tokio::test]
2082 async fn refine_amendments_coherence_round_trip() {
2083 let mock = SchemaRecordingMockAiClient::with_response(
2084 true, "amendments: []".to_string(),
2086 );
2087 let recorded_opts = mock.recorded_options.clone();
2088 let client = ClaudeClient::new(Box::new(mock));
2089
2090 let amendment = crate::data::amendments::Amendment {
2091 commit: "abc123".to_string(),
2092 message: "feat: do thing".to_string(),
2093 summary: "did the thing".to_string(),
2094 };
2095 let items = vec![(amendment, "summary text".to_string())];
2096
2097 let result = client
2098 .refine_amendments_coherence(&items)
2099 .await
2100 .expect("coherence refinement should succeed");
2101 assert!(result.amendments.is_empty());
2102
2103 let recorded = recorded_opts.lock().unwrap();
2106 assert_eq!(recorded.len(), 1);
2107 let attached = recorded[0]
2108 .response_schema
2109 .as_ref()
2110 .expect("schema must be attached when capability is true");
2111 assert_eq!(
2112 attached,
2113 response_schema::amendment_file_schema(),
2114 "refine_amendments_coherence should attach the AmendmentFile schema"
2115 );
2116 }
2117
2118 #[tokio::test]
2123 async fn refine_checks_coherence_round_trip() {
2124 let mock = SchemaRecordingMockAiClient::with_response(
2125 true, "checks: []".to_string(),
2127 );
2128 let recorded_opts = mock.recorded_options.clone();
2129 let client = ClaudeClient::new(Box::new(mock));
2130
2131 let check = crate::data::check::CommitCheckResult {
2132 hash: "abc123".to_string(),
2133 message: "feat: do thing".to_string(),
2134 issues: Vec::new(),
2135 suggestion: None,
2136 passes: true,
2137 summary: Some("summary".to_string()),
2138 };
2139 let items = vec![(check, "summary text".to_string())];
2140 let dir = tempfile::TempDir::new().unwrap();
2141 let repo_view = make_test_repo_view(&dir);
2142
2143 let result = client
2144 .refine_checks_coherence(&items, &repo_view)
2145 .await
2146 .expect("coherence refinement should succeed");
2147 assert_eq!(result.summary.total_commits, 0);
2148
2149 let recorded = recorded_opts.lock().unwrap();
2150 assert_eq!(recorded.len(), 1);
2151 let attached = recorded[0]
2152 .response_schema
2153 .as_ref()
2154 .expect("schema must be attached when capability is true");
2155 assert_eq!(
2156 attached,
2157 response_schema::check_response_schema(),
2158 "refine_checks_coherence should attach the AiCheckResponse schema"
2159 );
2160 }
2161
2162 #[tokio::test]
2166 async fn refine_amendments_coherence_without_schema_capability() {
2167 let mock = SchemaRecordingMockAiClient::with_response(
2168 false, "amendments: []".to_string(),
2170 );
2171 let recorded_plain = mock.recorded_plain.clone();
2172 let recorded_opts = mock.recorded_options.clone();
2173 let client = ClaudeClient::new(Box::new(mock));
2174
2175 let amendment = crate::data::amendments::Amendment {
2176 commit: "abc123".to_string(),
2177 message: "feat: do thing".to_string(),
2178 summary: String::new(),
2179 };
2180 let items = vec![(amendment, "summary".to_string())];
2181
2182 client
2183 .refine_amendments_coherence(&items)
2184 .await
2185 .expect("coherence refinement should succeed without schema support");
2186
2187 assert_eq!(recorded_plain.lock().unwrap().len(), 1);
2188 assert!(
2189 recorded_opts.lock().unwrap().is_empty(),
2190 "no-schema backend must not be reached via the options path"
2191 );
2192 }
2193
2194 #[test]
2197 fn extract_yaml_pure_amendments() {
2198 let client = make_client();
2199 let content = "amendments:\n - commit: abc123\n message: test";
2200 let result = client.extract_yaml_from_response(content);
2201 assert!(result.starts_with("amendments:"));
2202 }
2203
2204 #[test]
2205 fn extract_yaml_with_markdown_yaml_block() {
2206 let client = make_client();
2207 let content = "Here is the result:\n```yaml\namendments:\n - commit: abc\n```\n";
2208 let result = client.extract_yaml_from_response(content);
2209 assert!(result.starts_with("amendments:"));
2210 }
2211
2212 #[test]
2213 fn extract_yaml_with_generic_code_block() {
2214 let client = make_client();
2215 let content = "```\namendments:\n - commit: abc\n```";
2216 let result = client.extract_yaml_from_response(content);
2217 assert!(result.starts_with("amendments:"));
2218 }
2219
2220 #[test]
2221 fn extract_yaml_with_whitespace() {
2222 let client = make_client();
2223 let content = " \n amendments:\n - commit: abc\n ";
2224 let result = client.extract_yaml_from_response(content);
2225 assert!(result.starts_with("amendments:"));
2226 }
2227
2228 #[test]
2229 fn extract_yaml_fallback_returns_trimmed() {
2230 let client = make_client();
2231 let content = " some random text ";
2232 let result = client.extract_yaml_from_response(content);
2233 assert_eq!(result, "some random text");
2234 }
2235
2236 #[test]
2239 fn extract_check_yaml_pure() {
2240 let client = make_client();
2241 let content = "checks:\n - commit: abc123";
2242 let result = client.extract_yaml_from_check_response(content);
2243 assert!(result.starts_with("checks:"));
2244 }
2245
2246 #[test]
2247 fn extract_check_yaml_markdown_block() {
2248 let client = make_client();
2249 let content = "```yaml\nchecks:\n - commit: abc\n```";
2250 let result = client.extract_yaml_from_check_response(content);
2251 assert!(result.starts_with("checks:"));
2252 }
2253
2254 #[test]
2255 fn extract_check_yaml_generic_block() {
2256 let client = make_client();
2257 let content = "```\nchecks:\n - commit: abc\n```";
2258 let result = client.extract_yaml_from_check_response(content);
2259 assert!(result.starts_with("checks:"));
2260 }
2261
2262 #[test]
2263 fn extract_check_yaml_fallback() {
2264 let client = make_client();
2265 let content = " unexpected content ";
2266 let result = client.extract_yaml_from_check_response(content);
2267 assert_eq!(result, "unexpected content");
2268 }
2269
2270 #[test]
2273 fn parse_amendment_response_valid() {
2274 let client = make_client();
2275 let yaml = format!(
2276 "amendments:\n - commit: \"{}\"\n message: \"test message\"",
2277 "a".repeat(40)
2278 );
2279 let result = client.parse_amendment_response(&yaml);
2280 assert!(result.is_ok());
2281 assert_eq!(result.unwrap().amendments.len(), 1);
2282 }
2283
2284 #[test]
2285 fn parse_amendment_response_invalid_yaml() {
2286 let client = make_client();
2287 let result = client.parse_amendment_response("not: valid: yaml: [{{");
2288 assert!(result.is_err());
2289 }
2290
2291 #[test]
2292 fn parse_amendment_response_invalid_hash() {
2293 let client = make_client();
2294 let yaml = "amendments:\n - commit: \"short\"\n message: \"test\"";
2295 let result = client.parse_amendment_response(yaml);
2296 assert!(result.is_err());
2297 }
2298
2299 #[test]
2302 fn validate_beta_header_none_passes() {
2303 let result = validate_beta_header("claude-opus-4-1-20250805", &None);
2304 assert!(result.is_ok());
2305 }
2306
2307 #[test]
2308 fn validate_beta_header_unsupported_fails() {
2309 let header = Some(("fake-key".to_string(), "fake-value".to_string()));
2310 let result = validate_beta_header("claude-opus-4-1-20250805", &header);
2311 assert!(result.is_err());
2312 }
2313
2314 #[test]
2317 fn client_metadata() {
2318 let client = make_client();
2319 let metadata = client.get_ai_client_metadata();
2320 assert_eq!(metadata.provider, "Mock");
2321 assert_eq!(metadata.model, "mock-model");
2322 }
2323
2324 mod prop {
2327 use super::*;
2328 use proptest::prelude::*;
2329
2330 proptest! {
2331 #[test]
2332 fn yaml_response_output_trimmed(s in ".*") {
2333 let client = make_client();
2334 let result = client.extract_yaml_from_response(&s);
2335 prop_assert_eq!(&result, result.trim());
2336 }
2337
2338 #[test]
2339 fn yaml_response_amendments_prefix_preserved(tail in ".*") {
2340 let client = make_client();
2341 let input = format!("amendments:{tail}");
2342 let result = client.extract_yaml_from_response(&input);
2343 prop_assert!(result.starts_with("amendments:"));
2344 }
2345
2346 #[test]
2347 fn check_response_checks_prefix_preserved(tail in ".*") {
2348 let client = make_client();
2349 let input = format!("checks:{tail}");
2350 let result = client.extract_yaml_from_check_response(&input);
2351 prop_assert!(result.starts_with("checks:"));
2352 }
2353
2354 #[test]
2355 fn yaml_fenced_block_strips_fences(
2356 content in "[a-zA-Z0-9: _\\-\n]{1,100}",
2357 ) {
2358 let client = make_client();
2359 let input = format!("```yaml\n{content}\n```");
2360 let result = client.extract_yaml_from_response(&input);
2361 prop_assert!(!result.contains("```"));
2362 }
2363 }
2364 }
2365
2366 fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
2369 ClaudeClient::new(Box::new(
2370 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2371 ))
2372 }
2373
2374 fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2375 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2376 use crate::git::commit::FileChanges;
2377 use crate::git::{CommitAnalysis, CommitInfo};
2378
2379 let diff_path = dir.path().join("0.diff");
2380 std::fs::write(&diff_path, "+added line\n").unwrap();
2381
2382 crate::data::RepositoryView {
2383 versions: None,
2384 explanation: FieldExplanation::default(),
2385 working_directory: WorkingDirectoryInfo {
2386 clean: true,
2387 untracked_changes: Vec::new(),
2388 },
2389 remotes: Vec::new(),
2390 ai: AiInfo {
2391 scratch: String::new(),
2392 },
2393 branch_info: None,
2394 pr_template: None,
2395 pr_template_location: None,
2396 branch_prs: None,
2397 commits: vec![CommitInfo {
2398 hash: format!("{:0>40}", 0),
2399 author: "Test <test@test.com>".to_string(),
2400 date: chrono::Utc::now().fixed_offset(),
2401 original_message: "feat(test): add something".to_string(),
2402 in_main_branches: Vec::new(),
2403 analysis: CommitAnalysis {
2404 detected_type: "feat".to_string(),
2405 detected_scope: "test".to_string(),
2406 proposed_message: "feat(test): add something".to_string(),
2407 file_changes: FileChanges {
2408 total_files: 1,
2409 files_added: 1,
2410 files_deleted: 0,
2411 file_list: Vec::new(),
2412 },
2413 diff_summary: "file.rs | 1 +".to_string(),
2414 diff_file: diff_path.to_string_lossy().to_string(),
2415 file_diffs: Vec::new(),
2416 },
2417 }],
2418 }
2419 }
2420
2421 fn valid_check_yaml() -> String {
2422 format!(
2423 "checks:\n - commit: \"{hash}\"\n passes: true\n issues: []\n",
2424 hash = format!("{:0>40}", 0)
2425 )
2426 }
2427
2428 #[tokio::test]
2429 async fn send_message_propagates_ai_error() {
2430 let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
2431 let result = client.send_message("sys", "usr").await;
2432 assert!(result.is_err());
2433 assert!(result.unwrap_err().to_string().contains("mock error"));
2434 }
2435
2436 #[tokio::test]
2437 async fn check_commits_succeeds_after_request_error() {
2438 let dir = tempfile::tempdir().unwrap();
2439 let repo_view = make_test_repo_view(&dir);
2440 let client = make_configurable_client(vec![
2442 Err(anyhow::anyhow!("rate limit")),
2443 Ok(valid_check_yaml()),
2444 Ok(valid_check_yaml()),
2445 ]);
2446 let result = client
2447 .check_commits_with_scopes(&repo_view, None, &[], false)
2448 .await;
2449 assert!(result.is_ok());
2450 }
2451
2452 #[tokio::test]
2453 async fn check_commits_succeeds_after_parse_error() {
2454 let dir = tempfile::tempdir().unwrap();
2455 let repo_view = make_test_repo_view(&dir);
2456 let client = make_configurable_client(vec![
2458 Ok("not: valid: yaml: [[".to_string()),
2459 Ok(valid_check_yaml()),
2460 Ok(valid_check_yaml()),
2461 ]);
2462 let result = client
2463 .check_commits_with_scopes(&repo_view, None, &[], false)
2464 .await;
2465 assert!(result.is_ok());
2466 }
2467
2468 #[tokio::test]
2469 async fn check_commits_fails_after_all_retries_exhausted() {
2470 let dir = tempfile::tempdir().unwrap();
2471 let repo_view = make_test_repo_view(&dir);
2472 let client = make_configurable_client(vec![
2473 Err(anyhow::anyhow!("first failure")),
2474 Err(anyhow::anyhow!("second failure")),
2475 Err(anyhow::anyhow!("final failure")),
2476 ]);
2477 let result = client
2478 .check_commits_with_scopes(&repo_view, None, &[], false)
2479 .await;
2480 assert!(result.is_err());
2481 }
2482
2483 #[tokio::test]
2484 async fn check_commits_fails_when_all_parses_fail() {
2485 let dir = tempfile::tempdir().unwrap();
2486 let repo_view = make_test_repo_view(&dir);
2487 let client = make_configurable_client(vec![
2488 Ok("bad yaml [[".to_string()),
2489 Ok("bad yaml [[".to_string()),
2490 Ok("bad yaml [[".to_string()),
2491 ]);
2492 let result = client
2493 .check_commits_with_scopes(&repo_view, None, &[], false)
2494 .await;
2495 assert!(result.is_err());
2496 }
2497
2498 fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
2505 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2509 .with_context_length(50_000);
2510 ClaudeClient::new(Box::new(mock))
2511 }
2512
2513 fn make_small_context_client_tracked(
2516 responses: Vec<Result<String>>,
2517 ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
2518 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2519 .with_context_length(50_000);
2520 let handle = mock.response_handle();
2521 (ClaudeClient::new(Box::new(mock)), handle)
2522 }
2523
2524 fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2527 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2528 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2529 use crate::git::{CommitAnalysis, CommitInfo};
2530
2531 let hash = "a".repeat(40);
2532
2533 let full_diff = "x".repeat(120_000);
2537 let flat_diff_path = dir.path().join("full.diff");
2538 std::fs::write(&flat_diff_path, &full_diff).unwrap();
2539
2540 let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2543 let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2544
2545 let path_a = dir.path().join("0000.diff");
2546 let path_b = dir.path().join("0001.diff");
2547 std::fs::write(&path_a, &diff_a).unwrap();
2548 std::fs::write(&path_b, &diff_b).unwrap();
2549
2550 crate::data::RepositoryView {
2551 versions: None,
2552 explanation: FieldExplanation::default(),
2553 working_directory: WorkingDirectoryInfo {
2554 clean: true,
2555 untracked_changes: Vec::new(),
2556 },
2557 remotes: Vec::new(),
2558 ai: AiInfo {
2559 scratch: String::new(),
2560 },
2561 branch_info: None,
2562 pr_template: None,
2563 pr_template_location: None,
2564 branch_prs: None,
2565 commits: vec![CommitInfo {
2566 hash,
2567 author: "Test <test@test.com>".to_string(),
2568 date: chrono::Utc::now().fixed_offset(),
2569 original_message: "feat(test): large commit".to_string(),
2570 in_main_branches: Vec::new(),
2571 analysis: CommitAnalysis {
2572 detected_type: "feat".to_string(),
2573 detected_scope: "test".to_string(),
2574 proposed_message: "feat(test): large commit".to_string(),
2575 file_changes: FileChanges {
2576 total_files: 2,
2577 files_added: 2,
2578 files_deleted: 0,
2579 file_list: vec![
2580 FileChange {
2581 status: "A".to_string(),
2582 file: "src/a.rs".to_string(),
2583 },
2584 FileChange {
2585 status: "A".to_string(),
2586 file: "src/b.rs".to_string(),
2587 },
2588 ],
2589 },
2590 diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2591 diff_file: flat_diff_path.to_string_lossy().to_string(),
2592 file_diffs: vec![
2593 FileDiffRef {
2594 path: "src/a.rs".to_string(),
2595 diff_file: path_a.to_string_lossy().to_string(),
2596 byte_len: diff_a.len(),
2597 },
2598 FileDiffRef {
2599 path: "src/b.rs".to_string(),
2600 diff_file: path_b.to_string_lossy().to_string(),
2601 byte_len: diff_b.len(),
2602 },
2603 ],
2604 },
2605 }],
2606 }
2607 }
2608
2609 fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2610 format!("amendments:\n - commit: \"{hash}\"\n message: \"{message}\"")
2611 }
2612
2613 #[tokio::test]
2614 async fn generate_amendments_split_dispatch() {
2615 let dir = tempfile::tempdir().unwrap();
2616 let repo_view = make_large_diff_repo_view(&dir);
2617 let hash = "a".repeat(40);
2618
2619 let client = make_small_context_client(vec![
2621 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2622 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2623 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2624 ]);
2625
2626 let result = client
2627 .generate_amendments_with_options(&repo_view, false)
2628 .await;
2629
2630 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2631 let amendments = result.unwrap();
2632 assert_eq!(amendments.amendments.len(), 1);
2633 assert_eq!(amendments.amendments[0].commit, hash);
2634 assert!(amendments.amendments[0]
2635 .message
2636 .contains("add a.rs and b.rs"));
2637 }
2638
2639 #[tokio::test]
2640 async fn generate_amendments_split_chunk_failure() {
2641 let dir = tempfile::tempdir().unwrap();
2642 let repo_view = make_large_diff_repo_view(&dir);
2643 let hash = "a".repeat(40);
2644
2645 let client = make_small_context_client(vec![
2647 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2648 Err(anyhow::anyhow!("rate limit exceeded")),
2649 ]);
2650
2651 let result = client
2652 .generate_amendments_with_options(&repo_view, false)
2653 .await;
2654
2655 assert!(result.is_err());
2656 }
2657
2658 #[tokio::test]
2659 async fn generate_amendments_no_split_when_fits() {
2660 let dir = tempfile::tempdir().unwrap();
2661 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2663
2664 let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2666 &hash,
2667 "feat(test): improved message",
2668 ))]);
2669
2670 let result = client
2671 .generate_amendments_with_options(&repo_view, false)
2672 .await;
2673
2674 assert!(result.is_ok());
2675 assert_eq!(result.unwrap().amendments.len(), 1);
2676 }
2677
2678 fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2681 format!(
2682 "checks:\n - commit: \"{hash}\"\n passes: {passes}\n issues: []\n summary: \"test summary\"\n"
2683 )
2684 }
2685
2686 fn valid_check_yaml_with_issues(hash: &str) -> String {
2687 format!(
2688 concat!(
2689 "checks:\n",
2690 " - commit: \"{hash}\"\n",
2691 " passes: false\n",
2692 " issues:\n",
2693 " - severity: error\n",
2694 " section: \"Subject Line\"\n",
2695 " rule: \"imperative-mood\"\n",
2696 " explanation: \"Subject uses past tense\"\n",
2697 " suggestion:\n",
2698 " message: \"feat(test): shorter subject\"\n",
2699 " explanation: \"Shortened subject line\"\n",
2700 " summary: \"Large commit with issues\"\n",
2701 ),
2702 hash = hash,
2703 )
2704 }
2705
2706 fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2707 format!(
2708 concat!(
2709 "checks:\n",
2710 " - commit: \"{hash}\"\n",
2711 " passes: true\n",
2712 " issues: []\n",
2713 " summary: \"chunk summary\"\n",
2714 ),
2715 hash = hash,
2716 )
2717 }
2718
2719 #[tokio::test]
2720 async fn check_commits_split_dispatch() {
2721 let dir = tempfile::tempdir().unwrap();
2722 let repo_view = make_large_diff_repo_view(&dir);
2723 let hash = "a".repeat(40);
2724
2725 let client = make_small_context_client(vec![
2727 Ok(valid_check_yaml_with_issues(&hash)),
2728 Ok(valid_check_yaml_with_issues(&hash)),
2729 Ok(valid_check_yaml_with_issues(&hash)), ]);
2731
2732 let result = client
2733 .check_commits_with_scopes(&repo_view, None, &[], true)
2734 .await;
2735
2736 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2737 let report = result.unwrap();
2738 assert_eq!(report.commits.len(), 1);
2739 assert!(!report.commits[0].passes);
2740 assert_eq!(report.commits[0].issues.len(), 1);
2742 assert_eq!(report.commits[0].issues[0].rule, "imperative-mood");
2743 }
2744
2745 #[tokio::test]
2746 async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2747 let dir = tempfile::tempdir().unwrap();
2748 let repo_view = make_large_diff_repo_view(&dir);
2749 let hash = "a".repeat(40);
2750
2751 let client = make_small_context_client(vec![
2754 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2755 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2756 ]);
2757
2758 let result = client
2759 .check_commits_with_scopes(&repo_view, None, &[], false)
2760 .await;
2761
2762 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2763 let report = result.unwrap();
2764 assert_eq!(report.commits.len(), 1);
2765 assert!(report.commits[0].passes);
2766 assert!(report.commits[0].issues.is_empty());
2767 assert!(report.commits[0].suggestion.is_none());
2768 assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2770 }
2771
2772 #[tokio::test]
2773 async fn check_commits_split_chunk_failure() {
2774 let dir = tempfile::tempdir().unwrap();
2775 let repo_view = make_large_diff_repo_view(&dir);
2776 let hash = "a".repeat(40);
2777
2778 let client = make_small_context_client(vec![
2780 Ok(valid_check_yaml_for(&hash, true)),
2781 Err(anyhow::anyhow!("rate limit exceeded")),
2782 ]);
2783
2784 let result = client
2785 .check_commits_with_scopes(&repo_view, None, &[], false)
2786 .await;
2787
2788 assert!(result.is_err());
2789 }
2790
2791 #[tokio::test]
2792 async fn check_commits_no_split_when_fits() {
2793 let dir = tempfile::tempdir().unwrap();
2794 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2796
2797 let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2799
2800 let result = client
2801 .check_commits_with_scopes(&repo_view, None, &[], false)
2802 .await;
2803
2804 assert!(result.is_ok());
2805 assert_eq!(result.unwrap().commits.len(), 1);
2806 }
2807
2808 #[tokio::test]
2809 async fn check_commits_split_dedup_across_chunks() {
2810 let dir = tempfile::tempdir().unwrap();
2811 let repo_view = make_large_diff_repo_view(&dir);
2812 let hash = "a".repeat(40);
2813
2814 let chunk1 = format!(
2816 concat!(
2817 "checks:\n",
2818 " - commit: \"{hash}\"\n",
2819 " passes: false\n",
2820 " issues:\n",
2821 " - severity: error\n",
2822 " section: \"Subject Line\"\n",
2823 " rule: \"imperative-mood\"\n",
2824 " explanation: \"Subject uses past tense\"\n",
2825 " - severity: warning\n",
2826 " section: \"Content\"\n",
2827 " rule: \"body-required\"\n",
2828 " explanation: \"Large change needs body\"\n",
2829 ),
2830 hash = hash,
2831 );
2832
2833 let chunk2 = format!(
2835 concat!(
2836 "checks:\n",
2837 " - commit: \"{hash}\"\n",
2838 " passes: false\n",
2839 " issues:\n",
2840 " - severity: error\n",
2841 " section: \"Subject Line\"\n",
2842 " rule: \"imperative-mood\"\n",
2843 " explanation: \"Subject line is too long\"\n",
2844 " - severity: info\n",
2845 " section: \"Style\"\n",
2846 " rule: \"scope-suggestion\"\n",
2847 " explanation: \"Consider more specific scope\"\n",
2848 ),
2849 hash = hash,
2850 );
2851
2852 let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2854
2855 let result = client
2856 .check_commits_with_scopes(&repo_view, None, &[], false)
2857 .await;
2858
2859 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2860 let report = result.unwrap();
2861 assert_eq!(report.commits.len(), 1);
2862 assert!(!report.commits[0].passes);
2863 assert_eq!(report.commits[0].issues.len(), 3);
2866 }
2867
2868 #[tokio::test]
2869 async fn check_commits_split_passes_only_when_all_chunks_pass() {
2870 let dir = tempfile::tempdir().unwrap();
2871 let repo_view = make_large_diff_repo_view(&dir);
2872 let hash = "a".repeat(40);
2873
2874 let client = make_small_context_client(vec![
2876 Ok(valid_check_yaml_for(&hash, true)),
2877 Ok(valid_check_yaml_for(&hash, false)),
2878 ]);
2879
2880 let result = client
2881 .check_commits_with_scopes(&repo_view, None, &[], false)
2882 .await;
2883
2884 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2885 let report = result.unwrap();
2886 assert!(
2887 !report.commits[0].passes,
2888 "should fail when any chunk fails"
2889 );
2890 }
2891
2892 fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2896 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2897 use crate::git::commit::FileChanges;
2898 use crate::git::{CommitAnalysis, CommitInfo};
2899
2900 let diff_a = dir.path().join("0.diff");
2901 let diff_b = dir.path().join("1.diff");
2902 std::fs::write(&diff_a, "+line a\n").unwrap();
2903 std::fs::write(&diff_b, "+line b\n").unwrap();
2904
2905 let hash_a = "a".repeat(40);
2906 let hash_b = "b".repeat(40);
2907
2908 crate::data::RepositoryView {
2909 versions: None,
2910 explanation: FieldExplanation::default(),
2911 working_directory: WorkingDirectoryInfo {
2912 clean: true,
2913 untracked_changes: Vec::new(),
2914 },
2915 remotes: Vec::new(),
2916 ai: AiInfo {
2917 scratch: String::new(),
2918 },
2919 branch_info: None,
2920 pr_template: None,
2921 pr_template_location: None,
2922 branch_prs: None,
2923 commits: vec![
2924 CommitInfo {
2925 hash: hash_a,
2926 author: "Test <test@test.com>".to_string(),
2927 date: chrono::Utc::now().fixed_offset(),
2928 original_message: "feat(a): add a".to_string(),
2929 in_main_branches: Vec::new(),
2930 analysis: CommitAnalysis {
2931 detected_type: "feat".to_string(),
2932 detected_scope: "a".to_string(),
2933 proposed_message: "feat(a): add a".to_string(),
2934 file_changes: FileChanges {
2935 total_files: 1,
2936 files_added: 1,
2937 files_deleted: 0,
2938 file_list: Vec::new(),
2939 },
2940 diff_summary: "a.rs | 1 +".to_string(),
2941 diff_file: diff_a.to_string_lossy().to_string(),
2942 file_diffs: Vec::new(),
2943 },
2944 },
2945 CommitInfo {
2946 hash: hash_b,
2947 author: "Test <test@test.com>".to_string(),
2948 date: chrono::Utc::now().fixed_offset(),
2949 original_message: "feat(b): add b".to_string(),
2950 in_main_branches: Vec::new(),
2951 analysis: CommitAnalysis {
2952 detected_type: "feat".to_string(),
2953 detected_scope: "b".to_string(),
2954 proposed_message: "feat(b): add b".to_string(),
2955 file_changes: FileChanges {
2956 total_files: 1,
2957 files_added: 1,
2958 files_deleted: 0,
2959 file_list: Vec::new(),
2960 },
2961 diff_summary: "b.rs | 1 +".to_string(),
2962 diff_file: diff_b.to_string_lossy().to_string(),
2963 file_diffs: Vec::new(),
2964 },
2965 },
2966 ],
2967 }
2968 }
2969
2970 #[tokio::test]
2971 async fn generate_amendments_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 "amendments:\n",
2980 " - commit: \"{hash_a}\"\n",
2981 " message: \"feat(a): improved a\"\n",
2982 " - commit: \"{hash_b}\"\n",
2983 " message: \"feat(b): improved b\"\n",
2984 ),
2985 hash_a = hash_a,
2986 hash_b = hash_b,
2987 );
2988 let client = make_configurable_client(vec![Ok(response)]);
2989
2990 let result = client
2991 .generate_amendments_with_options(&repo_view, false)
2992 .await;
2993
2994 assert!(
2995 result.is_ok(),
2996 "multi-commit amendment failed: {:?}",
2997 result.err()
2998 );
2999 let amendments = result.unwrap();
3000 assert_eq!(amendments.amendments.len(), 2);
3001 }
3002
3003 #[tokio::test]
3004 async fn generate_contextual_amendments_multi_commit() {
3005 let dir = tempfile::tempdir().unwrap();
3006 let repo_view = make_multi_commit_repo_view(&dir);
3007 let hash_a = "a".repeat(40);
3008 let hash_b = "b".repeat(40);
3009
3010 let response = format!(
3011 concat!(
3012 "amendments:\n",
3013 " - commit: \"{hash_a}\"\n",
3014 " message: \"feat(a): improved a\"\n",
3015 " - commit: \"{hash_b}\"\n",
3016 " message: \"feat(b): improved b\"\n",
3017 ),
3018 hash_a = hash_a,
3019 hash_b = hash_b,
3020 );
3021 let client = make_configurable_client(vec![Ok(response)]);
3022 let context = crate::data::context::CommitContext::default();
3023
3024 let result = client
3025 .generate_contextual_amendments_with_options(&repo_view, &context, false)
3026 .await;
3027
3028 assert!(
3029 result.is_ok(),
3030 "multi-commit contextual amendment failed: {:?}",
3031 result.err()
3032 );
3033 let amendments = result.unwrap();
3034 assert_eq!(amendments.amendments.len(), 2);
3035 }
3036
3037 #[tokio::test]
3038 async fn generate_pr_content_succeeds() {
3039 let dir = tempfile::tempdir().unwrap();
3040 let repo_view = make_test_repo_view(&dir);
3041
3042 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
3043 let client = make_configurable_client(vec![Ok(response.to_string())]);
3044
3045 let result = client.generate_pr_content(&repo_view, "").await;
3046
3047 assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
3048 let pr = result.unwrap();
3049 assert_eq!(pr.title, "feat: add something");
3050 assert_eq!(pr.description, "Adds a new feature.");
3051 }
3052
3053 #[tokio::test]
3054 async fn generate_pr_content_with_context_succeeds() {
3055 let dir = tempfile::tempdir().unwrap();
3056 let repo_view = make_test_repo_view(&dir);
3057 let context = crate::data::context::CommitContext::default();
3058
3059 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
3060 let client = make_configurable_client(vec![Ok(response.to_string())]);
3061
3062 let result = client
3063 .generate_pr_content_with_context(&repo_view, "", &context)
3064 .await;
3065
3066 assert!(
3067 result.is_ok(),
3068 "PR generation with context failed: {:?}",
3069 result.err()
3070 );
3071 let pr = result.unwrap();
3072 assert_eq!(pr.title, "feat: add something");
3073 }
3074
3075 #[tokio::test]
3076 async fn check_commits_multi_commit() {
3077 let dir = tempfile::tempdir().unwrap();
3078 let repo_view = make_multi_commit_repo_view(&dir);
3079 let hash_a = "a".repeat(40);
3080 let hash_b = "b".repeat(40);
3081
3082 let response = format!(
3083 concat!(
3084 "checks:\n",
3085 " - commit: \"{hash_a}\"\n",
3086 " passes: true\n",
3087 " issues: []\n",
3088 " - commit: \"{hash_b}\"\n",
3089 " passes: true\n",
3090 " issues: []\n",
3091 ),
3092 hash_a = hash_a,
3093 hash_b = hash_b,
3094 );
3095 let client = make_configurable_client(vec![Ok(response)]);
3096
3097 let result = client
3098 .check_commits_with_scopes(&repo_view, None, &[], false)
3099 .await;
3100
3101 assert!(
3102 result.is_ok(),
3103 "multi-commit check failed: {:?}",
3104 result.err()
3105 );
3106 let report = result.unwrap();
3107 assert_eq!(report.commits.len(), 2);
3108 assert!(report.commits[0].passes);
3109 assert!(report.commits[1].passes);
3110 }
3111
3112 fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
3117 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3118 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3119 use crate::git::{CommitAnalysis, CommitInfo};
3120
3121 let hash_a = "a".repeat(40);
3122 let hash_b = "b".repeat(40);
3123
3124 let diff_content_a = "x".repeat(60_000);
3127 let diff_content_b = "y".repeat(60_000);
3128 let flat_a = dir.path().join("flat_a.diff");
3129 let flat_b = dir.path().join("flat_b.diff");
3130 std::fs::write(&flat_a, &diff_content_a).unwrap();
3131 std::fs::write(&flat_b, &diff_content_b).unwrap();
3132
3133 let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
3135 let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
3136 let per_file_a = dir.path().join("pf_a.diff");
3137 let per_file_b = dir.path().join("pf_b.diff");
3138 std::fs::write(&per_file_a, &file_diff_a).unwrap();
3139 std::fs::write(&per_file_b, &file_diff_b).unwrap();
3140
3141 crate::data::RepositoryView {
3142 versions: None,
3143 explanation: FieldExplanation::default(),
3144 working_directory: WorkingDirectoryInfo {
3145 clean: true,
3146 untracked_changes: Vec::new(),
3147 },
3148 remotes: Vec::new(),
3149 ai: AiInfo {
3150 scratch: String::new(),
3151 },
3152 branch_info: None,
3153 pr_template: None,
3154 pr_template_location: None,
3155 branch_prs: None,
3156 commits: vec![
3157 CommitInfo {
3158 hash: hash_a,
3159 author: "Test <test@test.com>".to_string(),
3160 date: chrono::Utc::now().fixed_offset(),
3161 original_message: "feat(a): add module a".to_string(),
3162 in_main_branches: Vec::new(),
3163 analysis: CommitAnalysis {
3164 detected_type: "feat".to_string(),
3165 detected_scope: "a".to_string(),
3166 proposed_message: "feat(a): add module a".to_string(),
3167 file_changes: FileChanges {
3168 total_files: 1,
3169 files_added: 1,
3170 files_deleted: 0,
3171 file_list: vec![FileChange {
3172 status: "A".to_string(),
3173 file: "src/a.rs".to_string(),
3174 }],
3175 },
3176 diff_summary: " src/a.rs | 100 ++++\n".to_string(),
3177 diff_file: flat_a.to_string_lossy().to_string(),
3178 file_diffs: vec![FileDiffRef {
3179 path: "src/a.rs".to_string(),
3180 diff_file: per_file_a.to_string_lossy().to_string(),
3181 byte_len: file_diff_a.len(),
3182 }],
3183 },
3184 },
3185 CommitInfo {
3186 hash: hash_b,
3187 author: "Test <test@test.com>".to_string(),
3188 date: chrono::Utc::now().fixed_offset(),
3189 original_message: "feat(b): add module b".to_string(),
3190 in_main_branches: Vec::new(),
3191 analysis: CommitAnalysis {
3192 detected_type: "feat".to_string(),
3193 detected_scope: "b".to_string(),
3194 proposed_message: "feat(b): add module b".to_string(),
3195 file_changes: FileChanges {
3196 total_files: 1,
3197 files_added: 1,
3198 files_deleted: 0,
3199 file_list: vec![FileChange {
3200 status: "A".to_string(),
3201 file: "src/b.rs".to_string(),
3202 }],
3203 },
3204 diff_summary: " src/b.rs | 100 ++++\n".to_string(),
3205 diff_file: flat_b.to_string_lossy().to_string(),
3206 file_diffs: vec![FileDiffRef {
3207 path: "src/b.rs".to_string(),
3208 diff_file: per_file_b.to_string_lossy().to_string(),
3209 byte_len: file_diff_b.len(),
3210 }],
3211 },
3212 },
3213 ],
3214 }
3215 }
3216
3217 fn valid_pr_yaml(title: &str, description: &str) -> String {
3218 format!("title: \"{title}\"\ndescription: \"{description}\"\n")
3219 }
3220
3221 #[tokio::test]
3224 async fn generate_amendments_multi_commit_split_dispatch() {
3225 let dir = tempfile::tempdir().unwrap();
3226 let repo_view = make_large_multi_commit_repo_view(&dir);
3227 let hash_a = "a".repeat(40);
3228 let hash_b = "b".repeat(40);
3229
3230 let (client, handle) = make_small_context_client_tracked(vec![
3233 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3234 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3235 ]);
3236
3237 let result = client
3238 .generate_amendments_with_options(&repo_view, false)
3239 .await;
3240
3241 assert!(
3242 result.is_ok(),
3243 "multi-commit split dispatch failed: {:?}",
3244 result.err()
3245 );
3246 let amendments = result.unwrap();
3247 assert_eq!(amendments.amendments.len(), 2);
3248 assert_eq!(amendments.amendments[0].commit, hash_a);
3249 assert_eq!(amendments.amendments[1].commit, hash_b);
3250 assert!(amendments.amendments[0].message.contains("improved a"));
3251 assert!(amendments.amendments[1].message.contains("improved b"));
3252 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3253 }
3254
3255 #[tokio::test]
3256 async fn generate_contextual_amendments_multi_commit_split_dispatch() {
3257 let dir = tempfile::tempdir().unwrap();
3258 let repo_view = make_large_multi_commit_repo_view(&dir);
3259 let hash_a = "a".repeat(40);
3260 let hash_b = "b".repeat(40);
3261 let context = crate::data::context::CommitContext::default();
3262
3263 let (client, handle) = make_small_context_client_tracked(vec![
3264 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3265 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3266 ]);
3267
3268 let result = client
3269 .generate_contextual_amendments_with_options(&repo_view, &context, false)
3270 .await;
3271
3272 assert!(
3273 result.is_ok(),
3274 "multi-commit contextual split dispatch failed: {:?}",
3275 result.err()
3276 );
3277 let amendments = result.unwrap();
3278 assert_eq!(amendments.amendments.len(), 2);
3279 assert_eq!(amendments.amendments[0].commit, hash_a);
3280 assert_eq!(amendments.amendments[1].commit, hash_b);
3281 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3282 }
3283
3284 #[tokio::test]
3287 async fn check_commits_multi_commit_split_dispatch() {
3288 let dir = tempfile::tempdir().unwrap();
3289 let repo_view = make_large_multi_commit_repo_view(&dir);
3290 let hash_a = "a".repeat(40);
3291 let hash_b = "b".repeat(40);
3292
3293 let (client, handle) = make_small_context_client_tracked(vec![
3295 Ok(valid_check_yaml_for(&hash_a, true)),
3296 Ok(valid_check_yaml_for(&hash_b, true)),
3297 ]);
3298
3299 let result = client
3300 .check_commits_with_scopes(&repo_view, None, &[], false)
3301 .await;
3302
3303 assert!(
3304 result.is_ok(),
3305 "multi-commit check split dispatch failed: {:?}",
3306 result.err()
3307 );
3308 let report = result.unwrap();
3309 assert_eq!(report.commits.len(), 2);
3310 assert!(report.commits[0].passes);
3311 assert!(report.commits[1].passes);
3312 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3313 }
3314
3315 #[tokio::test]
3318 async fn generate_pr_content_split_dispatch() {
3319 let dir = tempfile::tempdir().unwrap();
3320 let repo_view = make_large_diff_repo_view(&dir);
3321
3322 let (client, handle) = make_small_context_client_tracked(vec![
3326 Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
3327 Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
3328 Ok(valid_pr_yaml(
3329 "feat(test): add modules",
3330 "Adds a.rs and b.rs",
3331 )),
3332 ]);
3333
3334 let result = client.generate_pr_content(&repo_view, "").await;
3335
3336 assert!(
3337 result.is_ok(),
3338 "PR split dispatch failed: {:?}",
3339 result.err()
3340 );
3341 let pr = result.unwrap();
3342 assert!(pr.title.contains("add modules"));
3343 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3344 }
3345
3346 #[tokio::test]
3347 async fn generate_pr_content_multi_commit_split_dispatch() {
3348 let dir = tempfile::tempdir().unwrap();
3349 let repo_view = make_large_multi_commit_repo_view(&dir);
3350
3351 let (client, handle) = make_small_context_client_tracked(vec![
3354 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3355 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3356 Ok(valid_pr_yaml(
3357 "feat: add modules a and b",
3358 "Adds both modules",
3359 )),
3360 ]);
3361
3362 let result = client.generate_pr_content(&repo_view, "").await;
3363
3364 assert!(
3365 result.is_ok(),
3366 "PR multi-commit split dispatch failed: {:?}",
3367 result.err()
3368 );
3369 let pr = result.unwrap();
3370 assert!(pr.title.contains("modules"));
3371 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3372 }
3373
3374 #[tokio::test]
3375 async fn generate_pr_content_with_context_from_commits_succeeds() {
3376 let dir = tempfile::tempdir().unwrap();
3377 let repo_view = make_test_repo_view(&dir);
3378 let context = crate::data::context::CommitContext::default();
3379
3380 let response = "title: \"feat: from commits\"\ndescription: \"derived from commit\"\n";
3381 let client = make_configurable_client(vec![Ok(response.to_string())]);
3382
3383 let result = client
3384 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3385 .await;
3386
3387 assert!(
3388 result.is_ok(),
3389 "PR from-commits generation failed: {:?}",
3390 result.err()
3391 );
3392 let pr = result.unwrap();
3393 assert_eq!(pr.title, "feat: from commits");
3394 }
3395
3396 #[tokio::test]
3397 async fn generate_pr_content_with_context_from_commits_omits_diff_in_prompt() {
3398 let dir = tempfile::tempdir().unwrap();
3399 let diff_path = dir.path().join("recognisable.diff");
3402 std::fs::write(
3403 &diff_path,
3404 "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+UNIQUE_DIFF_MARKER\n",
3405 )
3406 .unwrap();
3407
3408 let repo_view = {
3411 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3412 use crate::git::commit::FileChanges;
3413 use crate::git::{CommitAnalysis, CommitInfo};
3414 crate::data::RepositoryView {
3415 versions: None,
3416 explanation: FieldExplanation::default(),
3417 working_directory: WorkingDirectoryInfo {
3418 clean: true,
3419 untracked_changes: Vec::new(),
3420 },
3421 remotes: Vec::new(),
3422 ai: AiInfo {
3423 scratch: String::new(),
3424 },
3425 branch_info: None,
3426 pr_template: None,
3427 pr_template_location: None,
3428 branch_prs: None,
3429 commits: vec![CommitInfo {
3430 hash: format!("{:0>40}", 0),
3431 author: "Test <test@test.com>".to_string(),
3432 date: chrono::Utc::now().fixed_offset(),
3433 original_message: "feat(test): UNIQUE_COMMIT_SUBJECT_MARKER".to_string(),
3434 in_main_branches: Vec::new(),
3435 analysis: CommitAnalysis {
3436 detected_type: "feat".to_string(),
3437 detected_scope: "test".to_string(),
3438 proposed_message: "feat(test): unique".to_string(),
3439 file_changes: FileChanges {
3440 total_files: 1,
3441 files_added: 1,
3442 files_deleted: 0,
3443 file_list: Vec::new(),
3444 },
3445 diff_summary: "UNIQUE_STAT_MARKER | 1 +".to_string(),
3446 diff_file: diff_path.to_string_lossy().to_string(),
3447 file_diffs: Vec::new(),
3448 },
3449 }],
3450 }
3451 };
3452 let context = crate::data::context::CommitContext::default();
3453
3454 let (client, _resp_handle, prompt_handle) =
3455 make_configurable_client_with_prompts(vec![Ok(
3456 "title: \"feat: x\"\ndescription: \"y\"\n".to_string(),
3457 )]);
3458
3459 client
3460 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3461 .await
3462 .unwrap();
3463
3464 let prompts = prompt_handle.prompts();
3465 assert_eq!(prompts.len(), 1, "expected exactly one AI call");
3466 let (system_prompt, user_prompt) = &prompts[0];
3467
3468 assert!(
3470 user_prompt.contains("UNIQUE_COMMIT_SUBJECT_MARKER"),
3471 "user prompt must include the commit subject"
3472 );
3473 assert!(
3475 !user_prompt.contains("UNIQUE_DIFF_MARKER"),
3476 "user prompt must NOT include diff content: {user_prompt}"
3477 );
3478 assert!(
3479 !user_prompt.contains("diff --git"),
3480 "user prompt must NOT include diff hunks"
3481 );
3482 assert!(
3483 !user_prompt.contains("diff_content"),
3484 "user prompt must NOT include diff_content YAML field"
3485 );
3486 assert!(
3487 !user_prompt.contains("UNIQUE_STAT_MARKER"),
3488 "user prompt must NOT include diff_summary"
3489 );
3490 assert!(
3492 !system_prompt.contains("diff files"),
3493 "system prompt must not mention diff files"
3494 );
3495 }
3496
3497 #[tokio::test]
3498 async fn generate_pr_content_with_context_default_mode_includes_diff_in_prompt() {
3499 let dir = tempfile::tempdir().unwrap();
3502 let repo_view = make_test_repo_view(&dir);
3503 let context = crate::data::context::CommitContext::default();
3504
3505 let (client, _resp_handle, prompt_handle) =
3506 make_configurable_client_with_prompts(vec![Ok(
3507 "title: \"feat: x\"\ndescription: \"y\"\n".to_string(),
3508 )]);
3509
3510 client
3511 .generate_pr_content_with_context(&repo_view, "", &context)
3512 .await
3513 .unwrap();
3514
3515 let prompts = prompt_handle.prompts();
3516 assert_eq!(prompts.len(), 1);
3517 let (_system_prompt, user_prompt) = &prompts[0];
3518 assert!(
3519 user_prompt.contains("diff_content"),
3520 "default mode must still serialise diff_content into the prompt"
3521 );
3522 }
3523
3524 #[tokio::test]
3525 async fn generate_pr_content_with_context_from_commits_multi_commit_per_commit_dispatch() {
3526 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3529 use crate::git::commit::FileChanges;
3530 use crate::git::{CommitAnalysis, CommitInfo};
3531
3532 let dir = tempfile::tempdir().unwrap();
3533 let make_commit = |hash: String, marker: &str, msg_size: usize| {
3534 let diff_path = dir.path().join(format!("{}.diff", &hash[..4]));
3535 std::fs::write(&diff_path, "+x\n").unwrap();
3536 CommitInfo {
3537 hash,
3538 author: "Test <test@test.com>".to_string(),
3539 date: chrono::Utc::now().fixed_offset(),
3540 original_message: format!("feat({marker}): {}", "x".repeat(msg_size)),
3541 in_main_branches: Vec::new(),
3542 analysis: CommitAnalysis {
3543 detected_type: "feat".to_string(),
3544 detected_scope: marker.to_string(),
3545 proposed_message: format!("feat({marker}): t"),
3546 file_changes: FileChanges {
3547 total_files: 1,
3548 files_added: 1,
3549 files_deleted: 0,
3550 file_list: Vec::new(),
3551 },
3552 diff_summary: String::new(),
3553 diff_file: diff_path.to_string_lossy().to_string(),
3554 file_diffs: Vec::new(),
3555 },
3556 }
3557 };
3558
3559 let repo_view = crate::data::RepositoryView {
3562 versions: None,
3563 explanation: FieldExplanation::default(),
3564 working_directory: WorkingDirectoryInfo {
3565 clean: true,
3566 untracked_changes: Vec::new(),
3567 },
3568 remotes: Vec::new(),
3569 ai: AiInfo {
3570 scratch: String::new(),
3571 },
3572 branch_info: None,
3573 pr_template: None,
3574 pr_template_location: None,
3575 branch_prs: None,
3576 commits: vec![
3577 make_commit("a".repeat(40), "a", 80_000),
3578 make_commit("b".repeat(40), "b", 80_000),
3579 ],
3580 };
3581 let context = crate::data::context::CommitContext::default();
3582
3583 let (client, handle) = make_small_context_client_tracked(vec![
3585 Ok(valid_pr_yaml("feat(a): a", "did a")),
3586 Ok(valid_pr_yaml("feat(b): b", "did b")),
3587 Ok(valid_pr_yaml("feat: a and b", "did both")),
3588 ]);
3589
3590 let result = client
3591 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3592 .await;
3593
3594 assert!(
3595 result.is_ok(),
3596 "from-commits per-commit dispatch failed: {:?}",
3597 result.err()
3598 );
3599 let pr = result.unwrap();
3600 assert!(
3601 pr.title.contains("and"),
3602 "unexpected merged title: {}",
3603 pr.title
3604 );
3605 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3606 }
3607
3608 #[tokio::test]
3609 async fn generate_pr_content_with_context_from_commits_single_commit_per_commit_return() {
3610 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3615 use crate::git::commit::FileChanges;
3616 use crate::git::{CommitAnalysis, CommitInfo};
3617
3618 let dir = tempfile::tempdir().unwrap();
3619 let diff_path = dir.path().join("0.diff");
3620 std::fs::write(&diff_path, "+x\n").unwrap();
3621 let commit = CommitInfo {
3622 hash: "a".repeat(40),
3623 author: "Test <test@test.com>".to_string(),
3624 date: chrono::Utc::now().fixed_offset(),
3625 original_message: format!("feat(only): {}", "x".repeat(80_000)),
3629 in_main_branches: Vec::new(),
3630 analysis: CommitAnalysis {
3631 detected_type: "feat".to_string(),
3632 detected_scope: "only".to_string(),
3633 proposed_message: "feat(only): m".to_string(),
3634 file_changes: FileChanges {
3635 total_files: 1,
3636 files_added: 1,
3637 files_deleted: 0,
3638 file_list: Vec::new(),
3639 },
3640 diff_summary: String::new(),
3641 diff_file: diff_path.to_string_lossy().to_string(),
3642 file_diffs: Vec::new(),
3643 },
3644 };
3645 let repo_view = crate::data::RepositoryView {
3646 versions: None,
3647 explanation: FieldExplanation::default(),
3648 working_directory: WorkingDirectoryInfo {
3649 clean: true,
3650 untracked_changes: Vec::new(),
3651 },
3652 remotes: Vec::new(),
3653 ai: AiInfo {
3654 scratch: String::new(),
3655 },
3656 branch_info: None,
3657 pr_template: None,
3658 pr_template_location: None,
3659 branch_prs: None,
3660 commits: vec![commit],
3661 };
3662 let context = crate::data::context::CommitContext::default();
3663
3664 let (client, handle) = make_small_context_client_tracked(vec![Ok(valid_pr_yaml(
3667 "feat(only): direct return",
3668 "single commit body",
3669 ))]);
3670
3671 let result = client
3672 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3673 .await;
3674
3675 assert!(
3676 result.is_ok(),
3677 "from-commits single-commit per-commit dispatch failed: {:?}",
3678 result.err()
3679 );
3680 let pr = result.unwrap();
3681 assert_eq!(pr.title, "feat(only): direct return");
3682 assert_eq!(
3683 handle.remaining(),
3684 0,
3685 "exactly one response should be consumed (no merge call)"
3686 );
3687 }
3688
3689 #[tokio::test]
3690 async fn generate_pr_content_with_context_from_commits_bails_on_oversized_single_commit() {
3691 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3694 use crate::git::commit::FileChanges;
3695 use crate::git::{CommitAnalysis, CommitInfo};
3696
3697 let dir = tempfile::tempdir().unwrap();
3698 let diff_path = dir.path().join("0.diff");
3699 std::fs::write(&diff_path, "+x\n").unwrap();
3700 let commit = CommitInfo {
3701 hash: "c".repeat(40),
3702 author: "Test <test@test.com>".to_string(),
3703 date: chrono::Utc::now().fixed_offset(),
3704 original_message: format!("feat: {}", "z".repeat(200_000)),
3707 in_main_branches: Vec::new(),
3708 analysis: CommitAnalysis {
3709 detected_type: "feat".to_string(),
3710 detected_scope: String::new(),
3711 proposed_message: "feat: oversized".to_string(),
3712 file_changes: FileChanges {
3713 total_files: 0,
3714 files_added: 0,
3715 files_deleted: 0,
3716 file_list: Vec::new(),
3717 },
3718 diff_summary: String::new(),
3719 diff_file: diff_path.to_string_lossy().to_string(),
3720 file_diffs: Vec::new(),
3721 },
3722 };
3723 let repo_view = crate::data::RepositoryView {
3724 versions: None,
3725 explanation: FieldExplanation::default(),
3726 working_directory: WorkingDirectoryInfo {
3727 clean: true,
3728 untracked_changes: Vec::new(),
3729 },
3730 remotes: Vec::new(),
3731 ai: AiInfo {
3732 scratch: String::new(),
3733 },
3734 branch_info: None,
3735 pr_template: None,
3736 pr_template_location: None,
3737 branch_prs: None,
3738 commits: vec![commit],
3739 };
3740 let context = crate::data::context::CommitContext::default();
3741
3742 let client = make_small_context_client(Vec::new());
3745
3746 let result = client
3747 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3748 .await;
3749
3750 assert!(result.is_err(), "expected bail on oversized single commit");
3751 let msg = format!("{:#}", result.unwrap_err());
3752 assert!(
3753 msg.contains("Token budget exceeded"),
3754 "expected token-budget error message, got: {msg}"
3755 );
3756 assert!(
3757 msg.contains("--from-commits"),
3758 "error should reference the from-commits mode"
3759 );
3760 }
3761
3762 #[tokio::test]
3763 async fn generate_pr_content_with_context_split_dispatch() {
3764 let dir = tempfile::tempdir().unwrap();
3765 let repo_view = make_large_multi_commit_repo_view(&dir);
3766 let context = crate::data::context::CommitContext::default();
3767
3768 let (client, handle) = make_small_context_client_tracked(vec![
3770 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3771 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3772 Ok(valid_pr_yaml(
3773 "feat: add modules a and b",
3774 "Adds both modules",
3775 )),
3776 ]);
3777
3778 let result = client
3779 .generate_pr_content_with_context(&repo_view, "", &context)
3780 .await;
3781
3782 assert!(
3783 result.is_ok(),
3784 "PR with context split dispatch failed: {:?}",
3785 result.err()
3786 );
3787 let pr = result.unwrap();
3788 assert!(pr.title.contains("modules"));
3789 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3790 }
3791
3792 fn make_small_context_client_with_prompts(
3797 responses: Vec<Result<String>>,
3798 ) -> (
3799 ClaudeClient,
3800 crate::claude::test_utils::ResponseQueueHandle,
3801 crate::claude::test_utils::PromptRecordHandle,
3802 ) {
3803 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
3804 .with_context_length(50_000);
3805 let response_handle = mock.response_handle();
3806 let prompt_handle = mock.prompt_handle();
3807 (
3808 ClaudeClient::new(Box::new(mock)),
3809 response_handle,
3810 prompt_handle,
3811 )
3812 }
3813
3814 fn make_configurable_client_with_prompts(
3816 responses: Vec<Result<String>>,
3817 ) -> (
3818 ClaudeClient,
3819 crate::claude::test_utils::ResponseQueueHandle,
3820 crate::claude::test_utils::PromptRecordHandle,
3821 ) {
3822 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
3823 let response_handle = mock.response_handle();
3824 let prompt_handle = mock.prompt_handle();
3825 (
3826 ClaudeClient::new(Box::new(mock)),
3827 response_handle,
3828 prompt_handle,
3829 )
3830 }
3831
3832 fn make_single_oversized_file_repo_view(
3839 dir: &tempfile::TempDir,
3840 ) -> crate::data::RepositoryView {
3841 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3842 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3843 use crate::git::{CommitAnalysis, CommitInfo};
3844
3845 let hash = "c".repeat(40);
3846
3847 let diff_content = format!(
3850 "diff --git a/src/big.rs b/src/big.rs\n{}\n",
3851 "x".repeat(80_000)
3852 );
3853
3854 let flat_diff_path = dir.path().join("full.diff");
3855 std::fs::write(&flat_diff_path, &diff_content).unwrap();
3856
3857 let per_file_path = dir.path().join("0000.diff");
3858 std::fs::write(&per_file_path, &diff_content).unwrap();
3859
3860 crate::data::RepositoryView {
3861 versions: None,
3862 explanation: FieldExplanation::default(),
3863 working_directory: WorkingDirectoryInfo {
3864 clean: true,
3865 untracked_changes: Vec::new(),
3866 },
3867 remotes: Vec::new(),
3868 ai: AiInfo {
3869 scratch: String::new(),
3870 },
3871 branch_info: None,
3872 pr_template: None,
3873 pr_template_location: None,
3874 branch_prs: None,
3875 commits: vec![CommitInfo {
3876 hash,
3877 author: "Test <test@test.com>".to_string(),
3878 date: chrono::Utc::now().fixed_offset(),
3879 original_message: "feat(big): add large module".to_string(),
3880 in_main_branches: Vec::new(),
3881 analysis: CommitAnalysis {
3882 detected_type: "feat".to_string(),
3883 detected_scope: "big".to_string(),
3884 proposed_message: "feat(big): add large module".to_string(),
3885 file_changes: FileChanges {
3886 total_files: 1,
3887 files_added: 1,
3888 files_deleted: 0,
3889 file_list: vec![FileChange {
3890 status: "A".to_string(),
3891 file: "src/big.rs".to_string(),
3892 }],
3893 },
3894 diff_summary: " src/big.rs | 80 ++++\n".to_string(),
3895 diff_file: flat_diff_path.to_string_lossy().to_string(),
3896 file_diffs: vec![FileDiffRef {
3897 path: "src/big.rs".to_string(),
3898 diff_file: per_file_path.to_string_lossy().to_string(),
3899 byte_len: diff_content.len(),
3900 }],
3901 },
3902 }],
3903 }
3904 }
3905
3906 #[tokio::test]
3913 async fn amendment_single_file_under_budget_no_split() {
3914 let dir = tempfile::tempdir().unwrap();
3915 let repo_view = make_test_repo_view(&dir);
3916 let hash = format!("{:0>40}", 0);
3917
3918 let (client, response_handle, prompt_handle) =
3919 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3920 &hash,
3921 "feat(test): improved message",
3922 ))]);
3923
3924 let result = client
3925 .generate_amendments_with_options(&repo_view, false)
3926 .await;
3927
3928 assert!(result.is_ok());
3929 assert_eq!(result.unwrap().amendments.len(), 1);
3930 assert_eq!(response_handle.remaining(), 0);
3931
3932 let prompts = prompt_handle.prompts();
3933 assert_eq!(
3934 prompts.len(),
3935 1,
3936 "expected exactly one AI request, no split"
3937 );
3938
3939 let (_, user_prompt) = &prompts[0];
3940 assert!(
3941 user_prompt.contains("added line"),
3942 "user prompt should contain the diff content"
3943 );
3944 }
3945
3946 #[tokio::test]
3957 async fn amendment_two_chunks_prompt_content() {
3958 let dir = tempfile::tempdir().unwrap();
3959 let repo_view = make_large_diff_repo_view(&dir);
3960 let hash = "a".repeat(40);
3961
3962 let (client, response_handle, prompt_handle) =
3963 make_small_context_client_with_prompts(vec![
3964 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3965 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3966 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3967 ]);
3968
3969 let result = client
3970 .generate_amendments_with_options(&repo_view, false)
3971 .await;
3972
3973 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3974 let amendments = result.unwrap();
3975 assert_eq!(amendments.amendments.len(), 1);
3976 assert!(amendments.amendments[0]
3977 .message
3978 .contains("add a.rs and b.rs"));
3979 assert_eq!(response_handle.remaining(), 0);
3980
3981 let prompts = prompt_handle.prompts();
3982 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3983
3984 let (_, chunk1_user) = &prompts[0];
3986 assert!(
3987 chunk1_user.contains("aaa"),
3988 "chunk 1 prompt should contain file-a diff content"
3989 );
3990
3991 let (_, chunk2_user) = &prompts[1];
3993 assert!(
3994 chunk2_user.contains("bbb"),
3995 "chunk 2 prompt should contain file-b diff content"
3996 );
3997
3998 let (merge_sys, merge_user) = &prompts[2];
4000 assert!(
4001 merge_sys.contains("synthesiz"),
4002 "merge system prompt should contain synthesis instructions"
4003 );
4004 assert!(
4006 merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
4007 "merge user prompt should contain both partial amendment messages"
4008 );
4009 }
4010
4011 #[tokio::test]
4023 async fn amendment_single_oversized_file_gets_placeholder() {
4024 let dir = tempfile::tempdir().unwrap();
4025 let repo_view = make_single_oversized_file_repo_view(&dir);
4026 let hash = "c".repeat(40);
4027
4028 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4033 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
4034 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
4035 ]);
4036
4037 let result = client
4038 .generate_amendments_with_options(&repo_view, false)
4039 .await;
4040
4041 assert!(
4043 result.is_ok(),
4044 "expected success with placeholder, got: {result:?}"
4045 );
4046
4047 assert!(
4049 prompt_handle.request_count() >= 1,
4050 "expected at least 1 request, got {}",
4051 prompt_handle.request_count()
4052 );
4053 }
4054
4055 #[tokio::test]
4064 async fn amendment_chunk_failure_stops_dispatch() {
4065 let dir = tempfile::tempdir().unwrap();
4066 let repo_view = make_large_diff_repo_view(&dir);
4067 let hash = "a".repeat(40);
4068
4069 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4071 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
4072 Err(anyhow::anyhow!("rate limit exceeded")),
4073 ]);
4074
4075 let result = client
4076 .generate_amendments_with_options(&repo_view, false)
4077 .await;
4078
4079 assert!(result.is_err());
4080
4081 let prompts = prompt_handle.prompts();
4083 assert_eq!(
4084 prompts.len(),
4085 2,
4086 "should stop after the failing chunk, got {} requests",
4087 prompts.len()
4088 );
4089
4090 let (_, first_user) = &prompts[0];
4092 assert!(
4093 first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
4094 "first chunk prompt should reference a file"
4095 );
4096 }
4097
4098 #[tokio::test]
4109 async fn amendment_reduce_pass_prompt_content() {
4110 let dir = tempfile::tempdir().unwrap();
4111 let repo_view = make_large_diff_repo_view(&dir);
4112 let hash = "a".repeat(40);
4113
4114 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4115 Ok(valid_amendment_yaml(
4116 &hash,
4117 "feat(a): add module a implementation",
4118 )),
4119 Ok(valid_amendment_yaml(
4120 &hash,
4121 "feat(b): add module b implementation",
4122 )),
4123 Ok(valid_amendment_yaml(
4124 &hash,
4125 "feat(test): add modules a and b",
4126 )),
4127 ]);
4128
4129 let result = client
4130 .generate_amendments_with_options(&repo_view, false)
4131 .await;
4132
4133 assert!(result.is_ok());
4134
4135 let prompts = prompt_handle.prompts();
4136 assert_eq!(prompts.len(), 3);
4137
4138 let (merge_system, merge_user) = &prompts[2];
4140
4141 assert!(
4143 merge_system.contains("synthesiz"),
4144 "merge system prompt should contain synthesis instructions"
4145 );
4146
4147 assert!(
4149 merge_user.contains("feat(a): add module a implementation"),
4150 "merge user prompt should contain chunk 1's partial message"
4151 );
4152 assert!(
4153 merge_user.contains("feat(b): add module b implementation"),
4154 "merge user prompt should contain chunk 2's partial message"
4155 );
4156
4157 assert!(
4159 merge_user.contains("feat(test): large commit"),
4160 "merge user prompt should contain the original commit message"
4161 );
4162
4163 assert!(
4165 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
4166 "merge user prompt should contain the diff_summary"
4167 );
4168
4169 assert!(
4171 merge_user.contains(&hash),
4172 "merge user prompt should reference the commit hash"
4173 );
4174 }
4175
4176 #[tokio::test]
4193 async fn check_split_dedup_and_merge_prompt() {
4194 let dir = tempfile::tempdir().unwrap();
4195 let repo_view = make_large_diff_repo_view(&dir);
4196 let hash = "a".repeat(40);
4197
4198 let chunk1_yaml = format!(
4200 concat!(
4201 "checks:\n",
4202 " - commit: \"{hash}\"\n",
4203 " passes: false\n",
4204 " issues:\n",
4205 " - severity: error\n",
4206 " section: \"Subject Line\"\n",
4207 " rule: \"imperative-mood\"\n",
4208 " explanation: \"Subject uses past tense\"\n",
4209 " - severity: warning\n",
4210 " section: \"Content\"\n",
4211 " rule: \"body-required\"\n",
4212 " explanation: \"Large change needs body\"\n",
4213 " suggestion:\n",
4214 " message: \"feat(a): shorter subject for a\"\n",
4215 " explanation: \"Shortened subject for file a\"\n",
4216 " summary: \"Adds module a\"\n",
4217 ),
4218 hash = hash,
4219 );
4220
4221 let chunk2_yaml = format!(
4223 concat!(
4224 "checks:\n",
4225 " - commit: \"{hash}\"\n",
4226 " passes: false\n",
4227 " issues:\n",
4228 " - severity: error\n",
4229 " section: \"Subject Line\"\n",
4230 " rule: \"imperative-mood\"\n",
4231 " explanation: \"Subject line is way too long\"\n",
4232 " - severity: info\n",
4233 " section: \"Style\"\n",
4234 " rule: \"scope-suggestion\"\n",
4235 " explanation: \"Consider more specific scope\"\n",
4236 " suggestion:\n",
4237 " message: \"feat(b): shorter subject for b\"\n",
4238 " explanation: \"Shortened subject for file b\"\n",
4239 " summary: \"Adds module b\"\n",
4240 ),
4241 hash = hash,
4242 );
4243
4244 let merge_yaml = format!(
4246 concat!(
4247 "checks:\n",
4248 " - commit: \"{hash}\"\n",
4249 " passes: false\n",
4250 " issues: []\n",
4251 " suggestion:\n",
4252 " message: \"feat(test): add modules a and b\"\n",
4253 " explanation: \"Combined suggestion\"\n",
4254 " summary: \"Adds modules a and b\"\n",
4255 ),
4256 hash = hash,
4257 );
4258
4259 let (client, response_handle, prompt_handle) =
4260 make_small_context_client_with_prompts(vec![
4261 Ok(chunk1_yaml),
4262 Ok(chunk2_yaml),
4263 Ok(merge_yaml),
4264 ]);
4265
4266 let result = client
4267 .check_commits_with_scopes(&repo_view, None, &[], true)
4268 .await;
4269
4270 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
4271 let report = result.unwrap();
4272 assert_eq!(report.commits.len(), 1);
4273 assert!(!report.commits[0].passes);
4274 assert_eq!(response_handle.remaining(), 0);
4275
4276 assert_eq!(
4281 report.commits[0].issues.len(),
4282 3,
4283 "expected 3 unique issues after dedup, got {:?}",
4284 report.commits[0]
4285 .issues
4286 .iter()
4287 .map(|i| &i.rule)
4288 .collect::<Vec<_>>()
4289 );
4290
4291 assert!(report.commits[0].suggestion.is_some());
4293 assert!(
4294 report.commits[0]
4295 .suggestion
4296 .as_ref()
4297 .unwrap()
4298 .message
4299 .contains("add modules a and b"),
4300 "suggestion should come from the merge pass"
4301 );
4302
4303 let prompts = prompt_handle.prompts();
4305 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
4306
4307 let (_, chunk1_user) = &prompts[0];
4309 let (_, chunk2_user) = &prompts[1];
4310 let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
4311 assert!(
4312 combined_chunk_prompts.contains("src/a.rs")
4313 && combined_chunk_prompts.contains("src/b.rs"),
4314 "chunk prompts should collectively cover both files"
4315 );
4316
4317 let (merge_sys, merge_user) = &prompts[2];
4319 assert!(
4320 merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
4321 "merge system prompt should be the check chunk merge prompt"
4322 );
4323 assert!(
4324 merge_user.contains("feat(a): shorter subject for a")
4325 && merge_user.contains("feat(b): shorter subject for b"),
4326 "merge user prompt should contain both partial suggestions"
4327 );
4328 assert!(
4330 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
4331 "merge user prompt should contain the diff_summary"
4332 );
4333 }
4334
4335 #[tokio::test]
4338 async fn amendment_retry_parse_failure_then_success() {
4339 let dir = tempfile::tempdir().unwrap();
4340 let repo_view = make_test_repo_view(&dir);
4341 let hash = format!("{:0>40}", 0);
4342
4343 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4344 Ok("not valid yaml {{[".to_string()),
4345 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
4346 ]);
4347
4348 let result = client
4349 .generate_amendments_with_options(&repo_view, false)
4350 .await;
4351
4352 assert!(
4353 result.is_ok(),
4354 "should succeed after retry: {:?}",
4355 result.err()
4356 );
4357 assert_eq!(result.unwrap().amendments.len(), 1);
4358 assert_eq!(response_handle.remaining(), 0, "both responses consumed");
4359 assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
4360 }
4361
4362 #[tokio::test]
4363 async fn amendment_retry_request_failure_then_success() {
4364 let dir = tempfile::tempdir().unwrap();
4365 let repo_view = make_test_repo_view(&dir);
4366 let hash = format!("{:0>40}", 0);
4367
4368 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4369 Err(anyhow::anyhow!("rate limit")),
4370 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
4371 ]);
4372
4373 let result = client
4374 .generate_amendments_with_options(&repo_view, false)
4375 .await;
4376
4377 assert!(
4378 result.is_ok(),
4379 "should succeed after retry: {:?}",
4380 result.err()
4381 );
4382 assert_eq!(result.unwrap().amendments.len(), 1);
4383 assert_eq!(response_handle.remaining(), 0);
4384 assert_eq!(prompt_handle.request_count(), 2);
4385 }
4386
4387 #[tokio::test]
4388 async fn amendment_retry_all_attempts_exhausted() {
4389 let dir = tempfile::tempdir().unwrap();
4390 let repo_view = make_test_repo_view(&dir);
4391
4392 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4393 Ok("bad yaml 1".to_string()),
4394 Ok("bad yaml 2".to_string()),
4395 Ok("bad yaml 3".to_string()),
4396 ]);
4397
4398 let result = client
4399 .generate_amendments_with_options(&repo_view, false)
4400 .await;
4401
4402 assert!(result.is_err(), "should fail after all retries exhausted");
4403 assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
4404 assert_eq!(
4405 prompt_handle.request_count(),
4406 3,
4407 "exactly 3 AI requests (1 + 2 retries)"
4408 );
4409 }
4410
4411 #[tokio::test]
4412 async fn amendment_retry_success_first_attempt() {
4413 let dir = tempfile::tempdir().unwrap();
4414 let repo_view = make_test_repo_view(&dir);
4415 let hash = format!("{:0>40}", 0);
4416
4417 let (client, response_handle, prompt_handle) =
4418 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
4419 &hash,
4420 "feat(test): works first time",
4421 ))]);
4422
4423 let result = client
4424 .generate_amendments_with_options(&repo_view, false)
4425 .await;
4426
4427 assert!(result.is_ok());
4428 assert_eq!(response_handle.remaining(), 0);
4429 assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
4430 }
4431
4432 #[tokio::test]
4433 async fn amendment_retry_mixed_request_and_parse_failures() {
4434 let dir = tempfile::tempdir().unwrap();
4435 let repo_view = make_test_repo_view(&dir);
4436 let hash = format!("{:0>40}", 0);
4437
4438 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4439 Err(anyhow::anyhow!("network error")),
4440 Ok("invalid yaml {{".to_string()),
4441 Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
4442 ]);
4443
4444 let result = client
4445 .generate_amendments_with_options(&repo_view, false)
4446 .await;
4447
4448 assert!(
4449 result.is_ok(),
4450 "should succeed on third attempt: {:?}",
4451 result.err()
4452 );
4453 assert_eq!(result.unwrap().amendments.len(), 1);
4454 assert_eq!(response_handle.remaining(), 0);
4455 assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
4456 }
4457
4458 use crate::test_support::env::MapEnv;
4465
4466 #[tokio::test]
4467 async fn factory_claude_cli_backend_dispatches_to_claude_cli_client() {
4468 let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "claude-cli");
4469
4470 let client = create_default_claude_client_with(&env, None, None)
4471 .await
4472 .expect("factory should succeed");
4473 let metadata = client.get_ai_client_metadata();
4474 assert_eq!(metadata.provider, "Claude CLI");
4475 assert_eq!(metadata.model, "claude-sonnet-4-6");
4477 }
4478
4479 #[tokio::test]
4480 async fn factory_claude_cli_backend_honours_model_precedence() {
4481 let env = MapEnv::new()
4483 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
4484 .with("CLAUDE_CODE_MODEL", "opus")
4485 .with("CLAUDE_MODEL", "haiku");
4486
4487 let client = create_default_claude_client_with(&env, None, None)
4488 .await
4489 .expect("factory should succeed");
4490 let metadata = client.get_ai_client_metadata();
4491 assert_eq!(metadata.provider, "Claude CLI");
4492 assert_eq!(metadata.model, "haiku");
4493 }
4494
4495 #[tokio::test]
4496 async fn factory_claude_cli_backend_explicit_model_wins_over_env() {
4497 let env = MapEnv::new()
4498 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
4499 .with("CLAUDE_MODEL", "haiku");
4500
4501 let client = create_default_claude_client_with(&env, Some("opus".to_string()), None)
4502 .await
4503 .expect("factory should succeed");
4504 let metadata = client.get_ai_client_metadata();
4505 assert_eq!(metadata.model, "opus");
4506 }
4507
4508 #[tokio::test]
4509 async fn factory_claude_cli_backend_accepts_underscore_alias() {
4510 let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "claude_cli");
4511
4512 let client = create_default_claude_client_with(&env, None, None)
4513 .await
4514 .expect("factory should succeed");
4515 let metadata = client.get_ai_client_metadata();
4516 assert_eq!(metadata.provider, "Claude CLI");
4517 }
4518
4519 #[tokio::test]
4520 async fn factory_claude_cli_backend_ignores_beta_header_without_validation() {
4521 let env = MapEnv::new()
4526 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
4527 .with("CLAUDE_MODEL", "sonnet")
4528 .with("OMNI_DEV_BETA_HEADER", "anthropic-beta:not-a-real-beta");
4529
4530 let client = create_default_claude_client_with(&env, None, None)
4531 .await
4532 .expect("claude-cli must ignore the beta header, not validate it");
4533 let metadata = client.get_ai_client_metadata();
4534 assert_eq!(metadata.provider, "Claude CLI");
4535 assert_eq!(metadata.model, "sonnet");
4536 assert_eq!(
4537 metadata.active_beta, None,
4538 "beta header must not be forwarded"
4539 );
4540 }
4541
4542 #[tokio::test]
4543 async fn factory_ollama_branch_probes_loaded_context_length() {
4544 use wiremock::matchers::{method, path};
4545 use wiremock::{Mock, MockServer, ResponseTemplate};
4546
4547 let _log_guard = tracing::subscriber::set_default(
4552 tracing_subscriber::fmt()
4553 .with_max_level(tracing::Level::INFO)
4554 .with_writer(std::io::sink)
4555 .finish(),
4556 );
4557
4558 let server = MockServer::start().await;
4559 Mock::given(method("GET"))
4560 .and(path("/api/v0/models"))
4561 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4562 "data": [
4563 { "id": "lm-loaded", "state": "loaded", "loaded_context_length": 6144_u64 }
4564 ]
4565 })))
4566 .mount(&server)
4567 .await;
4568
4569 let env = MapEnv::new()
4570 .with("USE_OLLAMA", "true")
4571 .with("OLLAMA_BASE_URL", &server.uri())
4572 .with("OLLAMA_MODEL", "lm-loaded");
4573
4574 let client = create_default_claude_client_with(&env, None, None)
4575 .await
4576 .expect("factory should succeed");
4577 let metadata = client.get_ai_client_metadata();
4578 assert_eq!(metadata.provider, "Ollama");
4579 assert_eq!(metadata.model, "lm-loaded");
4580 assert_eq!(metadata.max_context_length, 6144);
4582 }
4583
4584 #[tokio::test]
4585 async fn factory_ollama_branch_falls_back_when_probe_fails() {
4586 use wiremock::matchers::{method, path};
4587 use wiremock::{Mock, MockServer, ResponseTemplate};
4588
4589 let server = MockServer::start().await;
4590 Mock::given(method("GET"))
4591 .and(path("/api/v0/models"))
4592 .respond_with(ResponseTemplate::new(500))
4593 .mount(&server)
4594 .await;
4595 Mock::given(method("POST"))
4596 .and(path("/api/show"))
4597 .respond_with(ResponseTemplate::new(500))
4598 .mount(&server)
4599 .await;
4600
4601 let env = MapEnv::new()
4602 .with("USE_OLLAMA", "true")
4603 .with("OLLAMA_BASE_URL", &server.uri())
4604 .with("OLLAMA_MODEL", "no-such-model");
4605
4606 let client = create_default_claude_client_with(&env, None, None)
4607 .await
4608 .expect("factory should succeed");
4609 let metadata = client.get_ai_client_metadata();
4610 let registry_value =
4613 crate::claude::model_config::get_model_registry().get_input_context("no-such-model");
4614 assert_eq!(metadata.max_context_length, registry_value);
4615 }
4616
4617 #[tokio::test]
4621 async fn factory_ollama_branch_probes_via_ollama_native() {
4622 use wiremock::matchers::{method, path};
4623 use wiremock::{Mock, MockServer, ResponseTemplate};
4624
4625 let server = MockServer::start().await;
4626 Mock::given(method("GET"))
4627 .and(path("/api/v0/models"))
4628 .respond_with(ResponseTemplate::new(404))
4629 .mount(&server)
4630 .await;
4631 Mock::given(method("POST"))
4632 .and(path("/api/show"))
4633 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4634 "model_info": { "llama.context_length": 12288_u64 }
4635 })))
4636 .mount(&server)
4637 .await;
4638
4639 let env = MapEnv::new()
4640 .with("USE_OLLAMA", "true")
4641 .with("OLLAMA_BASE_URL", &server.uri())
4642 .with("OLLAMA_MODEL", "ollama-native-model");
4643
4644 let client = create_default_claude_client_with(&env, None, None)
4645 .await
4646 .expect("factory should succeed");
4647 let metadata = client.get_ai_client_metadata();
4648 assert_eq!(metadata.max_context_length, 12288);
4649 }
4650
4651 #[tokio::test]
4656 async fn factory_openai_branch_builds_client() {
4657 let env = MapEnv::new()
4658 .with("USE_OPENAI", "true")
4659 .with("OPENAI_MODEL", "gpt-4.1")
4660 .with("OPENAI_API_KEY", "sk-test");
4661
4662 let client = create_default_claude_client_with(&env, None, None)
4663 .await
4664 .expect("factory should succeed");
4665 assert_eq!(client.get_ai_client_metadata().model, "gpt-4.1");
4666 }
4667
4668 #[tokio::test]
4669 async fn factory_openai_branch_errors_without_api_key() {
4670 let env = MapEnv::new().with("USE_OPENAI", "true");
4671 let result = create_default_claude_client_with(&env, None, None).await;
4672 assert!(result.is_err());
4673 }
4674
4675 #[tokio::test]
4676 async fn factory_bedrock_branch_builds_client() {
4677 let env = MapEnv::new()
4678 .with("CLAUDE_CODE_USE_BEDROCK", "true")
4679 .with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
4680 .with("ANTHROPIC_AUTH_TOKEN", "tok")
4681 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
4682
4683 let client = create_default_claude_client_with(&env, None, None)
4684 .await
4685 .expect("factory should succeed");
4686 assert_eq!(client.get_ai_client_metadata().model, "claude-sonnet-4-6");
4687 }
4688
4689 #[tokio::test]
4690 async fn factory_bedrock_branch_errors_without_auth_token() {
4691 let env = MapEnv::new().with("CLAUDE_CODE_USE_BEDROCK", "true");
4692 let result = create_default_claude_client_with(&env, None, None).await;
4693 assert!(result.is_err());
4694 }
4695
4696 #[tokio::test]
4697 async fn factory_bedrock_branch_errors_without_base_url() {
4698 let env = MapEnv::new()
4699 .with("CLAUDE_CODE_USE_BEDROCK", "true")
4700 .with("ANTHROPIC_AUTH_TOKEN", "tok");
4701 let result = create_default_claude_client_with(&env, None, None).await;
4702 assert!(result.is_err());
4703 }
4704
4705 #[tokio::test]
4706 async fn factory_default_claude_branch_builds_client() {
4707 let env = MapEnv::new()
4708 .with("ANTHROPIC_MODEL", "claude-opus-4-6")
4709 .with("CLAUDE_API_KEY", "sk-test");
4710
4711 let client = create_default_claude_client_with(&env, None, None)
4712 .await
4713 .expect("factory should succeed");
4714 assert_eq!(client.get_ai_client_metadata().model, "claude-opus-4-6");
4715 }
4716
4717 #[tokio::test]
4718 async fn factory_default_claude_branch_errors_without_api_key() {
4719 let result = create_default_claude_client_with(&MapEnv::new(), None, None).await;
4720 assert!(result.is_err());
4721 }
4722
4723 #[tokio::test]
4726 async fn factory_default_claude_branch_honours_claude_model_chain() {
4727 let env = MapEnv::new()
4730 .with("CLAUDE_MODEL", "claude-opus-4-6")
4731 .with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
4732 .with("CLAUDE_API_KEY", "sk-test");
4733
4734 let client = create_default_claude_client_with(&env, None, None)
4735 .await
4736 .expect("factory should succeed");
4737 assert_eq!(client.get_ai_client_metadata().model, "claude-opus-4-6");
4738 }
4739
4740 #[tokio::test]
4741 async fn factory_bedrock_branch_honours_claude_model_chain() {
4742 let env = MapEnv::new()
4743 .with("CLAUDE_CODE_USE_BEDROCK", "true")
4744 .with("CLAUDE_CODE_MODEL", "claude-opus-4-6")
4745 .with("ANTHROPIC_AUTH_TOKEN", "tok")
4746 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
4747
4748 let client = create_default_claude_client_with(&env, None, None)
4749 .await
4750 .expect("factory should succeed");
4751 assert_eq!(client.get_ai_client_metadata().model, "claude-opus-4-6");
4752 }
4753
4754 #[tokio::test]
4755 async fn factory_omni_dev_model_beats_provider_var() {
4756 let env = MapEnv::new()
4757 .with("USE_OPENAI", "true")
4758 .with("OMNI_DEV_MODEL", "gpt-4.1")
4759 .with("OPENAI_MODEL", "gpt-5-mini")
4760 .with("OPENAI_API_KEY", "sk-test");
4761
4762 let client = create_default_claude_client_with(&env, None, None)
4763 .await
4764 .expect("factory should succeed");
4765 assert_eq!(client.get_ai_client_metadata().model, "gpt-4.1");
4766 }
4767
4768 #[tokio::test]
4769 async fn factory_backend_env_var_beats_legacy_use_flags() {
4770 let env = MapEnv::new()
4772 .with("OMNI_DEV_AI_BACKEND", "openai")
4773 .with("USE_OLLAMA", "true")
4774 .with("OPENAI_API_KEY", "sk-test");
4775
4776 let client = create_default_claude_client_with(&env, None, None)
4777 .await
4778 .expect("factory should succeed");
4779 assert_eq!(client.get_ai_client_metadata().model, "gpt-5-mini");
4780 }
4781
4782 #[tokio::test]
4783 async fn factory_backend_default_value_forces_direct_api() {
4784 let env = MapEnv::new()
4787 .with("OMNI_DEV_AI_BACKEND", "default")
4788 .with("USE_OLLAMA", "true")
4789 .with("CLAUDE_API_KEY", "sk-test");
4790
4791 let client = create_default_claude_client_with(&env, None, None)
4792 .await
4793 .expect("factory should succeed");
4794 assert_eq!(client.get_ai_client_metadata().model, "claude-sonnet-4-6");
4795 }
4796
4797 #[tokio::test]
4798 async fn factory_unknown_backend_value_is_hard_error() {
4799 let env = MapEnv::new()
4800 .with("OMNI_DEV_AI_BACKEND", "junk")
4801 .with("CLAUDE_API_KEY", "sk-test");
4802
4803 let err = create_default_claude_client_with(&env, None, None)
4804 .await
4805 .map(|_| ())
4806 .expect_err("unknown backend must error");
4807 assert!(format!("{err:#}").contains("junk"));
4808 }
4809
4810 #[tokio::test]
4811 async fn factory_beta_header_env_var_is_applied() {
4812 let env = MapEnv::new()
4813 .with("ANTHROPIC_MODEL", "claude-3-7-sonnet-20250219")
4814 .with(
4815 "OMNI_DEV_BETA_HEADER",
4816 "anthropic-beta:output-128k-2025-02-19",
4817 )
4818 .with("CLAUDE_API_KEY", "sk-test");
4819
4820 let client = create_default_claude_client_with(&env, None, None)
4821 .await
4822 .expect("factory should accept a registry-supported beta header");
4823 assert_eq!(client.get_ai_client_metadata().max_response_length, 128_000);
4825 }
4826
4827 #[tokio::test]
4828 async fn factory_beta_header_env_var_malformed_is_hard_error() {
4829 let env = MapEnv::new()
4830 .with("OMNI_DEV_BETA_HEADER", "no-colon-here")
4831 .with("CLAUDE_API_KEY", "sk-test");
4832
4833 let err = create_default_claude_client_with(&env, None, None)
4834 .await
4835 .map(|_| ())
4836 .expect_err("malformed beta header must error");
4837 assert!(format!("{err:#}").contains("no-colon-here"));
4838 }
4839}