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