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(
1768 env: &(impl crate::utils::env::EnvSource + Sync),
1769 model: Option<String>,
1770 beta_header: Option<(String, String)>,
1771) -> Result<ClaudeClient> {
1772 use crate::claude::ai::claude_cli::ClaudeCliAiClient;
1773 use crate::claude::ai::openai::OpenAiAiClient;
1774
1775 let ai_backend = env.var("OMNI_DEV_AI_BACKEND");
1780 let use_claude_cli = ai_backend
1781 .as_deref()
1782 .is_some_and(|v| matches!(v, "claude-cli" | "claude_cli"));
1783
1784 if use_claude_cli {
1785 if beta_header.is_some() {
1786 warn!(
1787 "--beta-header is ignored when OMNI_DEV_AI_BACKEND=claude-cli \
1788 (the CLI's --betas flag has different semantics and is not forwarded)"
1789 );
1790 }
1791 let registry = crate::claude::model_config::get_model_registry();
1792 let cli_model = model
1793 .or_else(|| env.var("CLAUDE_MODEL"))
1794 .or_else(|| env.var("CLAUDE_CODE_MODEL"))
1795 .or_else(|| env.var("ANTHROPIC_MODEL"))
1796 .unwrap_or_else(|| {
1797 registry
1798 .get_default_model("claude")
1799 .unwrap_or("claude-sonnet-4-6")
1800 .to_string()
1801 });
1802 debug!(model = %cli_model, "Creating claude -p subprocess client");
1803 let ai_client = ClaudeCliAiClient::new(cli_model);
1804 return Ok(ClaudeClient::new(Box::new(ai_client)));
1805 }
1806
1807 let use_openai = env.var("USE_OPENAI").is_some_and(|val| val == "true");
1809
1810 let use_ollama = env.var("USE_OLLAMA").is_some_and(|val| val == "true");
1811
1812 let use_bedrock = env
1814 .var("CLAUDE_CODE_USE_BEDROCK")
1815 .is_some_and(|val| val == "true");
1816
1817 debug!(
1818 use_openai = use_openai,
1819 use_ollama = use_ollama,
1820 use_bedrock = use_bedrock,
1821 "Client selection flags"
1822 );
1823
1824 let registry = crate::claude::model_config::get_model_registry();
1825
1826 if use_ollama {
1828 let ollama_model = model
1829 .or_else(|| env.var("OLLAMA_MODEL"))
1830 .unwrap_or_else(|| "llama2".to_string());
1831 validate_beta_header(&ollama_model, &beta_header)?;
1832 let base_url = env.var("OLLAMA_BASE_URL");
1833 let mut ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1834 match ai_client.probe_loaded_context_length().await {
1835 Some(source) => {
1836 info!(
1837 loaded_context_length = ai_client.loaded_context_length(),
1838 source = source.as_str(),
1839 model = %ai_client.get_metadata().model,
1840 "Probed loaded context length from local server"
1841 );
1842 }
1843 None => {
1844 debug!(
1845 "Loaded context length probe did not return a value; \
1846 falling back to registry/default for token budget"
1847 );
1848 }
1849 }
1850 return Ok(ClaudeClient::new(Box::new(ai_client)));
1851 }
1852
1853 if use_openai {
1855 debug!("Creating OpenAI client");
1856 let openai_model = model
1857 .or_else(|| env.var("OPENAI_MODEL"))
1858 .unwrap_or_else(|| {
1859 registry
1860 .get_default_model("openai")
1861 .unwrap_or("gpt-5")
1862 .to_string()
1863 });
1864 debug!(openai_model = %openai_model, "Selected OpenAI model");
1865 validate_beta_header(&openai_model, &beta_header)?;
1866
1867 let api_key = env
1868 .var_any(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"])
1869 .ok_or_else(|| {
1870 debug!("Failed to get OpenAI API key");
1871 ClaudeError::ApiKeyNotFound
1872 })?;
1873 debug!("OpenAI API key found");
1874
1875 let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1876 debug!("OpenAI client created successfully");
1877 return Ok(ClaudeClient::new(Box::new(ai_client)));
1878 }
1879
1880 let claude_model = model
1882 .or_else(|| env.var("ANTHROPIC_MODEL"))
1883 .unwrap_or_else(|| {
1884 registry
1885 .get_default_model("claude")
1886 .unwrap_or("claude-sonnet-4-6")
1887 .to_string()
1888 });
1889 validate_beta_header(&claude_model, &beta_header)?;
1890
1891 if use_bedrock {
1892 let auth_token = env
1894 .var("ANTHROPIC_AUTH_TOKEN")
1895 .ok_or(ClaudeError::ApiKeyNotFound)?;
1896
1897 let base_url = env
1898 .var("ANTHROPIC_BEDROCK_BASE_URL")
1899 .ok_or(ClaudeError::ApiKeyNotFound)?;
1900
1901 let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1902 return Ok(ClaudeClient::new(Box::new(ai_client)));
1903 }
1904
1905 debug!("Falling back to Claude client");
1907 let api_key = env
1908 .var_any(&[
1909 "CLAUDE_API_KEY",
1910 "ANTHROPIC_API_KEY",
1911 "ANTHROPIC_AUTH_TOKEN",
1912 ])
1913 .ok_or(ClaudeError::ApiKeyNotFound)?;
1914
1915 let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1916 debug!("Claude client created successfully");
1917 Ok(ClaudeClient::new(Box::new(ai_client)))
1918}
1919
1920#[cfg(test)]
1921#[allow(
1922 clippy::unwrap_used,
1923 clippy::expect_used,
1924 clippy::format_in_format_args
1925)]
1926mod tests {
1927 use super::*;
1928 use crate::claude::ai::{AiClient, AiClientCapabilities, AiClientMetadata};
1929 use std::future::Future;
1930 use std::pin::Pin;
1931 use std::sync::{Arc, Mutex};
1932
1933 struct MockAiClient;
1935
1936 impl AiClient for MockAiClient {
1937 fn send_request<'a>(
1938 &'a self,
1939 _system_prompt: &'a str,
1940 _user_prompt: &'a str,
1941 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1942 Box::pin(async { Ok(String::new()) })
1943 }
1944
1945 fn get_metadata(&self) -> AiClientMetadata {
1946 AiClientMetadata {
1947 provider: "Mock".to_string(),
1948 model: "mock-model".to_string(),
1949 max_context_length: 200_000,
1950 max_response_length: 8_192,
1951 active_beta: None,
1952 }
1953 }
1954 }
1955
1956 fn make_client() -> ClaudeClient {
1957 ClaudeClient::new(Box::new(MockAiClient))
1958 }
1959
1960 struct SchemaRecordingMockAiClient {
1971 capabilities: AiClientCapabilities,
1972 response: String,
1973 recorded_options: Arc<Mutex<Vec<RequestOptions>>>,
1974 recorded_plain: Arc<Mutex<Vec<(String, String)>>>,
1975 }
1976 impl SchemaRecordingMockAiClient {
1977 fn new(supports_response_schema: bool) -> Self {
1978 Self::with_response(supports_response_schema, String::new())
1979 }
1980
1981 fn with_response(supports_response_schema: bool, response: String) -> Self {
1982 Self {
1983 capabilities: AiClientCapabilities {
1984 supports_response_schema,
1985 },
1986 response,
1987 recorded_options: Arc::new(Mutex::new(Vec::new())),
1988 recorded_plain: Arc::new(Mutex::new(Vec::new())),
1989 }
1990 }
1991 }
1992
1993 impl AiClient for SchemaRecordingMockAiClient {
1994 fn send_request<'a>(
1995 &'a self,
1996 system_prompt: &'a str,
1997 user_prompt: &'a str,
1998 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1999 let plain = self.recorded_plain.clone();
2000 let sys = system_prompt.to_string();
2001 let usr = user_prompt.to_string();
2002 let response = self.response.clone();
2003 Box::pin(async move {
2004 plain.lock().unwrap().push((sys, usr));
2005 Ok(response)
2006 })
2007 }
2008
2009 fn capabilities(&self) -> AiClientCapabilities {
2010 self.capabilities
2011 }
2012
2013 fn send_request_with_options<'a>(
2014 &'a self,
2015 _system_prompt: &'a str,
2016 _user_prompt: &'a str,
2017 options: RequestOptions,
2018 ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
2019 let recorded = self.recorded_options.clone();
2020 let response = self.response.clone();
2021 Box::pin(async move {
2022 recorded.lock().unwrap().push(options);
2023 Ok(response)
2024 })
2025 }
2026
2027 fn get_metadata(&self) -> AiClientMetadata {
2028 AiClientMetadata {
2029 provider: "SchemaMock".to_string(),
2030 model: "schema-mock".to_string(),
2031 max_context_length: 200_000,
2032 max_response_length: 8_192,
2033 active_beta: None,
2034 }
2035 }
2036 }
2037
2038 #[tokio::test]
2044 async fn send_with_optional_schema_without_caps_uses_plain_send() {
2045 let inner = SchemaRecordingMockAiClient::new(false);
2046 let plain_log = inner.recorded_plain.clone();
2047 let opts_log = inner.recorded_options.clone();
2048 let client = ClaudeClient::new(Box::new(inner));
2049
2050 let schema = serde_json::json!({"type": "object"});
2051 client
2052 .send_with_optional_schema(
2053 "sys",
2054 "usr",
2055 client.schema_if_supported(&schema), )
2057 .await
2058 .unwrap();
2059
2060 assert_eq!(plain_log.lock().unwrap().len(), 1);
2061 assert!(opts_log.lock().unwrap().is_empty());
2062 }
2063
2064 #[tokio::test]
2068 async fn send_with_optional_schema_with_caps_uses_options_send() {
2069 let inner = SchemaRecordingMockAiClient::new(true);
2070 let plain_log = inner.recorded_plain.clone();
2071 let opts_log = inner.recorded_options.clone();
2072 let client = ClaudeClient::new(Box::new(inner));
2073
2074 let schema = serde_json::json!({"type": "object", "additionalProperties": false});
2075 client
2076 .send_with_optional_schema(
2077 "sys",
2078 "usr",
2079 client.schema_if_supported(&schema), )
2081 .await
2082 .unwrap();
2083
2084 let recorded = opts_log.lock().unwrap();
2085 assert_eq!(recorded.len(), 1);
2086 assert_eq!(recorded[0].response_schema.as_ref(), Some(&schema));
2087 assert!(plain_log.lock().unwrap().is_empty());
2088 }
2089
2090 #[test]
2093 fn adjusted_system_prompt_adds_suffix_when_supported() {
2094 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
2095 let result = client.adjusted_system_prompt("body".to_string());
2096 assert!(result.starts_with("body"));
2097 assert!(result.contains("STRUCTURED OUTPUT OVERRIDE"));
2098 }
2099
2100 #[test]
2101 fn adjusted_system_prompt_passes_through_when_not_supported() {
2102 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
2103 let result = client.adjusted_system_prompt("body".to_string());
2104 assert_eq!(result, "body");
2105 }
2106
2107 #[test]
2108 fn schema_if_supported_returns_some_when_supported() {
2109 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(true)));
2110 let schema = serde_json::json!({"type": "object"});
2111 let returned = client.schema_if_supported(&schema);
2112 assert!(returned.is_some());
2113 assert!(std::ptr::eq(
2114 std::ptr::from_ref(returned.unwrap()),
2115 std::ptr::addr_of!(schema)
2116 ));
2117 }
2118
2119 #[test]
2120 fn schema_if_supported_returns_none_when_not_supported() {
2121 let client = ClaudeClient::new(Box::new(SchemaRecordingMockAiClient::new(false)));
2122 let schema = serde_json::json!({"type": "object"});
2123 assert!(client.schema_if_supported(&schema).is_none());
2124 }
2125
2126 #[tokio::test]
2133 async fn refine_amendments_coherence_round_trip() {
2134 let mock = SchemaRecordingMockAiClient::with_response(
2135 true, "amendments: []".to_string(),
2137 );
2138 let recorded_opts = mock.recorded_options.clone();
2139 let client = ClaudeClient::new(Box::new(mock));
2140
2141 let amendment = crate::data::amendments::Amendment {
2142 commit: "abc123".to_string(),
2143 message: "feat: do thing".to_string(),
2144 summary: "did the thing".to_string(),
2145 };
2146 let items = vec![(amendment, "summary text".to_string())];
2147
2148 let result = client
2149 .refine_amendments_coherence(&items)
2150 .await
2151 .expect("coherence refinement should succeed");
2152 assert!(result.amendments.is_empty());
2153
2154 let recorded = recorded_opts.lock().unwrap();
2157 assert_eq!(recorded.len(), 1);
2158 let attached = recorded[0]
2159 .response_schema
2160 .as_ref()
2161 .expect("schema must be attached when capability is true");
2162 assert_eq!(
2163 attached,
2164 response_schema::amendment_file_schema(),
2165 "refine_amendments_coherence should attach the AmendmentFile schema"
2166 );
2167 }
2168
2169 #[tokio::test]
2174 async fn refine_checks_coherence_round_trip() {
2175 let mock = SchemaRecordingMockAiClient::with_response(
2176 true, "checks: []".to_string(),
2178 );
2179 let recorded_opts = mock.recorded_options.clone();
2180 let client = ClaudeClient::new(Box::new(mock));
2181
2182 let check = crate::data::check::CommitCheckResult {
2183 hash: "abc123".to_string(),
2184 message: "feat: do thing".to_string(),
2185 issues: Vec::new(),
2186 suggestion: None,
2187 passes: true,
2188 summary: Some("summary".to_string()),
2189 };
2190 let items = vec![(check, "summary text".to_string())];
2191 let dir = tempfile::TempDir::new().unwrap();
2192 let repo_view = make_test_repo_view(&dir);
2193
2194 let result = client
2195 .refine_checks_coherence(&items, &repo_view)
2196 .await
2197 .expect("coherence refinement should succeed");
2198 assert_eq!(result.summary.total_commits, 0);
2199
2200 let recorded = recorded_opts.lock().unwrap();
2201 assert_eq!(recorded.len(), 1);
2202 let attached = recorded[0]
2203 .response_schema
2204 .as_ref()
2205 .expect("schema must be attached when capability is true");
2206 assert_eq!(
2207 attached,
2208 response_schema::check_response_schema(),
2209 "refine_checks_coherence should attach the AiCheckResponse schema"
2210 );
2211 }
2212
2213 #[tokio::test]
2217 async fn refine_amendments_coherence_without_schema_capability() {
2218 let mock = SchemaRecordingMockAiClient::with_response(
2219 false, "amendments: []".to_string(),
2221 );
2222 let recorded_plain = mock.recorded_plain.clone();
2223 let recorded_opts = mock.recorded_options.clone();
2224 let client = ClaudeClient::new(Box::new(mock));
2225
2226 let amendment = crate::data::amendments::Amendment {
2227 commit: "abc123".to_string(),
2228 message: "feat: do thing".to_string(),
2229 summary: String::new(),
2230 };
2231 let items = vec![(amendment, "summary".to_string())];
2232
2233 client
2234 .refine_amendments_coherence(&items)
2235 .await
2236 .expect("coherence refinement should succeed without schema support");
2237
2238 assert_eq!(recorded_plain.lock().unwrap().len(), 1);
2239 assert!(
2240 recorded_opts.lock().unwrap().is_empty(),
2241 "no-schema backend must not be reached via the options path"
2242 );
2243 }
2244
2245 #[test]
2248 fn extract_yaml_pure_amendments() {
2249 let client = make_client();
2250 let content = "amendments:\n - commit: abc123\n message: test";
2251 let result = client.extract_yaml_from_response(content);
2252 assert!(result.starts_with("amendments:"));
2253 }
2254
2255 #[test]
2256 fn extract_yaml_with_markdown_yaml_block() {
2257 let client = make_client();
2258 let content = "Here is the result:\n```yaml\namendments:\n - commit: abc\n```\n";
2259 let result = client.extract_yaml_from_response(content);
2260 assert!(result.starts_with("amendments:"));
2261 }
2262
2263 #[test]
2264 fn extract_yaml_with_generic_code_block() {
2265 let client = make_client();
2266 let content = "```\namendments:\n - commit: abc\n```";
2267 let result = client.extract_yaml_from_response(content);
2268 assert!(result.starts_with("amendments:"));
2269 }
2270
2271 #[test]
2272 fn extract_yaml_with_whitespace() {
2273 let client = make_client();
2274 let content = " \n amendments:\n - commit: abc\n ";
2275 let result = client.extract_yaml_from_response(content);
2276 assert!(result.starts_with("amendments:"));
2277 }
2278
2279 #[test]
2280 fn extract_yaml_fallback_returns_trimmed() {
2281 let client = make_client();
2282 let content = " some random text ";
2283 let result = client.extract_yaml_from_response(content);
2284 assert_eq!(result, "some random text");
2285 }
2286
2287 #[test]
2290 fn extract_check_yaml_pure() {
2291 let client = make_client();
2292 let content = "checks:\n - commit: abc123";
2293 let result = client.extract_yaml_from_check_response(content);
2294 assert!(result.starts_with("checks:"));
2295 }
2296
2297 #[test]
2298 fn extract_check_yaml_markdown_block() {
2299 let client = make_client();
2300 let content = "```yaml\nchecks:\n - commit: abc\n```";
2301 let result = client.extract_yaml_from_check_response(content);
2302 assert!(result.starts_with("checks:"));
2303 }
2304
2305 #[test]
2306 fn extract_check_yaml_generic_block() {
2307 let client = make_client();
2308 let content = "```\nchecks:\n - commit: abc\n```";
2309 let result = client.extract_yaml_from_check_response(content);
2310 assert!(result.starts_with("checks:"));
2311 }
2312
2313 #[test]
2314 fn extract_check_yaml_fallback() {
2315 let client = make_client();
2316 let content = " unexpected content ";
2317 let result = client.extract_yaml_from_check_response(content);
2318 assert_eq!(result, "unexpected content");
2319 }
2320
2321 #[test]
2324 fn parse_amendment_response_valid() {
2325 let client = make_client();
2326 let yaml = format!(
2327 "amendments:\n - commit: \"{}\"\n message: \"test message\"",
2328 "a".repeat(40)
2329 );
2330 let result = client.parse_amendment_response(&yaml);
2331 assert!(result.is_ok());
2332 assert_eq!(result.unwrap().amendments.len(), 1);
2333 }
2334
2335 #[test]
2336 fn parse_amendment_response_invalid_yaml() {
2337 let client = make_client();
2338 let result = client.parse_amendment_response("not: valid: yaml: [{{");
2339 assert!(result.is_err());
2340 }
2341
2342 #[test]
2343 fn parse_amendment_response_invalid_hash() {
2344 let client = make_client();
2345 let yaml = "amendments:\n - commit: \"short\"\n message: \"test\"";
2346 let result = client.parse_amendment_response(yaml);
2347 assert!(result.is_err());
2348 }
2349
2350 #[test]
2353 fn validate_beta_header_none_passes() {
2354 let result = validate_beta_header("claude-opus-4-1-20250805", &None);
2355 assert!(result.is_ok());
2356 }
2357
2358 #[test]
2359 fn validate_beta_header_unsupported_fails() {
2360 let header = Some(("fake-key".to_string(), "fake-value".to_string()));
2361 let result = validate_beta_header("claude-opus-4-1-20250805", &header);
2362 assert!(result.is_err());
2363 }
2364
2365 #[test]
2368 fn client_metadata() {
2369 let client = make_client();
2370 let metadata = client.get_ai_client_metadata();
2371 assert_eq!(metadata.provider, "Mock");
2372 assert_eq!(metadata.model, "mock-model");
2373 }
2374
2375 mod prop {
2378 use super::*;
2379 use proptest::prelude::*;
2380
2381 proptest! {
2382 #[test]
2383 fn yaml_response_output_trimmed(s in ".*") {
2384 let client = make_client();
2385 let result = client.extract_yaml_from_response(&s);
2386 prop_assert_eq!(&result, result.trim());
2387 }
2388
2389 #[test]
2390 fn yaml_response_amendments_prefix_preserved(tail in ".*") {
2391 let client = make_client();
2392 let input = format!("amendments:{tail}");
2393 let result = client.extract_yaml_from_response(&input);
2394 prop_assert!(result.starts_with("amendments:"));
2395 }
2396
2397 #[test]
2398 fn check_response_checks_prefix_preserved(tail in ".*") {
2399 let client = make_client();
2400 let input = format!("checks:{tail}");
2401 let result = client.extract_yaml_from_check_response(&input);
2402 prop_assert!(result.starts_with("checks:"));
2403 }
2404
2405 #[test]
2406 fn yaml_fenced_block_strips_fences(
2407 content in "[a-zA-Z0-9: _\\-\n]{1,100}",
2408 ) {
2409 let client = make_client();
2410 let input = format!("```yaml\n{content}\n```");
2411 let result = client.extract_yaml_from_response(&input);
2412 prop_assert!(!result.contains("```"));
2413 }
2414 }
2415 }
2416
2417 fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
2420 ClaudeClient::new(Box::new(
2421 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2422 ))
2423 }
2424
2425 fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2426 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2427 use crate::git::commit::FileChanges;
2428 use crate::git::{CommitAnalysis, CommitInfo};
2429
2430 let diff_path = dir.path().join("0.diff");
2431 std::fs::write(&diff_path, "+added line\n").unwrap();
2432
2433 crate::data::RepositoryView {
2434 versions: None,
2435 explanation: FieldExplanation::default(),
2436 working_directory: WorkingDirectoryInfo {
2437 clean: true,
2438 untracked_changes: Vec::new(),
2439 },
2440 remotes: Vec::new(),
2441 ai: AiInfo {
2442 scratch: String::new(),
2443 },
2444 branch_info: None,
2445 pr_template: None,
2446 pr_template_location: None,
2447 branch_prs: None,
2448 commits: vec![CommitInfo {
2449 hash: format!("{:0>40}", 0),
2450 author: "Test <test@test.com>".to_string(),
2451 date: chrono::Utc::now().fixed_offset(),
2452 original_message: "feat(test): add something".to_string(),
2453 in_main_branches: Vec::new(),
2454 analysis: CommitAnalysis {
2455 detected_type: "feat".to_string(),
2456 detected_scope: "test".to_string(),
2457 proposed_message: "feat(test): add something".to_string(),
2458 file_changes: FileChanges {
2459 total_files: 1,
2460 files_added: 1,
2461 files_deleted: 0,
2462 file_list: Vec::new(),
2463 },
2464 diff_summary: "file.rs | 1 +".to_string(),
2465 diff_file: diff_path.to_string_lossy().to_string(),
2466 file_diffs: Vec::new(),
2467 },
2468 }],
2469 }
2470 }
2471
2472 fn valid_check_yaml() -> String {
2473 format!(
2474 "checks:\n - commit: \"{hash}\"\n passes: true\n issues: []\n",
2475 hash = format!("{:0>40}", 0)
2476 )
2477 }
2478
2479 #[tokio::test]
2480 async fn send_message_propagates_ai_error() {
2481 let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
2482 let result = client.send_message("sys", "usr").await;
2483 assert!(result.is_err());
2484 assert!(result.unwrap_err().to_string().contains("mock error"));
2485 }
2486
2487 #[tokio::test]
2488 async fn check_commits_succeeds_after_request_error() {
2489 let dir = tempfile::tempdir().unwrap();
2490 let repo_view = make_test_repo_view(&dir);
2491 let client = make_configurable_client(vec![
2493 Err(anyhow::anyhow!("rate limit")),
2494 Ok(valid_check_yaml()),
2495 Ok(valid_check_yaml()),
2496 ]);
2497 let result = client
2498 .check_commits_with_scopes(&repo_view, None, &[], false)
2499 .await;
2500 assert!(result.is_ok());
2501 }
2502
2503 #[tokio::test]
2504 async fn check_commits_succeeds_after_parse_error() {
2505 let dir = tempfile::tempdir().unwrap();
2506 let repo_view = make_test_repo_view(&dir);
2507 let client = make_configurable_client(vec![
2509 Ok("not: valid: yaml: [[".to_string()),
2510 Ok(valid_check_yaml()),
2511 Ok(valid_check_yaml()),
2512 ]);
2513 let result = client
2514 .check_commits_with_scopes(&repo_view, None, &[], false)
2515 .await;
2516 assert!(result.is_ok());
2517 }
2518
2519 #[tokio::test]
2520 async fn check_commits_fails_after_all_retries_exhausted() {
2521 let dir = tempfile::tempdir().unwrap();
2522 let repo_view = make_test_repo_view(&dir);
2523 let client = make_configurable_client(vec![
2524 Err(anyhow::anyhow!("first failure")),
2525 Err(anyhow::anyhow!("second failure")),
2526 Err(anyhow::anyhow!("final failure")),
2527 ]);
2528 let result = client
2529 .check_commits_with_scopes(&repo_view, None, &[], false)
2530 .await;
2531 assert!(result.is_err());
2532 }
2533
2534 #[tokio::test]
2535 async fn check_commits_fails_when_all_parses_fail() {
2536 let dir = tempfile::tempdir().unwrap();
2537 let repo_view = make_test_repo_view(&dir);
2538 let client = make_configurable_client(vec![
2539 Ok("bad yaml [[".to_string()),
2540 Ok("bad yaml [[".to_string()),
2541 Ok("bad yaml [[".to_string()),
2542 ]);
2543 let result = client
2544 .check_commits_with_scopes(&repo_view, None, &[], false)
2545 .await;
2546 assert!(result.is_err());
2547 }
2548
2549 fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
2556 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2560 .with_context_length(50_000);
2561 ClaudeClient::new(Box::new(mock))
2562 }
2563
2564 fn make_small_context_client_tracked(
2567 responses: Vec<Result<String>>,
2568 ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
2569 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2570 .with_context_length(50_000);
2571 let handle = mock.response_handle();
2572 (ClaudeClient::new(Box::new(mock)), handle)
2573 }
2574
2575 fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2578 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2579 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2580 use crate::git::{CommitAnalysis, CommitInfo};
2581
2582 let hash = "a".repeat(40);
2583
2584 let full_diff = "x".repeat(120_000);
2588 let flat_diff_path = dir.path().join("full.diff");
2589 std::fs::write(&flat_diff_path, &full_diff).unwrap();
2590
2591 let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2594 let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2595
2596 let path_a = dir.path().join("0000.diff");
2597 let path_b = dir.path().join("0001.diff");
2598 std::fs::write(&path_a, &diff_a).unwrap();
2599 std::fs::write(&path_b, &diff_b).unwrap();
2600
2601 crate::data::RepositoryView {
2602 versions: None,
2603 explanation: FieldExplanation::default(),
2604 working_directory: WorkingDirectoryInfo {
2605 clean: true,
2606 untracked_changes: Vec::new(),
2607 },
2608 remotes: Vec::new(),
2609 ai: AiInfo {
2610 scratch: String::new(),
2611 },
2612 branch_info: None,
2613 pr_template: None,
2614 pr_template_location: None,
2615 branch_prs: None,
2616 commits: vec![CommitInfo {
2617 hash,
2618 author: "Test <test@test.com>".to_string(),
2619 date: chrono::Utc::now().fixed_offset(),
2620 original_message: "feat(test): large commit".to_string(),
2621 in_main_branches: Vec::new(),
2622 analysis: CommitAnalysis {
2623 detected_type: "feat".to_string(),
2624 detected_scope: "test".to_string(),
2625 proposed_message: "feat(test): large commit".to_string(),
2626 file_changes: FileChanges {
2627 total_files: 2,
2628 files_added: 2,
2629 files_deleted: 0,
2630 file_list: vec![
2631 FileChange {
2632 status: "A".to_string(),
2633 file: "src/a.rs".to_string(),
2634 },
2635 FileChange {
2636 status: "A".to_string(),
2637 file: "src/b.rs".to_string(),
2638 },
2639 ],
2640 },
2641 diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2642 diff_file: flat_diff_path.to_string_lossy().to_string(),
2643 file_diffs: vec![
2644 FileDiffRef {
2645 path: "src/a.rs".to_string(),
2646 diff_file: path_a.to_string_lossy().to_string(),
2647 byte_len: diff_a.len(),
2648 },
2649 FileDiffRef {
2650 path: "src/b.rs".to_string(),
2651 diff_file: path_b.to_string_lossy().to_string(),
2652 byte_len: diff_b.len(),
2653 },
2654 ],
2655 },
2656 }],
2657 }
2658 }
2659
2660 fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2661 format!("amendments:\n - commit: \"{hash}\"\n message: \"{message}\"")
2662 }
2663
2664 #[tokio::test]
2665 async fn generate_amendments_split_dispatch() {
2666 let dir = tempfile::tempdir().unwrap();
2667 let repo_view = make_large_diff_repo_view(&dir);
2668 let hash = "a".repeat(40);
2669
2670 let client = make_small_context_client(vec![
2672 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2673 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2674 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2675 ]);
2676
2677 let result = client
2678 .generate_amendments_with_options(&repo_view, false)
2679 .await;
2680
2681 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2682 let amendments = result.unwrap();
2683 assert_eq!(amendments.amendments.len(), 1);
2684 assert_eq!(amendments.amendments[0].commit, hash);
2685 assert!(amendments.amendments[0]
2686 .message
2687 .contains("add a.rs and b.rs"));
2688 }
2689
2690 #[tokio::test]
2691 async fn generate_amendments_split_chunk_failure() {
2692 let dir = tempfile::tempdir().unwrap();
2693 let repo_view = make_large_diff_repo_view(&dir);
2694 let hash = "a".repeat(40);
2695
2696 let client = make_small_context_client(vec![
2698 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2699 Err(anyhow::anyhow!("rate limit exceeded")),
2700 ]);
2701
2702 let result = client
2703 .generate_amendments_with_options(&repo_view, false)
2704 .await;
2705
2706 assert!(result.is_err());
2707 }
2708
2709 #[tokio::test]
2710 async fn generate_amendments_no_split_when_fits() {
2711 let dir = tempfile::tempdir().unwrap();
2712 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2714
2715 let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2717 &hash,
2718 "feat(test): improved message",
2719 ))]);
2720
2721 let result = client
2722 .generate_amendments_with_options(&repo_view, false)
2723 .await;
2724
2725 assert!(result.is_ok());
2726 assert_eq!(result.unwrap().amendments.len(), 1);
2727 }
2728
2729 fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2732 format!(
2733 "checks:\n - commit: \"{hash}\"\n passes: {passes}\n issues: []\n summary: \"test summary\"\n"
2734 )
2735 }
2736
2737 fn valid_check_yaml_with_issues(hash: &str) -> String {
2738 format!(
2739 concat!(
2740 "checks:\n",
2741 " - commit: \"{hash}\"\n",
2742 " passes: false\n",
2743 " issues:\n",
2744 " - severity: error\n",
2745 " section: \"Subject Line\"\n",
2746 " rule: \"imperative-mood\"\n",
2747 " explanation: \"Subject uses past tense\"\n",
2748 " suggestion:\n",
2749 " message: \"feat(test): shorter subject\"\n",
2750 " explanation: \"Shortened subject line\"\n",
2751 " summary: \"Large commit with issues\"\n",
2752 ),
2753 hash = hash,
2754 )
2755 }
2756
2757 fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2758 format!(
2759 concat!(
2760 "checks:\n",
2761 " - commit: \"{hash}\"\n",
2762 " passes: true\n",
2763 " issues: []\n",
2764 " summary: \"chunk summary\"\n",
2765 ),
2766 hash = hash,
2767 )
2768 }
2769
2770 #[tokio::test]
2771 async fn check_commits_split_dispatch() {
2772 let dir = tempfile::tempdir().unwrap();
2773 let repo_view = make_large_diff_repo_view(&dir);
2774 let hash = "a".repeat(40);
2775
2776 let client = make_small_context_client(vec![
2778 Ok(valid_check_yaml_with_issues(&hash)),
2779 Ok(valid_check_yaml_with_issues(&hash)),
2780 Ok(valid_check_yaml_with_issues(&hash)), ]);
2782
2783 let result = client
2784 .check_commits_with_scopes(&repo_view, None, &[], true)
2785 .await;
2786
2787 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2788 let report = result.unwrap();
2789 assert_eq!(report.commits.len(), 1);
2790 assert!(!report.commits[0].passes);
2791 assert_eq!(report.commits[0].issues.len(), 1);
2793 assert_eq!(report.commits[0].issues[0].rule, "imperative-mood");
2794 }
2795
2796 #[tokio::test]
2797 async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2798 let dir = tempfile::tempdir().unwrap();
2799 let repo_view = make_large_diff_repo_view(&dir);
2800 let hash = "a".repeat(40);
2801
2802 let client = make_small_context_client(vec![
2805 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2806 Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2807 ]);
2808
2809 let result = client
2810 .check_commits_with_scopes(&repo_view, None, &[], false)
2811 .await;
2812
2813 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2814 let report = result.unwrap();
2815 assert_eq!(report.commits.len(), 1);
2816 assert!(report.commits[0].passes);
2817 assert!(report.commits[0].issues.is_empty());
2818 assert!(report.commits[0].suggestion.is_none());
2819 assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2821 }
2822
2823 #[tokio::test]
2824 async fn check_commits_split_chunk_failure() {
2825 let dir = tempfile::tempdir().unwrap();
2826 let repo_view = make_large_diff_repo_view(&dir);
2827 let hash = "a".repeat(40);
2828
2829 let client = make_small_context_client(vec![
2831 Ok(valid_check_yaml_for(&hash, true)),
2832 Err(anyhow::anyhow!("rate limit exceeded")),
2833 ]);
2834
2835 let result = client
2836 .check_commits_with_scopes(&repo_view, None, &[], false)
2837 .await;
2838
2839 assert!(result.is_err());
2840 }
2841
2842 #[tokio::test]
2843 async fn check_commits_no_split_when_fits() {
2844 let dir = tempfile::tempdir().unwrap();
2845 let repo_view = make_test_repo_view(&dir); let hash = format!("{:0>40}", 0);
2847
2848 let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2850
2851 let result = client
2852 .check_commits_with_scopes(&repo_view, None, &[], false)
2853 .await;
2854
2855 assert!(result.is_ok());
2856 assert_eq!(result.unwrap().commits.len(), 1);
2857 }
2858
2859 #[tokio::test]
2860 async fn check_commits_split_dedup_across_chunks() {
2861 let dir = tempfile::tempdir().unwrap();
2862 let repo_view = make_large_diff_repo_view(&dir);
2863 let hash = "a".repeat(40);
2864
2865 let chunk1 = format!(
2867 concat!(
2868 "checks:\n",
2869 " - commit: \"{hash}\"\n",
2870 " passes: false\n",
2871 " issues:\n",
2872 " - severity: error\n",
2873 " section: \"Subject Line\"\n",
2874 " rule: \"imperative-mood\"\n",
2875 " explanation: \"Subject uses past tense\"\n",
2876 " - severity: warning\n",
2877 " section: \"Content\"\n",
2878 " rule: \"body-required\"\n",
2879 " explanation: \"Large change needs body\"\n",
2880 ),
2881 hash = hash,
2882 );
2883
2884 let chunk2 = format!(
2886 concat!(
2887 "checks:\n",
2888 " - commit: \"{hash}\"\n",
2889 " passes: false\n",
2890 " issues:\n",
2891 " - severity: error\n",
2892 " section: \"Subject Line\"\n",
2893 " rule: \"imperative-mood\"\n",
2894 " explanation: \"Subject line is too long\"\n",
2895 " - severity: info\n",
2896 " section: \"Style\"\n",
2897 " rule: \"scope-suggestion\"\n",
2898 " explanation: \"Consider more specific scope\"\n",
2899 ),
2900 hash = hash,
2901 );
2902
2903 let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2905
2906 let result = client
2907 .check_commits_with_scopes(&repo_view, None, &[], false)
2908 .await;
2909
2910 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2911 let report = result.unwrap();
2912 assert_eq!(report.commits.len(), 1);
2913 assert!(!report.commits[0].passes);
2914 assert_eq!(report.commits[0].issues.len(), 3);
2917 }
2918
2919 #[tokio::test]
2920 async fn check_commits_split_passes_only_when_all_chunks_pass() {
2921 let dir = tempfile::tempdir().unwrap();
2922 let repo_view = make_large_diff_repo_view(&dir);
2923 let hash = "a".repeat(40);
2924
2925 let client = make_small_context_client(vec![
2927 Ok(valid_check_yaml_for(&hash, true)),
2928 Ok(valid_check_yaml_for(&hash, false)),
2929 ]);
2930
2931 let result = client
2932 .check_commits_with_scopes(&repo_view, None, &[], false)
2933 .await;
2934
2935 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2936 let report = result.unwrap();
2937 assert!(
2938 !report.commits[0].passes,
2939 "should fail when any chunk fails"
2940 );
2941 }
2942
2943 fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2947 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2948 use crate::git::commit::FileChanges;
2949 use crate::git::{CommitAnalysis, CommitInfo};
2950
2951 let diff_a = dir.path().join("0.diff");
2952 let diff_b = dir.path().join("1.diff");
2953 std::fs::write(&diff_a, "+line a\n").unwrap();
2954 std::fs::write(&diff_b, "+line b\n").unwrap();
2955
2956 let hash_a = "a".repeat(40);
2957 let hash_b = "b".repeat(40);
2958
2959 crate::data::RepositoryView {
2960 versions: None,
2961 explanation: FieldExplanation::default(),
2962 working_directory: WorkingDirectoryInfo {
2963 clean: true,
2964 untracked_changes: Vec::new(),
2965 },
2966 remotes: Vec::new(),
2967 ai: AiInfo {
2968 scratch: String::new(),
2969 },
2970 branch_info: None,
2971 pr_template: None,
2972 pr_template_location: None,
2973 branch_prs: None,
2974 commits: vec![
2975 CommitInfo {
2976 hash: hash_a,
2977 author: "Test <test@test.com>".to_string(),
2978 date: chrono::Utc::now().fixed_offset(),
2979 original_message: "feat(a): add a".to_string(),
2980 in_main_branches: Vec::new(),
2981 analysis: CommitAnalysis {
2982 detected_type: "feat".to_string(),
2983 detected_scope: "a".to_string(),
2984 proposed_message: "feat(a): add a".to_string(),
2985 file_changes: FileChanges {
2986 total_files: 1,
2987 files_added: 1,
2988 files_deleted: 0,
2989 file_list: Vec::new(),
2990 },
2991 diff_summary: "a.rs | 1 +".to_string(),
2992 diff_file: diff_a.to_string_lossy().to_string(),
2993 file_diffs: Vec::new(),
2994 },
2995 },
2996 CommitInfo {
2997 hash: hash_b,
2998 author: "Test <test@test.com>".to_string(),
2999 date: chrono::Utc::now().fixed_offset(),
3000 original_message: "feat(b): add b".to_string(),
3001 in_main_branches: Vec::new(),
3002 analysis: CommitAnalysis {
3003 detected_type: "feat".to_string(),
3004 detected_scope: "b".to_string(),
3005 proposed_message: "feat(b): add b".to_string(),
3006 file_changes: FileChanges {
3007 total_files: 1,
3008 files_added: 1,
3009 files_deleted: 0,
3010 file_list: Vec::new(),
3011 },
3012 diff_summary: "b.rs | 1 +".to_string(),
3013 diff_file: diff_b.to_string_lossy().to_string(),
3014 file_diffs: Vec::new(),
3015 },
3016 },
3017 ],
3018 }
3019 }
3020
3021 #[tokio::test]
3022 async fn generate_amendments_multi_commit() {
3023 let dir = tempfile::tempdir().unwrap();
3024 let repo_view = make_multi_commit_repo_view(&dir);
3025 let hash_a = "a".repeat(40);
3026 let hash_b = "b".repeat(40);
3027
3028 let response = format!(
3029 concat!(
3030 "amendments:\n",
3031 " - commit: \"{hash_a}\"\n",
3032 " message: \"feat(a): improved a\"\n",
3033 " - commit: \"{hash_b}\"\n",
3034 " message: \"feat(b): improved b\"\n",
3035 ),
3036 hash_a = hash_a,
3037 hash_b = hash_b,
3038 );
3039 let client = make_configurable_client(vec![Ok(response)]);
3040
3041 let result = client
3042 .generate_amendments_with_options(&repo_view, false)
3043 .await;
3044
3045 assert!(
3046 result.is_ok(),
3047 "multi-commit amendment failed: {:?}",
3048 result.err()
3049 );
3050 let amendments = result.unwrap();
3051 assert_eq!(amendments.amendments.len(), 2);
3052 }
3053
3054 #[tokio::test]
3055 async fn generate_contextual_amendments_multi_commit() {
3056 let dir = tempfile::tempdir().unwrap();
3057 let repo_view = make_multi_commit_repo_view(&dir);
3058 let hash_a = "a".repeat(40);
3059 let hash_b = "b".repeat(40);
3060
3061 let response = format!(
3062 concat!(
3063 "amendments:\n",
3064 " - commit: \"{hash_a}\"\n",
3065 " message: \"feat(a): improved a\"\n",
3066 " - commit: \"{hash_b}\"\n",
3067 " message: \"feat(b): improved b\"\n",
3068 ),
3069 hash_a = hash_a,
3070 hash_b = hash_b,
3071 );
3072 let client = make_configurable_client(vec![Ok(response)]);
3073 let context = crate::data::context::CommitContext::default();
3074
3075 let result = client
3076 .generate_contextual_amendments_with_options(&repo_view, &context, false)
3077 .await;
3078
3079 assert!(
3080 result.is_ok(),
3081 "multi-commit contextual amendment failed: {:?}",
3082 result.err()
3083 );
3084 let amendments = result.unwrap();
3085 assert_eq!(amendments.amendments.len(), 2);
3086 }
3087
3088 #[tokio::test]
3089 async fn generate_pr_content_succeeds() {
3090 let dir = tempfile::tempdir().unwrap();
3091 let repo_view = make_test_repo_view(&dir);
3092
3093 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
3094 let client = make_configurable_client(vec![Ok(response.to_string())]);
3095
3096 let result = client.generate_pr_content(&repo_view, "").await;
3097
3098 assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
3099 let pr = result.unwrap();
3100 assert_eq!(pr.title, "feat: add something");
3101 assert_eq!(pr.description, "Adds a new feature.");
3102 }
3103
3104 #[tokio::test]
3105 async fn generate_pr_content_with_context_succeeds() {
3106 let dir = tempfile::tempdir().unwrap();
3107 let repo_view = make_test_repo_view(&dir);
3108 let context = crate::data::context::CommitContext::default();
3109
3110 let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
3111 let client = make_configurable_client(vec![Ok(response.to_string())]);
3112
3113 let result = client
3114 .generate_pr_content_with_context(&repo_view, "", &context)
3115 .await;
3116
3117 assert!(
3118 result.is_ok(),
3119 "PR generation with context failed: {:?}",
3120 result.err()
3121 );
3122 let pr = result.unwrap();
3123 assert_eq!(pr.title, "feat: add something");
3124 }
3125
3126 #[tokio::test]
3127 async fn check_commits_multi_commit() {
3128 let dir = tempfile::tempdir().unwrap();
3129 let repo_view = make_multi_commit_repo_view(&dir);
3130 let hash_a = "a".repeat(40);
3131 let hash_b = "b".repeat(40);
3132
3133 let response = format!(
3134 concat!(
3135 "checks:\n",
3136 " - commit: \"{hash_a}\"\n",
3137 " passes: true\n",
3138 " issues: []\n",
3139 " - commit: \"{hash_b}\"\n",
3140 " passes: true\n",
3141 " issues: []\n",
3142 ),
3143 hash_a = hash_a,
3144 hash_b = hash_b,
3145 );
3146 let client = make_configurable_client(vec![Ok(response)]);
3147
3148 let result = client
3149 .check_commits_with_scopes(&repo_view, None, &[], false)
3150 .await;
3151
3152 assert!(
3153 result.is_ok(),
3154 "multi-commit check failed: {:?}",
3155 result.err()
3156 );
3157 let report = result.unwrap();
3158 assert_eq!(report.commits.len(), 2);
3159 assert!(report.commits[0].passes);
3160 assert!(report.commits[1].passes);
3161 }
3162
3163 fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
3168 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3169 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3170 use crate::git::{CommitAnalysis, CommitInfo};
3171
3172 let hash_a = "a".repeat(40);
3173 let hash_b = "b".repeat(40);
3174
3175 let diff_content_a = "x".repeat(60_000);
3178 let diff_content_b = "y".repeat(60_000);
3179 let flat_a = dir.path().join("flat_a.diff");
3180 let flat_b = dir.path().join("flat_b.diff");
3181 std::fs::write(&flat_a, &diff_content_a).unwrap();
3182 std::fs::write(&flat_b, &diff_content_b).unwrap();
3183
3184 let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
3186 let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
3187 let per_file_a = dir.path().join("pf_a.diff");
3188 let per_file_b = dir.path().join("pf_b.diff");
3189 std::fs::write(&per_file_a, &file_diff_a).unwrap();
3190 std::fs::write(&per_file_b, &file_diff_b).unwrap();
3191
3192 crate::data::RepositoryView {
3193 versions: None,
3194 explanation: FieldExplanation::default(),
3195 working_directory: WorkingDirectoryInfo {
3196 clean: true,
3197 untracked_changes: Vec::new(),
3198 },
3199 remotes: Vec::new(),
3200 ai: AiInfo {
3201 scratch: String::new(),
3202 },
3203 branch_info: None,
3204 pr_template: None,
3205 pr_template_location: None,
3206 branch_prs: None,
3207 commits: vec![
3208 CommitInfo {
3209 hash: hash_a,
3210 author: "Test <test@test.com>".to_string(),
3211 date: chrono::Utc::now().fixed_offset(),
3212 original_message: "feat(a): add module a".to_string(),
3213 in_main_branches: Vec::new(),
3214 analysis: CommitAnalysis {
3215 detected_type: "feat".to_string(),
3216 detected_scope: "a".to_string(),
3217 proposed_message: "feat(a): add module a".to_string(),
3218 file_changes: FileChanges {
3219 total_files: 1,
3220 files_added: 1,
3221 files_deleted: 0,
3222 file_list: vec![FileChange {
3223 status: "A".to_string(),
3224 file: "src/a.rs".to_string(),
3225 }],
3226 },
3227 diff_summary: " src/a.rs | 100 ++++\n".to_string(),
3228 diff_file: flat_a.to_string_lossy().to_string(),
3229 file_diffs: vec![FileDiffRef {
3230 path: "src/a.rs".to_string(),
3231 diff_file: per_file_a.to_string_lossy().to_string(),
3232 byte_len: file_diff_a.len(),
3233 }],
3234 },
3235 },
3236 CommitInfo {
3237 hash: hash_b,
3238 author: "Test <test@test.com>".to_string(),
3239 date: chrono::Utc::now().fixed_offset(),
3240 original_message: "feat(b): add module b".to_string(),
3241 in_main_branches: Vec::new(),
3242 analysis: CommitAnalysis {
3243 detected_type: "feat".to_string(),
3244 detected_scope: "b".to_string(),
3245 proposed_message: "feat(b): add module b".to_string(),
3246 file_changes: FileChanges {
3247 total_files: 1,
3248 files_added: 1,
3249 files_deleted: 0,
3250 file_list: vec![FileChange {
3251 status: "A".to_string(),
3252 file: "src/b.rs".to_string(),
3253 }],
3254 },
3255 diff_summary: " src/b.rs | 100 ++++\n".to_string(),
3256 diff_file: flat_b.to_string_lossy().to_string(),
3257 file_diffs: vec![FileDiffRef {
3258 path: "src/b.rs".to_string(),
3259 diff_file: per_file_b.to_string_lossy().to_string(),
3260 byte_len: file_diff_b.len(),
3261 }],
3262 },
3263 },
3264 ],
3265 }
3266 }
3267
3268 fn valid_pr_yaml(title: &str, description: &str) -> String {
3269 format!("title: \"{title}\"\ndescription: \"{description}\"\n")
3270 }
3271
3272 #[tokio::test]
3275 async fn generate_amendments_multi_commit_split_dispatch() {
3276 let dir = tempfile::tempdir().unwrap();
3277 let repo_view = make_large_multi_commit_repo_view(&dir);
3278 let hash_a = "a".repeat(40);
3279 let hash_b = "b".repeat(40);
3280
3281 let (client, handle) = make_small_context_client_tracked(vec![
3284 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3285 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3286 ]);
3287
3288 let result = client
3289 .generate_amendments_with_options(&repo_view, false)
3290 .await;
3291
3292 assert!(
3293 result.is_ok(),
3294 "multi-commit split dispatch failed: {:?}",
3295 result.err()
3296 );
3297 let amendments = result.unwrap();
3298 assert_eq!(amendments.amendments.len(), 2);
3299 assert_eq!(amendments.amendments[0].commit, hash_a);
3300 assert_eq!(amendments.amendments[1].commit, hash_b);
3301 assert!(amendments.amendments[0].message.contains("improved a"));
3302 assert!(amendments.amendments[1].message.contains("improved b"));
3303 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3304 }
3305
3306 #[tokio::test]
3307 async fn generate_contextual_amendments_multi_commit_split_dispatch() {
3308 let dir = tempfile::tempdir().unwrap();
3309 let repo_view = make_large_multi_commit_repo_view(&dir);
3310 let hash_a = "a".repeat(40);
3311 let hash_b = "b".repeat(40);
3312 let context = crate::data::context::CommitContext::default();
3313
3314 let (client, handle) = make_small_context_client_tracked(vec![
3315 Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
3316 Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
3317 ]);
3318
3319 let result = client
3320 .generate_contextual_amendments_with_options(&repo_view, &context, false)
3321 .await;
3322
3323 assert!(
3324 result.is_ok(),
3325 "multi-commit contextual split dispatch failed: {:?}",
3326 result.err()
3327 );
3328 let amendments = result.unwrap();
3329 assert_eq!(amendments.amendments.len(), 2);
3330 assert_eq!(amendments.amendments[0].commit, hash_a);
3331 assert_eq!(amendments.amendments[1].commit, hash_b);
3332 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3333 }
3334
3335 #[tokio::test]
3338 async fn check_commits_multi_commit_split_dispatch() {
3339 let dir = tempfile::tempdir().unwrap();
3340 let repo_view = make_large_multi_commit_repo_view(&dir);
3341 let hash_a = "a".repeat(40);
3342 let hash_b = "b".repeat(40);
3343
3344 let (client, handle) = make_small_context_client_tracked(vec![
3346 Ok(valid_check_yaml_for(&hash_a, true)),
3347 Ok(valid_check_yaml_for(&hash_b, true)),
3348 ]);
3349
3350 let result = client
3351 .check_commits_with_scopes(&repo_view, None, &[], false)
3352 .await;
3353
3354 assert!(
3355 result.is_ok(),
3356 "multi-commit check split dispatch failed: {:?}",
3357 result.err()
3358 );
3359 let report = result.unwrap();
3360 assert_eq!(report.commits.len(), 2);
3361 assert!(report.commits[0].passes);
3362 assert!(report.commits[1].passes);
3363 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3364 }
3365
3366 #[tokio::test]
3369 async fn generate_pr_content_split_dispatch() {
3370 let dir = tempfile::tempdir().unwrap();
3371 let repo_view = make_large_diff_repo_view(&dir);
3372
3373 let (client, handle) = make_small_context_client_tracked(vec![
3377 Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
3378 Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
3379 Ok(valid_pr_yaml(
3380 "feat(test): add modules",
3381 "Adds a.rs and b.rs",
3382 )),
3383 ]);
3384
3385 let result = client.generate_pr_content(&repo_view, "").await;
3386
3387 assert!(
3388 result.is_ok(),
3389 "PR split dispatch failed: {:?}",
3390 result.err()
3391 );
3392 let pr = result.unwrap();
3393 assert!(pr.title.contains("add modules"));
3394 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3395 }
3396
3397 #[tokio::test]
3398 async fn generate_pr_content_multi_commit_split_dispatch() {
3399 let dir = tempfile::tempdir().unwrap();
3400 let repo_view = make_large_multi_commit_repo_view(&dir);
3401
3402 let (client, handle) = make_small_context_client_tracked(vec![
3405 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3406 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3407 Ok(valid_pr_yaml(
3408 "feat: add modules a and b",
3409 "Adds both modules",
3410 )),
3411 ]);
3412
3413 let result = client.generate_pr_content(&repo_view, "").await;
3414
3415 assert!(
3416 result.is_ok(),
3417 "PR multi-commit split dispatch failed: {:?}",
3418 result.err()
3419 );
3420 let pr = result.unwrap();
3421 assert!(pr.title.contains("modules"));
3422 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3423 }
3424
3425 #[tokio::test]
3426 async fn generate_pr_content_with_context_from_commits_succeeds() {
3427 let dir = tempfile::tempdir().unwrap();
3428 let repo_view = make_test_repo_view(&dir);
3429 let context = crate::data::context::CommitContext::default();
3430
3431 let response = "title: \"feat: from commits\"\ndescription: \"derived from commit\"\n";
3432 let client = make_configurable_client(vec![Ok(response.to_string())]);
3433
3434 let result = client
3435 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3436 .await;
3437
3438 assert!(
3439 result.is_ok(),
3440 "PR from-commits generation failed: {:?}",
3441 result.err()
3442 );
3443 let pr = result.unwrap();
3444 assert_eq!(pr.title, "feat: from commits");
3445 }
3446
3447 #[tokio::test]
3448 async fn generate_pr_content_with_context_from_commits_omits_diff_in_prompt() {
3449 let dir = tempfile::tempdir().unwrap();
3450 let diff_path = dir.path().join("recognisable.diff");
3453 std::fs::write(
3454 &diff_path,
3455 "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+UNIQUE_DIFF_MARKER\n",
3456 )
3457 .unwrap();
3458
3459 let repo_view = {
3462 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3463 use crate::git::commit::FileChanges;
3464 use crate::git::{CommitAnalysis, CommitInfo};
3465 crate::data::RepositoryView {
3466 versions: None,
3467 explanation: FieldExplanation::default(),
3468 working_directory: WorkingDirectoryInfo {
3469 clean: true,
3470 untracked_changes: Vec::new(),
3471 },
3472 remotes: Vec::new(),
3473 ai: AiInfo {
3474 scratch: String::new(),
3475 },
3476 branch_info: None,
3477 pr_template: None,
3478 pr_template_location: None,
3479 branch_prs: None,
3480 commits: vec![CommitInfo {
3481 hash: format!("{:0>40}", 0),
3482 author: "Test <test@test.com>".to_string(),
3483 date: chrono::Utc::now().fixed_offset(),
3484 original_message: "feat(test): UNIQUE_COMMIT_SUBJECT_MARKER".to_string(),
3485 in_main_branches: Vec::new(),
3486 analysis: CommitAnalysis {
3487 detected_type: "feat".to_string(),
3488 detected_scope: "test".to_string(),
3489 proposed_message: "feat(test): unique".to_string(),
3490 file_changes: FileChanges {
3491 total_files: 1,
3492 files_added: 1,
3493 files_deleted: 0,
3494 file_list: Vec::new(),
3495 },
3496 diff_summary: "UNIQUE_STAT_MARKER | 1 +".to_string(),
3497 diff_file: diff_path.to_string_lossy().to_string(),
3498 file_diffs: Vec::new(),
3499 },
3500 }],
3501 }
3502 };
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_from_commits(&repo_view, "", &context)
3512 .await
3513 .unwrap();
3514
3515 let prompts = prompt_handle.prompts();
3516 assert_eq!(prompts.len(), 1, "expected exactly one AI call");
3517 let (system_prompt, user_prompt) = &prompts[0];
3518
3519 assert!(
3521 user_prompt.contains("UNIQUE_COMMIT_SUBJECT_MARKER"),
3522 "user prompt must include the commit subject"
3523 );
3524 assert!(
3526 !user_prompt.contains("UNIQUE_DIFF_MARKER"),
3527 "user prompt must NOT include diff content: {user_prompt}"
3528 );
3529 assert!(
3530 !user_prompt.contains("diff --git"),
3531 "user prompt must NOT include diff hunks"
3532 );
3533 assert!(
3534 !user_prompt.contains("diff_content"),
3535 "user prompt must NOT include diff_content YAML field"
3536 );
3537 assert!(
3538 !user_prompt.contains("UNIQUE_STAT_MARKER"),
3539 "user prompt must NOT include diff_summary"
3540 );
3541 assert!(
3543 !system_prompt.contains("diff files"),
3544 "system prompt must not mention diff files"
3545 );
3546 }
3547
3548 #[tokio::test]
3549 async fn generate_pr_content_with_context_default_mode_includes_diff_in_prompt() {
3550 let dir = tempfile::tempdir().unwrap();
3553 let repo_view = make_test_repo_view(&dir);
3554 let context = crate::data::context::CommitContext::default();
3555
3556 let (client, _resp_handle, prompt_handle) =
3557 make_configurable_client_with_prompts(vec![Ok(
3558 "title: \"feat: x\"\ndescription: \"y\"\n".to_string(),
3559 )]);
3560
3561 client
3562 .generate_pr_content_with_context(&repo_view, "", &context)
3563 .await
3564 .unwrap();
3565
3566 let prompts = prompt_handle.prompts();
3567 assert_eq!(prompts.len(), 1);
3568 let (_system_prompt, user_prompt) = &prompts[0];
3569 assert!(
3570 user_prompt.contains("diff_content"),
3571 "default mode must still serialise diff_content into the prompt"
3572 );
3573 }
3574
3575 #[tokio::test]
3576 async fn generate_pr_content_with_context_from_commits_multi_commit_per_commit_dispatch() {
3577 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3580 use crate::git::commit::FileChanges;
3581 use crate::git::{CommitAnalysis, CommitInfo};
3582
3583 let dir = tempfile::tempdir().unwrap();
3584 let make_commit = |hash: String, marker: &str, msg_size: usize| {
3585 let diff_path = dir.path().join(format!("{}.diff", &hash[..4]));
3586 std::fs::write(&diff_path, "+x\n").unwrap();
3587 CommitInfo {
3588 hash,
3589 author: "Test <test@test.com>".to_string(),
3590 date: chrono::Utc::now().fixed_offset(),
3591 original_message: format!("feat({marker}): {}", "x".repeat(msg_size)),
3592 in_main_branches: Vec::new(),
3593 analysis: CommitAnalysis {
3594 detected_type: "feat".to_string(),
3595 detected_scope: marker.to_string(),
3596 proposed_message: format!("feat({marker}): t"),
3597 file_changes: FileChanges {
3598 total_files: 1,
3599 files_added: 1,
3600 files_deleted: 0,
3601 file_list: Vec::new(),
3602 },
3603 diff_summary: String::new(),
3604 diff_file: diff_path.to_string_lossy().to_string(),
3605 file_diffs: Vec::new(),
3606 },
3607 }
3608 };
3609
3610 let repo_view = crate::data::RepositoryView {
3613 versions: None,
3614 explanation: FieldExplanation::default(),
3615 working_directory: WorkingDirectoryInfo {
3616 clean: true,
3617 untracked_changes: Vec::new(),
3618 },
3619 remotes: Vec::new(),
3620 ai: AiInfo {
3621 scratch: String::new(),
3622 },
3623 branch_info: None,
3624 pr_template: None,
3625 pr_template_location: None,
3626 branch_prs: None,
3627 commits: vec![
3628 make_commit("a".repeat(40), "a", 80_000),
3629 make_commit("b".repeat(40), "b", 80_000),
3630 ],
3631 };
3632 let context = crate::data::context::CommitContext::default();
3633
3634 let (client, handle) = make_small_context_client_tracked(vec![
3636 Ok(valid_pr_yaml("feat(a): a", "did a")),
3637 Ok(valid_pr_yaml("feat(b): b", "did b")),
3638 Ok(valid_pr_yaml("feat: a and b", "did both")),
3639 ]);
3640
3641 let result = client
3642 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3643 .await;
3644
3645 assert!(
3646 result.is_ok(),
3647 "from-commits per-commit dispatch failed: {:?}",
3648 result.err()
3649 );
3650 let pr = result.unwrap();
3651 assert!(
3652 pr.title.contains("and"),
3653 "unexpected merged title: {}",
3654 pr.title
3655 );
3656 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3657 }
3658
3659 #[tokio::test]
3660 async fn generate_pr_content_with_context_from_commits_single_commit_per_commit_return() {
3661 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3666 use crate::git::commit::FileChanges;
3667 use crate::git::{CommitAnalysis, CommitInfo};
3668
3669 let dir = tempfile::tempdir().unwrap();
3670 let diff_path = dir.path().join("0.diff");
3671 std::fs::write(&diff_path, "+x\n").unwrap();
3672 let commit = CommitInfo {
3673 hash: "a".repeat(40),
3674 author: "Test <test@test.com>".to_string(),
3675 date: chrono::Utc::now().fixed_offset(),
3676 original_message: format!("feat(only): {}", "x".repeat(80_000)),
3680 in_main_branches: Vec::new(),
3681 analysis: CommitAnalysis {
3682 detected_type: "feat".to_string(),
3683 detected_scope: "only".to_string(),
3684 proposed_message: "feat(only): m".to_string(),
3685 file_changes: FileChanges {
3686 total_files: 1,
3687 files_added: 1,
3688 files_deleted: 0,
3689 file_list: Vec::new(),
3690 },
3691 diff_summary: String::new(),
3692 diff_file: diff_path.to_string_lossy().to_string(),
3693 file_diffs: Vec::new(),
3694 },
3695 };
3696 let repo_view = crate::data::RepositoryView {
3697 versions: None,
3698 explanation: FieldExplanation::default(),
3699 working_directory: WorkingDirectoryInfo {
3700 clean: true,
3701 untracked_changes: Vec::new(),
3702 },
3703 remotes: Vec::new(),
3704 ai: AiInfo {
3705 scratch: String::new(),
3706 },
3707 branch_info: None,
3708 pr_template: None,
3709 pr_template_location: None,
3710 branch_prs: None,
3711 commits: vec![commit],
3712 };
3713 let context = crate::data::context::CommitContext::default();
3714
3715 let (client, handle) = make_small_context_client_tracked(vec![Ok(valid_pr_yaml(
3718 "feat(only): direct return",
3719 "single commit body",
3720 ))]);
3721
3722 let result = client
3723 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3724 .await;
3725
3726 assert!(
3727 result.is_ok(),
3728 "from-commits single-commit per-commit dispatch failed: {:?}",
3729 result.err()
3730 );
3731 let pr = result.unwrap();
3732 assert_eq!(pr.title, "feat(only): direct return");
3733 assert_eq!(
3734 handle.remaining(),
3735 0,
3736 "exactly one response should be consumed (no merge call)"
3737 );
3738 }
3739
3740 #[tokio::test]
3741 async fn generate_pr_content_with_context_from_commits_bails_on_oversized_single_commit() {
3742 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3745 use crate::git::commit::FileChanges;
3746 use crate::git::{CommitAnalysis, CommitInfo};
3747
3748 let dir = tempfile::tempdir().unwrap();
3749 let diff_path = dir.path().join("0.diff");
3750 std::fs::write(&diff_path, "+x\n").unwrap();
3751 let commit = CommitInfo {
3752 hash: "c".repeat(40),
3753 author: "Test <test@test.com>".to_string(),
3754 date: chrono::Utc::now().fixed_offset(),
3755 original_message: format!("feat: {}", "z".repeat(200_000)),
3758 in_main_branches: Vec::new(),
3759 analysis: CommitAnalysis {
3760 detected_type: "feat".to_string(),
3761 detected_scope: String::new(),
3762 proposed_message: "feat: oversized".to_string(),
3763 file_changes: FileChanges {
3764 total_files: 0,
3765 files_added: 0,
3766 files_deleted: 0,
3767 file_list: Vec::new(),
3768 },
3769 diff_summary: String::new(),
3770 diff_file: diff_path.to_string_lossy().to_string(),
3771 file_diffs: Vec::new(),
3772 },
3773 };
3774 let repo_view = crate::data::RepositoryView {
3775 versions: None,
3776 explanation: FieldExplanation::default(),
3777 working_directory: WorkingDirectoryInfo {
3778 clean: true,
3779 untracked_changes: Vec::new(),
3780 },
3781 remotes: Vec::new(),
3782 ai: AiInfo {
3783 scratch: String::new(),
3784 },
3785 branch_info: None,
3786 pr_template: None,
3787 pr_template_location: None,
3788 branch_prs: None,
3789 commits: vec![commit],
3790 };
3791 let context = crate::data::context::CommitContext::default();
3792
3793 let client = make_small_context_client(Vec::new());
3796
3797 let result = client
3798 .generate_pr_content_with_context_from_commits(&repo_view, "", &context)
3799 .await;
3800
3801 assert!(result.is_err(), "expected bail on oversized single commit");
3802 let msg = format!("{:#}", result.unwrap_err());
3803 assert!(
3804 msg.contains("Token budget exceeded"),
3805 "expected token-budget error message, got: {msg}"
3806 );
3807 assert!(
3808 msg.contains("--from-commits"),
3809 "error should reference the from-commits mode"
3810 );
3811 }
3812
3813 #[tokio::test]
3814 async fn generate_pr_content_with_context_split_dispatch() {
3815 let dir = tempfile::tempdir().unwrap();
3816 let repo_view = make_large_multi_commit_repo_view(&dir);
3817 let context = crate::data::context::CommitContext::default();
3818
3819 let (client, handle) = make_small_context_client_tracked(vec![
3821 Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
3822 Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
3823 Ok(valid_pr_yaml(
3824 "feat: add modules a and b",
3825 "Adds both modules",
3826 )),
3827 ]);
3828
3829 let result = client
3830 .generate_pr_content_with_context(&repo_view, "", &context)
3831 .await;
3832
3833 assert!(
3834 result.is_ok(),
3835 "PR with context split dispatch failed: {:?}",
3836 result.err()
3837 );
3838 let pr = result.unwrap();
3839 assert!(pr.title.contains("modules"));
3840 assert_eq!(handle.remaining(), 0, "expected all responses consumed");
3841 }
3842
3843 fn make_small_context_client_with_prompts(
3848 responses: Vec<Result<String>>,
3849 ) -> (
3850 ClaudeClient,
3851 crate::claude::test_utils::ResponseQueueHandle,
3852 crate::claude::test_utils::PromptRecordHandle,
3853 ) {
3854 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
3855 .with_context_length(50_000);
3856 let response_handle = mock.response_handle();
3857 let prompt_handle = mock.prompt_handle();
3858 (
3859 ClaudeClient::new(Box::new(mock)),
3860 response_handle,
3861 prompt_handle,
3862 )
3863 }
3864
3865 fn make_configurable_client_with_prompts(
3867 responses: Vec<Result<String>>,
3868 ) -> (
3869 ClaudeClient,
3870 crate::claude::test_utils::ResponseQueueHandle,
3871 crate::claude::test_utils::PromptRecordHandle,
3872 ) {
3873 let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
3874 let response_handle = mock.response_handle();
3875 let prompt_handle = mock.prompt_handle();
3876 (
3877 ClaudeClient::new(Box::new(mock)),
3878 response_handle,
3879 prompt_handle,
3880 )
3881 }
3882
3883 fn make_single_oversized_file_repo_view(
3890 dir: &tempfile::TempDir,
3891 ) -> crate::data::RepositoryView {
3892 use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
3893 use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
3894 use crate::git::{CommitAnalysis, CommitInfo};
3895
3896 let hash = "c".repeat(40);
3897
3898 let diff_content = format!(
3901 "diff --git a/src/big.rs b/src/big.rs\n{}\n",
3902 "x".repeat(80_000)
3903 );
3904
3905 let flat_diff_path = dir.path().join("full.diff");
3906 std::fs::write(&flat_diff_path, &diff_content).unwrap();
3907
3908 let per_file_path = dir.path().join("0000.diff");
3909 std::fs::write(&per_file_path, &diff_content).unwrap();
3910
3911 crate::data::RepositoryView {
3912 versions: None,
3913 explanation: FieldExplanation::default(),
3914 working_directory: WorkingDirectoryInfo {
3915 clean: true,
3916 untracked_changes: Vec::new(),
3917 },
3918 remotes: Vec::new(),
3919 ai: AiInfo {
3920 scratch: String::new(),
3921 },
3922 branch_info: None,
3923 pr_template: None,
3924 pr_template_location: None,
3925 branch_prs: None,
3926 commits: vec![CommitInfo {
3927 hash,
3928 author: "Test <test@test.com>".to_string(),
3929 date: chrono::Utc::now().fixed_offset(),
3930 original_message: "feat(big): add large module".to_string(),
3931 in_main_branches: Vec::new(),
3932 analysis: CommitAnalysis {
3933 detected_type: "feat".to_string(),
3934 detected_scope: "big".to_string(),
3935 proposed_message: "feat(big): add large module".to_string(),
3936 file_changes: FileChanges {
3937 total_files: 1,
3938 files_added: 1,
3939 files_deleted: 0,
3940 file_list: vec![FileChange {
3941 status: "A".to_string(),
3942 file: "src/big.rs".to_string(),
3943 }],
3944 },
3945 diff_summary: " src/big.rs | 80 ++++\n".to_string(),
3946 diff_file: flat_diff_path.to_string_lossy().to_string(),
3947 file_diffs: vec![FileDiffRef {
3948 path: "src/big.rs".to_string(),
3949 diff_file: per_file_path.to_string_lossy().to_string(),
3950 byte_len: diff_content.len(),
3951 }],
3952 },
3953 }],
3954 }
3955 }
3956
3957 #[tokio::test]
3964 async fn amendment_single_file_under_budget_no_split() {
3965 let dir = tempfile::tempdir().unwrap();
3966 let repo_view = make_test_repo_view(&dir);
3967 let hash = format!("{:0>40}", 0);
3968
3969 let (client, response_handle, prompt_handle) =
3970 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3971 &hash,
3972 "feat(test): improved message",
3973 ))]);
3974
3975 let result = client
3976 .generate_amendments_with_options(&repo_view, false)
3977 .await;
3978
3979 assert!(result.is_ok());
3980 assert_eq!(result.unwrap().amendments.len(), 1);
3981 assert_eq!(response_handle.remaining(), 0);
3982
3983 let prompts = prompt_handle.prompts();
3984 assert_eq!(
3985 prompts.len(),
3986 1,
3987 "expected exactly one AI request, no split"
3988 );
3989
3990 let (_, user_prompt) = &prompts[0];
3991 assert!(
3992 user_prompt.contains("added line"),
3993 "user prompt should contain the diff content"
3994 );
3995 }
3996
3997 #[tokio::test]
4008 async fn amendment_two_chunks_prompt_content() {
4009 let dir = tempfile::tempdir().unwrap();
4010 let repo_view = make_large_diff_repo_view(&dir);
4011 let hash = "a".repeat(40);
4012
4013 let (client, response_handle, prompt_handle) =
4014 make_small_context_client_with_prompts(vec![
4015 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
4016 Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
4017 Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
4018 ]);
4019
4020 let result = client
4021 .generate_amendments_with_options(&repo_view, false)
4022 .await;
4023
4024 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
4025 let amendments = result.unwrap();
4026 assert_eq!(amendments.amendments.len(), 1);
4027 assert!(amendments.amendments[0]
4028 .message
4029 .contains("add a.rs and b.rs"));
4030 assert_eq!(response_handle.remaining(), 0);
4031
4032 let prompts = prompt_handle.prompts();
4033 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
4034
4035 let (_, chunk1_user) = &prompts[0];
4037 assert!(
4038 chunk1_user.contains("aaa"),
4039 "chunk 1 prompt should contain file-a diff content"
4040 );
4041
4042 let (_, chunk2_user) = &prompts[1];
4044 assert!(
4045 chunk2_user.contains("bbb"),
4046 "chunk 2 prompt should contain file-b diff content"
4047 );
4048
4049 let (merge_sys, merge_user) = &prompts[2];
4051 assert!(
4052 merge_sys.contains("synthesiz"),
4053 "merge system prompt should contain synthesis instructions"
4054 );
4055 assert!(
4057 merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
4058 "merge user prompt should contain both partial amendment messages"
4059 );
4060 }
4061
4062 #[tokio::test]
4074 async fn amendment_single_oversized_file_gets_placeholder() {
4075 let dir = tempfile::tempdir().unwrap();
4076 let repo_view = make_single_oversized_file_repo_view(&dir);
4077 let hash = "c".repeat(40);
4078
4079 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4084 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
4085 Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
4086 ]);
4087
4088 let result = client
4089 .generate_amendments_with_options(&repo_view, false)
4090 .await;
4091
4092 assert!(
4094 result.is_ok(),
4095 "expected success with placeholder, got: {result:?}"
4096 );
4097
4098 assert!(
4100 prompt_handle.request_count() >= 1,
4101 "expected at least 1 request, got {}",
4102 prompt_handle.request_count()
4103 );
4104 }
4105
4106 #[tokio::test]
4115 async fn amendment_chunk_failure_stops_dispatch() {
4116 let dir = tempfile::tempdir().unwrap();
4117 let repo_view = make_large_diff_repo_view(&dir);
4118 let hash = "a".repeat(40);
4119
4120 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4122 Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
4123 Err(anyhow::anyhow!("rate limit exceeded")),
4124 ]);
4125
4126 let result = client
4127 .generate_amendments_with_options(&repo_view, false)
4128 .await;
4129
4130 assert!(result.is_err());
4131
4132 let prompts = prompt_handle.prompts();
4134 assert_eq!(
4135 prompts.len(),
4136 2,
4137 "should stop after the failing chunk, got {} requests",
4138 prompts.len()
4139 );
4140
4141 let (_, first_user) = &prompts[0];
4143 assert!(
4144 first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
4145 "first chunk prompt should reference a file"
4146 );
4147 }
4148
4149 #[tokio::test]
4160 async fn amendment_reduce_pass_prompt_content() {
4161 let dir = tempfile::tempdir().unwrap();
4162 let repo_view = make_large_diff_repo_view(&dir);
4163 let hash = "a".repeat(40);
4164
4165 let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
4166 Ok(valid_amendment_yaml(
4167 &hash,
4168 "feat(a): add module a implementation",
4169 )),
4170 Ok(valid_amendment_yaml(
4171 &hash,
4172 "feat(b): add module b implementation",
4173 )),
4174 Ok(valid_amendment_yaml(
4175 &hash,
4176 "feat(test): add modules a and b",
4177 )),
4178 ]);
4179
4180 let result = client
4181 .generate_amendments_with_options(&repo_view, false)
4182 .await;
4183
4184 assert!(result.is_ok());
4185
4186 let prompts = prompt_handle.prompts();
4187 assert_eq!(prompts.len(), 3);
4188
4189 let (merge_system, merge_user) = &prompts[2];
4191
4192 assert!(
4194 merge_system.contains("synthesiz"),
4195 "merge system prompt should contain synthesis instructions"
4196 );
4197
4198 assert!(
4200 merge_user.contains("feat(a): add module a implementation"),
4201 "merge user prompt should contain chunk 1's partial message"
4202 );
4203 assert!(
4204 merge_user.contains("feat(b): add module b implementation"),
4205 "merge user prompt should contain chunk 2's partial message"
4206 );
4207
4208 assert!(
4210 merge_user.contains("feat(test): large commit"),
4211 "merge user prompt should contain the original commit message"
4212 );
4213
4214 assert!(
4216 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
4217 "merge user prompt should contain the diff_summary"
4218 );
4219
4220 assert!(
4222 merge_user.contains(&hash),
4223 "merge user prompt should reference the commit hash"
4224 );
4225 }
4226
4227 #[tokio::test]
4244 async fn check_split_dedup_and_merge_prompt() {
4245 let dir = tempfile::tempdir().unwrap();
4246 let repo_view = make_large_diff_repo_view(&dir);
4247 let hash = "a".repeat(40);
4248
4249 let chunk1_yaml = format!(
4251 concat!(
4252 "checks:\n",
4253 " - commit: \"{hash}\"\n",
4254 " passes: false\n",
4255 " issues:\n",
4256 " - severity: error\n",
4257 " section: \"Subject Line\"\n",
4258 " rule: \"imperative-mood\"\n",
4259 " explanation: \"Subject uses past tense\"\n",
4260 " - severity: warning\n",
4261 " section: \"Content\"\n",
4262 " rule: \"body-required\"\n",
4263 " explanation: \"Large change needs body\"\n",
4264 " suggestion:\n",
4265 " message: \"feat(a): shorter subject for a\"\n",
4266 " explanation: \"Shortened subject for file a\"\n",
4267 " summary: \"Adds module a\"\n",
4268 ),
4269 hash = hash,
4270 );
4271
4272 let chunk2_yaml = format!(
4274 concat!(
4275 "checks:\n",
4276 " - commit: \"{hash}\"\n",
4277 " passes: false\n",
4278 " issues:\n",
4279 " - severity: error\n",
4280 " section: \"Subject Line\"\n",
4281 " rule: \"imperative-mood\"\n",
4282 " explanation: \"Subject line is way too long\"\n",
4283 " - severity: info\n",
4284 " section: \"Style\"\n",
4285 " rule: \"scope-suggestion\"\n",
4286 " explanation: \"Consider more specific scope\"\n",
4287 " suggestion:\n",
4288 " message: \"feat(b): shorter subject for b\"\n",
4289 " explanation: \"Shortened subject for file b\"\n",
4290 " summary: \"Adds module b\"\n",
4291 ),
4292 hash = hash,
4293 );
4294
4295 let merge_yaml = format!(
4297 concat!(
4298 "checks:\n",
4299 " - commit: \"{hash}\"\n",
4300 " passes: false\n",
4301 " issues: []\n",
4302 " suggestion:\n",
4303 " message: \"feat(test): add modules a and b\"\n",
4304 " explanation: \"Combined suggestion\"\n",
4305 " summary: \"Adds modules a and b\"\n",
4306 ),
4307 hash = hash,
4308 );
4309
4310 let (client, response_handle, prompt_handle) =
4311 make_small_context_client_with_prompts(vec![
4312 Ok(chunk1_yaml),
4313 Ok(chunk2_yaml),
4314 Ok(merge_yaml),
4315 ]);
4316
4317 let result = client
4318 .check_commits_with_scopes(&repo_view, None, &[], true)
4319 .await;
4320
4321 assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
4322 let report = result.unwrap();
4323 assert_eq!(report.commits.len(), 1);
4324 assert!(!report.commits[0].passes);
4325 assert_eq!(response_handle.remaining(), 0);
4326
4327 assert_eq!(
4332 report.commits[0].issues.len(),
4333 3,
4334 "expected 3 unique issues after dedup, got {:?}",
4335 report.commits[0]
4336 .issues
4337 .iter()
4338 .map(|i| &i.rule)
4339 .collect::<Vec<_>>()
4340 );
4341
4342 assert!(report.commits[0].suggestion.is_some());
4344 assert!(
4345 report.commits[0]
4346 .suggestion
4347 .as_ref()
4348 .unwrap()
4349 .message
4350 .contains("add modules a and b"),
4351 "suggestion should come from the merge pass"
4352 );
4353
4354 let prompts = prompt_handle.prompts();
4356 assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
4357
4358 let (_, chunk1_user) = &prompts[0];
4360 let (_, chunk2_user) = &prompts[1];
4361 let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
4362 assert!(
4363 combined_chunk_prompts.contains("src/a.rs")
4364 && combined_chunk_prompts.contains("src/b.rs"),
4365 "chunk prompts should collectively cover both files"
4366 );
4367
4368 let (merge_sys, merge_user) = &prompts[2];
4370 assert!(
4371 merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
4372 "merge system prompt should be the check chunk merge prompt"
4373 );
4374 assert!(
4375 merge_user.contains("feat(a): shorter subject for a")
4376 && merge_user.contains("feat(b): shorter subject for b"),
4377 "merge user prompt should contain both partial suggestions"
4378 );
4379 assert!(
4381 merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
4382 "merge user prompt should contain the diff_summary"
4383 );
4384 }
4385
4386 #[tokio::test]
4389 async fn amendment_retry_parse_failure_then_success() {
4390 let dir = tempfile::tempdir().unwrap();
4391 let repo_view = make_test_repo_view(&dir);
4392 let hash = format!("{:0>40}", 0);
4393
4394 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4395 Ok("not valid yaml {{[".to_string()),
4396 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
4397 ]);
4398
4399 let result = client
4400 .generate_amendments_with_options(&repo_view, false)
4401 .await;
4402
4403 assert!(
4404 result.is_ok(),
4405 "should succeed after retry: {:?}",
4406 result.err()
4407 );
4408 assert_eq!(result.unwrap().amendments.len(), 1);
4409 assert_eq!(response_handle.remaining(), 0, "both responses consumed");
4410 assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
4411 }
4412
4413 #[tokio::test]
4414 async fn amendment_retry_request_failure_then_success() {
4415 let dir = tempfile::tempdir().unwrap();
4416 let repo_view = make_test_repo_view(&dir);
4417 let hash = format!("{:0>40}", 0);
4418
4419 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4420 Err(anyhow::anyhow!("rate limit")),
4421 Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
4422 ]);
4423
4424 let result = client
4425 .generate_amendments_with_options(&repo_view, false)
4426 .await;
4427
4428 assert!(
4429 result.is_ok(),
4430 "should succeed after retry: {:?}",
4431 result.err()
4432 );
4433 assert_eq!(result.unwrap().amendments.len(), 1);
4434 assert_eq!(response_handle.remaining(), 0);
4435 assert_eq!(prompt_handle.request_count(), 2);
4436 }
4437
4438 #[tokio::test]
4439 async fn amendment_retry_all_attempts_exhausted() {
4440 let dir = tempfile::tempdir().unwrap();
4441 let repo_view = make_test_repo_view(&dir);
4442
4443 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4444 Ok("bad yaml 1".to_string()),
4445 Ok("bad yaml 2".to_string()),
4446 Ok("bad yaml 3".to_string()),
4447 ]);
4448
4449 let result = client
4450 .generate_amendments_with_options(&repo_view, false)
4451 .await;
4452
4453 assert!(result.is_err(), "should fail after all retries exhausted");
4454 assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
4455 assert_eq!(
4456 prompt_handle.request_count(),
4457 3,
4458 "exactly 3 AI requests (1 + 2 retries)"
4459 );
4460 }
4461
4462 #[tokio::test]
4463 async fn amendment_retry_success_first_attempt() {
4464 let dir = tempfile::tempdir().unwrap();
4465 let repo_view = make_test_repo_view(&dir);
4466 let hash = format!("{:0>40}", 0);
4467
4468 let (client, response_handle, prompt_handle) =
4469 make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
4470 &hash,
4471 "feat(test): works first time",
4472 ))]);
4473
4474 let result = client
4475 .generate_amendments_with_options(&repo_view, false)
4476 .await;
4477
4478 assert!(result.is_ok());
4479 assert_eq!(response_handle.remaining(), 0);
4480 assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
4481 }
4482
4483 #[tokio::test]
4484 async fn amendment_retry_mixed_request_and_parse_failures() {
4485 let dir = tempfile::tempdir().unwrap();
4486 let repo_view = make_test_repo_view(&dir);
4487 let hash = format!("{:0>40}", 0);
4488
4489 let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
4490 Err(anyhow::anyhow!("network error")),
4491 Ok("invalid yaml {{".to_string()),
4492 Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
4493 ]);
4494
4495 let result = client
4496 .generate_amendments_with_options(&repo_view, false)
4497 .await;
4498
4499 assert!(
4500 result.is_ok(),
4501 "should succeed on third attempt: {:?}",
4502 result.err()
4503 );
4504 assert_eq!(result.unwrap().amendments.len(), 1);
4505 assert_eq!(response_handle.remaining(), 0);
4506 assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
4507 }
4508
4509 use crate::test_support::env::MapEnv;
4516
4517 #[tokio::test]
4518 async fn factory_claude_cli_backend_dispatches_to_claude_cli_client() {
4519 let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "claude-cli");
4520
4521 let client = create_default_claude_client_with(&env, None, None)
4522 .await
4523 .expect("factory should succeed");
4524 let metadata = client.get_ai_client_metadata();
4525 assert_eq!(metadata.provider, "Claude CLI");
4526 assert_eq!(metadata.model, "claude-sonnet-4-6");
4528 }
4529
4530 #[tokio::test]
4531 async fn factory_claude_cli_backend_honours_model_precedence() {
4532 let env = MapEnv::new()
4534 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
4535 .with("CLAUDE_CODE_MODEL", "opus")
4536 .with("CLAUDE_MODEL", "haiku");
4537
4538 let client = create_default_claude_client_with(&env, None, None)
4539 .await
4540 .expect("factory should succeed");
4541 let metadata = client.get_ai_client_metadata();
4542 assert_eq!(metadata.provider, "Claude CLI");
4543 assert_eq!(metadata.model, "haiku");
4544 }
4545
4546 #[tokio::test]
4547 async fn factory_claude_cli_backend_explicit_model_wins_over_env() {
4548 let env = MapEnv::new()
4549 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
4550 .with("CLAUDE_MODEL", "haiku");
4551
4552 let client = create_default_claude_client_with(&env, Some("opus".to_string()), None)
4553 .await
4554 .expect("factory should succeed");
4555 let metadata = client.get_ai_client_metadata();
4556 assert_eq!(metadata.model, "opus");
4557 }
4558
4559 #[tokio::test]
4560 async fn factory_claude_cli_backend_accepts_underscore_alias() {
4561 let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "claude_cli");
4562
4563 let client = create_default_claude_client_with(&env, None, None)
4564 .await
4565 .expect("factory should succeed");
4566 let metadata = client.get_ai_client_metadata();
4567 assert_eq!(metadata.provider, "Claude CLI");
4568 }
4569
4570 #[tokio::test]
4571 async fn factory_ollama_branch_probes_loaded_context_length() {
4572 use wiremock::matchers::{method, path};
4573 use wiremock::{Mock, MockServer, ResponseTemplate};
4574
4575 let server = MockServer::start().await;
4576 Mock::given(method("GET"))
4577 .and(path("/api/v0/models"))
4578 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4579 "data": [
4580 { "id": "lm-loaded", "state": "loaded", "loaded_context_length": 6144_u64 }
4581 ]
4582 })))
4583 .mount(&server)
4584 .await;
4585
4586 let env = MapEnv::new()
4587 .with("USE_OLLAMA", "true")
4588 .with("OLLAMA_BASE_URL", &server.uri())
4589 .with("OLLAMA_MODEL", "lm-loaded");
4590
4591 let client = create_default_claude_client_with(&env, None, None)
4592 .await
4593 .expect("factory should succeed");
4594 let metadata = client.get_ai_client_metadata();
4595 assert_eq!(metadata.provider, "Ollama");
4596 assert_eq!(metadata.model, "lm-loaded");
4597 assert_eq!(metadata.max_context_length, 6144);
4599 }
4600
4601 #[tokio::test]
4602 async fn factory_ollama_branch_falls_back_when_probe_fails() {
4603 use wiremock::matchers::{method, path};
4604 use wiremock::{Mock, MockServer, ResponseTemplate};
4605
4606 let server = MockServer::start().await;
4607 Mock::given(method("GET"))
4608 .and(path("/api/v0/models"))
4609 .respond_with(ResponseTemplate::new(500))
4610 .mount(&server)
4611 .await;
4612 Mock::given(method("POST"))
4613 .and(path("/api/show"))
4614 .respond_with(ResponseTemplate::new(500))
4615 .mount(&server)
4616 .await;
4617
4618 let env = MapEnv::new()
4619 .with("USE_OLLAMA", "true")
4620 .with("OLLAMA_BASE_URL", &server.uri())
4621 .with("OLLAMA_MODEL", "no-such-model");
4622
4623 let client = create_default_claude_client_with(&env, None, None)
4624 .await
4625 .expect("factory should succeed");
4626 let metadata = client.get_ai_client_metadata();
4627 let registry_value =
4630 crate::claude::model_config::get_model_registry().get_input_context("no-such-model");
4631 assert_eq!(metadata.max_context_length, registry_value);
4632 }
4633
4634 #[tokio::test]
4638 async fn factory_ollama_branch_probes_via_ollama_native() {
4639 use wiremock::matchers::{method, path};
4640 use wiremock::{Mock, MockServer, ResponseTemplate};
4641
4642 let server = MockServer::start().await;
4643 Mock::given(method("GET"))
4644 .and(path("/api/v0/models"))
4645 .respond_with(ResponseTemplate::new(404))
4646 .mount(&server)
4647 .await;
4648 Mock::given(method("POST"))
4649 .and(path("/api/show"))
4650 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4651 "model_info": { "llama.context_length": 12288_u64 }
4652 })))
4653 .mount(&server)
4654 .await;
4655
4656 let env = MapEnv::new()
4657 .with("USE_OLLAMA", "true")
4658 .with("OLLAMA_BASE_URL", &server.uri())
4659 .with("OLLAMA_MODEL", "ollama-native-model");
4660
4661 let client = create_default_claude_client_with(&env, None, None)
4662 .await
4663 .expect("factory should succeed");
4664 let metadata = client.get_ai_client_metadata();
4665 assert_eq!(metadata.max_context_length, 12288);
4666 }
4667
4668 #[tokio::test]
4673 async fn factory_openai_branch_builds_client() {
4674 let env = MapEnv::new()
4675 .with("USE_OPENAI", "true")
4676 .with("OPENAI_MODEL", "gpt-4.1")
4677 .with("OPENAI_API_KEY", "sk-test");
4678
4679 let client = create_default_claude_client_with(&env, None, None)
4680 .await
4681 .expect("factory should succeed");
4682 assert_eq!(client.get_ai_client_metadata().model, "gpt-4.1");
4683 }
4684
4685 #[tokio::test]
4686 async fn factory_openai_branch_errors_without_api_key() {
4687 let env = MapEnv::new().with("USE_OPENAI", "true");
4688 let result = create_default_claude_client_with(&env, None, None).await;
4689 assert!(result.is_err());
4690 }
4691
4692 #[tokio::test]
4693 async fn factory_bedrock_branch_builds_client() {
4694 let env = MapEnv::new()
4695 .with("CLAUDE_CODE_USE_BEDROCK", "true")
4696 .with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
4697 .with("ANTHROPIC_AUTH_TOKEN", "tok")
4698 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
4699
4700 let client = create_default_claude_client_with(&env, None, None)
4701 .await
4702 .expect("factory should succeed");
4703 assert_eq!(client.get_ai_client_metadata().model, "claude-sonnet-4-6");
4704 }
4705
4706 #[tokio::test]
4707 async fn factory_bedrock_branch_errors_without_auth_token() {
4708 let env = MapEnv::new().with("CLAUDE_CODE_USE_BEDROCK", "true");
4709 let result = create_default_claude_client_with(&env, None, None).await;
4710 assert!(result.is_err());
4711 }
4712
4713 #[tokio::test]
4714 async fn factory_bedrock_branch_errors_without_base_url() {
4715 let env = MapEnv::new()
4716 .with("CLAUDE_CODE_USE_BEDROCK", "true")
4717 .with("ANTHROPIC_AUTH_TOKEN", "tok");
4718 let result = create_default_claude_client_with(&env, None, None).await;
4719 assert!(result.is_err());
4720 }
4721
4722 #[tokio::test]
4723 async fn factory_default_claude_branch_builds_client() {
4724 let env = MapEnv::new()
4725 .with("ANTHROPIC_MODEL", "claude-opus-4-6")
4726 .with("CLAUDE_API_KEY", "sk-test");
4727
4728 let client = create_default_claude_client_with(&env, None, None)
4729 .await
4730 .expect("factory should succeed");
4731 assert_eq!(client.get_ai_client_metadata().model, "claude-opus-4-6");
4732 }
4733
4734 #[tokio::test]
4735 async fn factory_default_claude_branch_errors_without_api_key() {
4736 let result = create_default_claude_client_with(&MapEnv::new(), None, None).await;
4737 assert!(result.is_err());
4738 }
4739}