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