Skip to main content

omni_dev/claude/
client.rs

1//! Claude client for commit message improvement.
2
3use anyhow::{Context, Result};
4use tracing::{debug, info};
5
6use crate::claude::token_budget::TokenBudget;
7use crate::claude::{ai::bedrock::BedrockAiClient, ai::claude::ClaudeAiClient};
8use crate::claude::{ai::AiClient, error::ClaudeError, prompts};
9use crate::data::{
10    amendments::{Amendment, AmendmentFile},
11    context::CommitContext,
12    RepositoryView, RepositoryViewForAI,
13};
14
15/// Returned when the full diff does not fit the token budget.
16///
17/// Carries the data needed for split dispatch so the caller can size
18/// diff chunks appropriately.
19struct BudgetExceeded {
20    /// Available input tokens for this model (context window minus output reserve).
21    available_input_tokens: usize,
22}
23
24/// Maximum retries for amendment parse/request failures (matches check retry count).
25const AMENDMENT_PARSE_MAX_RETRIES: u32 = 2;
26
27/// Claude client for commit message improvement.
28pub struct ClaudeClient {
29    /// AI client implementation.
30    ai_client: Box<dyn AiClient>,
31}
32
33impl ClaudeClient {
34    /// Creates a new Claude client with the provided AI client implementation.
35    pub fn new(ai_client: Box<dyn AiClient>) -> Self {
36        Self { ai_client }
37    }
38
39    /// Returns metadata about the AI client.
40    pub fn get_ai_client_metadata(&self) -> crate::claude::ai::AiClientMetadata {
41        self.ai_client.get_metadata()
42    }
43
44    /// Validates that the prompt fits within the model's token budget.
45    ///
46    /// Estimates token counts and logs utilization before each AI request.
47    /// Returns an error if the prompt exceeds available input tokens.
48    fn validate_prompt_budget(&self, system_prompt: &str, user_prompt: &str) -> Result<()> {
49        let metadata = self.ai_client.get_metadata();
50        let budget = TokenBudget::from_metadata(&metadata);
51        let estimate = budget.validate_prompt(system_prompt, user_prompt)?;
52
53        debug!(
54            model = %metadata.model,
55            estimated_tokens = estimate.estimated_tokens,
56            available_tokens = estimate.available_tokens,
57            utilization_pct = format!("{:.1}%", estimate.utilization_pct),
58            "Token budget check passed"
59        );
60
61        Ok(())
62    }
63
64    /// Builds a user prompt and validates it against the model's token budget.
65    ///
66    /// Serializes the repository view to YAML, constructs the user prompt, and
67    /// checks that it fits within the available input tokens. Returns an error
68    /// if the prompt exceeds the budget.
69    fn build_prompt_fitting_budget(
70        &self,
71        ai_view: &RepositoryViewForAI,
72        system_prompt: &str,
73        build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
74    ) -> Result<String> {
75        let metadata = self.ai_client.get_metadata();
76        let budget = TokenBudget::from_metadata(&metadata);
77
78        let yaml =
79            crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
80        let user_prompt = build_user_prompt(&yaml);
81
82        let estimate = budget.validate_prompt(system_prompt, &user_prompt)?;
83        debug!(
84            model = %metadata.model,
85            estimated_tokens = estimate.estimated_tokens,
86            available_tokens = estimate.available_tokens,
87            utilization_pct = format!("{:.1}%", estimate.utilization_pct),
88            "Token budget check passed"
89        );
90
91        Ok(user_prompt)
92    }
93
94    /// Tests whether the full diff fits the token budget.
95    ///
96    /// Returns `Ok(Ok(user_prompt))` when the full diff fits,
97    /// `Ok(Err(BudgetExceeded))` when it does not, or a top-level error
98    /// on serialization failure.
99    fn try_full_diff_budget(
100        &self,
101        ai_view: &RepositoryViewForAI,
102        system_prompt: &str,
103        build_user_prompt: &(impl Fn(&str) -> String + ?Sized),
104    ) -> Result<std::result::Result<String, BudgetExceeded>> {
105        let metadata = self.ai_client.get_metadata();
106        let budget = TokenBudget::from_metadata(&metadata);
107
108        let yaml =
109            crate::data::to_yaml(ai_view).context("Failed to serialize repository view to YAML")?;
110        let user_prompt = build_user_prompt(&yaml);
111
112        if let Ok(estimate) = budget.validate_prompt(system_prompt, &user_prompt) {
113            debug!(
114                model = %metadata.model,
115                estimated_tokens = estimate.estimated_tokens,
116                available_tokens = estimate.available_tokens,
117                utilization_pct = format!("{:.1}%", estimate.utilization_pct),
118                "Token budget check passed"
119            );
120            return Ok(Ok(user_prompt));
121        }
122
123        Ok(Err(BudgetExceeded {
124            available_input_tokens: budget.available_input_tokens(),
125        }))
126    }
127
128    /// Generates an amendment for a single commit whose diff exceeds the
129    /// token budget by splitting it into file-level chunks.
130    ///
131    /// Uses [`pack_file_diffs`](crate::claude::diff_pack::pack_file_diffs) to
132    /// create chunks, sends one AI request per chunk, then runs a merge pass
133    /// to synthesize a single [`Amendment`].
134    async fn generate_amendment_split(
135        &self,
136        commit: &crate::git::CommitInfo,
137        repo_view_for_ai: &RepositoryViewForAI,
138        system_prompt: &str,
139        build_user_prompt: &(dyn Fn(&str) -> String + Sync),
140        available_input_tokens: usize,
141        fresh: bool,
142    ) -> Result<Amendment> {
143        use crate::claude::batch::{
144            PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
145            VIEW_ENVELOPE_OVERHEAD_TOKENS,
146        };
147        use crate::claude::diff_pack::pack_file_diffs;
148        use crate::claude::token_budget;
149        use crate::git::commit::CommitInfoForAI;
150
151        // Compute effective capacity for diff packing by subtracting overhead
152        // that will be added when the full prompt is assembled. This mirrors
153        // the calculation in `batch::plan_batches`.
154        //
155        // Each chunk includes the FULL original_message and diff_summary (not
156        // just the partial diff), so we must subtract those from capacity.
157        // We also subtract user prompt template overhead for instruction text.
158        let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
159        let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
160            + token_budget::estimate_tokens(&commit.analysis.diff_summary);
161        let chunk_capacity = available_input_tokens
162            .saturating_sub(system_prompt_tokens)
163            .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
164            .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
165            .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
166            .saturating_sub(commit_text_tokens);
167
168        debug!(
169            commit = %&commit.hash[..8],
170            available_input_tokens,
171            system_prompt_tokens,
172            envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
173            metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
174            template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
175            commit_text_tokens,
176            chunk_capacity,
177            "Split dispatch: computed chunk capacity"
178        );
179
180        let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
181            .with_context(|| {
182                format!(
183                    "Failed to plan diff chunks for commit {}",
184                    &commit.hash[..8]
185                )
186            })?;
187
188        let total_chunks = plan.chunks.len();
189        debug!(
190            commit = %&commit.hash[..8],
191            chunks = total_chunks,
192            chunk_capacity,
193            "Split dispatch: processing commit in chunks"
194        );
195
196        let mut chunk_amendments = Vec::with_capacity(total_chunks);
197        for (i, chunk) in plan.chunks.iter().enumerate() {
198            let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
199                commit.clone(),
200                &chunk.file_paths,
201                &chunk.diff_overrides,
202            )
203            .with_context(|| {
204                format!(
205                    "Failed to build partial view for chunk {}/{} of commit {}",
206                    i + 1,
207                    total_chunks,
208                    &commit.hash[..8]
209                )
210            })?;
211
212            if fresh {
213                partial.base.original_message =
214                    "(Original message hidden - generate fresh message from diff)".to_string();
215            }
216
217            let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
218
219            // Log the actual diff content size for this chunk
220            let diff_content_len = partial.base.analysis.diff_content.len();
221            let diff_content_tokens =
222                token_budget::estimate_tokens_from_char_count(diff_content_len);
223            debug!(
224                commit = %&commit.hash[..8],
225                chunk_index = i,
226                diff_content_len,
227                diff_content_tokens,
228                "Split dispatch: chunk diff content size"
229            );
230
231            let user_prompt =
232                self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
233
234            info!(
235                commit = %&commit.hash[..8],
236                chunk = i + 1,
237                total_chunks,
238                user_prompt_len = user_prompt.len(),
239                "Split dispatch: sending chunk to AI"
240            );
241
242            let content = match self
243                .ai_client
244                .send_request(system_prompt, &user_prompt)
245                .await
246            {
247                Ok(content) => content,
248                Err(e) => {
249                    // Log the underlying error before wrapping
250                    tracing::error!(
251                        commit = %&commit.hash[..8],
252                        chunk = i + 1,
253                        error = %e,
254                        error_debug = ?e,
255                        "Split dispatch: AI request failed"
256                    );
257                    return Err(e).with_context(|| {
258                        format!(
259                            "Chunk {}/{} failed for commit {}",
260                            i + 1,
261                            total_chunks,
262                            &commit.hash[..8]
263                        )
264                    });
265                }
266            };
267
268            info!(
269                commit = %&commit.hash[..8],
270                chunk = i + 1,
271                response_len = content.len(),
272                "Split dispatch: received chunk response"
273            );
274
275            let amendment_file = self.parse_amendment_response(&content).with_context(|| {
276                format!(
277                    "Failed to parse chunk {}/{} response for commit {}",
278                    i + 1,
279                    total_chunks,
280                    &commit.hash[..8]
281                )
282            })?;
283
284            if let Some(amendment) = amendment_file.amendments.into_iter().next() {
285                chunk_amendments.push(amendment);
286            }
287        }
288
289        self.merge_amendment_chunks(
290            &commit.hash,
291            &commit.original_message,
292            &commit.analysis.diff_summary,
293            &chunk_amendments,
294        )
295        .await
296    }
297
298    /// Runs an AI reduce pass to synthesize a single amendment from partial
299    /// chunk amendments for the same commit.
300    ///
301    /// Follows the same pattern as
302    /// [`refine_amendments_coherence`](Self::refine_amendments_coherence).
303    async fn merge_amendment_chunks(
304        &self,
305        commit_hash: &str,
306        original_message: &str,
307        diff_summary: &str,
308        chunk_amendments: &[Amendment],
309    ) -> Result<Amendment> {
310        let system_prompt = prompts::AMENDMENT_CHUNK_MERGE_SYSTEM_PROMPT;
311        let user_prompt = prompts::generate_chunk_merge_user_prompt(
312            commit_hash,
313            original_message,
314            diff_summary,
315            chunk_amendments,
316        );
317
318        self.validate_prompt_budget(system_prompt, &user_prompt)?;
319
320        let content = self
321            .ai_client
322            .send_request(system_prompt, &user_prompt)
323            .await
324            .context("Merge pass failed for chunk amendments")?;
325
326        let amendment_file = self
327            .parse_amendment_response(&content)
328            .context("Failed to parse merge pass response")?;
329
330        amendment_file
331            .amendments
332            .into_iter()
333            .next()
334            .context("Merge pass returned no amendments")
335    }
336
337    /// Generates an amendment for a single commit, using split dispatch
338    /// if the full diff exceeds the token budget.
339    ///
340    /// Tries the full diff first. If it exceeds the budget and the commit
341    /// has file-level diffs, falls back to
342    /// [`generate_amendment_split`](Self::generate_amendment_split).
343    async fn generate_amendment_for_commit(
344        &self,
345        commit: &crate::git::CommitInfo,
346        repo_view_for_ai: &RepositoryViewForAI,
347        system_prompt: &str,
348        build_user_prompt: &(dyn Fn(&str) -> String + Sync),
349        fresh: bool,
350    ) -> Result<Amendment> {
351        let mut ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
352        if fresh {
353            ai_commit.base.original_message =
354                "(Original message hidden - generate fresh message from diff)".to_string();
355        }
356        let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
357
358        match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
359            Ok(user_prompt) => {
360                let amendment_file = self
361                    .send_and_parse_amendment_with_retry(system_prompt, &user_prompt)
362                    .await?;
363                amendment_file
364                    .amendments
365                    .into_iter()
366                    .next()
367                    .context("AI returned no amendments for commit")
368            }
369            Err(exceeded) => {
370                if commit.analysis.file_diffs.is_empty() {
371                    anyhow::bail!(
372                        "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
373                        &commit.hash[..8]
374                    );
375                }
376                self.generate_amendment_split(
377                    commit,
378                    repo_view_for_ai,
379                    system_prompt,
380                    build_user_prompt,
381                    exceeded.available_input_tokens,
382                    fresh,
383                )
384                .await
385            }
386        }
387    }
388
389    /// Checks a single commit whose diff exceeds the token budget by
390    /// splitting it into file-level chunks.
391    ///
392    /// Uses [`pack_file_diffs`](crate::claude::diff_pack::pack_file_diffs) to
393    /// create chunks, sends one check request per chunk, then merges results
394    /// deterministically (issue union + dedup). Runs an AI reduce pass only
395    /// when at least one chunk returns a suggestion.
396    async fn check_commit_split(
397        &self,
398        commit: &crate::git::CommitInfo,
399        repo_view: &RepositoryView,
400        system_prompt: &str,
401        valid_scopes: &[crate::data::context::ScopeDefinition],
402        include_suggestions: bool,
403        available_input_tokens: usize,
404    ) -> Result<crate::data::check::CheckReport> {
405        use crate::claude::batch::{
406            PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
407            VIEW_ENVELOPE_OVERHEAD_TOKENS,
408        };
409        use crate::claude::diff_pack::pack_file_diffs;
410        use crate::claude::token_budget;
411        use crate::data::check::{CommitCheckResult, CommitIssue, IssueSeverity};
412        use crate::git::commit::CommitInfoForAI;
413
414        // Compute effective capacity for diff packing by subtracting overhead
415        // that will be added when the full prompt is assembled. This mirrors
416        // the calculation in `batch::plan_batches`.
417        //
418        // Each chunk includes the FULL original_message and diff_summary (not
419        // just the partial diff), so we must subtract those from capacity.
420        // We also subtract user prompt template overhead for instruction text.
421        let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
422        let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
423            + token_budget::estimate_tokens(&commit.analysis.diff_summary);
424        let chunk_capacity = available_input_tokens
425            .saturating_sub(system_prompt_tokens)
426            .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
427            .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
428            .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
429            .saturating_sub(commit_text_tokens);
430
431        debug!(
432            commit = %&commit.hash[..8],
433            available_input_tokens,
434            system_prompt_tokens,
435            envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
436            metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
437            template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
438            commit_text_tokens,
439            chunk_capacity,
440            "Check split dispatch: computed chunk capacity"
441        );
442
443        let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
444            .with_context(|| {
445                format!(
446                    "Failed to plan diff chunks for commit {}",
447                    &commit.hash[..8]
448                )
449            })?;
450
451        let total_chunks = plan.chunks.len();
452        debug!(
453            commit = %&commit.hash[..8],
454            chunks = total_chunks,
455            chunk_capacity,
456            "Check split dispatch: processing commit in chunks"
457        );
458
459        let build_user_prompt =
460            |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
461
462        let mut chunk_results = Vec::with_capacity(total_chunks);
463        for (i, chunk) in plan.chunks.iter().enumerate() {
464            let mut partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
465                commit.clone(),
466                &chunk.file_paths,
467                &chunk.diff_overrides,
468            )
469            .with_context(|| {
470                format!(
471                    "Failed to build partial view for chunk {}/{} of commit {}",
472                    i + 1,
473                    total_chunks,
474                    &commit.hash[..8]
475                )
476            })?;
477
478            partial.run_pre_validation_checks(valid_scopes);
479
480            let partial_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
481                .context("Failed to enhance repository view with diff content")?
482                .single_commit_view_for_ai(&partial);
483
484            let user_prompt =
485                self.build_prompt_fitting_budget(&partial_view, system_prompt, &build_user_prompt)?;
486
487            let content = self
488                .ai_client
489                .send_request(system_prompt, &user_prompt)
490                .await
491                .with_context(|| {
492                    format!(
493                        "Check chunk {}/{} failed for commit {}",
494                        i + 1,
495                        total_chunks,
496                        &commit.hash[..8]
497                    )
498                })?;
499
500            let report = self
501                .parse_check_response(&content, repo_view)
502                .with_context(|| {
503                    format!(
504                        "Failed to parse check chunk {}/{} response for commit {}",
505                        i + 1,
506                        total_chunks,
507                        &commit.hash[..8]
508                    )
509                })?;
510
511            if let Some(result) = report.commits.into_iter().next() {
512                chunk_results.push(result);
513            }
514        }
515
516        // Deterministic merge: union issues, dedup by (rule, severity, section)
517        let mut seen = std::collections::HashSet::new();
518        let mut merged_issues: Vec<CommitIssue> = Vec::new();
519        for result in &chunk_results {
520            for issue in &result.issues {
521                let key: (String, IssueSeverity, String) =
522                    (issue.rule.clone(), issue.severity, issue.section.clone());
523                if seen.insert(key) {
524                    merged_issues.push(issue.clone());
525                }
526            }
527        }
528
529        let passes = chunk_results.iter().all(|r| r.passes);
530
531        // AI reduce pass for suggestion/summary only when needed
532        let has_suggestions = chunk_results.iter().any(|r| r.suggestion.is_some());
533
534        let (merged_suggestion, merged_summary) = if has_suggestions {
535            self.merge_check_chunks(
536                &commit.hash,
537                &commit.original_message,
538                &commit.analysis.diff_summary,
539                passes,
540                &chunk_results,
541                repo_view,
542            )
543            .await?
544        } else {
545            // Take first non-None summary
546            let summary = chunk_results.iter().find_map(|r| r.summary.clone());
547            (None, summary)
548        };
549
550        let original_message = commit
551            .original_message
552            .lines()
553            .next()
554            .unwrap_or("")
555            .to_string();
556
557        let merged_result = CommitCheckResult {
558            hash: commit.hash.clone(),
559            message: original_message,
560            issues: merged_issues,
561            suggestion: merged_suggestion,
562            passes,
563            summary: merged_summary,
564        };
565
566        Ok(crate::data::check::CheckReport::new(vec![merged_result]))
567    }
568
569    /// Runs an AI reduce pass to synthesize a single suggestion and summary
570    /// from partial chunk check results for the same commit.
571    ///
572    /// Only called when at least one chunk returned a suggestion.
573    async fn merge_check_chunks(
574        &self,
575        commit_hash: &str,
576        original_message: &str,
577        diff_summary: &str,
578        passes: bool,
579        chunk_results: &[crate::data::check::CommitCheckResult],
580        repo_view: &RepositoryView,
581    ) -> Result<(Option<crate::data::check::CommitSuggestion>, Option<String>)> {
582        let suggestions: Vec<&crate::data::check::CommitSuggestion> = chunk_results
583            .iter()
584            .filter_map(|r| r.suggestion.as_ref())
585            .collect();
586
587        let summaries: Vec<Option<&str>> =
588            chunk_results.iter().map(|r| r.summary.as_deref()).collect();
589
590        let system_prompt = prompts::CHECK_CHUNK_MERGE_SYSTEM_PROMPT;
591        let user_prompt = prompts::generate_check_chunk_merge_user_prompt(
592            commit_hash,
593            original_message,
594            diff_summary,
595            passes,
596            &suggestions,
597            &summaries,
598        );
599
600        self.validate_prompt_budget(system_prompt, &user_prompt)?;
601
602        let content = self
603            .ai_client
604            .send_request(system_prompt, &user_prompt)
605            .await
606            .context("Merge pass failed for check chunk suggestions")?;
607
608        let report = self
609            .parse_check_response(&content, repo_view)
610            .context("Failed to parse check merge pass response")?;
611
612        let result = report.commits.into_iter().next();
613        Ok(match result {
614            Some(r) => (r.suggestion, r.summary),
615            None => (None, None),
616        })
617    }
618
619    /// Sends a raw prompt to the AI client and returns the text response.
620    pub async fn send_message(&self, system_prompt: &str, user_prompt: &str) -> Result<String> {
621        self.validate_prompt_budget(system_prompt, user_prompt)?;
622        self.ai_client
623            .send_request(system_prompt, user_prompt)
624            .await
625    }
626
627    /// Creates a new Claude client with API key from environment variables.
628    pub fn from_env(model: String) -> Result<Self> {
629        // Try to get API key from environment variables
630        let api_key = std::env::var("CLAUDE_API_KEY")
631            .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
632            .map_err(|_| ClaudeError::ApiKeyNotFound)?;
633
634        let ai_client = ClaudeAiClient::new(model, api_key, None)?;
635        Ok(Self::new(Box::new(ai_client)))
636    }
637
638    /// Generates commit message amendments from repository view.
639    pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
640        self.generate_amendments_with_options(repo_view, false)
641            .await
642    }
643
644    /// Generates commit message amendments from repository view with options.
645    ///
646    /// If `fresh` is true, ignores existing commit messages and generates new ones
647    /// based solely on the diff content.
648    ///
649    /// For single-commit views whose full diff exceeds the token budget,
650    /// splits the diff into file-level chunks and dispatches multiple AI
651    /// requests, then merges results. Multi-commit views fall back to
652    /// progressive diff reduction (the caller retries individually on
653    /// failure).
654    pub async fn generate_amendments_with_options(
655        &self,
656        repo_view: &RepositoryView,
657        fresh: bool,
658    ) -> Result<AmendmentFile> {
659        // Convert to AI-enhanced view with diff content
660        let ai_repo_view =
661            RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
662                .context("Failed to enhance repository view with diff content")?;
663
664        let system_prompt = prompts::SYSTEM_PROMPT;
665        let build_user_prompt = |yaml: &str| prompts::generate_user_prompt(yaml);
666
667        // Try full view first; fall back to per-commit split dispatch
668        match self.try_full_diff_budget(&ai_repo_view, system_prompt, &build_user_prompt)? {
669            Ok(user_prompt) => {
670                self.send_and_parse_amendment_with_retry(system_prompt, &user_prompt)
671                    .await
672            }
673            Err(_exceeded) => {
674                let mut amendments = Vec::new();
675                for commit in &repo_view.commits {
676                    let amendment = self
677                        .generate_amendment_for_commit(
678                            commit,
679                            &ai_repo_view,
680                            system_prompt,
681                            &build_user_prompt,
682                            fresh,
683                        )
684                        .await?;
685                    amendments.push(amendment);
686                }
687                Ok(AmendmentFile { amendments })
688            }
689        }
690    }
691
692    /// Generates contextual commit message amendments with enhanced intelligence.
693    pub async fn generate_contextual_amendments(
694        &self,
695        repo_view: &RepositoryView,
696        context: &CommitContext,
697    ) -> Result<AmendmentFile> {
698        self.generate_contextual_amendments_with_options(repo_view, context, false)
699            .await
700    }
701
702    /// Generates contextual commit message amendments with options.
703    ///
704    /// If `fresh` is true, ignores existing commit messages and generates new ones
705    /// based solely on the diff content.
706    ///
707    /// For single-commit views whose full diff exceeds the token budget,
708    /// splits the diff into file-level chunks and dispatches multiple AI
709    /// requests, then merges results. Multi-commit views fall back to
710    /// progressive diff reduction.
711    pub async fn generate_contextual_amendments_with_options(
712        &self,
713        repo_view: &RepositoryView,
714        context: &CommitContext,
715        fresh: bool,
716    ) -> Result<AmendmentFile> {
717        // Convert to AI-enhanced view with diff content
718        let ai_repo_view =
719            RepositoryViewForAI::from_repository_view_with_options(repo_view.clone(), fresh)
720                .context("Failed to enhance repository view with diff content")?;
721
722        // Generate contextual prompts using intelligence
723        let prompt_style = self.ai_client.get_metadata().prompt_style();
724        let system_prompt =
725            prompts::generate_contextual_system_prompt_for_provider(context, prompt_style);
726
727        // Debug logging to troubleshoot custom commit type issue
728        match &context.project.commit_guidelines {
729            Some(guidelines) => {
730                debug!(length = guidelines.len(), "Project commit guidelines found");
731                debug!(guidelines = %guidelines, "Commit guidelines content");
732            }
733            None => {
734                debug!("No project commit guidelines found");
735            }
736        }
737
738        let build_user_prompt =
739            |yaml: &str| prompts::generate_contextual_user_prompt(yaml, context);
740
741        // Try full view first; fall back to per-commit split dispatch
742        match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
743            Ok(user_prompt) => {
744                self.send_and_parse_amendment_with_retry(&system_prompt, &user_prompt)
745                    .await
746            }
747            Err(_exceeded) => {
748                let mut amendments = Vec::new();
749                for commit in &repo_view.commits {
750                    let amendment = self
751                        .generate_amendment_for_commit(
752                            commit,
753                            &ai_repo_view,
754                            &system_prompt,
755                            &build_user_prompt,
756                            fresh,
757                        )
758                        .await?;
759                    amendments.push(amendment);
760                }
761                Ok(AmendmentFile { amendments })
762            }
763        }
764    }
765
766    /// Parses Claude's YAML response into an AmendmentFile.
767    fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
768        // Extract YAML from potential markdown wrapper
769        let yaml_content = self.extract_yaml_from_response(content);
770
771        // Try to parse YAML using our hybrid YAML parser
772        let amendment_file: AmendmentFile = crate::data::from_yaml(&yaml_content).map_err(|e| {
773            debug!(
774                error = %e,
775                content_length = content.len(),
776                yaml_length = yaml_content.len(),
777                "YAML parsing failed"
778            );
779            debug!(content = %content, "Raw Claude response");
780            debug!(yaml = %yaml_content, "Extracted YAML content");
781
782            // Try to provide more helpful error messages for common issues
783            if yaml_content.lines().any(|line| line.contains('\t')) {
784                ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
785            } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
786                ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
787            } else {
788                ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {e}"))
789            }
790        })?;
791
792        // Validate the parsed amendments
793        amendment_file
794            .validate()
795            .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {e}")))?;
796
797        Ok(amendment_file)
798    }
799
800    /// Sends a prompt to the AI and parses the response as an [`AmendmentFile`],
801    /// retrying on parse or request failures.
802    ///
803    /// Mirrors the retry pattern in [`check_commits_with_retry`](Self::check_commits_with_retry):
804    /// up to [`AMENDMENT_PARSE_MAX_RETRIES`] additional attempts after the first
805    /// failure. Logs a warning via `eprintln!` and a `debug!` trace on each retry.
806    /// Returns the last error if all attempts are exhausted.
807    async fn send_and_parse_amendment_with_retry(
808        &self,
809        system_prompt: &str,
810        user_prompt: &str,
811    ) -> Result<AmendmentFile> {
812        let mut last_error = None;
813        for attempt in 0..=AMENDMENT_PARSE_MAX_RETRIES {
814            match self
815                .ai_client
816                .send_request(system_prompt, user_prompt)
817                .await
818            {
819                Ok(content) => match self.parse_amendment_response(&content) {
820                    Ok(amendment_file) => return Ok(amendment_file),
821                    Err(e) => {
822                        if attempt < AMENDMENT_PARSE_MAX_RETRIES {
823                            eprintln!(
824                                "warning: failed to parse amendment response (attempt {}), retrying...",
825                                attempt + 1
826                            );
827                            debug!(error = %e, attempt = attempt + 1, "Amendment response parse failed, retrying");
828                        }
829                        last_error = Some(e);
830                    }
831                },
832                Err(e) => {
833                    if attempt < AMENDMENT_PARSE_MAX_RETRIES {
834                        eprintln!(
835                            "warning: AI request failed (attempt {}), retrying...",
836                            attempt + 1
837                        );
838                        debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
839                    }
840                    last_error = Some(e);
841                }
842            }
843        }
844        Err(last_error
845            .unwrap_or_else(|| anyhow::anyhow!("Amendment generation failed after retries")))
846    }
847
848    /// Parses an AI response as PR content YAML.
849    fn parse_pr_response(&self, content: &str) -> Result<crate::cli::git::PrContent> {
850        let yaml_content = content.trim();
851        crate::data::from_yaml(yaml_content)
852            .context("Failed to parse AI response as YAML. AI may have returned malformed output.")
853    }
854
855    /// Generates PR content for a single commit whose diff exceeds the token
856    /// budget by splitting it into file-level chunks.
857    ///
858    /// Analogous to [`generate_amendment_split`](Self::generate_amendment_split)
859    /// but produces [`PrContent`](crate::cli::git::PrContent) instead of an
860    /// amendment.
861    async fn generate_pr_content_split(
862        &self,
863        commit: &crate::git::CommitInfo,
864        repo_view_for_ai: &RepositoryViewForAI,
865        system_prompt: &str,
866        build_user_prompt: &(dyn Fn(&str) -> String + Sync),
867        available_input_tokens: usize,
868        pr_template: &str,
869    ) -> Result<crate::cli::git::PrContent> {
870        use crate::claude::batch::{
871            PER_COMMIT_METADATA_OVERHEAD_TOKENS, USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
872            VIEW_ENVELOPE_OVERHEAD_TOKENS,
873        };
874        use crate::claude::diff_pack::pack_file_diffs;
875        use crate::claude::token_budget;
876        use crate::git::commit::CommitInfoForAI;
877
878        // Compute effective capacity for diff packing by subtracting overhead
879        // that will be added when the full prompt is assembled. This mirrors
880        // the calculation in `batch::plan_batches`.
881        //
882        // Each chunk includes the FULL original_message and diff_summary (not
883        // just the partial diff), so we must subtract those from capacity.
884        // We also subtract user prompt template overhead for instruction text.
885        let system_prompt_tokens = token_budget::estimate_tokens(system_prompt);
886        let commit_text_tokens = token_budget::estimate_tokens(&commit.original_message)
887            + token_budget::estimate_tokens(&commit.analysis.diff_summary);
888        let chunk_capacity = available_input_tokens
889            .saturating_sub(system_prompt_tokens)
890            .saturating_sub(VIEW_ENVELOPE_OVERHEAD_TOKENS)
891            .saturating_sub(PER_COMMIT_METADATA_OVERHEAD_TOKENS)
892            .saturating_sub(USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS)
893            .saturating_sub(commit_text_tokens);
894
895        debug!(
896            commit = %&commit.hash[..8],
897            available_input_tokens,
898            system_prompt_tokens,
899            envelope_overhead = VIEW_ENVELOPE_OVERHEAD_TOKENS,
900            metadata_overhead = PER_COMMIT_METADATA_OVERHEAD_TOKENS,
901            template_overhead = USER_PROMPT_TEMPLATE_OVERHEAD_TOKENS,
902            commit_text_tokens,
903            chunk_capacity,
904            "PR split dispatch: computed chunk capacity"
905        );
906
907        let plan = pack_file_diffs(&commit.hash, &commit.analysis.file_diffs, chunk_capacity)
908            .with_context(|| {
909                format!(
910                    "Failed to plan diff chunks for commit {}",
911                    &commit.hash[..8]
912                )
913            })?;
914
915        let total_chunks = plan.chunks.len();
916        debug!(
917            commit = %&commit.hash[..8],
918            chunks = total_chunks,
919            chunk_capacity,
920            "PR split dispatch: processing commit in chunks"
921        );
922
923        let mut chunk_contents = Vec::with_capacity(total_chunks);
924        for (i, chunk) in plan.chunks.iter().enumerate() {
925            let partial = CommitInfoForAI::from_commit_info_partial_with_overrides(
926                commit.clone(),
927                &chunk.file_paths,
928                &chunk.diff_overrides,
929            )
930            .with_context(|| {
931                format!(
932                    "Failed to build partial view for chunk {}/{} of commit {}",
933                    i + 1,
934                    total_chunks,
935                    &commit.hash[..8]
936                )
937            })?;
938
939            let partial_view = repo_view_for_ai.single_commit_view_for_ai(&partial);
940
941            let user_prompt =
942                self.build_prompt_fitting_budget(&partial_view, system_prompt, build_user_prompt)?;
943
944            let content = self
945                .ai_client
946                .send_request(system_prompt, &user_prompt)
947                .await
948                .with_context(|| {
949                    format!(
950                        "PR chunk {}/{} failed for commit {}",
951                        i + 1,
952                        total_chunks,
953                        &commit.hash[..8]
954                    )
955                })?;
956
957            let pr_content = self.parse_pr_response(&content).with_context(|| {
958                format!(
959                    "Failed to parse PR chunk {}/{} response for commit {}",
960                    i + 1,
961                    total_chunks,
962                    &commit.hash[..8]
963                )
964            })?;
965
966            chunk_contents.push(pr_content);
967        }
968
969        self.merge_pr_content_chunks(&chunk_contents, pr_template)
970            .await
971    }
972
973    /// Runs an AI reduce pass to synthesize a single PR content from partial
974    /// per-commit or per-chunk PR contents.
975    async fn merge_pr_content_chunks(
976        &self,
977        partial_contents: &[crate::cli::git::PrContent],
978        pr_template: &str,
979    ) -> Result<crate::cli::git::PrContent> {
980        let system_prompt = prompts::PR_CONTENT_MERGE_SYSTEM_PROMPT;
981        let user_prompt =
982            prompts::generate_pr_content_merge_user_prompt(partial_contents, pr_template);
983
984        self.validate_prompt_budget(system_prompt, &user_prompt)?;
985
986        let content = self
987            .ai_client
988            .send_request(system_prompt, &user_prompt)
989            .await
990            .context("Merge pass failed for PR content chunks")?;
991
992        self.parse_pr_response(&content)
993            .context("Failed to parse PR content merge pass response")
994    }
995
996    /// Generates PR content for a single commit, using split dispatch if needed.
997    async fn generate_pr_content_for_commit(
998        &self,
999        commit: &crate::git::CommitInfo,
1000        repo_view_for_ai: &RepositoryViewForAI,
1001        system_prompt: &str,
1002        build_user_prompt: &(dyn Fn(&str) -> String + Sync),
1003        pr_template: &str,
1004    ) -> Result<crate::cli::git::PrContent> {
1005        let ai_commit = crate::git::commit::CommitInfoForAI::from_commit_info(commit.clone())?;
1006        let single_view = repo_view_for_ai.single_commit_view_for_ai(&ai_commit);
1007
1008        match self.try_full_diff_budget(&single_view, system_prompt, build_user_prompt)? {
1009            Ok(user_prompt) => {
1010                let content = self
1011                    .ai_client
1012                    .send_request(system_prompt, &user_prompt)
1013                    .await?;
1014                self.parse_pr_response(&content)
1015            }
1016            Err(exceeded) => {
1017                if commit.analysis.file_diffs.is_empty() {
1018                    anyhow::bail!(
1019                        "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1020                        &commit.hash[..8]
1021                    );
1022                }
1023                self.generate_pr_content_split(
1024                    commit,
1025                    repo_view_for_ai,
1026                    system_prompt,
1027                    build_user_prompt,
1028                    exceeded.available_input_tokens,
1029                    pr_template,
1030                )
1031                .await
1032            }
1033        }
1034    }
1035
1036    /// Generates AI-powered PR content (title + description) from repository view and template.
1037    pub async fn generate_pr_content(
1038        &self,
1039        repo_view: &RepositoryView,
1040        pr_template: &str,
1041    ) -> Result<crate::cli::git::PrContent> {
1042        // Convert to AI-enhanced view with diff content
1043        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1044            .context("Failed to enhance repository view with diff content")?;
1045
1046        let build_user_prompt =
1047            |yaml: &str| prompts::generate_pr_description_prompt(yaml, pr_template);
1048
1049        // Try full view first; fall back to per-commit split dispatch
1050        match self.try_full_diff_budget(
1051            &ai_repo_view,
1052            prompts::PR_GENERATION_SYSTEM_PROMPT,
1053            &build_user_prompt,
1054        )? {
1055            Ok(user_prompt) => {
1056                let content = self
1057                    .ai_client
1058                    .send_request(prompts::PR_GENERATION_SYSTEM_PROMPT, &user_prompt)
1059                    .await?;
1060                self.parse_pr_response(&content)
1061            }
1062            Err(_exceeded) => {
1063                let mut per_commit_contents = Vec::new();
1064                for commit in &repo_view.commits {
1065                    let pr = self
1066                        .generate_pr_content_for_commit(
1067                            commit,
1068                            &ai_repo_view,
1069                            prompts::PR_GENERATION_SYSTEM_PROMPT,
1070                            &build_user_prompt,
1071                            pr_template,
1072                        )
1073                        .await?;
1074                    per_commit_contents.push(pr);
1075                }
1076                if per_commit_contents.len() == 1 {
1077                    return per_commit_contents
1078                        .into_iter()
1079                        .next()
1080                        .context("Per-commit PR contents unexpectedly empty");
1081                }
1082                self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1083                    .await
1084            }
1085        }
1086    }
1087
1088    /// Generates AI-powered PR content with project context (title + description).
1089    pub async fn generate_pr_content_with_context(
1090        &self,
1091        repo_view: &RepositoryView,
1092        pr_template: &str,
1093        context: &crate::data::context::CommitContext,
1094    ) -> Result<crate::cli::git::PrContent> {
1095        // Convert to AI-enhanced view with diff content
1096        let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1097            .context("Failed to enhance repository view with diff content")?;
1098
1099        // Generate contextual prompts for PR description with provider-specific handling
1100        let prompt_style = self.ai_client.get_metadata().prompt_style();
1101        let system_prompt =
1102            prompts::generate_pr_system_prompt_with_context_for_provider(context, prompt_style);
1103
1104        let build_user_prompt = |yaml: &str| {
1105            prompts::generate_pr_description_prompt_with_context(yaml, pr_template, context)
1106        };
1107
1108        // Try full view first; fall back to per-commit split dispatch
1109        match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1110            Ok(user_prompt) => {
1111                let content = self
1112                    .ai_client
1113                    .send_request(&system_prompt, &user_prompt)
1114                    .await?;
1115
1116                debug!(
1117                    content_length = content.len(),
1118                    "Received AI response for PR content"
1119                );
1120
1121                let pr_content = self.parse_pr_response(&content)?;
1122
1123                debug!(
1124                    parsed_title = %pr_content.title,
1125                    parsed_description_length = pr_content.description.len(),
1126                    parsed_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1127                    "Successfully parsed PR content from YAML"
1128                );
1129
1130                Ok(pr_content)
1131            }
1132            Err(_exceeded) => {
1133                let mut per_commit_contents = Vec::new();
1134                for commit in &repo_view.commits {
1135                    let pr = self
1136                        .generate_pr_content_for_commit(
1137                            commit,
1138                            &ai_repo_view,
1139                            &system_prompt,
1140                            &build_user_prompt,
1141                            pr_template,
1142                        )
1143                        .await?;
1144                    per_commit_contents.push(pr);
1145                }
1146                if per_commit_contents.len() == 1 {
1147                    return per_commit_contents
1148                        .into_iter()
1149                        .next()
1150                        .context("Per-commit PR contents unexpectedly empty");
1151                }
1152                self.merge_pr_content_chunks(&per_commit_contents, pr_template)
1153                    .await
1154            }
1155        }
1156    }
1157
1158    /// Checks commit messages against guidelines and returns a report.
1159    ///
1160    /// Validates commit messages against project guidelines or defaults,
1161    /// returning a structured report with issues and suggestions.
1162    pub async fn check_commits(
1163        &self,
1164        repo_view: &RepositoryView,
1165        guidelines: Option<&str>,
1166        include_suggestions: bool,
1167    ) -> Result<crate::data::check::CheckReport> {
1168        self.check_commits_with_scopes(repo_view, guidelines, &[], include_suggestions)
1169            .await
1170    }
1171
1172    /// Checks commit messages against guidelines with valid scopes and returns a report.
1173    ///
1174    /// Validates commit messages against project guidelines or defaults,
1175    /// using the provided valid scopes for scope validation.
1176    pub async fn check_commits_with_scopes(
1177        &self,
1178        repo_view: &RepositoryView,
1179        guidelines: Option<&str>,
1180        valid_scopes: &[crate::data::context::ScopeDefinition],
1181        include_suggestions: bool,
1182    ) -> Result<crate::data::check::CheckReport> {
1183        self.check_commits_with_retry(repo_view, guidelines, valid_scopes, include_suggestions, 2)
1184            .await
1185    }
1186
1187    /// Checks commit messages with retry logic for parse failures.
1188    ///
1189    /// For single-commit views whose full diff exceeds the token budget,
1190    /// splits the diff into file-level chunks and dispatches multiple AI
1191    /// requests, then merges results. Multi-commit views fall back to
1192    /// progressive diff reduction (the caller retries individually on
1193    /// failure).
1194    async fn check_commits_with_retry(
1195        &self,
1196        repo_view: &RepositoryView,
1197        guidelines: Option<&str>,
1198        valid_scopes: &[crate::data::context::ScopeDefinition],
1199        include_suggestions: bool,
1200        max_retries: u32,
1201    ) -> Result<crate::data::check::CheckReport> {
1202        // Generate system prompt with scopes
1203        let system_prompt =
1204            prompts::generate_check_system_prompt_with_scopes(guidelines, valid_scopes);
1205
1206        let build_user_prompt =
1207            |yaml: &str| prompts::generate_check_user_prompt(yaml, include_suggestions);
1208
1209        let mut ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
1210            .context("Failed to enhance repository view with diff content")?;
1211        for commit in &mut ai_repo_view.commits {
1212            commit.run_pre_validation_checks(valid_scopes);
1213        }
1214
1215        // Try full view first; fall back to per-commit split dispatch
1216        match self.try_full_diff_budget(&ai_repo_view, &system_prompt, &build_user_prompt)? {
1217            Ok(user_prompt) => {
1218                // Full view fits: send with retry loop
1219                let mut last_error = None;
1220                for attempt in 0..=max_retries {
1221                    match self
1222                        .ai_client
1223                        .send_request(&system_prompt, &user_prompt)
1224                        .await
1225                    {
1226                        Ok(content) => match self.parse_check_response(&content, repo_view) {
1227                            Ok(report) => return Ok(report),
1228                            Err(e) => {
1229                                if attempt < max_retries {
1230                                    eprintln!(
1231                                        "warning: failed to parse AI response (attempt {}), retrying...",
1232                                        attempt + 1
1233                                    );
1234                                    debug!(error = %e, attempt = attempt + 1, "Check response parse failed, retrying");
1235                                }
1236                                last_error = Some(e);
1237                            }
1238                        },
1239                        Err(e) => {
1240                            if attempt < max_retries {
1241                                eprintln!(
1242                                    "warning: AI request failed (attempt {}), retrying...",
1243                                    attempt + 1
1244                                );
1245                                debug!(error = %e, attempt = attempt + 1, "AI request failed, retrying");
1246                            }
1247                            last_error = Some(e);
1248                        }
1249                    }
1250                }
1251                Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Check failed after retries")))
1252            }
1253            Err(_exceeded) => {
1254                // Per-commit split dispatch
1255                let mut all_results = Vec::new();
1256                for commit in &repo_view.commits {
1257                    let single_view = repo_view.single_commit_view(commit);
1258                    let mut single_ai_view =
1259                        RepositoryViewForAI::from_repository_view(single_view.clone())
1260                            .context("Failed to enhance single-commit view with diff content")?;
1261                    for c in &mut single_ai_view.commits {
1262                        c.run_pre_validation_checks(valid_scopes);
1263                    }
1264
1265                    match self.try_full_diff_budget(
1266                        &single_ai_view,
1267                        &system_prompt,
1268                        &build_user_prompt,
1269                    )? {
1270                        Ok(user_prompt) => {
1271                            let content = self
1272                                .ai_client
1273                                .send_request(&system_prompt, &user_prompt)
1274                                .await?;
1275                            let report = self.parse_check_response(&content, &single_view)?;
1276                            all_results.extend(report.commits);
1277                        }
1278                        Err(exceeded) => {
1279                            if commit.analysis.file_diffs.is_empty() {
1280                                anyhow::bail!(
1281                                    "Token budget exceeded for commit {} but no file-level diffs available for split dispatch",
1282                                    &commit.hash[..8]
1283                                );
1284                            }
1285                            let report = self
1286                                .check_commit_split(
1287                                    commit,
1288                                    &single_view,
1289                                    &system_prompt,
1290                                    valid_scopes,
1291                                    include_suggestions,
1292                                    exceeded.available_input_tokens,
1293                                )
1294                                .await?;
1295                            all_results.extend(report.commits);
1296                        }
1297                    }
1298                }
1299                Ok(crate::data::check::CheckReport::new(all_results))
1300            }
1301        }
1302    }
1303
1304    /// Parses the check response from AI.
1305    fn parse_check_response(
1306        &self,
1307        content: &str,
1308        repo_view: &RepositoryView,
1309    ) -> Result<crate::data::check::CheckReport> {
1310        use crate::data::check::{
1311            AiCheckResponse, CheckReport, CommitCheckResult as CheckResultType,
1312        };
1313
1314        // Extract YAML from potential markdown wrapper
1315        let yaml_content = self.extract_yaml_from_check_response(content);
1316
1317        // Parse YAML response
1318        let ai_response: AiCheckResponse = crate::data::from_yaml(&yaml_content).map_err(|e| {
1319            debug!(
1320                error = %e,
1321                content_length = content.len(),
1322                yaml_length = yaml_content.len(),
1323                "Check YAML parsing failed"
1324            );
1325            debug!(content = %content, "Raw AI response");
1326            debug!(yaml = %yaml_content, "Extracted YAML content");
1327            ClaudeError::AmendmentParsingFailed(format!("Check response parsing error: {e}"))
1328        })?;
1329
1330        // Create a map of commit hashes to original messages for lookup
1331        let commit_messages: std::collections::HashMap<&str, &str> = repo_view
1332            .commits
1333            .iter()
1334            .map(|c| (c.hash.as_str(), c.original_message.as_str()))
1335            .collect();
1336
1337        // Convert AI response to CheckReport
1338        let results: Vec<CheckResultType> = ai_response
1339            .checks
1340            .into_iter()
1341            .map(|check| {
1342                let mut result: CheckResultType = check.into();
1343                // Fill in the original message from repo_view
1344                if let Some(msg) = commit_messages.get(result.hash.as_str()) {
1345                    result.message = msg.lines().next().unwrap_or("").to_string();
1346                } else {
1347                    // Try to find by prefix
1348                    for (hash, msg) in &commit_messages {
1349                        if hash.starts_with(&result.hash) || result.hash.starts_with(*hash) {
1350                            result.message = msg.lines().next().unwrap_or("").to_string();
1351                            break;
1352                        }
1353                    }
1354                }
1355                result
1356            })
1357            .collect();
1358
1359        Ok(CheckReport::new(results))
1360    }
1361
1362    /// Extracts YAML content from check response, handling markdown wrappers.
1363    fn extract_yaml_from_check_response(&self, content: &str) -> String {
1364        let content = content.trim();
1365
1366        // If content already starts with "checks:", it's pure YAML - return as-is
1367        if content.starts_with("checks:") {
1368            return content.to_string();
1369        }
1370
1371        // Try to extract from ```yaml blocks first
1372        if let Some(yaml_start) = content.find("```yaml") {
1373            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1374                return yaml_content.trim().to_string();
1375            }
1376        }
1377
1378        // Try to extract from generic ``` blocks
1379        if let Some(code_start) = content.find("```") {
1380            if let Some(code_content) = content[code_start + 3..].split("```").next() {
1381                let potential_yaml = code_content.trim();
1382                // Check if it looks like YAML (starts with expected structure)
1383                if potential_yaml.starts_with("checks:") {
1384                    return potential_yaml.to_string();
1385                }
1386            }
1387        }
1388
1389        // If no markdown blocks found or extraction failed, return trimmed content
1390        content.to_string()
1391    }
1392
1393    /// Refines individually-generated amendments for cross-commit coherence.
1394    ///
1395    /// Sends commit summaries and proposed messages to the AI for a second pass
1396    /// that normalizes scopes, detects rename chains, and removes redundancy.
1397    pub async fn refine_amendments_coherence(
1398        &self,
1399        items: &[(crate::data::amendments::Amendment, String)],
1400    ) -> Result<AmendmentFile> {
1401        let system_prompt = prompts::AMENDMENT_COHERENCE_SYSTEM_PROMPT;
1402        let user_prompt = prompts::generate_amendment_coherence_user_prompt(items);
1403
1404        self.validate_prompt_budget(system_prompt, &user_prompt)?;
1405
1406        let content = self
1407            .ai_client
1408            .send_request(system_prompt, &user_prompt)
1409            .await?;
1410
1411        self.parse_amendment_response(&content)
1412    }
1413
1414    /// Refines individually-generated check results for cross-commit coherence.
1415    ///
1416    /// Sends commit summaries and check outcomes to the AI for a second pass
1417    /// that ensures consistent severity, detects cross-commit issues, and
1418    /// normalizes scope validation.
1419    pub async fn refine_checks_coherence(
1420        &self,
1421        items: &[(crate::data::check::CommitCheckResult, String)],
1422        repo_view: &RepositoryView,
1423    ) -> Result<crate::data::check::CheckReport> {
1424        let system_prompt = prompts::CHECK_COHERENCE_SYSTEM_PROMPT;
1425        let user_prompt = prompts::generate_check_coherence_user_prompt(items);
1426
1427        self.validate_prompt_budget(system_prompt, &user_prompt)?;
1428
1429        let content = self
1430            .ai_client
1431            .send_request(system_prompt, &user_prompt)
1432            .await?;
1433
1434        self.parse_check_response(&content, repo_view)
1435    }
1436
1437    /// Extracts YAML content from Claude response, handling markdown wrappers.
1438    fn extract_yaml_from_response(&self, content: &str) -> String {
1439        let content = content.trim();
1440
1441        // If content already starts with "amendments:", it's pure YAML - return as-is
1442        if content.starts_with("amendments:") {
1443            return content.to_string();
1444        }
1445
1446        // Try to extract from ```yaml blocks first
1447        if let Some(yaml_start) = content.find("```yaml") {
1448            if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
1449                return yaml_content.trim().to_string();
1450            }
1451        }
1452
1453        // Try to extract from generic ``` blocks
1454        if let Some(code_start) = content.find("```") {
1455            if let Some(code_content) = content[code_start + 3..].split("```").next() {
1456                let potential_yaml = code_content.trim();
1457                // Check if it looks like YAML (starts with expected structure)
1458                if potential_yaml.starts_with("amendments:") {
1459                    return potential_yaml.to_string();
1460                }
1461            }
1462        }
1463
1464        // If no markdown blocks found or extraction failed, return trimmed content
1465        content.to_string()
1466    }
1467}
1468
1469/// Validates a beta header against the model registry.
1470fn validate_beta_header(model: &str, beta_header: &Option<(String, String)>) -> Result<()> {
1471    if let Some((ref key, ref value)) = beta_header {
1472        let registry = crate::claude::model_config::get_model_registry();
1473        let supported = registry.get_beta_headers(model);
1474        if !supported
1475            .iter()
1476            .any(|bh| bh.key == *key && bh.value == *value)
1477        {
1478            let available: Vec<String> = supported
1479                .iter()
1480                .map(|bh| format!("{}:{}", bh.key, bh.value))
1481                .collect();
1482            if available.is_empty() {
1483                anyhow::bail!("Model '{model}' does not support any beta headers");
1484            }
1485            anyhow::bail!(
1486                "Beta header '{key}:{value}' is not supported for model '{model}'. Supported: {}",
1487                available.join(", ")
1488            );
1489        }
1490    }
1491    Ok(())
1492}
1493
1494/// Creates a default Claude client using environment variables and settings.
1495pub fn create_default_claude_client(
1496    model: Option<String>,
1497    beta_header: Option<(String, String)>,
1498) -> Result<ClaudeClient> {
1499    use crate::claude::ai::openai::OpenAiAiClient;
1500    use crate::utils::settings::{get_env_var, get_env_vars};
1501
1502    // Check if we should use OpenAI-compatible API (OpenAI or Ollama)
1503    let use_openai = get_env_var("USE_OPENAI")
1504        .map(|val| val == "true")
1505        .unwrap_or(false);
1506
1507    let use_ollama = get_env_var("USE_OLLAMA")
1508        .map(|val| val == "true")
1509        .unwrap_or(false);
1510
1511    // Check if we should use Bedrock
1512    let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK")
1513        .map(|val| val == "true")
1514        .unwrap_or(false);
1515
1516    debug!(
1517        use_openai = use_openai,
1518        use_ollama = use_ollama,
1519        use_bedrock = use_bedrock,
1520        "Client selection flags"
1521    );
1522
1523    let registry = crate::claude::model_config::get_model_registry();
1524
1525    // Handle Ollama configuration
1526    if use_ollama {
1527        let ollama_model = model
1528            .or_else(|| get_env_var("OLLAMA_MODEL").ok())
1529            .unwrap_or_else(|| "llama2".to_string());
1530        validate_beta_header(&ollama_model, &beta_header)?;
1531        let base_url = get_env_var("OLLAMA_BASE_URL").ok();
1532        let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1533        return Ok(ClaudeClient::new(Box::new(ai_client)));
1534    }
1535
1536    // Handle OpenAI configuration
1537    if use_openai {
1538        debug!("Creating OpenAI client");
1539        let openai_model = model
1540            .or_else(|| get_env_var("OPENAI_MODEL").ok())
1541            .unwrap_or_else(|| {
1542                registry
1543                    .get_default_model("openai")
1544                    .unwrap_or("gpt-5")
1545                    .to_string()
1546            });
1547        debug!(openai_model = %openai_model, "Selected OpenAI model");
1548        validate_beta_header(&openai_model, &beta_header)?;
1549
1550        let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
1551            debug!(error = ?e, "Failed to get OpenAI API key");
1552            ClaudeError::ApiKeyNotFound
1553        })?;
1554        debug!("OpenAI API key found");
1555
1556        let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1557        debug!("OpenAI client created successfully");
1558        return Ok(ClaudeClient::new(Box::new(ai_client)));
1559    }
1560
1561    // For Claude clients, try to get model from env vars or use default
1562    let claude_model = model
1563        .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1564        .unwrap_or_else(|| {
1565            registry
1566                .get_default_model("claude")
1567                .unwrap_or("claude-sonnet-4-6")
1568                .to_string()
1569        });
1570    validate_beta_header(&claude_model, &beta_header)?;
1571
1572    if use_bedrock {
1573        // Use Bedrock AI client
1574        let auth_token =
1575            get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1576
1577        let base_url =
1578            get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1579
1580        let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1581        return Ok(ClaudeClient::new(Box::new(ai_client)));
1582    }
1583
1584    // Default: use standard Claude AI client
1585    debug!("Falling back to Claude client");
1586    let api_key = get_env_vars(&[
1587        "CLAUDE_API_KEY",
1588        "ANTHROPIC_API_KEY",
1589        "ANTHROPIC_AUTH_TOKEN",
1590    ])
1591    .map_err(|_| ClaudeError::ApiKeyNotFound)?;
1592
1593    let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1594    debug!("Claude client created successfully");
1595    Ok(ClaudeClient::new(Box::new(ai_client)))
1596}
1597
1598#[cfg(test)]
1599#[allow(clippy::unwrap_used, clippy::expect_used)]
1600mod tests {
1601    use super::*;
1602    use crate::claude::ai::{AiClient, AiClientMetadata};
1603    use std::future::Future;
1604    use std::pin::Pin;
1605
1606    /// Mock AI client for testing — never makes real HTTP requests.
1607    struct MockAiClient;
1608
1609    impl AiClient for MockAiClient {
1610        fn send_request<'a>(
1611            &'a self,
1612            _system_prompt: &'a str,
1613            _user_prompt: &'a str,
1614        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1615            Box::pin(async { Ok(String::new()) })
1616        }
1617
1618        fn get_metadata(&self) -> AiClientMetadata {
1619            AiClientMetadata {
1620                provider: "Mock".to_string(),
1621                model: "mock-model".to_string(),
1622                max_context_length: 200_000,
1623                max_response_length: 8_192,
1624                active_beta: None,
1625            }
1626        }
1627    }
1628
1629    fn make_client() -> ClaudeClient {
1630        ClaudeClient::new(Box::new(MockAiClient))
1631    }
1632
1633    // ── extract_yaml_from_response ─────────────────────────────────
1634
1635    #[test]
1636    fn extract_yaml_pure_amendments() {
1637        let client = make_client();
1638        let content = "amendments:\n  - commit: abc123\n    message: test";
1639        let result = client.extract_yaml_from_response(content);
1640        assert!(result.starts_with("amendments:"));
1641    }
1642
1643    #[test]
1644    fn extract_yaml_with_markdown_yaml_block() {
1645        let client = make_client();
1646        let content = "Here is the result:\n```yaml\namendments:\n  - commit: abc\n```\n";
1647        let result = client.extract_yaml_from_response(content);
1648        assert!(result.starts_with("amendments:"));
1649    }
1650
1651    #[test]
1652    fn extract_yaml_with_generic_code_block() {
1653        let client = make_client();
1654        let content = "```\namendments:\n  - commit: abc\n```";
1655        let result = client.extract_yaml_from_response(content);
1656        assert!(result.starts_with("amendments:"));
1657    }
1658
1659    #[test]
1660    fn extract_yaml_with_whitespace() {
1661        let client = make_client();
1662        let content = "  \n  amendments:\n  - commit: abc\n  ";
1663        let result = client.extract_yaml_from_response(content);
1664        assert!(result.starts_with("amendments:"));
1665    }
1666
1667    #[test]
1668    fn extract_yaml_fallback_returns_trimmed() {
1669        let client = make_client();
1670        let content = "  some random text  ";
1671        let result = client.extract_yaml_from_response(content);
1672        assert_eq!(result, "some random text");
1673    }
1674
1675    // ── extract_yaml_from_check_response ───────────────────────────
1676
1677    #[test]
1678    fn extract_check_yaml_pure() {
1679        let client = make_client();
1680        let content = "checks:\n  - commit: abc123";
1681        let result = client.extract_yaml_from_check_response(content);
1682        assert!(result.starts_with("checks:"));
1683    }
1684
1685    #[test]
1686    fn extract_check_yaml_markdown_block() {
1687        let client = make_client();
1688        let content = "```yaml\nchecks:\n  - commit: abc\n```";
1689        let result = client.extract_yaml_from_check_response(content);
1690        assert!(result.starts_with("checks:"));
1691    }
1692
1693    #[test]
1694    fn extract_check_yaml_generic_block() {
1695        let client = make_client();
1696        let content = "```\nchecks:\n  - commit: abc\n```";
1697        let result = client.extract_yaml_from_check_response(content);
1698        assert!(result.starts_with("checks:"));
1699    }
1700
1701    #[test]
1702    fn extract_check_yaml_fallback() {
1703        let client = make_client();
1704        let content = "  unexpected content  ";
1705        let result = client.extract_yaml_from_check_response(content);
1706        assert_eq!(result, "unexpected content");
1707    }
1708
1709    // ── parse_amendment_response ────────────────────────────────────
1710
1711    #[test]
1712    fn parse_amendment_response_valid() {
1713        let client = make_client();
1714        let yaml = format!(
1715            "amendments:\n  - commit: \"{}\"\n    message: \"test message\"",
1716            "a".repeat(40)
1717        );
1718        let result = client.parse_amendment_response(&yaml);
1719        assert!(result.is_ok());
1720        assert_eq!(result.unwrap().amendments.len(), 1);
1721    }
1722
1723    #[test]
1724    fn parse_amendment_response_invalid_yaml() {
1725        let client = make_client();
1726        let result = client.parse_amendment_response("not: valid: yaml: [{{");
1727        assert!(result.is_err());
1728    }
1729
1730    #[test]
1731    fn parse_amendment_response_invalid_hash() {
1732        let client = make_client();
1733        let yaml = "amendments:\n  - commit: \"short\"\n    message: \"test\"";
1734        let result = client.parse_amendment_response(yaml);
1735        assert!(result.is_err());
1736    }
1737
1738    // ── validate_beta_header ───────────────────────────────────────
1739
1740    #[test]
1741    fn validate_beta_header_none_passes() {
1742        let result = validate_beta_header("claude-opus-4-1-20250805", &None);
1743        assert!(result.is_ok());
1744    }
1745
1746    #[test]
1747    fn validate_beta_header_unsupported_fails() {
1748        let header = Some(("fake-key".to_string(), "fake-value".to_string()));
1749        let result = validate_beta_header("claude-opus-4-1-20250805", &header);
1750        assert!(result.is_err());
1751    }
1752
1753    // ── ClaudeClient::new / get_ai_client_metadata ─────────────────
1754
1755    #[test]
1756    fn client_metadata() {
1757        let client = make_client();
1758        let metadata = client.get_ai_client_metadata();
1759        assert_eq!(metadata.provider, "Mock");
1760        assert_eq!(metadata.model, "mock-model");
1761    }
1762
1763    // ── property tests ────────────────────────────────────────────
1764
1765    mod prop {
1766        use super::*;
1767        use proptest::prelude::*;
1768
1769        proptest! {
1770            #[test]
1771            fn yaml_response_output_trimmed(s in ".*") {
1772                let client = make_client();
1773                let result = client.extract_yaml_from_response(&s);
1774                prop_assert_eq!(&result, result.trim());
1775            }
1776
1777            #[test]
1778            fn yaml_response_amendments_prefix_preserved(tail in ".*") {
1779                let client = make_client();
1780                let input = format!("amendments:{tail}");
1781                let result = client.extract_yaml_from_response(&input);
1782                prop_assert!(result.starts_with("amendments:"));
1783            }
1784
1785            #[test]
1786            fn check_response_checks_prefix_preserved(tail in ".*") {
1787                let client = make_client();
1788                let input = format!("checks:{tail}");
1789                let result = client.extract_yaml_from_check_response(&input);
1790                prop_assert!(result.starts_with("checks:"));
1791            }
1792
1793            #[test]
1794            fn yaml_fenced_block_strips_fences(
1795                content in "[a-zA-Z0-9: _\\-\n]{1,100}",
1796            ) {
1797                let client = make_client();
1798                let input = format!("```yaml\n{content}\n```");
1799                let result = client.extract_yaml_from_response(&input);
1800                prop_assert!(!result.contains("```"));
1801            }
1802        }
1803    }
1804
1805    // ── ConfigurableMockAiClient tests ──────────────────────────────
1806
1807    fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
1808        ClaudeClient::new(Box::new(
1809            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
1810        ))
1811    }
1812
1813    fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
1814        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
1815        use crate::git::commit::FileChanges;
1816        use crate::git::{CommitAnalysis, CommitInfo};
1817
1818        let diff_path = dir.path().join("0.diff");
1819        std::fs::write(&diff_path, "+added line\n").unwrap();
1820
1821        crate::data::RepositoryView {
1822            versions: None,
1823            explanation: FieldExplanation::default(),
1824            working_directory: WorkingDirectoryInfo {
1825                clean: true,
1826                untracked_changes: Vec::new(),
1827            },
1828            remotes: Vec::new(),
1829            ai: AiInfo {
1830                scratch: String::new(),
1831            },
1832            branch_info: None,
1833            pr_template: None,
1834            pr_template_location: None,
1835            branch_prs: None,
1836            commits: vec![CommitInfo {
1837                hash: format!("{:0>40}", 0),
1838                author: "Test <test@test.com>".to_string(),
1839                date: chrono::Utc::now().fixed_offset(),
1840                original_message: "feat(test): add something".to_string(),
1841                in_main_branches: Vec::new(),
1842                analysis: CommitAnalysis {
1843                    detected_type: "feat".to_string(),
1844                    detected_scope: "test".to_string(),
1845                    proposed_message: "feat(test): add something".to_string(),
1846                    file_changes: FileChanges {
1847                        total_files: 1,
1848                        files_added: 1,
1849                        files_deleted: 0,
1850                        file_list: Vec::new(),
1851                    },
1852                    diff_summary: "file.rs | 1 +".to_string(),
1853                    diff_file: diff_path.to_string_lossy().to_string(),
1854                    file_diffs: Vec::new(),
1855                },
1856            }],
1857        }
1858    }
1859
1860    fn valid_check_yaml() -> String {
1861        format!(
1862            "checks:\n  - commit: \"{hash}\"\n    passes: true\n    issues: []\n",
1863            hash = format!("{:0>40}", 0)
1864        )
1865    }
1866
1867    #[tokio::test]
1868    async fn send_message_propagates_ai_error() {
1869        let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
1870        let result = client.send_message("sys", "usr").await;
1871        assert!(result.is_err());
1872        assert!(result.unwrap_err().to_string().contains("mock error"));
1873    }
1874
1875    #[tokio::test]
1876    async fn check_commits_succeeds_after_request_error() {
1877        let dir = tempfile::tempdir().unwrap();
1878        let repo_view = make_test_repo_view(&dir);
1879        // First attempt: request error; retries return valid response.
1880        let client = make_configurable_client(vec![
1881            Err(anyhow::anyhow!("rate limit")),
1882            Ok(valid_check_yaml()),
1883            Ok(valid_check_yaml()),
1884        ]);
1885        let result = client
1886            .check_commits_with_scopes(&repo_view, None, &[], false)
1887            .await;
1888        assert!(result.is_ok());
1889    }
1890
1891    #[tokio::test]
1892    async fn check_commits_succeeds_after_parse_error() {
1893        let dir = tempfile::tempdir().unwrap();
1894        let repo_view = make_test_repo_view(&dir);
1895        // First attempt: AI returns malformed YAML; retry succeeds.
1896        let client = make_configurable_client(vec![
1897            Ok("not: valid: yaml: [[".to_string()),
1898            Ok(valid_check_yaml()),
1899            Ok(valid_check_yaml()),
1900        ]);
1901        let result = client
1902            .check_commits_with_scopes(&repo_view, None, &[], false)
1903            .await;
1904        assert!(result.is_ok());
1905    }
1906
1907    #[tokio::test]
1908    async fn check_commits_fails_after_all_retries_exhausted() {
1909        let dir = tempfile::tempdir().unwrap();
1910        let repo_view = make_test_repo_view(&dir);
1911        let client = make_configurable_client(vec![
1912            Err(anyhow::anyhow!("first failure")),
1913            Err(anyhow::anyhow!("second failure")),
1914            Err(anyhow::anyhow!("final failure")),
1915        ]);
1916        let result = client
1917            .check_commits_with_scopes(&repo_view, None, &[], false)
1918            .await;
1919        assert!(result.is_err());
1920    }
1921
1922    #[tokio::test]
1923    async fn check_commits_fails_when_all_parses_fail() {
1924        let dir = tempfile::tempdir().unwrap();
1925        let repo_view = make_test_repo_view(&dir);
1926        let client = make_configurable_client(vec![
1927            Ok("bad yaml [[".to_string()),
1928            Ok("bad yaml [[".to_string()),
1929            Ok("bad yaml [[".to_string()),
1930        ]);
1931        let result = client
1932            .check_commits_with_scopes(&repo_view, None, &[], false)
1933            .await;
1934        assert!(result.is_err());
1935    }
1936
1937    // ── split dispatch tests ─────────────────────────────────────
1938
1939    /// Creates a mock client with a constrained context window.
1940    ///
1941    /// The window is large enough that a single-file chunk fits, but too
1942    /// small for both files together (including system prompt overhead).
1943    fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
1944        // Context of 50k with more conservative token estimation (2.5 chars/token
1945        // vs 3.5) ensures per-file diffs fit in chunks without placeholders while
1946        // still being large enough to trigger split dispatch for multiple files.
1947        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
1948            .with_context_length(50_000);
1949        ClaudeClient::new(Box::new(mock))
1950    }
1951
1952    /// Like [`make_small_context_client`] but also returns a handle to inspect
1953    /// how many mock responses remain unconsumed after the test runs.
1954    fn make_small_context_client_tracked(
1955        responses: Vec<Result<String>>,
1956    ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
1957        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
1958            .with_context_length(50_000);
1959        let handle = mock.response_handle();
1960        (ClaudeClient::new(Box::new(mock)), handle)
1961    }
1962
1963    /// Creates a repo view with per-file diffs large enough to exceed the
1964    /// constrained context window, ensuring the split dispatch path triggers.
1965    fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
1966        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
1967        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
1968        use crate::git::{CommitAnalysis, CommitInfo};
1969
1970        let hash = "a".repeat(40);
1971
1972        // Write a full (flat) diff file large enough to bust the budget.
1973        // With 50k context / 2.5 chars-per-token / 1.2 margin, available ≈ 41k tokens.
1974        // 120k chars → ~57,600 tokens → well over budget.
1975        let full_diff = "x".repeat(120_000);
1976        let flat_diff_path = dir.path().join("full.diff");
1977        std::fs::write(&flat_diff_path, &full_diff).unwrap();
1978
1979        // Write two large per-file diff files (~30K chars each ≈ 14,400 tokens with
1980        // conservative 2.5 chars/token * 1.2 margin estimation)
1981        let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
1982        let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
1983
1984        let path_a = dir.path().join("0000.diff");
1985        let path_b = dir.path().join("0001.diff");
1986        std::fs::write(&path_a, &diff_a).unwrap();
1987        std::fs::write(&path_b, &diff_b).unwrap();
1988
1989        crate::data::RepositoryView {
1990            versions: None,
1991            explanation: FieldExplanation::default(),
1992            working_directory: WorkingDirectoryInfo {
1993                clean: true,
1994                untracked_changes: Vec::new(),
1995            },
1996            remotes: Vec::new(),
1997            ai: AiInfo {
1998                scratch: String::new(),
1999            },
2000            branch_info: None,
2001            pr_template: None,
2002            pr_template_location: None,
2003            branch_prs: None,
2004            commits: vec![CommitInfo {
2005                hash,
2006                author: "Test <test@test.com>".to_string(),
2007                date: chrono::Utc::now().fixed_offset(),
2008                original_message: "feat(test): large commit".to_string(),
2009                in_main_branches: Vec::new(),
2010                analysis: CommitAnalysis {
2011                    detected_type: "feat".to_string(),
2012                    detected_scope: "test".to_string(),
2013                    proposed_message: "feat(test): large commit".to_string(),
2014                    file_changes: FileChanges {
2015                        total_files: 2,
2016                        files_added: 2,
2017                        files_deleted: 0,
2018                        file_list: vec![
2019                            FileChange {
2020                                status: "A".to_string(),
2021                                file: "src/a.rs".to_string(),
2022                            },
2023                            FileChange {
2024                                status: "A".to_string(),
2025                                file: "src/b.rs".to_string(),
2026                            },
2027                        ],
2028                    },
2029                    diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2030                    diff_file: flat_diff_path.to_string_lossy().to_string(),
2031                    file_diffs: vec![
2032                        FileDiffRef {
2033                            path: "src/a.rs".to_string(),
2034                            diff_file: path_a.to_string_lossy().to_string(),
2035                            byte_len: diff_a.len(),
2036                        },
2037                        FileDiffRef {
2038                            path: "src/b.rs".to_string(),
2039                            diff_file: path_b.to_string_lossy().to_string(),
2040                            byte_len: diff_b.len(),
2041                        },
2042                    ],
2043                },
2044            }],
2045        }
2046    }
2047
2048    fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2049        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"{message}\"")
2050    }
2051
2052    #[tokio::test]
2053    async fn generate_amendments_split_dispatch() {
2054        let dir = tempfile::tempdir().unwrap();
2055        let repo_view = make_large_diff_repo_view(&dir);
2056        let hash = "a".repeat(40);
2057
2058        // Responses: chunk 1 + chunk 2 + merge pass
2059        let client = make_small_context_client(vec![
2060            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2061            Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2062            Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2063        ]);
2064
2065        let result = client
2066            .generate_amendments_with_options(&repo_view, false)
2067            .await;
2068
2069        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2070        let amendments = result.unwrap();
2071        assert_eq!(amendments.amendments.len(), 1);
2072        assert_eq!(amendments.amendments[0].commit, hash);
2073        assert!(amendments.amendments[0]
2074            .message
2075            .contains("add a.rs and b.rs"));
2076    }
2077
2078    #[tokio::test]
2079    async fn generate_amendments_split_chunk_failure() {
2080        let dir = tempfile::tempdir().unwrap();
2081        let repo_view = make_large_diff_repo_view(&dir);
2082        let hash = "a".repeat(40);
2083
2084        // First chunk succeeds, second chunk fails
2085        let client = make_small_context_client(vec![
2086            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2087            Err(anyhow::anyhow!("rate limit exceeded")),
2088        ]);
2089
2090        let result = client
2091            .generate_amendments_with_options(&repo_view, false)
2092            .await;
2093
2094        assert!(result.is_err());
2095    }
2096
2097    #[tokio::test]
2098    async fn generate_amendments_no_split_when_fits() {
2099        let dir = tempfile::tempdir().unwrap();
2100        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2101        let hash = format!("{:0>40}", 0);
2102
2103        // Only one response needed — no split dispatch
2104        let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2105            &hash,
2106            "feat(test): improved message",
2107        ))]);
2108
2109        let result = client
2110            .generate_amendments_with_options(&repo_view, false)
2111            .await;
2112
2113        assert!(result.is_ok());
2114        assert_eq!(result.unwrap().amendments.len(), 1);
2115    }
2116
2117    // ── check split dispatch tests ──────────────────────────────
2118
2119    fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2120        format!(
2121            "checks:\n  - commit: \"{hash}\"\n    passes: {passes}\n    issues: []\n    summary: \"test summary\"\n"
2122        )
2123    }
2124
2125    fn valid_check_yaml_with_issues(hash: &str) -> String {
2126        format!(
2127            concat!(
2128                "checks:\n",
2129                "  - commit: \"{hash}\"\n",
2130                "    passes: false\n",
2131                "    issues:\n",
2132                "      - severity: error\n",
2133                "        section: \"Subject Line\"\n",
2134                "        rule: \"subject-too-long\"\n",
2135                "        explanation: \"Subject exceeds 72 characters\"\n",
2136                "    suggestion:\n",
2137                "      message: \"feat(test): shorter subject\"\n",
2138                "      explanation: \"Shortened subject line\"\n",
2139                "    summary: \"Large commit with issues\"\n",
2140            ),
2141            hash = hash,
2142        )
2143    }
2144
2145    fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2146        format!(
2147            concat!(
2148                "checks:\n",
2149                "  - commit: \"{hash}\"\n",
2150                "    passes: true\n",
2151                "    issues: []\n",
2152                "    summary: \"chunk summary\"\n",
2153            ),
2154            hash = hash,
2155        )
2156    }
2157
2158    #[tokio::test]
2159    async fn check_commits_split_dispatch() {
2160        let dir = tempfile::tempdir().unwrap();
2161        let repo_view = make_large_diff_repo_view(&dir);
2162        let hash = "a".repeat(40);
2163
2164        // Responses: chunk 1 (issues + suggestion) + chunk 2 (issues + suggestion) + merge pass
2165        let client = make_small_context_client(vec![
2166            Ok(valid_check_yaml_with_issues(&hash)),
2167            Ok(valid_check_yaml_with_issues(&hash)),
2168            Ok(valid_check_yaml_with_issues(&hash)), // merge pass response
2169        ]);
2170
2171        let result = client
2172            .check_commits_with_scopes(&repo_view, None, &[], true)
2173            .await;
2174
2175        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2176        let report = result.unwrap();
2177        assert_eq!(report.commits.len(), 1);
2178        assert!(!report.commits[0].passes);
2179        // Dedup: both chunks report the same (rule, severity, section), so only 1 unique issue
2180        assert_eq!(report.commits[0].issues.len(), 1);
2181        assert_eq!(report.commits[0].issues[0].rule, "subject-too-long");
2182    }
2183
2184    #[tokio::test]
2185    async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2186        let dir = tempfile::tempdir().unwrap();
2187        let repo_view = make_large_diff_repo_view(&dir);
2188        let hash = "a".repeat(40);
2189
2190        // Responses: chunk 1 + chunk 2, both passing with no suggestions
2191        // No merge pass needed — only 2 responses
2192        let client = make_small_context_client(vec![
2193            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2194            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2195        ]);
2196
2197        let result = client
2198            .check_commits_with_scopes(&repo_view, None, &[], false)
2199            .await;
2200
2201        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2202        let report = result.unwrap();
2203        assert_eq!(report.commits.len(), 1);
2204        assert!(report.commits[0].passes);
2205        assert!(report.commits[0].issues.is_empty());
2206        assert!(report.commits[0].suggestion.is_none());
2207        // First non-None summary from chunks
2208        assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2209    }
2210
2211    #[tokio::test]
2212    async fn check_commits_split_chunk_failure() {
2213        let dir = tempfile::tempdir().unwrap();
2214        let repo_view = make_large_diff_repo_view(&dir);
2215        let hash = "a".repeat(40);
2216
2217        // First chunk succeeds, second chunk fails
2218        let client = make_small_context_client(vec![
2219            Ok(valid_check_yaml_for(&hash, true)),
2220            Err(anyhow::anyhow!("rate limit exceeded")),
2221        ]);
2222
2223        let result = client
2224            .check_commits_with_scopes(&repo_view, None, &[], false)
2225            .await;
2226
2227        assert!(result.is_err());
2228    }
2229
2230    #[tokio::test]
2231    async fn check_commits_no_split_when_fits() {
2232        let dir = tempfile::tempdir().unwrap();
2233        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2234        let hash = format!("{:0>40}", 0);
2235
2236        // Only one response needed — no split dispatch
2237        let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2238
2239        let result = client
2240            .check_commits_with_scopes(&repo_view, None, &[], false)
2241            .await;
2242
2243        assert!(result.is_ok());
2244        assert_eq!(result.unwrap().commits.len(), 1);
2245    }
2246
2247    #[tokio::test]
2248    async fn check_commits_split_dedup_across_chunks() {
2249        let dir = tempfile::tempdir().unwrap();
2250        let repo_view = make_large_diff_repo_view(&dir);
2251        let hash = "a".repeat(40);
2252
2253        // Chunk 1: two issues (error + warning)
2254        let chunk1 = format!(
2255            concat!(
2256                "checks:\n",
2257                "  - commit: \"{hash}\"\n",
2258                "    passes: false\n",
2259                "    issues:\n",
2260                "      - severity: error\n",
2261                "        section: \"Subject Line\"\n",
2262                "        rule: \"subject-too-long\"\n",
2263                "        explanation: \"Subject exceeds 72 characters\"\n",
2264                "      - severity: warning\n",
2265                "        section: \"Content\"\n",
2266                "        rule: \"body-required\"\n",
2267                "        explanation: \"Large change needs body\"\n",
2268            ),
2269            hash = hash,
2270        );
2271
2272        // Chunk 2: same error (different wording) + new info issue
2273        let chunk2 = format!(
2274            concat!(
2275                "checks:\n",
2276                "  - commit: \"{hash}\"\n",
2277                "    passes: false\n",
2278                "    issues:\n",
2279                "      - severity: error\n",
2280                "        section: \"Subject Line\"\n",
2281                "        rule: \"subject-too-long\"\n",
2282                "        explanation: \"Subject line is too long\"\n",
2283                "      - severity: info\n",
2284                "        section: \"Style\"\n",
2285                "        rule: \"scope-suggestion\"\n",
2286                "        explanation: \"Consider more specific scope\"\n",
2287            ),
2288            hash = hash,
2289        );
2290
2291        // No suggestions → no merge pass needed
2292        let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2293
2294        let result = client
2295            .check_commits_with_scopes(&repo_view, None, &[], false)
2296            .await;
2297
2298        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2299        let report = result.unwrap();
2300        assert_eq!(report.commits.len(), 1);
2301        assert!(!report.commits[0].passes);
2302        // 3 unique issues: subject-too-long, body-required, scope-suggestion
2303        // (subject-too-long appears in both chunks but deduped)
2304        assert_eq!(report.commits[0].issues.len(), 3);
2305    }
2306
2307    #[tokio::test]
2308    async fn check_commits_split_passes_only_when_all_chunks_pass() {
2309        let dir = tempfile::tempdir().unwrap();
2310        let repo_view = make_large_diff_repo_view(&dir);
2311        let hash = "a".repeat(40);
2312
2313        // Chunk 1 passes, chunk 2 fails
2314        let client = make_small_context_client(vec![
2315            Ok(valid_check_yaml_for(&hash, true)),
2316            Ok(valid_check_yaml_for(&hash, false)),
2317        ]);
2318
2319        let result = client
2320            .check_commits_with_scopes(&repo_view, None, &[], false)
2321            .await;
2322
2323        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2324        let report = result.unwrap();
2325        assert!(
2326            !report.commits[0].passes,
2327            "should fail when any chunk fails"
2328        );
2329    }
2330
2331    // ── multi-commit and PR generation paths ──────────────────────
2332
2333    /// Creates a repo view with two small commits (fits budget without split dispatch).
2334    fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2335        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2336        use crate::git::commit::FileChanges;
2337        use crate::git::{CommitAnalysis, CommitInfo};
2338
2339        let diff_a = dir.path().join("0.diff");
2340        let diff_b = dir.path().join("1.diff");
2341        std::fs::write(&diff_a, "+line a\n").unwrap();
2342        std::fs::write(&diff_b, "+line b\n").unwrap();
2343
2344        let hash_a = "a".repeat(40);
2345        let hash_b = "b".repeat(40);
2346
2347        crate::data::RepositoryView {
2348            versions: None,
2349            explanation: FieldExplanation::default(),
2350            working_directory: WorkingDirectoryInfo {
2351                clean: true,
2352                untracked_changes: Vec::new(),
2353            },
2354            remotes: Vec::new(),
2355            ai: AiInfo {
2356                scratch: String::new(),
2357            },
2358            branch_info: None,
2359            pr_template: None,
2360            pr_template_location: None,
2361            branch_prs: None,
2362            commits: vec![
2363                CommitInfo {
2364                    hash: hash_a,
2365                    author: "Test <test@test.com>".to_string(),
2366                    date: chrono::Utc::now().fixed_offset(),
2367                    original_message: "feat(a): add a".to_string(),
2368                    in_main_branches: Vec::new(),
2369                    analysis: CommitAnalysis {
2370                        detected_type: "feat".to_string(),
2371                        detected_scope: "a".to_string(),
2372                        proposed_message: "feat(a): add a".to_string(),
2373                        file_changes: FileChanges {
2374                            total_files: 1,
2375                            files_added: 1,
2376                            files_deleted: 0,
2377                            file_list: Vec::new(),
2378                        },
2379                        diff_summary: "a.rs | 1 +".to_string(),
2380                        diff_file: diff_a.to_string_lossy().to_string(),
2381                        file_diffs: Vec::new(),
2382                    },
2383                },
2384                CommitInfo {
2385                    hash: hash_b,
2386                    author: "Test <test@test.com>".to_string(),
2387                    date: chrono::Utc::now().fixed_offset(),
2388                    original_message: "feat(b): add b".to_string(),
2389                    in_main_branches: Vec::new(),
2390                    analysis: CommitAnalysis {
2391                        detected_type: "feat".to_string(),
2392                        detected_scope: "b".to_string(),
2393                        proposed_message: "feat(b): add b".to_string(),
2394                        file_changes: FileChanges {
2395                            total_files: 1,
2396                            files_added: 1,
2397                            files_deleted: 0,
2398                            file_list: Vec::new(),
2399                        },
2400                        diff_summary: "b.rs | 1 +".to_string(),
2401                        diff_file: diff_b.to_string_lossy().to_string(),
2402                        file_diffs: Vec::new(),
2403                    },
2404                },
2405            ],
2406        }
2407    }
2408
2409    #[tokio::test]
2410    async fn generate_amendments_multi_commit() {
2411        let dir = tempfile::tempdir().unwrap();
2412        let repo_view = make_multi_commit_repo_view(&dir);
2413        let hash_a = "a".repeat(40);
2414        let hash_b = "b".repeat(40);
2415
2416        let response = format!(
2417            concat!(
2418                "amendments:\n",
2419                "  - commit: \"{hash_a}\"\n",
2420                "    message: \"feat(a): improved a\"\n",
2421                "  - commit: \"{hash_b}\"\n",
2422                "    message: \"feat(b): improved b\"\n",
2423            ),
2424            hash_a = hash_a,
2425            hash_b = hash_b,
2426        );
2427        let client = make_configurable_client(vec![Ok(response)]);
2428
2429        let result = client
2430            .generate_amendments_with_options(&repo_view, false)
2431            .await;
2432
2433        assert!(
2434            result.is_ok(),
2435            "multi-commit amendment failed: {:?}",
2436            result.err()
2437        );
2438        let amendments = result.unwrap();
2439        assert_eq!(amendments.amendments.len(), 2);
2440    }
2441
2442    #[tokio::test]
2443    async fn generate_contextual_amendments_multi_commit() {
2444        let dir = tempfile::tempdir().unwrap();
2445        let repo_view = make_multi_commit_repo_view(&dir);
2446        let hash_a = "a".repeat(40);
2447        let hash_b = "b".repeat(40);
2448
2449        let response = format!(
2450            concat!(
2451                "amendments:\n",
2452                "  - commit: \"{hash_a}\"\n",
2453                "    message: \"feat(a): improved a\"\n",
2454                "  - commit: \"{hash_b}\"\n",
2455                "    message: \"feat(b): improved b\"\n",
2456            ),
2457            hash_a = hash_a,
2458            hash_b = hash_b,
2459        );
2460        let client = make_configurable_client(vec![Ok(response)]);
2461        let context = crate::data::context::CommitContext::default();
2462
2463        let result = client
2464            .generate_contextual_amendments_with_options(&repo_view, &context, false)
2465            .await;
2466
2467        assert!(
2468            result.is_ok(),
2469            "multi-commit contextual amendment failed: {:?}",
2470            result.err()
2471        );
2472        let amendments = result.unwrap();
2473        assert_eq!(amendments.amendments.len(), 2);
2474    }
2475
2476    #[tokio::test]
2477    async fn generate_pr_content_succeeds() {
2478        let dir = tempfile::tempdir().unwrap();
2479        let repo_view = make_test_repo_view(&dir);
2480
2481        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2482        let client = make_configurable_client(vec![Ok(response.to_string())]);
2483
2484        let result = client.generate_pr_content(&repo_view, "").await;
2485
2486        assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
2487        let pr = result.unwrap();
2488        assert_eq!(pr.title, "feat: add something");
2489        assert_eq!(pr.description, "Adds a new feature.");
2490    }
2491
2492    #[tokio::test]
2493    async fn generate_pr_content_with_context_succeeds() {
2494        let dir = tempfile::tempdir().unwrap();
2495        let repo_view = make_test_repo_view(&dir);
2496        let context = crate::data::context::CommitContext::default();
2497
2498        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2499        let client = make_configurable_client(vec![Ok(response.to_string())]);
2500
2501        let result = client
2502            .generate_pr_content_with_context(&repo_view, "", &context)
2503            .await;
2504
2505        assert!(
2506            result.is_ok(),
2507            "PR generation with context failed: {:?}",
2508            result.err()
2509        );
2510        let pr = result.unwrap();
2511        assert_eq!(pr.title, "feat: add something");
2512    }
2513
2514    #[tokio::test]
2515    async fn check_commits_multi_commit() {
2516        let dir = tempfile::tempdir().unwrap();
2517        let repo_view = make_multi_commit_repo_view(&dir);
2518        let hash_a = "a".repeat(40);
2519        let hash_b = "b".repeat(40);
2520
2521        let response = format!(
2522            concat!(
2523                "checks:\n",
2524                "  - commit: \"{hash_a}\"\n",
2525                "    passes: true\n",
2526                "    issues: []\n",
2527                "  - commit: \"{hash_b}\"\n",
2528                "    passes: true\n",
2529                "    issues: []\n",
2530            ),
2531            hash_a = hash_a,
2532            hash_b = hash_b,
2533        );
2534        let client = make_configurable_client(vec![Ok(response)]);
2535
2536        let result = client
2537            .check_commits_with_scopes(&repo_view, None, &[], false)
2538            .await;
2539
2540        assert!(
2541            result.is_ok(),
2542            "multi-commit check failed: {:?}",
2543            result.err()
2544        );
2545        let report = result.unwrap();
2546        assert_eq!(report.commits.len(), 2);
2547        assert!(report.commits[0].passes);
2548        assert!(report.commits[1].passes);
2549    }
2550
2551    // ── Multi-commit split dispatch helpers ──────────────────────────
2552
2553    /// Creates a repo view with two large-diff commits whose combined view
2554    /// exceeds the constrained 25KB context window.
2555    fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2556        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2557        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2558        use crate::git::{CommitAnalysis, CommitInfo};
2559
2560        let hash_a = "a".repeat(40);
2561        let hash_b = "b".repeat(40);
2562
2563        // Write flat diff files large enough to bust the 50K-token budget when combined.
2564        // Each 60k chars ≈ 28,800 tokens; combined ≈ 57,600 > 41,808 available.
2565        let diff_content_a = "x".repeat(60_000);
2566        let diff_content_b = "y".repeat(60_000);
2567        let flat_a = dir.path().join("flat_a.diff");
2568        let flat_b = dir.path().join("flat_b.diff");
2569        std::fs::write(&flat_a, &diff_content_a).unwrap();
2570        std::fs::write(&flat_b, &diff_content_b).unwrap();
2571
2572        // Write per-file diff files for split dispatch
2573        let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2574        let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2575        let per_file_a = dir.path().join("pf_a.diff");
2576        let per_file_b = dir.path().join("pf_b.diff");
2577        std::fs::write(&per_file_a, &file_diff_a).unwrap();
2578        std::fs::write(&per_file_b, &file_diff_b).unwrap();
2579
2580        crate::data::RepositoryView {
2581            versions: None,
2582            explanation: FieldExplanation::default(),
2583            working_directory: WorkingDirectoryInfo {
2584                clean: true,
2585                untracked_changes: Vec::new(),
2586            },
2587            remotes: Vec::new(),
2588            ai: AiInfo {
2589                scratch: String::new(),
2590            },
2591            branch_info: None,
2592            pr_template: None,
2593            pr_template_location: None,
2594            branch_prs: None,
2595            commits: vec![
2596                CommitInfo {
2597                    hash: hash_a,
2598                    author: "Test <test@test.com>".to_string(),
2599                    date: chrono::Utc::now().fixed_offset(),
2600                    original_message: "feat(a): add module a".to_string(),
2601                    in_main_branches: Vec::new(),
2602                    analysis: CommitAnalysis {
2603                        detected_type: "feat".to_string(),
2604                        detected_scope: "a".to_string(),
2605                        proposed_message: "feat(a): add module a".to_string(),
2606                        file_changes: FileChanges {
2607                            total_files: 1,
2608                            files_added: 1,
2609                            files_deleted: 0,
2610                            file_list: vec![FileChange {
2611                                status: "A".to_string(),
2612                                file: "src/a.rs".to_string(),
2613                            }],
2614                        },
2615                        diff_summary: " src/a.rs | 100 ++++\n".to_string(),
2616                        diff_file: flat_a.to_string_lossy().to_string(),
2617                        file_diffs: vec![FileDiffRef {
2618                            path: "src/a.rs".to_string(),
2619                            diff_file: per_file_a.to_string_lossy().to_string(),
2620                            byte_len: file_diff_a.len(),
2621                        }],
2622                    },
2623                },
2624                CommitInfo {
2625                    hash: hash_b,
2626                    author: "Test <test@test.com>".to_string(),
2627                    date: chrono::Utc::now().fixed_offset(),
2628                    original_message: "feat(b): add module b".to_string(),
2629                    in_main_branches: Vec::new(),
2630                    analysis: CommitAnalysis {
2631                        detected_type: "feat".to_string(),
2632                        detected_scope: "b".to_string(),
2633                        proposed_message: "feat(b): add module b".to_string(),
2634                        file_changes: FileChanges {
2635                            total_files: 1,
2636                            files_added: 1,
2637                            files_deleted: 0,
2638                            file_list: vec![FileChange {
2639                                status: "A".to_string(),
2640                                file: "src/b.rs".to_string(),
2641                            }],
2642                        },
2643                        diff_summary: " src/b.rs | 100 ++++\n".to_string(),
2644                        diff_file: flat_b.to_string_lossy().to_string(),
2645                        file_diffs: vec![FileDiffRef {
2646                            path: "src/b.rs".to_string(),
2647                            diff_file: per_file_b.to_string_lossy().to_string(),
2648                            byte_len: file_diff_b.len(),
2649                        }],
2650                    },
2651                },
2652            ],
2653        }
2654    }
2655
2656    fn valid_pr_yaml(title: &str, description: &str) -> String {
2657        format!("title: \"{title}\"\ndescription: \"{description}\"\n")
2658    }
2659
2660    // ── Multi-commit amendment split dispatch tests ──────────────────
2661
2662    #[tokio::test]
2663    async fn generate_amendments_multi_commit_split_dispatch() {
2664        let dir = tempfile::tempdir().unwrap();
2665        let repo_view = make_large_multi_commit_repo_view(&dir);
2666        let hash_a = "a".repeat(40);
2667        let hash_b = "b".repeat(40);
2668
2669        // Full view exceeds budget → per-commit fallback
2670        // Each commit fits individually (1 file each) → 1 response per commit
2671        let (client, handle) = make_small_context_client_tracked(vec![
2672            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
2673            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
2674        ]);
2675
2676        let result = client
2677            .generate_amendments_with_options(&repo_view, false)
2678            .await;
2679
2680        assert!(
2681            result.is_ok(),
2682            "multi-commit split dispatch failed: {:?}",
2683            result.err()
2684        );
2685        let amendments = result.unwrap();
2686        assert_eq!(amendments.amendments.len(), 2);
2687        assert_eq!(amendments.amendments[0].commit, hash_a);
2688        assert_eq!(amendments.amendments[1].commit, hash_b);
2689        assert!(amendments.amendments[0].message.contains("improved a"));
2690        assert!(amendments.amendments[1].message.contains("improved b"));
2691        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2692    }
2693
2694    #[tokio::test]
2695    async fn generate_contextual_amendments_multi_commit_split_dispatch() {
2696        let dir = tempfile::tempdir().unwrap();
2697        let repo_view = make_large_multi_commit_repo_view(&dir);
2698        let hash_a = "a".repeat(40);
2699        let hash_b = "b".repeat(40);
2700        let context = crate::data::context::CommitContext::default();
2701
2702        let (client, handle) = make_small_context_client_tracked(vec![
2703            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
2704            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
2705        ]);
2706
2707        let result = client
2708            .generate_contextual_amendments_with_options(&repo_view, &context, false)
2709            .await;
2710
2711        assert!(
2712            result.is_ok(),
2713            "multi-commit contextual split dispatch failed: {:?}",
2714            result.err()
2715        );
2716        let amendments = result.unwrap();
2717        assert_eq!(amendments.amendments.len(), 2);
2718        assert_eq!(amendments.amendments[0].commit, hash_a);
2719        assert_eq!(amendments.amendments[1].commit, hash_b);
2720        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2721    }
2722
2723    // ── Multi-commit check split dispatch tests ──────────────────────
2724
2725    #[tokio::test]
2726    async fn check_commits_multi_commit_split_dispatch() {
2727        let dir = tempfile::tempdir().unwrap();
2728        let repo_view = make_large_multi_commit_repo_view(&dir);
2729        let hash_a = "a".repeat(40);
2730        let hash_b = "b".repeat(40);
2731
2732        // Full view exceeds budget → per-commit fallback
2733        let (client, handle) = make_small_context_client_tracked(vec![
2734            Ok(valid_check_yaml_for(&hash_a, true)),
2735            Ok(valid_check_yaml_for(&hash_b, true)),
2736        ]);
2737
2738        let result = client
2739            .check_commits_with_scopes(&repo_view, None, &[], false)
2740            .await;
2741
2742        assert!(
2743            result.is_ok(),
2744            "multi-commit check split dispatch failed: {:?}",
2745            result.err()
2746        );
2747        let report = result.unwrap();
2748        assert_eq!(report.commits.len(), 2);
2749        assert!(report.commits[0].passes);
2750        assert!(report.commits[1].passes);
2751        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2752    }
2753
2754    // ── PR split dispatch tests ──────────────────────────────────────
2755
2756    #[tokio::test]
2757    async fn generate_pr_content_split_dispatch() {
2758        let dir = tempfile::tempdir().unwrap();
2759        let repo_view = make_large_diff_repo_view(&dir);
2760
2761        // Single large commit: full view exceeds budget → per-commit fallback
2762        // 1 commit with 2 file chunks → chunk 1 + chunk 2 + chunk merge pass
2763        // Single per-commit result → returned directly (no extra merge)
2764        let (client, handle) = make_small_context_client_tracked(vec![
2765            Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
2766            Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
2767            Ok(valid_pr_yaml(
2768                "feat(test): add modules",
2769                "Adds a.rs and b.rs",
2770            )),
2771        ]);
2772
2773        let result = client.generate_pr_content(&repo_view, "").await;
2774
2775        assert!(
2776            result.is_ok(),
2777            "PR split dispatch failed: {:?}",
2778            result.err()
2779        );
2780        let pr = result.unwrap();
2781        assert!(pr.title.contains("add modules"));
2782        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2783    }
2784
2785    #[tokio::test]
2786    async fn generate_pr_content_multi_commit_split_dispatch() {
2787        let dir = tempfile::tempdir().unwrap();
2788        let repo_view = make_large_multi_commit_repo_view(&dir);
2789
2790        // Full view exceeds budget → per-commit fallback
2791        // Each commit fits individually → 1 response per commit, then merge pass
2792        let (client, handle) = make_small_context_client_tracked(vec![
2793            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
2794            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
2795            Ok(valid_pr_yaml(
2796                "feat: add modules a and b",
2797                "Adds both modules",
2798            )),
2799        ]);
2800
2801        let result = client.generate_pr_content(&repo_view, "").await;
2802
2803        assert!(
2804            result.is_ok(),
2805            "PR multi-commit split dispatch failed: {:?}",
2806            result.err()
2807        );
2808        let pr = result.unwrap();
2809        assert!(pr.title.contains("modules"));
2810        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2811    }
2812
2813    #[tokio::test]
2814    async fn generate_pr_content_with_context_split_dispatch() {
2815        let dir = tempfile::tempdir().unwrap();
2816        let repo_view = make_large_multi_commit_repo_view(&dir);
2817        let context = crate::data::context::CommitContext::default();
2818
2819        // Full view exceeds budget → per-commit fallback → merge pass
2820        let (client, handle) = make_small_context_client_tracked(vec![
2821            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
2822            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
2823            Ok(valid_pr_yaml(
2824                "feat: add modules a and b",
2825                "Adds both modules",
2826            )),
2827        ]);
2828
2829        let result = client
2830            .generate_pr_content_with_context(&repo_view, "", &context)
2831            .await;
2832
2833        assert!(
2834            result.is_ok(),
2835            "PR with context split dispatch failed: {:?}",
2836            result.err()
2837        );
2838        let pr = result.unwrap();
2839        assert!(pr.title.contains("modules"));
2840        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2841    }
2842
2843    // ── prompt-recording split dispatch tests ────────────────────
2844
2845    /// Like [`make_small_context_client_tracked`] but also returns a
2846    /// [`PromptRecordHandle`] for inspecting which prompts were sent.
2847    fn make_small_context_client_with_prompts(
2848        responses: Vec<Result<String>>,
2849    ) -> (
2850        ClaudeClient,
2851        crate::claude::test_utils::ResponseQueueHandle,
2852        crate::claude::test_utils::PromptRecordHandle,
2853    ) {
2854        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2855            .with_context_length(50_000);
2856        let response_handle = mock.response_handle();
2857        let prompt_handle = mock.prompt_handle();
2858        (
2859            ClaudeClient::new(Box::new(mock)),
2860            response_handle,
2861            prompt_handle,
2862        )
2863    }
2864
2865    /// Creates a default-context mock client that also records prompts.
2866    fn make_configurable_client_with_prompts(
2867        responses: Vec<Result<String>>,
2868    ) -> (
2869        ClaudeClient,
2870        crate::claude::test_utils::ResponseQueueHandle,
2871        crate::claude::test_utils::PromptRecordHandle,
2872    ) {
2873        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
2874        let response_handle = mock.response_handle();
2875        let prompt_handle = mock.prompt_handle();
2876        (
2877            ClaudeClient::new(Box::new(mock)),
2878            response_handle,
2879            prompt_handle,
2880        )
2881    }
2882
2883    /// Creates a repo view with one commit containing a single large file
2884    /// whose diff exceeds the token budget. Because the per-file diff is
2885    /// loaded as a whole (hunk-level granularity from the packer is lost
2886    /// at the dispatch layer), the split dispatch path will fail with a
2887    /// budget error. This helper exists to test that the error propagates
2888    /// cleanly rather than silently degrading.
2889    fn make_single_oversized_file_repo_view(
2890        dir: &tempfile::TempDir,
2891    ) -> crate::data::RepositoryView {
2892        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2893        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2894        use crate::git::{CommitAnalysis, CommitInfo};
2895
2896        let hash = "c".repeat(40);
2897
2898        // A single file diff large enough (~80K bytes ≈ 25K tokens) to
2899        // exceed the 25K context window budget even for a single chunk.
2900        let diff_content = format!(
2901            "diff --git a/src/big.rs b/src/big.rs\n{}\n",
2902            "x".repeat(80_000)
2903        );
2904
2905        let flat_diff_path = dir.path().join("full.diff");
2906        std::fs::write(&flat_diff_path, &diff_content).unwrap();
2907
2908        let per_file_path = dir.path().join("0000.diff");
2909        std::fs::write(&per_file_path, &diff_content).unwrap();
2910
2911        crate::data::RepositoryView {
2912            versions: None,
2913            explanation: FieldExplanation::default(),
2914            working_directory: WorkingDirectoryInfo {
2915                clean: true,
2916                untracked_changes: Vec::new(),
2917            },
2918            remotes: Vec::new(),
2919            ai: AiInfo {
2920                scratch: String::new(),
2921            },
2922            branch_info: None,
2923            pr_template: None,
2924            pr_template_location: None,
2925            branch_prs: None,
2926            commits: vec![CommitInfo {
2927                hash,
2928                author: "Test <test@test.com>".to_string(),
2929                date: chrono::Utc::now().fixed_offset(),
2930                original_message: "feat(big): add large module".to_string(),
2931                in_main_branches: Vec::new(),
2932                analysis: CommitAnalysis {
2933                    detected_type: "feat".to_string(),
2934                    detected_scope: "big".to_string(),
2935                    proposed_message: "feat(big): add large module".to_string(),
2936                    file_changes: FileChanges {
2937                        total_files: 1,
2938                        files_added: 1,
2939                        files_deleted: 0,
2940                        file_list: vec![FileChange {
2941                            status: "A".to_string(),
2942                            file: "src/big.rs".to_string(),
2943                        }],
2944                    },
2945                    diff_summary: " src/big.rs | 80 ++++\n".to_string(),
2946                    diff_file: flat_diff_path.to_string_lossy().to_string(),
2947                    file_diffs: vec![FileDiffRef {
2948                        path: "src/big.rs".to_string(),
2949                        diff_file: per_file_path.to_string_lossy().to_string(),
2950                        byte_len: diff_content.len(),
2951                    }],
2952                },
2953            }],
2954        }
2955    }
2956
2957    /// A small single-file commit whose diff fits within the token budget.
2958    ///
2959    /// Exercises the non-split path: `generate_amendments_with_options` →
2960    /// `try_full_diff_budget` succeeds → single AI request → amendment
2961    /// returned directly. Verifies exactly one request is made and the
2962    /// user prompt contains the actual diff content.
2963    #[tokio::test]
2964    async fn amendment_single_file_under_budget_no_split() {
2965        let dir = tempfile::tempdir().unwrap();
2966        let repo_view = make_test_repo_view(&dir);
2967        let hash = format!("{:0>40}", 0);
2968
2969        let (client, response_handle, prompt_handle) =
2970            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
2971                &hash,
2972                "feat(test): improved message",
2973            ))]);
2974
2975        let result = client
2976            .generate_amendments_with_options(&repo_view, false)
2977            .await;
2978
2979        assert!(result.is_ok());
2980        assert_eq!(result.unwrap().amendments.len(), 1);
2981        assert_eq!(response_handle.remaining(), 0);
2982
2983        let prompts = prompt_handle.prompts();
2984        assert_eq!(
2985            prompts.len(),
2986            1,
2987            "expected exactly one AI request, no split"
2988        );
2989
2990        let (_, user_prompt) = &prompts[0];
2991        assert!(
2992            user_prompt.contains("added line"),
2993            "user prompt should contain the diff content"
2994        );
2995    }
2996
2997    /// A two-file commit that exceeds the token budget when combined.
2998    ///
2999    /// Exercises the file-level split path: `generate_amendments_with_options`
3000    /// → `try_full_diff_budget` fails → `generate_amendment_for_commit` →
3001    /// `try_full_diff_budget` fails again → `generate_amendment_split` →
3002    /// `pack_file_diffs` creates 2 chunks (one file each) → 2 AI requests
3003    /// → `merge_amendment_chunks` reduce pass → 1 merged amendment.
3004    ///
3005    /// Verifies that each chunk's user prompt contains only its file's diff
3006    /// content, and the merge prompt contains both partial amendment messages.
3007    #[tokio::test]
3008    async fn amendment_two_chunks_prompt_content() {
3009        let dir = tempfile::tempdir().unwrap();
3010        let repo_view = make_large_diff_repo_view(&dir);
3011        let hash = "a".repeat(40);
3012
3013        let (client, response_handle, prompt_handle) =
3014            make_small_context_client_with_prompts(vec![
3015                Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3016                Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3017                Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3018            ]);
3019
3020        let result = client
3021            .generate_amendments_with_options(&repo_view, false)
3022            .await;
3023
3024        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3025        let amendments = result.unwrap();
3026        assert_eq!(amendments.amendments.len(), 1);
3027        assert!(amendments.amendments[0]
3028            .message
3029            .contains("add a.rs and b.rs"));
3030        assert_eq!(response_handle.remaining(), 0);
3031
3032        let prompts = prompt_handle.prompts();
3033        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3034
3035        // Chunk 1 should contain file-a diff content (repeated 'a' chars)
3036        let (_, chunk1_user) = &prompts[0];
3037        assert!(
3038            chunk1_user.contains("aaa"),
3039            "chunk 1 prompt should contain file-a diff content"
3040        );
3041
3042        // Chunk 2 should contain file-b diff content (repeated 'b' chars)
3043        let (_, chunk2_user) = &prompts[1];
3044        assert!(
3045            chunk2_user.contains("bbb"),
3046            "chunk 2 prompt should contain file-b diff content"
3047        );
3048
3049        // Merge pass: system prompt is the synthesis prompt
3050        let (merge_sys, merge_user) = &prompts[2];
3051        assert!(
3052            merge_sys.contains("synthesiz"),
3053            "merge system prompt should contain synthesis instructions"
3054        );
3055        // Merge user prompt should contain both partial messages
3056        assert!(
3057            merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
3058            "merge user prompt should contain both partial amendment messages"
3059        );
3060    }
3061
3062    /// A single file whose diff exceeds the budget even after split dispatch.
3063    ///
3064    /// Exercises the budget-error path: `generate_amendment_for_commit` →
3065    /// budget exceeded → `generate_amendment_split` → `pack_file_diffs`
3066    /// plans hunk-level chunks → but `from_commit_info_partial` loads the
3067    /// full per-file diff (deduplicates the repeated path) →
3068    /// Oversized files that can't be split get placeholders and proceed.
3069    ///
3070    /// Verifies that files too large for the budget are replaced with
3071    /// placeholder text indicating the file was omitted, rather than
3072    /// failing with a "prompt too large" error.
3073    #[tokio::test]
3074    async fn amendment_single_oversized_file_gets_placeholder() {
3075        let dir = tempfile::tempdir().unwrap();
3076        let repo_view = make_single_oversized_file_repo_view(&dir);
3077        let hash = "c".repeat(40);
3078
3079        // The file is too large for the full budget but gets a placeholder.
3080        // With 50k context, the placeholder is small enough to fit in a
3081        // single request (no split dispatch needed). We expect 1 request.
3082        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![Ok(
3083            valid_amendment_yaml(&hash, "feat(big): add large module"),
3084        )]);
3085
3086        let result = client
3087            .generate_amendments_with_options(&repo_view, false)
3088            .await;
3089
3090        // Should succeed (either single request or split with placeholder)
3091        assert!(
3092            result.is_ok(),
3093            "expected success with placeholder, got: {result:?}"
3094        );
3095
3096        // One request (placeholder makes it fit in single request)
3097        assert!(
3098            prompt_handle.request_count() >= 1,
3099            "expected at least 1 request, got {}",
3100            prompt_handle.request_count()
3101        );
3102    }
3103
3104    /// A two-chunk split where the second chunk's AI request fails.
3105    ///
3106    /// Exercises the error-propagation path within `generate_amendment_split`:
3107    /// chunk 1 succeeds → chunk 2 returns `Err` → the `?` operator in the
3108    /// loop body propagates the error immediately, skipping the merge pass.
3109    ///
3110    /// Verifies that exactly 2 requests are recorded (no further processing)
3111    /// and the overall result is `Err` (no silent degradation).
3112    #[tokio::test]
3113    async fn amendment_chunk_failure_stops_dispatch() {
3114        let dir = tempfile::tempdir().unwrap();
3115        let repo_view = make_large_diff_repo_view(&dir);
3116        let hash = "a".repeat(40);
3117
3118        // First chunk succeeds, second chunk fails
3119        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3120            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3121            Err(anyhow::anyhow!("rate limit exceeded")),
3122        ]);
3123
3124        let result = client
3125            .generate_amendments_with_options(&repo_view, false)
3126            .await;
3127
3128        assert!(result.is_err());
3129
3130        // Exactly 2 requests: chunk 1 (success) + chunk 2 (failure)
3131        let prompts = prompt_handle.prompts();
3132        assert_eq!(
3133            prompts.len(),
3134            2,
3135            "should stop after the failing chunk, got {} requests",
3136            prompts.len()
3137        );
3138
3139        // The first request should reference one of the files
3140        let (_, first_user) = &prompts[0];
3141        assert!(
3142            first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
3143            "first chunk prompt should reference a file"
3144        );
3145    }
3146
3147    /// Two-chunk amendment split dispatch, focused on the reduce pass inputs.
3148    ///
3149    /// Exercises `merge_amendment_chunks` which calls
3150    /// `generate_chunk_merge_user_prompt` to assemble the merge prompt from:
3151    /// the commit hash, original message, diff_summary, and the partial
3152    /// amendment messages returned by each chunk.
3153    ///
3154    /// Verifies that the merge (3rd) request's user prompt contains all of:
3155    /// both partial messages, the original commit message, the diff_summary
3156    /// file paths, and the commit hash.
3157    #[tokio::test]
3158    async fn amendment_reduce_pass_prompt_content() {
3159        let dir = tempfile::tempdir().unwrap();
3160        let repo_view = make_large_diff_repo_view(&dir);
3161        let hash = "a".repeat(40);
3162
3163        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3164            Ok(valid_amendment_yaml(
3165                &hash,
3166                "feat(a): add module a implementation",
3167            )),
3168            Ok(valid_amendment_yaml(
3169                &hash,
3170                "feat(b): add module b implementation",
3171            )),
3172            Ok(valid_amendment_yaml(
3173                &hash,
3174                "feat(test): add modules a and b",
3175            )),
3176        ]);
3177
3178        let result = client
3179            .generate_amendments_with_options(&repo_view, false)
3180            .await;
3181
3182        assert!(result.is_ok());
3183
3184        let prompts = prompt_handle.prompts();
3185        assert_eq!(prompts.len(), 3);
3186
3187        // The merge pass is the last (3rd) request
3188        let (merge_system, merge_user) = &prompts[2];
3189
3190        // System prompt should be the amendment chunk merge prompt
3191        assert!(
3192            merge_system.contains("synthesiz"),
3193            "merge system prompt should contain synthesis instructions"
3194        );
3195
3196        // User prompt should contain the partial messages from chunks
3197        assert!(
3198            merge_user.contains("feat(a): add module a implementation"),
3199            "merge user prompt should contain chunk 1's partial message"
3200        );
3201        assert!(
3202            merge_user.contains("feat(b): add module b implementation"),
3203            "merge user prompt should contain chunk 2's partial message"
3204        );
3205
3206        // User prompt should contain the original commit message
3207        assert!(
3208            merge_user.contains("feat(test): large commit"),
3209            "merge user prompt should contain the original commit message"
3210        );
3211
3212        // User prompt should contain the diff_summary referencing both files
3213        assert!(
3214            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3215            "merge user prompt should contain the diff_summary"
3216        );
3217
3218        // User prompt should reference the commit hash
3219        assert!(
3220            merge_user.contains(&hash),
3221            "merge user prompt should reference the commit hash"
3222        );
3223    }
3224
3225    /// Two-chunk check split dispatch with issue deduplication and merge.
3226    ///
3227    /// Exercises `check_commit_split` which:
3228    /// 1. Dispatches 2 chunk requests (one per file)
3229    /// 2. Collects issues from both chunks into a `HashSet` keyed by
3230    ///    `(rule, severity, section)` — duplicates are dropped
3231    /// 3. Detects that both chunks have suggestions → calls
3232    ///    `merge_check_chunks` for the AI reduce pass
3233    ///
3234    /// Chunk 1 reports: `error:subject-too-long:Subject Line` +
3235    ///                   `warning:body-required:Content`
3236    /// Chunk 2 reports: `error:subject-too-long:Subject Line` (duplicate) +
3237    ///                   `info:scope-suggestion:Style` (new)
3238    ///
3239    /// Verifies: 3 unique issues after dedup, suggestion from merge pass,
3240    /// and the merge prompt contains both partial suggestions + diff_summary.
3241    #[tokio::test]
3242    async fn check_split_dedup_and_merge_prompt() {
3243        let dir = tempfile::tempdir().unwrap();
3244        let repo_view = make_large_diff_repo_view(&dir);
3245        let hash = "a".repeat(40);
3246
3247        // Chunk 1: error (subject-too-long) + warning (body-required) + suggestion
3248        let chunk1_yaml = format!(
3249            concat!(
3250                "checks:\n",
3251                "  - commit: \"{hash}\"\n",
3252                "    passes: false\n",
3253                "    issues:\n",
3254                "      - severity: error\n",
3255                "        section: \"Subject Line\"\n",
3256                "        rule: \"subject-too-long\"\n",
3257                "        explanation: \"Subject exceeds 72 characters\"\n",
3258                "      - severity: warning\n",
3259                "        section: \"Content\"\n",
3260                "        rule: \"body-required\"\n",
3261                "        explanation: \"Large change needs body\"\n",
3262                "    suggestion:\n",
3263                "      message: \"feat(a): shorter subject for a\"\n",
3264                "      explanation: \"Shortened subject for file a\"\n",
3265                "    summary: \"Adds module a\"\n",
3266            ),
3267            hash = hash,
3268        );
3269
3270        // Chunk 2: same error (different explanation) + new info issue + suggestion
3271        let chunk2_yaml = format!(
3272            concat!(
3273                "checks:\n",
3274                "  - commit: \"{hash}\"\n",
3275                "    passes: false\n",
3276                "    issues:\n",
3277                "      - severity: error\n",
3278                "        section: \"Subject Line\"\n",
3279                "        rule: \"subject-too-long\"\n",
3280                "        explanation: \"Subject line is way too long\"\n",
3281                "      - severity: info\n",
3282                "        section: \"Style\"\n",
3283                "        rule: \"scope-suggestion\"\n",
3284                "        explanation: \"Consider more specific scope\"\n",
3285                "    suggestion:\n",
3286                "      message: \"feat(b): shorter subject for b\"\n",
3287                "      explanation: \"Shortened subject for file b\"\n",
3288                "    summary: \"Adds module b\"\n",
3289            ),
3290            hash = hash,
3291        );
3292
3293        // Merge pass (called because suggestions exist)
3294        let merge_yaml = format!(
3295            concat!(
3296                "checks:\n",
3297                "  - commit: \"{hash}\"\n",
3298                "    passes: false\n",
3299                "    issues: []\n",
3300                "    suggestion:\n",
3301                "      message: \"feat(test): add modules a and b\"\n",
3302                "      explanation: \"Combined suggestion\"\n",
3303                "    summary: \"Adds modules a and b\"\n",
3304            ),
3305            hash = hash,
3306        );
3307
3308        let (client, response_handle, prompt_handle) =
3309            make_small_context_client_with_prompts(vec![
3310                Ok(chunk1_yaml),
3311                Ok(chunk2_yaml),
3312                Ok(merge_yaml),
3313            ]);
3314
3315        let result = client
3316            .check_commits_with_scopes(&repo_view, None, &[], true)
3317            .await;
3318
3319        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3320        let report = result.unwrap();
3321        assert_eq!(report.commits.len(), 1);
3322        assert!(!report.commits[0].passes);
3323        assert_eq!(response_handle.remaining(), 0);
3324
3325        // Dedup: 3 unique (rule, severity, section) tuples
3326        //  - subject-too-long / error / Subject Line   (appears in both → deduped)
3327        //  - body-required    / warning / Content
3328        //  - scope-suggestion / info / Style
3329        assert_eq!(
3330            report.commits[0].issues.len(),
3331            3,
3332            "expected 3 unique issues after dedup, got {:?}",
3333            report.commits[0]
3334                .issues
3335                .iter()
3336                .map(|i| &i.rule)
3337                .collect::<Vec<_>>()
3338        );
3339
3340        // Suggestion should come from the merge pass
3341        assert!(report.commits[0].suggestion.is_some());
3342        assert!(
3343            report.commits[0]
3344                .suggestion
3345                .as_ref()
3346                .unwrap()
3347                .message
3348                .contains("add modules a and b"),
3349            "suggestion should come from the merge pass"
3350        );
3351
3352        // Prompt content assertions
3353        let prompts = prompt_handle.prompts();
3354        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
3355
3356        // Chunk prompts should collectively cover both files
3357        let (_, chunk1_user) = &prompts[0];
3358        let (_, chunk2_user) = &prompts[1];
3359        let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
3360        assert!(
3361            combined_chunk_prompts.contains("src/a.rs")
3362                && combined_chunk_prompts.contains("src/b.rs"),
3363            "chunk prompts should collectively cover both files"
3364        );
3365
3366        // Merge pass prompt should contain partial suggestions
3367        let (merge_sys, merge_user) = &prompts[2];
3368        assert!(
3369            merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
3370            "merge system prompt should be the check chunk merge prompt"
3371        );
3372        assert!(
3373            merge_user.contains("feat(a): shorter subject for a")
3374                && merge_user.contains("feat(b): shorter subject for b"),
3375            "merge user prompt should contain both partial suggestions"
3376        );
3377        // Merge prompt should contain the diff_summary
3378        assert!(
3379            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3380            "merge user prompt should contain the diff_summary"
3381        );
3382    }
3383
3384    // ── Amendment retry tests ──────────────────────────────────────────
3385
3386    #[tokio::test]
3387    async fn amendment_retry_parse_failure_then_success() {
3388        let dir = tempfile::tempdir().unwrap();
3389        let repo_view = make_test_repo_view(&dir);
3390        let hash = format!("{:0>40}", 0);
3391
3392        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3393            Ok("not valid yaml {{[".to_string()),
3394            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3395        ]);
3396
3397        let result = client
3398            .generate_amendments_with_options(&repo_view, false)
3399            .await;
3400
3401        assert!(
3402            result.is_ok(),
3403            "should succeed after retry: {:?}",
3404            result.err()
3405        );
3406        assert_eq!(result.unwrap().amendments.len(), 1);
3407        assert_eq!(response_handle.remaining(), 0, "both responses consumed");
3408        assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
3409    }
3410
3411    #[tokio::test]
3412    async fn amendment_retry_request_failure_then_success() {
3413        let dir = tempfile::tempdir().unwrap();
3414        let repo_view = make_test_repo_view(&dir);
3415        let hash = format!("{:0>40}", 0);
3416
3417        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3418            Err(anyhow::anyhow!("rate limit")),
3419            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3420        ]);
3421
3422        let result = client
3423            .generate_amendments_with_options(&repo_view, false)
3424            .await;
3425
3426        assert!(
3427            result.is_ok(),
3428            "should succeed after retry: {:?}",
3429            result.err()
3430        );
3431        assert_eq!(result.unwrap().amendments.len(), 1);
3432        assert_eq!(response_handle.remaining(), 0);
3433        assert_eq!(prompt_handle.request_count(), 2);
3434    }
3435
3436    #[tokio::test]
3437    async fn amendment_retry_all_attempts_exhausted() {
3438        let dir = tempfile::tempdir().unwrap();
3439        let repo_view = make_test_repo_view(&dir);
3440
3441        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3442            Ok("bad yaml 1".to_string()),
3443            Ok("bad yaml 2".to_string()),
3444            Ok("bad yaml 3".to_string()),
3445        ]);
3446
3447        let result = client
3448            .generate_amendments_with_options(&repo_view, false)
3449            .await;
3450
3451        assert!(result.is_err(), "should fail after all retries exhausted");
3452        assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
3453        assert_eq!(
3454            prompt_handle.request_count(),
3455            3,
3456            "exactly 3 AI requests (1 + 2 retries)"
3457        );
3458    }
3459
3460    #[tokio::test]
3461    async fn amendment_retry_success_first_attempt() {
3462        let dir = tempfile::tempdir().unwrap();
3463        let repo_view = make_test_repo_view(&dir);
3464        let hash = format!("{:0>40}", 0);
3465
3466        let (client, response_handle, prompt_handle) =
3467            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3468                &hash,
3469                "feat(test): works first time",
3470            ))]);
3471
3472        let result = client
3473            .generate_amendments_with_options(&repo_view, false)
3474            .await;
3475
3476        assert!(result.is_ok());
3477        assert_eq!(response_handle.remaining(), 0);
3478        assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
3479    }
3480
3481    #[tokio::test]
3482    async fn amendment_retry_mixed_request_and_parse_failures() {
3483        let dir = tempfile::tempdir().unwrap();
3484        let repo_view = make_test_repo_view(&dir);
3485        let hash = format!("{:0>40}", 0);
3486
3487        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3488            Err(anyhow::anyhow!("network error")),
3489            Ok("invalid yaml {{".to_string()),
3490            Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
3491        ]);
3492
3493        let result = client
3494            .generate_amendments_with_options(&repo_view, false)
3495            .await;
3496
3497        assert!(
3498            result.is_ok(),
3499            "should succeed on third attempt: {:?}",
3500            result.err()
3501        );
3502        assert_eq!(result.unwrap().amendments.len(), 1);
3503        assert_eq!(response_handle.remaining(), 0);
3504        assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
3505    }
3506}