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").is_ok_and(|val| val == "true");
1504
1505    let use_ollama = get_env_var("USE_OLLAMA").is_ok_and(|val| val == "true");
1506
1507    // Check if we should use Bedrock
1508    let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK").is_ok_and(|val| val == "true");
1509
1510    debug!(
1511        use_openai = use_openai,
1512        use_ollama = use_ollama,
1513        use_bedrock = use_bedrock,
1514        "Client selection flags"
1515    );
1516
1517    let registry = crate::claude::model_config::get_model_registry();
1518
1519    // Handle Ollama configuration
1520    if use_ollama {
1521        let ollama_model = model
1522            .or_else(|| get_env_var("OLLAMA_MODEL").ok())
1523            .unwrap_or_else(|| "llama2".to_string());
1524        validate_beta_header(&ollama_model, &beta_header)?;
1525        let base_url = get_env_var("OLLAMA_BASE_URL").ok();
1526        let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url, beta_header)?;
1527        return Ok(ClaudeClient::new(Box::new(ai_client)));
1528    }
1529
1530    // Handle OpenAI configuration
1531    if use_openai {
1532        debug!("Creating OpenAI client");
1533        let openai_model = model
1534            .or_else(|| get_env_var("OPENAI_MODEL").ok())
1535            .unwrap_or_else(|| {
1536                registry
1537                    .get_default_model("openai")
1538                    .unwrap_or("gpt-5")
1539                    .to_string()
1540            });
1541        debug!(openai_model = %openai_model, "Selected OpenAI model");
1542        validate_beta_header(&openai_model, &beta_header)?;
1543
1544        let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
1545            debug!(error = ?e, "Failed to get OpenAI API key");
1546            ClaudeError::ApiKeyNotFound
1547        })?;
1548        debug!("OpenAI API key found");
1549
1550        let ai_client = OpenAiAiClient::new_openai(openai_model, api_key, beta_header)?;
1551        debug!("OpenAI client created successfully");
1552        return Ok(ClaudeClient::new(Box::new(ai_client)));
1553    }
1554
1555    // For Claude clients, try to get model from env vars or use default
1556    let claude_model = model
1557        .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
1558        .unwrap_or_else(|| {
1559            registry
1560                .get_default_model("claude")
1561                .unwrap_or("claude-sonnet-4-6")
1562                .to_string()
1563        });
1564    validate_beta_header(&claude_model, &beta_header)?;
1565
1566    if use_bedrock {
1567        // Use Bedrock AI client
1568        let auth_token =
1569            get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1570
1571        let base_url =
1572            get_env_var("ANTHROPIC_BEDROCK_BASE_URL").map_err(|_| ClaudeError::ApiKeyNotFound)?;
1573
1574        let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url, beta_header)?;
1575        return Ok(ClaudeClient::new(Box::new(ai_client)));
1576    }
1577
1578    // Default: use standard Claude AI client
1579    debug!("Falling back to Claude client");
1580    let api_key = get_env_vars(&[
1581        "CLAUDE_API_KEY",
1582        "ANTHROPIC_API_KEY",
1583        "ANTHROPIC_AUTH_TOKEN",
1584    ])
1585    .map_err(|_| ClaudeError::ApiKeyNotFound)?;
1586
1587    let ai_client = ClaudeAiClient::new(claude_model, api_key, beta_header)?;
1588    debug!("Claude client created successfully");
1589    Ok(ClaudeClient::new(Box::new(ai_client)))
1590}
1591
1592#[cfg(test)]
1593#[allow(clippy::unwrap_used, clippy::expect_used)]
1594mod tests {
1595    use super::*;
1596    use crate::claude::ai::{AiClient, AiClientMetadata};
1597    use std::future::Future;
1598    use std::pin::Pin;
1599
1600    /// Mock AI client for testing — never makes real HTTP requests.
1601    struct MockAiClient;
1602
1603    impl AiClient for MockAiClient {
1604        fn send_request<'a>(
1605            &'a self,
1606            _system_prompt: &'a str,
1607            _user_prompt: &'a str,
1608        ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
1609            Box::pin(async { Ok(String::new()) })
1610        }
1611
1612        fn get_metadata(&self) -> AiClientMetadata {
1613            AiClientMetadata {
1614                provider: "Mock".to_string(),
1615                model: "mock-model".to_string(),
1616                max_context_length: 200_000,
1617                max_response_length: 8_192,
1618                active_beta: None,
1619            }
1620        }
1621    }
1622
1623    fn make_client() -> ClaudeClient {
1624        ClaudeClient::new(Box::new(MockAiClient))
1625    }
1626
1627    // ── extract_yaml_from_response ─────────────────────────────────
1628
1629    #[test]
1630    fn extract_yaml_pure_amendments() {
1631        let client = make_client();
1632        let content = "amendments:\n  - commit: abc123\n    message: test";
1633        let result = client.extract_yaml_from_response(content);
1634        assert!(result.starts_with("amendments:"));
1635    }
1636
1637    #[test]
1638    fn extract_yaml_with_markdown_yaml_block() {
1639        let client = make_client();
1640        let content = "Here is the result:\n```yaml\namendments:\n  - commit: abc\n```\n";
1641        let result = client.extract_yaml_from_response(content);
1642        assert!(result.starts_with("amendments:"));
1643    }
1644
1645    #[test]
1646    fn extract_yaml_with_generic_code_block() {
1647        let client = make_client();
1648        let content = "```\namendments:\n  - commit: abc\n```";
1649        let result = client.extract_yaml_from_response(content);
1650        assert!(result.starts_with("amendments:"));
1651    }
1652
1653    #[test]
1654    fn extract_yaml_with_whitespace() {
1655        let client = make_client();
1656        let content = "  \n  amendments:\n  - commit: abc\n  ";
1657        let result = client.extract_yaml_from_response(content);
1658        assert!(result.starts_with("amendments:"));
1659    }
1660
1661    #[test]
1662    fn extract_yaml_fallback_returns_trimmed() {
1663        let client = make_client();
1664        let content = "  some random text  ";
1665        let result = client.extract_yaml_from_response(content);
1666        assert_eq!(result, "some random text");
1667    }
1668
1669    // ── extract_yaml_from_check_response ───────────────────────────
1670
1671    #[test]
1672    fn extract_check_yaml_pure() {
1673        let client = make_client();
1674        let content = "checks:\n  - commit: abc123";
1675        let result = client.extract_yaml_from_check_response(content);
1676        assert!(result.starts_with("checks:"));
1677    }
1678
1679    #[test]
1680    fn extract_check_yaml_markdown_block() {
1681        let client = make_client();
1682        let content = "```yaml\nchecks:\n  - commit: abc\n```";
1683        let result = client.extract_yaml_from_check_response(content);
1684        assert!(result.starts_with("checks:"));
1685    }
1686
1687    #[test]
1688    fn extract_check_yaml_generic_block() {
1689        let client = make_client();
1690        let content = "```\nchecks:\n  - commit: abc\n```";
1691        let result = client.extract_yaml_from_check_response(content);
1692        assert!(result.starts_with("checks:"));
1693    }
1694
1695    #[test]
1696    fn extract_check_yaml_fallback() {
1697        let client = make_client();
1698        let content = "  unexpected content  ";
1699        let result = client.extract_yaml_from_check_response(content);
1700        assert_eq!(result, "unexpected content");
1701    }
1702
1703    // ── parse_amendment_response ────────────────────────────────────
1704
1705    #[test]
1706    fn parse_amendment_response_valid() {
1707        let client = make_client();
1708        let yaml = format!(
1709            "amendments:\n  - commit: \"{}\"\n    message: \"test message\"",
1710            "a".repeat(40)
1711        );
1712        let result = client.parse_amendment_response(&yaml);
1713        assert!(result.is_ok());
1714        assert_eq!(result.unwrap().amendments.len(), 1);
1715    }
1716
1717    #[test]
1718    fn parse_amendment_response_invalid_yaml() {
1719        let client = make_client();
1720        let result = client.parse_amendment_response("not: valid: yaml: [{{");
1721        assert!(result.is_err());
1722    }
1723
1724    #[test]
1725    fn parse_amendment_response_invalid_hash() {
1726        let client = make_client();
1727        let yaml = "amendments:\n  - commit: \"short\"\n    message: \"test\"";
1728        let result = client.parse_amendment_response(yaml);
1729        assert!(result.is_err());
1730    }
1731
1732    // ── validate_beta_header ───────────────────────────────────────
1733
1734    #[test]
1735    fn validate_beta_header_none_passes() {
1736        let result = validate_beta_header("claude-opus-4-1-20250805", &None);
1737        assert!(result.is_ok());
1738    }
1739
1740    #[test]
1741    fn validate_beta_header_unsupported_fails() {
1742        let header = Some(("fake-key".to_string(), "fake-value".to_string()));
1743        let result = validate_beta_header("claude-opus-4-1-20250805", &header);
1744        assert!(result.is_err());
1745    }
1746
1747    // ── ClaudeClient::new / get_ai_client_metadata ─────────────────
1748
1749    #[test]
1750    fn client_metadata() {
1751        let client = make_client();
1752        let metadata = client.get_ai_client_metadata();
1753        assert_eq!(metadata.provider, "Mock");
1754        assert_eq!(metadata.model, "mock-model");
1755    }
1756
1757    // ── property tests ────────────────────────────────────────────
1758
1759    mod prop {
1760        use super::*;
1761        use proptest::prelude::*;
1762
1763        proptest! {
1764            #[test]
1765            fn yaml_response_output_trimmed(s in ".*") {
1766                let client = make_client();
1767                let result = client.extract_yaml_from_response(&s);
1768                prop_assert_eq!(&result, result.trim());
1769            }
1770
1771            #[test]
1772            fn yaml_response_amendments_prefix_preserved(tail in ".*") {
1773                let client = make_client();
1774                let input = format!("amendments:{tail}");
1775                let result = client.extract_yaml_from_response(&input);
1776                prop_assert!(result.starts_with("amendments:"));
1777            }
1778
1779            #[test]
1780            fn check_response_checks_prefix_preserved(tail in ".*") {
1781                let client = make_client();
1782                let input = format!("checks:{tail}");
1783                let result = client.extract_yaml_from_check_response(&input);
1784                prop_assert!(result.starts_with("checks:"));
1785            }
1786
1787            #[test]
1788            fn yaml_fenced_block_strips_fences(
1789                content in "[a-zA-Z0-9: _\\-\n]{1,100}",
1790            ) {
1791                let client = make_client();
1792                let input = format!("```yaml\n{content}\n```");
1793                let result = client.extract_yaml_from_response(&input);
1794                prop_assert!(!result.contains("```"));
1795            }
1796        }
1797    }
1798
1799    // ── ConfigurableMockAiClient tests ──────────────────────────────
1800
1801    fn make_configurable_client(responses: Vec<Result<String>>) -> ClaudeClient {
1802        ClaudeClient::new(Box::new(
1803            crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
1804        ))
1805    }
1806
1807    fn make_test_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
1808        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
1809        use crate::git::commit::FileChanges;
1810        use crate::git::{CommitAnalysis, CommitInfo};
1811
1812        let diff_path = dir.path().join("0.diff");
1813        std::fs::write(&diff_path, "+added line\n").unwrap();
1814
1815        crate::data::RepositoryView {
1816            versions: None,
1817            explanation: FieldExplanation::default(),
1818            working_directory: WorkingDirectoryInfo {
1819                clean: true,
1820                untracked_changes: Vec::new(),
1821            },
1822            remotes: Vec::new(),
1823            ai: AiInfo {
1824                scratch: String::new(),
1825            },
1826            branch_info: None,
1827            pr_template: None,
1828            pr_template_location: None,
1829            branch_prs: None,
1830            commits: vec![CommitInfo {
1831                hash: format!("{:0>40}", 0),
1832                author: "Test <test@test.com>".to_string(),
1833                date: chrono::Utc::now().fixed_offset(),
1834                original_message: "feat(test): add something".to_string(),
1835                in_main_branches: Vec::new(),
1836                analysis: CommitAnalysis {
1837                    detected_type: "feat".to_string(),
1838                    detected_scope: "test".to_string(),
1839                    proposed_message: "feat(test): add something".to_string(),
1840                    file_changes: FileChanges {
1841                        total_files: 1,
1842                        files_added: 1,
1843                        files_deleted: 0,
1844                        file_list: Vec::new(),
1845                    },
1846                    diff_summary: "file.rs | 1 +".to_string(),
1847                    diff_file: diff_path.to_string_lossy().to_string(),
1848                    file_diffs: Vec::new(),
1849                },
1850            }],
1851        }
1852    }
1853
1854    fn valid_check_yaml() -> String {
1855        format!(
1856            "checks:\n  - commit: \"{hash}\"\n    passes: true\n    issues: []\n",
1857            hash = format!("{:0>40}", 0)
1858        )
1859    }
1860
1861    #[tokio::test]
1862    async fn send_message_propagates_ai_error() {
1863        let client = make_configurable_client(vec![Err(anyhow::anyhow!("mock error"))]);
1864        let result = client.send_message("sys", "usr").await;
1865        assert!(result.is_err());
1866        assert!(result.unwrap_err().to_string().contains("mock error"));
1867    }
1868
1869    #[tokio::test]
1870    async fn check_commits_succeeds_after_request_error() {
1871        let dir = tempfile::tempdir().unwrap();
1872        let repo_view = make_test_repo_view(&dir);
1873        // First attempt: request error; retries return valid response.
1874        let client = make_configurable_client(vec![
1875            Err(anyhow::anyhow!("rate limit")),
1876            Ok(valid_check_yaml()),
1877            Ok(valid_check_yaml()),
1878        ]);
1879        let result = client
1880            .check_commits_with_scopes(&repo_view, None, &[], false)
1881            .await;
1882        assert!(result.is_ok());
1883    }
1884
1885    #[tokio::test]
1886    async fn check_commits_succeeds_after_parse_error() {
1887        let dir = tempfile::tempdir().unwrap();
1888        let repo_view = make_test_repo_view(&dir);
1889        // First attempt: AI returns malformed YAML; retry succeeds.
1890        let client = make_configurable_client(vec![
1891            Ok("not: valid: yaml: [[".to_string()),
1892            Ok(valid_check_yaml()),
1893            Ok(valid_check_yaml()),
1894        ]);
1895        let result = client
1896            .check_commits_with_scopes(&repo_view, None, &[], false)
1897            .await;
1898        assert!(result.is_ok());
1899    }
1900
1901    #[tokio::test]
1902    async fn check_commits_fails_after_all_retries_exhausted() {
1903        let dir = tempfile::tempdir().unwrap();
1904        let repo_view = make_test_repo_view(&dir);
1905        let client = make_configurable_client(vec![
1906            Err(anyhow::anyhow!("first failure")),
1907            Err(anyhow::anyhow!("second failure")),
1908            Err(anyhow::anyhow!("final failure")),
1909        ]);
1910        let result = client
1911            .check_commits_with_scopes(&repo_view, None, &[], false)
1912            .await;
1913        assert!(result.is_err());
1914    }
1915
1916    #[tokio::test]
1917    async fn check_commits_fails_when_all_parses_fail() {
1918        let dir = tempfile::tempdir().unwrap();
1919        let repo_view = make_test_repo_view(&dir);
1920        let client = make_configurable_client(vec![
1921            Ok("bad yaml [[".to_string()),
1922            Ok("bad yaml [[".to_string()),
1923            Ok("bad yaml [[".to_string()),
1924        ]);
1925        let result = client
1926            .check_commits_with_scopes(&repo_view, None, &[], false)
1927            .await;
1928        assert!(result.is_err());
1929    }
1930
1931    // ── split dispatch tests ─────────────────────────────────────
1932
1933    /// Creates a mock client with a constrained context window.
1934    ///
1935    /// The window is large enough that a single-file chunk fits, but too
1936    /// small for both files together (including system prompt overhead).
1937    fn make_small_context_client(responses: Vec<Result<String>>) -> ClaudeClient {
1938        // Context of 50k with more conservative token estimation (2.5 chars/token
1939        // vs 3.5) ensures per-file diffs fit in chunks without placeholders while
1940        // still being large enough to trigger split dispatch for multiple files.
1941        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
1942            .with_context_length(50_000);
1943        ClaudeClient::new(Box::new(mock))
1944    }
1945
1946    /// Like [`make_small_context_client`] but also returns a handle to inspect
1947    /// how many mock responses remain unconsumed after the test runs.
1948    fn make_small_context_client_tracked(
1949        responses: Vec<Result<String>>,
1950    ) -> (ClaudeClient, crate::claude::test_utils::ResponseQueueHandle) {
1951        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
1952            .with_context_length(50_000);
1953        let handle = mock.response_handle();
1954        (ClaudeClient::new(Box::new(mock)), handle)
1955    }
1956
1957    /// Creates a repo view with per-file diffs large enough to exceed the
1958    /// constrained context window, ensuring the split dispatch path triggers.
1959    fn make_large_diff_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
1960        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
1961        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
1962        use crate::git::{CommitAnalysis, CommitInfo};
1963
1964        let hash = "a".repeat(40);
1965
1966        // Write a full (flat) diff file large enough to bust the budget.
1967        // With 50k context / 2.5 chars-per-token / 1.2 margin, available ≈ 41k tokens.
1968        // 120k chars → ~57,600 tokens → well over budget.
1969        let full_diff = "x".repeat(120_000);
1970        let flat_diff_path = dir.path().join("full.diff");
1971        std::fs::write(&flat_diff_path, &full_diff).unwrap();
1972
1973        // Write two large per-file diff files (~30K chars each ≈ 14,400 tokens with
1974        // conservative 2.5 chars/token * 1.2 margin estimation)
1975        let diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
1976        let diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
1977
1978        let path_a = dir.path().join("0000.diff");
1979        let path_b = dir.path().join("0001.diff");
1980        std::fs::write(&path_a, &diff_a).unwrap();
1981        std::fs::write(&path_b, &diff_b).unwrap();
1982
1983        crate::data::RepositoryView {
1984            versions: None,
1985            explanation: FieldExplanation::default(),
1986            working_directory: WorkingDirectoryInfo {
1987                clean: true,
1988                untracked_changes: Vec::new(),
1989            },
1990            remotes: Vec::new(),
1991            ai: AiInfo {
1992                scratch: String::new(),
1993            },
1994            branch_info: None,
1995            pr_template: None,
1996            pr_template_location: None,
1997            branch_prs: None,
1998            commits: vec![CommitInfo {
1999                hash,
2000                author: "Test <test@test.com>".to_string(),
2001                date: chrono::Utc::now().fixed_offset(),
2002                original_message: "feat(test): large commit".to_string(),
2003                in_main_branches: Vec::new(),
2004                analysis: CommitAnalysis {
2005                    detected_type: "feat".to_string(),
2006                    detected_scope: "test".to_string(),
2007                    proposed_message: "feat(test): large commit".to_string(),
2008                    file_changes: FileChanges {
2009                        total_files: 2,
2010                        files_added: 2,
2011                        files_deleted: 0,
2012                        file_list: vec![
2013                            FileChange {
2014                                status: "A".to_string(),
2015                                file: "src/a.rs".to_string(),
2016                            },
2017                            FileChange {
2018                                status: "A".to_string(),
2019                                file: "src/b.rs".to_string(),
2020                            },
2021                        ],
2022                    },
2023                    diff_summary: " src/a.rs | 100 ++++\n src/b.rs | 100 ++++\n".to_string(),
2024                    diff_file: flat_diff_path.to_string_lossy().to_string(),
2025                    file_diffs: vec![
2026                        FileDiffRef {
2027                            path: "src/a.rs".to_string(),
2028                            diff_file: path_a.to_string_lossy().to_string(),
2029                            byte_len: diff_a.len(),
2030                        },
2031                        FileDiffRef {
2032                            path: "src/b.rs".to_string(),
2033                            diff_file: path_b.to_string_lossy().to_string(),
2034                            byte_len: diff_b.len(),
2035                        },
2036                    ],
2037                },
2038            }],
2039        }
2040    }
2041
2042    fn valid_amendment_yaml(hash: &str, message: &str) -> String {
2043        format!("amendments:\n  - commit: \"{hash}\"\n    message: \"{message}\"")
2044    }
2045
2046    #[tokio::test]
2047    async fn generate_amendments_split_dispatch() {
2048        let dir = tempfile::tempdir().unwrap();
2049        let repo_view = make_large_diff_repo_view(&dir);
2050        let hash = "a".repeat(40);
2051
2052        // Responses: chunk 1 + chunk 2 + merge pass
2053        let client = make_small_context_client(vec![
2054            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2055            Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
2056            Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
2057        ]);
2058
2059        let result = client
2060            .generate_amendments_with_options(&repo_view, false)
2061            .await;
2062
2063        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2064        let amendments = result.unwrap();
2065        assert_eq!(amendments.amendments.len(), 1);
2066        assert_eq!(amendments.amendments[0].commit, hash);
2067        assert!(amendments.amendments[0]
2068            .message
2069            .contains("add a.rs and b.rs"));
2070    }
2071
2072    #[tokio::test]
2073    async fn generate_amendments_split_chunk_failure() {
2074        let dir = tempfile::tempdir().unwrap();
2075        let repo_view = make_large_diff_repo_view(&dir);
2076        let hash = "a".repeat(40);
2077
2078        // First chunk succeeds, second chunk fails
2079        let client = make_small_context_client(vec![
2080            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
2081            Err(anyhow::anyhow!("rate limit exceeded")),
2082        ]);
2083
2084        let result = client
2085            .generate_amendments_with_options(&repo_view, false)
2086            .await;
2087
2088        assert!(result.is_err());
2089    }
2090
2091    #[tokio::test]
2092    async fn generate_amendments_no_split_when_fits() {
2093        let dir = tempfile::tempdir().unwrap();
2094        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2095        let hash = format!("{:0>40}", 0);
2096
2097        // Only one response needed — no split dispatch
2098        let client = make_configurable_client(vec![Ok(valid_amendment_yaml(
2099            &hash,
2100            "feat(test): improved message",
2101        ))]);
2102
2103        let result = client
2104            .generate_amendments_with_options(&repo_view, false)
2105            .await;
2106
2107        assert!(result.is_ok());
2108        assert_eq!(result.unwrap().amendments.len(), 1);
2109    }
2110
2111    // ── check split dispatch tests ──────────────────────────────
2112
2113    fn valid_check_yaml_for(hash: &str, passes: bool) -> String {
2114        format!(
2115            "checks:\n  - commit: \"{hash}\"\n    passes: {passes}\n    issues: []\n    summary: \"test summary\"\n"
2116        )
2117    }
2118
2119    fn valid_check_yaml_with_issues(hash: &str) -> String {
2120        format!(
2121            concat!(
2122                "checks:\n",
2123                "  - commit: \"{hash}\"\n",
2124                "    passes: false\n",
2125                "    issues:\n",
2126                "      - severity: error\n",
2127                "        section: \"Subject Line\"\n",
2128                "        rule: \"subject-too-long\"\n",
2129                "        explanation: \"Subject exceeds 72 characters\"\n",
2130                "    suggestion:\n",
2131                "      message: \"feat(test): shorter subject\"\n",
2132                "      explanation: \"Shortened subject line\"\n",
2133                "    summary: \"Large commit with issues\"\n",
2134            ),
2135            hash = hash,
2136        )
2137    }
2138
2139    fn valid_check_yaml_chunk_no_suggestion(hash: &str) -> String {
2140        format!(
2141            concat!(
2142                "checks:\n",
2143                "  - commit: \"{hash}\"\n",
2144                "    passes: true\n",
2145                "    issues: []\n",
2146                "    summary: \"chunk summary\"\n",
2147            ),
2148            hash = hash,
2149        )
2150    }
2151
2152    #[tokio::test]
2153    async fn check_commits_split_dispatch() {
2154        let dir = tempfile::tempdir().unwrap();
2155        let repo_view = make_large_diff_repo_view(&dir);
2156        let hash = "a".repeat(40);
2157
2158        // Responses: chunk 1 (issues + suggestion) + chunk 2 (issues + suggestion) + merge pass
2159        let client = make_small_context_client(vec![
2160            Ok(valid_check_yaml_with_issues(&hash)),
2161            Ok(valid_check_yaml_with_issues(&hash)),
2162            Ok(valid_check_yaml_with_issues(&hash)), // merge pass response
2163        ]);
2164
2165        let result = client
2166            .check_commits_with_scopes(&repo_view, None, &[], true)
2167            .await;
2168
2169        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2170        let report = result.unwrap();
2171        assert_eq!(report.commits.len(), 1);
2172        assert!(!report.commits[0].passes);
2173        // Dedup: both chunks report the same (rule, severity, section), so only 1 unique issue
2174        assert_eq!(report.commits[0].issues.len(), 1);
2175        assert_eq!(report.commits[0].issues[0].rule, "subject-too-long");
2176    }
2177
2178    #[tokio::test]
2179    async fn check_commits_split_dispatch_no_merge_when_no_suggestions() {
2180        let dir = tempfile::tempdir().unwrap();
2181        let repo_view = make_large_diff_repo_view(&dir);
2182        let hash = "a".repeat(40);
2183
2184        // Responses: chunk 1 + chunk 2, both passing with no suggestions
2185        // No merge pass needed — only 2 responses
2186        let client = make_small_context_client(vec![
2187            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2188            Ok(valid_check_yaml_chunk_no_suggestion(&hash)),
2189        ]);
2190
2191        let result = client
2192            .check_commits_with_scopes(&repo_view, None, &[], false)
2193            .await;
2194
2195        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2196        let report = result.unwrap();
2197        assert_eq!(report.commits.len(), 1);
2198        assert!(report.commits[0].passes);
2199        assert!(report.commits[0].issues.is_empty());
2200        assert!(report.commits[0].suggestion.is_none());
2201        // First non-None summary from chunks
2202        assert_eq!(report.commits[0].summary.as_deref(), Some("chunk summary"));
2203    }
2204
2205    #[tokio::test]
2206    async fn check_commits_split_chunk_failure() {
2207        let dir = tempfile::tempdir().unwrap();
2208        let repo_view = make_large_diff_repo_view(&dir);
2209        let hash = "a".repeat(40);
2210
2211        // First chunk succeeds, second chunk fails
2212        let client = make_small_context_client(vec![
2213            Ok(valid_check_yaml_for(&hash, true)),
2214            Err(anyhow::anyhow!("rate limit exceeded")),
2215        ]);
2216
2217        let result = client
2218            .check_commits_with_scopes(&repo_view, None, &[], false)
2219            .await;
2220
2221        assert!(result.is_err());
2222    }
2223
2224    #[tokio::test]
2225    async fn check_commits_no_split_when_fits() {
2226        let dir = tempfile::tempdir().unwrap();
2227        let repo_view = make_test_repo_view(&dir); // Small diff, no file_diffs
2228        let hash = format!("{:0>40}", 0);
2229
2230        // Only one response needed — no split dispatch
2231        let client = make_configurable_client(vec![Ok(valid_check_yaml_for(&hash, true))]);
2232
2233        let result = client
2234            .check_commits_with_scopes(&repo_view, None, &[], false)
2235            .await;
2236
2237        assert!(result.is_ok());
2238        assert_eq!(result.unwrap().commits.len(), 1);
2239    }
2240
2241    #[tokio::test]
2242    async fn check_commits_split_dedup_across_chunks() {
2243        let dir = tempfile::tempdir().unwrap();
2244        let repo_view = make_large_diff_repo_view(&dir);
2245        let hash = "a".repeat(40);
2246
2247        // Chunk 1: two issues (error + warning)
2248        let chunk1 = format!(
2249            concat!(
2250                "checks:\n",
2251                "  - commit: \"{hash}\"\n",
2252                "    passes: false\n",
2253                "    issues:\n",
2254                "      - severity: error\n",
2255                "        section: \"Subject Line\"\n",
2256                "        rule: \"subject-too-long\"\n",
2257                "        explanation: \"Subject exceeds 72 characters\"\n",
2258                "      - severity: warning\n",
2259                "        section: \"Content\"\n",
2260                "        rule: \"body-required\"\n",
2261                "        explanation: \"Large change needs body\"\n",
2262            ),
2263            hash = hash,
2264        );
2265
2266        // Chunk 2: same error (different wording) + new info issue
2267        let chunk2 = format!(
2268            concat!(
2269                "checks:\n",
2270                "  - commit: \"{hash}\"\n",
2271                "    passes: false\n",
2272                "    issues:\n",
2273                "      - severity: error\n",
2274                "        section: \"Subject Line\"\n",
2275                "        rule: \"subject-too-long\"\n",
2276                "        explanation: \"Subject line is too long\"\n",
2277                "      - severity: info\n",
2278                "        section: \"Style\"\n",
2279                "        rule: \"scope-suggestion\"\n",
2280                "        explanation: \"Consider more specific scope\"\n",
2281            ),
2282            hash = hash,
2283        );
2284
2285        // No suggestions → no merge pass needed
2286        let client = make_small_context_client(vec![Ok(chunk1), Ok(chunk2)]);
2287
2288        let result = client
2289            .check_commits_with_scopes(&repo_view, None, &[], false)
2290            .await;
2291
2292        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2293        let report = result.unwrap();
2294        assert_eq!(report.commits.len(), 1);
2295        assert!(!report.commits[0].passes);
2296        // 3 unique issues: subject-too-long, body-required, scope-suggestion
2297        // (subject-too-long appears in both chunks but deduped)
2298        assert_eq!(report.commits[0].issues.len(), 3);
2299    }
2300
2301    #[tokio::test]
2302    async fn check_commits_split_passes_only_when_all_chunks_pass() {
2303        let dir = tempfile::tempdir().unwrap();
2304        let repo_view = make_large_diff_repo_view(&dir);
2305        let hash = "a".repeat(40);
2306
2307        // Chunk 1 passes, chunk 2 fails
2308        let client = make_small_context_client(vec![
2309            Ok(valid_check_yaml_for(&hash, true)),
2310            Ok(valid_check_yaml_for(&hash, false)),
2311        ]);
2312
2313        let result = client
2314            .check_commits_with_scopes(&repo_view, None, &[], false)
2315            .await;
2316
2317        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
2318        let report = result.unwrap();
2319        assert!(
2320            !report.commits[0].passes,
2321            "should fail when any chunk fails"
2322        );
2323    }
2324
2325    // ── multi-commit and PR generation paths ──────────────────────
2326
2327    /// Creates a repo view with two small commits (fits budget without split dispatch).
2328    fn make_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2329        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2330        use crate::git::commit::FileChanges;
2331        use crate::git::{CommitAnalysis, CommitInfo};
2332
2333        let diff_a = dir.path().join("0.diff");
2334        let diff_b = dir.path().join("1.diff");
2335        std::fs::write(&diff_a, "+line a\n").unwrap();
2336        std::fs::write(&diff_b, "+line b\n").unwrap();
2337
2338        let hash_a = "a".repeat(40);
2339        let hash_b = "b".repeat(40);
2340
2341        crate::data::RepositoryView {
2342            versions: None,
2343            explanation: FieldExplanation::default(),
2344            working_directory: WorkingDirectoryInfo {
2345                clean: true,
2346                untracked_changes: Vec::new(),
2347            },
2348            remotes: Vec::new(),
2349            ai: AiInfo {
2350                scratch: String::new(),
2351            },
2352            branch_info: None,
2353            pr_template: None,
2354            pr_template_location: None,
2355            branch_prs: None,
2356            commits: vec![
2357                CommitInfo {
2358                    hash: hash_a,
2359                    author: "Test <test@test.com>".to_string(),
2360                    date: chrono::Utc::now().fixed_offset(),
2361                    original_message: "feat(a): add a".to_string(),
2362                    in_main_branches: Vec::new(),
2363                    analysis: CommitAnalysis {
2364                        detected_type: "feat".to_string(),
2365                        detected_scope: "a".to_string(),
2366                        proposed_message: "feat(a): add a".to_string(),
2367                        file_changes: FileChanges {
2368                            total_files: 1,
2369                            files_added: 1,
2370                            files_deleted: 0,
2371                            file_list: Vec::new(),
2372                        },
2373                        diff_summary: "a.rs | 1 +".to_string(),
2374                        diff_file: diff_a.to_string_lossy().to_string(),
2375                        file_diffs: Vec::new(),
2376                    },
2377                },
2378                CommitInfo {
2379                    hash: hash_b,
2380                    author: "Test <test@test.com>".to_string(),
2381                    date: chrono::Utc::now().fixed_offset(),
2382                    original_message: "feat(b): add b".to_string(),
2383                    in_main_branches: Vec::new(),
2384                    analysis: CommitAnalysis {
2385                        detected_type: "feat".to_string(),
2386                        detected_scope: "b".to_string(),
2387                        proposed_message: "feat(b): add b".to_string(),
2388                        file_changes: FileChanges {
2389                            total_files: 1,
2390                            files_added: 1,
2391                            files_deleted: 0,
2392                            file_list: Vec::new(),
2393                        },
2394                        diff_summary: "b.rs | 1 +".to_string(),
2395                        diff_file: diff_b.to_string_lossy().to_string(),
2396                        file_diffs: Vec::new(),
2397                    },
2398                },
2399            ],
2400        }
2401    }
2402
2403    #[tokio::test]
2404    async fn generate_amendments_multi_commit() {
2405        let dir = tempfile::tempdir().unwrap();
2406        let repo_view = make_multi_commit_repo_view(&dir);
2407        let hash_a = "a".repeat(40);
2408        let hash_b = "b".repeat(40);
2409
2410        let response = format!(
2411            concat!(
2412                "amendments:\n",
2413                "  - commit: \"{hash_a}\"\n",
2414                "    message: \"feat(a): improved a\"\n",
2415                "  - commit: \"{hash_b}\"\n",
2416                "    message: \"feat(b): improved b\"\n",
2417            ),
2418            hash_a = hash_a,
2419            hash_b = hash_b,
2420        );
2421        let client = make_configurable_client(vec![Ok(response)]);
2422
2423        let result = client
2424            .generate_amendments_with_options(&repo_view, false)
2425            .await;
2426
2427        assert!(
2428            result.is_ok(),
2429            "multi-commit amendment failed: {:?}",
2430            result.err()
2431        );
2432        let amendments = result.unwrap();
2433        assert_eq!(amendments.amendments.len(), 2);
2434    }
2435
2436    #[tokio::test]
2437    async fn generate_contextual_amendments_multi_commit() {
2438        let dir = tempfile::tempdir().unwrap();
2439        let repo_view = make_multi_commit_repo_view(&dir);
2440        let hash_a = "a".repeat(40);
2441        let hash_b = "b".repeat(40);
2442
2443        let response = format!(
2444            concat!(
2445                "amendments:\n",
2446                "  - commit: \"{hash_a}\"\n",
2447                "    message: \"feat(a): improved a\"\n",
2448                "  - commit: \"{hash_b}\"\n",
2449                "    message: \"feat(b): improved b\"\n",
2450            ),
2451            hash_a = hash_a,
2452            hash_b = hash_b,
2453        );
2454        let client = make_configurable_client(vec![Ok(response)]);
2455        let context = crate::data::context::CommitContext::default();
2456
2457        let result = client
2458            .generate_contextual_amendments_with_options(&repo_view, &context, false)
2459            .await;
2460
2461        assert!(
2462            result.is_ok(),
2463            "multi-commit contextual amendment failed: {:?}",
2464            result.err()
2465        );
2466        let amendments = result.unwrap();
2467        assert_eq!(amendments.amendments.len(), 2);
2468    }
2469
2470    #[tokio::test]
2471    async fn generate_pr_content_succeeds() {
2472        let dir = tempfile::tempdir().unwrap();
2473        let repo_view = make_test_repo_view(&dir);
2474
2475        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2476        let client = make_configurable_client(vec![Ok(response.to_string())]);
2477
2478        let result = client.generate_pr_content(&repo_view, "").await;
2479
2480        assert!(result.is_ok(), "PR generation failed: {:?}", result.err());
2481        let pr = result.unwrap();
2482        assert_eq!(pr.title, "feat: add something");
2483        assert_eq!(pr.description, "Adds a new feature.");
2484    }
2485
2486    #[tokio::test]
2487    async fn generate_pr_content_with_context_succeeds() {
2488        let dir = tempfile::tempdir().unwrap();
2489        let repo_view = make_test_repo_view(&dir);
2490        let context = crate::data::context::CommitContext::default();
2491
2492        let response = "title: \"feat: add something\"\ndescription: \"Adds a new feature.\"\n";
2493        let client = make_configurable_client(vec![Ok(response.to_string())]);
2494
2495        let result = client
2496            .generate_pr_content_with_context(&repo_view, "", &context)
2497            .await;
2498
2499        assert!(
2500            result.is_ok(),
2501            "PR generation with context failed: {:?}",
2502            result.err()
2503        );
2504        let pr = result.unwrap();
2505        assert_eq!(pr.title, "feat: add something");
2506    }
2507
2508    #[tokio::test]
2509    async fn check_commits_multi_commit() {
2510        let dir = tempfile::tempdir().unwrap();
2511        let repo_view = make_multi_commit_repo_view(&dir);
2512        let hash_a = "a".repeat(40);
2513        let hash_b = "b".repeat(40);
2514
2515        let response = format!(
2516            concat!(
2517                "checks:\n",
2518                "  - commit: \"{hash_a}\"\n",
2519                "    passes: true\n",
2520                "    issues: []\n",
2521                "  - commit: \"{hash_b}\"\n",
2522                "    passes: true\n",
2523                "    issues: []\n",
2524            ),
2525            hash_a = hash_a,
2526            hash_b = hash_b,
2527        );
2528        let client = make_configurable_client(vec![Ok(response)]);
2529
2530        let result = client
2531            .check_commits_with_scopes(&repo_view, None, &[], false)
2532            .await;
2533
2534        assert!(
2535            result.is_ok(),
2536            "multi-commit check failed: {:?}",
2537            result.err()
2538        );
2539        let report = result.unwrap();
2540        assert_eq!(report.commits.len(), 2);
2541        assert!(report.commits[0].passes);
2542        assert!(report.commits[1].passes);
2543    }
2544
2545    // ── Multi-commit split dispatch helpers ──────────────────────────
2546
2547    /// Creates a repo view with two large-diff commits whose combined view
2548    /// exceeds the constrained 25KB context window.
2549    fn make_large_multi_commit_repo_view(dir: &tempfile::TempDir) -> crate::data::RepositoryView {
2550        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2551        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2552        use crate::git::{CommitAnalysis, CommitInfo};
2553
2554        let hash_a = "a".repeat(40);
2555        let hash_b = "b".repeat(40);
2556
2557        // Write flat diff files large enough to bust the 50K-token budget when combined.
2558        // Each 60k chars ≈ 28,800 tokens; combined ≈ 57,600 > 41,808 available.
2559        let diff_content_a = "x".repeat(60_000);
2560        let diff_content_b = "y".repeat(60_000);
2561        let flat_a = dir.path().join("flat_a.diff");
2562        let flat_b = dir.path().join("flat_b.diff");
2563        std::fs::write(&flat_a, &diff_content_a).unwrap();
2564        std::fs::write(&flat_b, &diff_content_b).unwrap();
2565
2566        // Write per-file diff files for split dispatch
2567        let file_diff_a = format!("diff --git a/src/a.rs b/src/a.rs\n{}\n", "a".repeat(30_000));
2568        let file_diff_b = format!("diff --git a/src/b.rs b/src/b.rs\n{}\n", "b".repeat(30_000));
2569        let per_file_a = dir.path().join("pf_a.diff");
2570        let per_file_b = dir.path().join("pf_b.diff");
2571        std::fs::write(&per_file_a, &file_diff_a).unwrap();
2572        std::fs::write(&per_file_b, &file_diff_b).unwrap();
2573
2574        crate::data::RepositoryView {
2575            versions: None,
2576            explanation: FieldExplanation::default(),
2577            working_directory: WorkingDirectoryInfo {
2578                clean: true,
2579                untracked_changes: Vec::new(),
2580            },
2581            remotes: Vec::new(),
2582            ai: AiInfo {
2583                scratch: String::new(),
2584            },
2585            branch_info: None,
2586            pr_template: None,
2587            pr_template_location: None,
2588            branch_prs: None,
2589            commits: vec![
2590                CommitInfo {
2591                    hash: hash_a,
2592                    author: "Test <test@test.com>".to_string(),
2593                    date: chrono::Utc::now().fixed_offset(),
2594                    original_message: "feat(a): add module a".to_string(),
2595                    in_main_branches: Vec::new(),
2596                    analysis: CommitAnalysis {
2597                        detected_type: "feat".to_string(),
2598                        detected_scope: "a".to_string(),
2599                        proposed_message: "feat(a): add module a".to_string(),
2600                        file_changes: FileChanges {
2601                            total_files: 1,
2602                            files_added: 1,
2603                            files_deleted: 0,
2604                            file_list: vec![FileChange {
2605                                status: "A".to_string(),
2606                                file: "src/a.rs".to_string(),
2607                            }],
2608                        },
2609                        diff_summary: " src/a.rs | 100 ++++\n".to_string(),
2610                        diff_file: flat_a.to_string_lossy().to_string(),
2611                        file_diffs: vec![FileDiffRef {
2612                            path: "src/a.rs".to_string(),
2613                            diff_file: per_file_a.to_string_lossy().to_string(),
2614                            byte_len: file_diff_a.len(),
2615                        }],
2616                    },
2617                },
2618                CommitInfo {
2619                    hash: hash_b,
2620                    author: "Test <test@test.com>".to_string(),
2621                    date: chrono::Utc::now().fixed_offset(),
2622                    original_message: "feat(b): add module b".to_string(),
2623                    in_main_branches: Vec::new(),
2624                    analysis: CommitAnalysis {
2625                        detected_type: "feat".to_string(),
2626                        detected_scope: "b".to_string(),
2627                        proposed_message: "feat(b): add module b".to_string(),
2628                        file_changes: FileChanges {
2629                            total_files: 1,
2630                            files_added: 1,
2631                            files_deleted: 0,
2632                            file_list: vec![FileChange {
2633                                status: "A".to_string(),
2634                                file: "src/b.rs".to_string(),
2635                            }],
2636                        },
2637                        diff_summary: " src/b.rs | 100 ++++\n".to_string(),
2638                        diff_file: flat_b.to_string_lossy().to_string(),
2639                        file_diffs: vec![FileDiffRef {
2640                            path: "src/b.rs".to_string(),
2641                            diff_file: per_file_b.to_string_lossy().to_string(),
2642                            byte_len: file_diff_b.len(),
2643                        }],
2644                    },
2645                },
2646            ],
2647        }
2648    }
2649
2650    fn valid_pr_yaml(title: &str, description: &str) -> String {
2651        format!("title: \"{title}\"\ndescription: \"{description}\"\n")
2652    }
2653
2654    // ── Multi-commit amendment split dispatch tests ──────────────────
2655
2656    #[tokio::test]
2657    async fn generate_amendments_multi_commit_split_dispatch() {
2658        let dir = tempfile::tempdir().unwrap();
2659        let repo_view = make_large_multi_commit_repo_view(&dir);
2660        let hash_a = "a".repeat(40);
2661        let hash_b = "b".repeat(40);
2662
2663        // Full view exceeds budget → per-commit fallback
2664        // Each commit fits individually (1 file each) → 1 response per commit
2665        let (client, handle) = make_small_context_client_tracked(vec![
2666            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
2667            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
2668        ]);
2669
2670        let result = client
2671            .generate_amendments_with_options(&repo_view, false)
2672            .await;
2673
2674        assert!(
2675            result.is_ok(),
2676            "multi-commit split dispatch failed: {:?}",
2677            result.err()
2678        );
2679        let amendments = result.unwrap();
2680        assert_eq!(amendments.amendments.len(), 2);
2681        assert_eq!(amendments.amendments[0].commit, hash_a);
2682        assert_eq!(amendments.amendments[1].commit, hash_b);
2683        assert!(amendments.amendments[0].message.contains("improved a"));
2684        assert!(amendments.amendments[1].message.contains("improved b"));
2685        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2686    }
2687
2688    #[tokio::test]
2689    async fn generate_contextual_amendments_multi_commit_split_dispatch() {
2690        let dir = tempfile::tempdir().unwrap();
2691        let repo_view = make_large_multi_commit_repo_view(&dir);
2692        let hash_a = "a".repeat(40);
2693        let hash_b = "b".repeat(40);
2694        let context = crate::data::context::CommitContext::default();
2695
2696        let (client, handle) = make_small_context_client_tracked(vec![
2697            Ok(valid_amendment_yaml(&hash_a, "feat(a): improved a")),
2698            Ok(valid_amendment_yaml(&hash_b, "feat(b): improved b")),
2699        ]);
2700
2701        let result = client
2702            .generate_contextual_amendments_with_options(&repo_view, &context, false)
2703            .await;
2704
2705        assert!(
2706            result.is_ok(),
2707            "multi-commit contextual split dispatch failed: {:?}",
2708            result.err()
2709        );
2710        let amendments = result.unwrap();
2711        assert_eq!(amendments.amendments.len(), 2);
2712        assert_eq!(amendments.amendments[0].commit, hash_a);
2713        assert_eq!(amendments.amendments[1].commit, hash_b);
2714        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2715    }
2716
2717    // ── Multi-commit check split dispatch tests ──────────────────────
2718
2719    #[tokio::test]
2720    async fn check_commits_multi_commit_split_dispatch() {
2721        let dir = tempfile::tempdir().unwrap();
2722        let repo_view = make_large_multi_commit_repo_view(&dir);
2723        let hash_a = "a".repeat(40);
2724        let hash_b = "b".repeat(40);
2725
2726        // Full view exceeds budget → per-commit fallback
2727        let (client, handle) = make_small_context_client_tracked(vec![
2728            Ok(valid_check_yaml_for(&hash_a, true)),
2729            Ok(valid_check_yaml_for(&hash_b, true)),
2730        ]);
2731
2732        let result = client
2733            .check_commits_with_scopes(&repo_view, None, &[], false)
2734            .await;
2735
2736        assert!(
2737            result.is_ok(),
2738            "multi-commit check split dispatch failed: {:?}",
2739            result.err()
2740        );
2741        let report = result.unwrap();
2742        assert_eq!(report.commits.len(), 2);
2743        assert!(report.commits[0].passes);
2744        assert!(report.commits[1].passes);
2745        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2746    }
2747
2748    // ── PR split dispatch tests ──────────────────────────────────────
2749
2750    #[tokio::test]
2751    async fn generate_pr_content_split_dispatch() {
2752        let dir = tempfile::tempdir().unwrap();
2753        let repo_view = make_large_diff_repo_view(&dir);
2754
2755        // Single large commit: full view exceeds budget → per-commit fallback
2756        // 1 commit with 2 file chunks → chunk 1 + chunk 2 + chunk merge pass
2757        // Single per-commit result → returned directly (no extra merge)
2758        let (client, handle) = make_small_context_client_tracked(vec![
2759            Ok(valid_pr_yaml("feat(a): add a.rs", "Adds a.rs module")),
2760            Ok(valid_pr_yaml("feat(b): add b.rs", "Adds b.rs module")),
2761            Ok(valid_pr_yaml(
2762                "feat(test): add modules",
2763                "Adds a.rs and b.rs",
2764            )),
2765        ]);
2766
2767        let result = client.generate_pr_content(&repo_view, "").await;
2768
2769        assert!(
2770            result.is_ok(),
2771            "PR split dispatch failed: {:?}",
2772            result.err()
2773        );
2774        let pr = result.unwrap();
2775        assert!(pr.title.contains("add modules"));
2776        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2777    }
2778
2779    #[tokio::test]
2780    async fn generate_pr_content_multi_commit_split_dispatch() {
2781        let dir = tempfile::tempdir().unwrap();
2782        let repo_view = make_large_multi_commit_repo_view(&dir);
2783
2784        // Full view exceeds budget → per-commit fallback
2785        // Each commit fits individually → 1 response per commit, then merge pass
2786        let (client, handle) = make_small_context_client_tracked(vec![
2787            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
2788            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
2789            Ok(valid_pr_yaml(
2790                "feat: add modules a and b",
2791                "Adds both modules",
2792            )),
2793        ]);
2794
2795        let result = client.generate_pr_content(&repo_view, "").await;
2796
2797        assert!(
2798            result.is_ok(),
2799            "PR multi-commit split dispatch failed: {:?}",
2800            result.err()
2801        );
2802        let pr = result.unwrap();
2803        assert!(pr.title.contains("modules"));
2804        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2805    }
2806
2807    #[tokio::test]
2808    async fn generate_pr_content_with_context_split_dispatch() {
2809        let dir = tempfile::tempdir().unwrap();
2810        let repo_view = make_large_multi_commit_repo_view(&dir);
2811        let context = crate::data::context::CommitContext::default();
2812
2813        // Full view exceeds budget → per-commit fallback → merge pass
2814        let (client, handle) = make_small_context_client_tracked(vec![
2815            Ok(valid_pr_yaml("feat(a): add module a", "Adds module a")),
2816            Ok(valid_pr_yaml("feat(b): add module b", "Adds module b")),
2817            Ok(valid_pr_yaml(
2818                "feat: add modules a and b",
2819                "Adds both modules",
2820            )),
2821        ]);
2822
2823        let result = client
2824            .generate_pr_content_with_context(&repo_view, "", &context)
2825            .await;
2826
2827        assert!(
2828            result.is_ok(),
2829            "PR with context split dispatch failed: {:?}",
2830            result.err()
2831        );
2832        let pr = result.unwrap();
2833        assert!(pr.title.contains("modules"));
2834        assert_eq!(handle.remaining(), 0, "expected all responses consumed");
2835    }
2836
2837    // ── prompt-recording split dispatch tests ────────────────────
2838
2839    /// Like [`make_small_context_client_tracked`] but also returns a
2840    /// [`PromptRecordHandle`] for inspecting which prompts were sent.
2841    fn make_small_context_client_with_prompts(
2842        responses: Vec<Result<String>>,
2843    ) -> (
2844        ClaudeClient,
2845        crate::claude::test_utils::ResponseQueueHandle,
2846        crate::claude::test_utils::PromptRecordHandle,
2847    ) {
2848        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses)
2849            .with_context_length(50_000);
2850        let response_handle = mock.response_handle();
2851        let prompt_handle = mock.prompt_handle();
2852        (
2853            ClaudeClient::new(Box::new(mock)),
2854            response_handle,
2855            prompt_handle,
2856        )
2857    }
2858
2859    /// Creates a default-context mock client that also records prompts.
2860    fn make_configurable_client_with_prompts(
2861        responses: Vec<Result<String>>,
2862    ) -> (
2863        ClaudeClient,
2864        crate::claude::test_utils::ResponseQueueHandle,
2865        crate::claude::test_utils::PromptRecordHandle,
2866    ) {
2867        let mock = crate::claude::test_utils::ConfigurableMockAiClient::new(responses);
2868        let response_handle = mock.response_handle();
2869        let prompt_handle = mock.prompt_handle();
2870        (
2871            ClaudeClient::new(Box::new(mock)),
2872            response_handle,
2873            prompt_handle,
2874        )
2875    }
2876
2877    /// Creates a repo view with one commit containing a single large file
2878    /// whose diff exceeds the token budget. Because the per-file diff is
2879    /// loaded as a whole (hunk-level granularity from the packer is lost
2880    /// at the dispatch layer), the split dispatch path will fail with a
2881    /// budget error. This helper exists to test that the error propagates
2882    /// cleanly rather than silently degrading.
2883    fn make_single_oversized_file_repo_view(
2884        dir: &tempfile::TempDir,
2885    ) -> crate::data::RepositoryView {
2886        use crate::data::{AiInfo, FieldExplanation, WorkingDirectoryInfo};
2887        use crate::git::commit::{FileChange, FileChanges, FileDiffRef};
2888        use crate::git::{CommitAnalysis, CommitInfo};
2889
2890        let hash = "c".repeat(40);
2891
2892        // A single file diff large enough (~80K bytes ≈ 25K tokens) to
2893        // exceed the 25K context window budget even for a single chunk.
2894        let diff_content = format!(
2895            "diff --git a/src/big.rs b/src/big.rs\n{}\n",
2896            "x".repeat(80_000)
2897        );
2898
2899        let flat_diff_path = dir.path().join("full.diff");
2900        std::fs::write(&flat_diff_path, &diff_content).unwrap();
2901
2902        let per_file_path = dir.path().join("0000.diff");
2903        std::fs::write(&per_file_path, &diff_content).unwrap();
2904
2905        crate::data::RepositoryView {
2906            versions: None,
2907            explanation: FieldExplanation::default(),
2908            working_directory: WorkingDirectoryInfo {
2909                clean: true,
2910                untracked_changes: Vec::new(),
2911            },
2912            remotes: Vec::new(),
2913            ai: AiInfo {
2914                scratch: String::new(),
2915            },
2916            branch_info: None,
2917            pr_template: None,
2918            pr_template_location: None,
2919            branch_prs: None,
2920            commits: vec![CommitInfo {
2921                hash,
2922                author: "Test <test@test.com>".to_string(),
2923                date: chrono::Utc::now().fixed_offset(),
2924                original_message: "feat(big): add large module".to_string(),
2925                in_main_branches: Vec::new(),
2926                analysis: CommitAnalysis {
2927                    detected_type: "feat".to_string(),
2928                    detected_scope: "big".to_string(),
2929                    proposed_message: "feat(big): add large module".to_string(),
2930                    file_changes: FileChanges {
2931                        total_files: 1,
2932                        files_added: 1,
2933                        files_deleted: 0,
2934                        file_list: vec![FileChange {
2935                            status: "A".to_string(),
2936                            file: "src/big.rs".to_string(),
2937                        }],
2938                    },
2939                    diff_summary: " src/big.rs | 80 ++++\n".to_string(),
2940                    diff_file: flat_diff_path.to_string_lossy().to_string(),
2941                    file_diffs: vec![FileDiffRef {
2942                        path: "src/big.rs".to_string(),
2943                        diff_file: per_file_path.to_string_lossy().to_string(),
2944                        byte_len: diff_content.len(),
2945                    }],
2946                },
2947            }],
2948        }
2949    }
2950
2951    /// A small single-file commit whose diff fits within the token budget.
2952    ///
2953    /// Exercises the non-split path: `generate_amendments_with_options` →
2954    /// `try_full_diff_budget` succeeds → single AI request → amendment
2955    /// returned directly. Verifies exactly one request is made and the
2956    /// user prompt contains the actual diff content.
2957    #[tokio::test]
2958    async fn amendment_single_file_under_budget_no_split() {
2959        let dir = tempfile::tempdir().unwrap();
2960        let repo_view = make_test_repo_view(&dir);
2961        let hash = format!("{:0>40}", 0);
2962
2963        let (client, response_handle, prompt_handle) =
2964            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
2965                &hash,
2966                "feat(test): improved message",
2967            ))]);
2968
2969        let result = client
2970            .generate_amendments_with_options(&repo_view, false)
2971            .await;
2972
2973        assert!(result.is_ok());
2974        assert_eq!(result.unwrap().amendments.len(), 1);
2975        assert_eq!(response_handle.remaining(), 0);
2976
2977        let prompts = prompt_handle.prompts();
2978        assert_eq!(
2979            prompts.len(),
2980            1,
2981            "expected exactly one AI request, no split"
2982        );
2983
2984        let (_, user_prompt) = &prompts[0];
2985        assert!(
2986            user_prompt.contains("added line"),
2987            "user prompt should contain the diff content"
2988        );
2989    }
2990
2991    /// A two-file commit that exceeds the token budget when combined.
2992    ///
2993    /// Exercises the file-level split path: `generate_amendments_with_options`
2994    /// → `try_full_diff_budget` fails → `generate_amendment_for_commit` →
2995    /// `try_full_diff_budget` fails again → `generate_amendment_split` →
2996    /// `pack_file_diffs` creates 2 chunks (one file each) → 2 AI requests
2997    /// → `merge_amendment_chunks` reduce pass → 1 merged amendment.
2998    ///
2999    /// Verifies that each chunk's user prompt contains only its file's diff
3000    /// content, and the merge prompt contains both partial amendment messages.
3001    #[tokio::test]
3002    async fn amendment_two_chunks_prompt_content() {
3003        let dir = tempfile::tempdir().unwrap();
3004        let repo_view = make_large_diff_repo_view(&dir);
3005        let hash = "a".repeat(40);
3006
3007        let (client, response_handle, prompt_handle) =
3008            make_small_context_client_with_prompts(vec![
3009                Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3010                Ok(valid_amendment_yaml(&hash, "feat(b): add b.rs")),
3011                Ok(valid_amendment_yaml(&hash, "feat(test): add a.rs and b.rs")),
3012            ]);
3013
3014        let result = client
3015            .generate_amendments_with_options(&repo_view, false)
3016            .await;
3017
3018        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3019        let amendments = result.unwrap();
3020        assert_eq!(amendments.amendments.len(), 1);
3021        assert!(amendments.amendments[0]
3022            .message
3023            .contains("add a.rs and b.rs"));
3024        assert_eq!(response_handle.remaining(), 0);
3025
3026        let prompts = prompt_handle.prompts();
3027        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge = 3 requests");
3028
3029        // Chunk 1 should contain file-a diff content (repeated 'a' chars)
3030        let (_, chunk1_user) = &prompts[0];
3031        assert!(
3032            chunk1_user.contains("aaa"),
3033            "chunk 1 prompt should contain file-a diff content"
3034        );
3035
3036        // Chunk 2 should contain file-b diff content (repeated 'b' chars)
3037        let (_, chunk2_user) = &prompts[1];
3038        assert!(
3039            chunk2_user.contains("bbb"),
3040            "chunk 2 prompt should contain file-b diff content"
3041        );
3042
3043        // Merge pass: system prompt is the synthesis prompt
3044        let (merge_sys, merge_user) = &prompts[2];
3045        assert!(
3046            merge_sys.contains("synthesiz"),
3047            "merge system prompt should contain synthesis instructions"
3048        );
3049        // Merge user prompt should contain both partial messages
3050        assert!(
3051            merge_user.contains("feat(a): add a.rs") && merge_user.contains("feat(b): add b.rs"),
3052            "merge user prompt should contain both partial amendment messages"
3053        );
3054    }
3055
3056    /// A single file whose diff exceeds the budget even after split dispatch.
3057    ///
3058    /// Exercises the budget-error path: `generate_amendment_for_commit` →
3059    /// budget exceeded → `generate_amendment_split` → `pack_file_diffs`
3060    /// plans hunk-level chunks → but `from_commit_info_partial` loads the
3061    /// full per-file diff (deduplicates the repeated path) →
3062    /// Oversized files that can't be split get placeholders and proceed.
3063    ///
3064    /// Verifies that files too large for the budget are replaced with
3065    /// placeholder text indicating the file was omitted, rather than
3066    /// failing with a "prompt too large" error.
3067    #[tokio::test]
3068    async fn amendment_single_oversized_file_gets_placeholder() {
3069        let dir = tempfile::tempdir().unwrap();
3070        let repo_view = make_single_oversized_file_repo_view(&dir);
3071        let hash = "c".repeat(40);
3072
3073        // The file is too large for the full budget but gets a placeholder.
3074        // With 50k context, the placeholder is small enough to fit in a
3075        // single request. Provide a second response in case the system prompt
3076        // is large enough to trigger split dispatch.
3077        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3078            Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3079            Ok(valid_amendment_yaml(&hash, "feat(big): add large module")),
3080        ]);
3081
3082        let result = client
3083            .generate_amendments_with_options(&repo_view, false)
3084            .await;
3085
3086        // Should succeed (either single request or split with placeholder)
3087        assert!(
3088            result.is_ok(),
3089            "expected success with placeholder, got: {result:?}"
3090        );
3091
3092        // One request (placeholder makes it fit in single request)
3093        assert!(
3094            prompt_handle.request_count() >= 1,
3095            "expected at least 1 request, got {}",
3096            prompt_handle.request_count()
3097        );
3098    }
3099
3100    /// A two-chunk split where the second chunk's AI request fails.
3101    ///
3102    /// Exercises the error-propagation path within `generate_amendment_split`:
3103    /// chunk 1 succeeds → chunk 2 returns `Err` → the `?` operator in the
3104    /// loop body propagates the error immediately, skipping the merge pass.
3105    ///
3106    /// Verifies that exactly 2 requests are recorded (no further processing)
3107    /// and the overall result is `Err` (no silent degradation).
3108    #[tokio::test]
3109    async fn amendment_chunk_failure_stops_dispatch() {
3110        let dir = tempfile::tempdir().unwrap();
3111        let repo_view = make_large_diff_repo_view(&dir);
3112        let hash = "a".repeat(40);
3113
3114        // First chunk succeeds, second chunk fails
3115        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3116            Ok(valid_amendment_yaml(&hash, "feat(a): add a.rs")),
3117            Err(anyhow::anyhow!("rate limit exceeded")),
3118        ]);
3119
3120        let result = client
3121            .generate_amendments_with_options(&repo_view, false)
3122            .await;
3123
3124        assert!(result.is_err());
3125
3126        // Exactly 2 requests: chunk 1 (success) + chunk 2 (failure)
3127        let prompts = prompt_handle.prompts();
3128        assert_eq!(
3129            prompts.len(),
3130            2,
3131            "should stop after the failing chunk, got {} requests",
3132            prompts.len()
3133        );
3134
3135        // The first request should reference one of the files
3136        let (_, first_user) = &prompts[0];
3137        assert!(
3138            first_user.contains("src/a.rs") || first_user.contains("src/b.rs"),
3139            "first chunk prompt should reference a file"
3140        );
3141    }
3142
3143    /// Two-chunk amendment split dispatch, focused on the reduce pass inputs.
3144    ///
3145    /// Exercises `merge_amendment_chunks` which calls
3146    /// `generate_chunk_merge_user_prompt` to assemble the merge prompt from:
3147    /// the commit hash, original message, diff_summary, and the partial
3148    /// amendment messages returned by each chunk.
3149    ///
3150    /// Verifies that the merge (3rd) request's user prompt contains all of:
3151    /// both partial messages, the original commit message, the diff_summary
3152    /// file paths, and the commit hash.
3153    #[tokio::test]
3154    async fn amendment_reduce_pass_prompt_content() {
3155        let dir = tempfile::tempdir().unwrap();
3156        let repo_view = make_large_diff_repo_view(&dir);
3157        let hash = "a".repeat(40);
3158
3159        let (client, _, prompt_handle) = make_small_context_client_with_prompts(vec![
3160            Ok(valid_amendment_yaml(
3161                &hash,
3162                "feat(a): add module a implementation",
3163            )),
3164            Ok(valid_amendment_yaml(
3165                &hash,
3166                "feat(b): add module b implementation",
3167            )),
3168            Ok(valid_amendment_yaml(
3169                &hash,
3170                "feat(test): add modules a and b",
3171            )),
3172        ]);
3173
3174        let result = client
3175            .generate_amendments_with_options(&repo_view, false)
3176            .await;
3177
3178        assert!(result.is_ok());
3179
3180        let prompts = prompt_handle.prompts();
3181        assert_eq!(prompts.len(), 3);
3182
3183        // The merge pass is the last (3rd) request
3184        let (merge_system, merge_user) = &prompts[2];
3185
3186        // System prompt should be the amendment chunk merge prompt
3187        assert!(
3188            merge_system.contains("synthesiz"),
3189            "merge system prompt should contain synthesis instructions"
3190        );
3191
3192        // User prompt should contain the partial messages from chunks
3193        assert!(
3194            merge_user.contains("feat(a): add module a implementation"),
3195            "merge user prompt should contain chunk 1's partial message"
3196        );
3197        assert!(
3198            merge_user.contains("feat(b): add module b implementation"),
3199            "merge user prompt should contain chunk 2's partial message"
3200        );
3201
3202        // User prompt should contain the original commit message
3203        assert!(
3204            merge_user.contains("feat(test): large commit"),
3205            "merge user prompt should contain the original commit message"
3206        );
3207
3208        // User prompt should contain the diff_summary referencing both files
3209        assert!(
3210            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3211            "merge user prompt should contain the diff_summary"
3212        );
3213
3214        // User prompt should reference the commit hash
3215        assert!(
3216            merge_user.contains(&hash),
3217            "merge user prompt should reference the commit hash"
3218        );
3219    }
3220
3221    /// Two-chunk check split dispatch with issue deduplication and merge.
3222    ///
3223    /// Exercises `check_commit_split` which:
3224    /// 1. Dispatches 2 chunk requests (one per file)
3225    /// 2. Collects issues from both chunks into a `HashSet` keyed by
3226    ///    `(rule, severity, section)` — duplicates are dropped
3227    /// 3. Detects that both chunks have suggestions → calls
3228    ///    `merge_check_chunks` for the AI reduce pass
3229    ///
3230    /// Chunk 1 reports: `error:subject-too-long:Subject Line` +
3231    ///                   `warning:body-required:Content`
3232    /// Chunk 2 reports: `error:subject-too-long:Subject Line` (duplicate) +
3233    ///                   `info:scope-suggestion:Style` (new)
3234    ///
3235    /// Verifies: 3 unique issues after dedup, suggestion from merge pass,
3236    /// and the merge prompt contains both partial suggestions + diff_summary.
3237    #[tokio::test]
3238    async fn check_split_dedup_and_merge_prompt() {
3239        let dir = tempfile::tempdir().unwrap();
3240        let repo_view = make_large_diff_repo_view(&dir);
3241        let hash = "a".repeat(40);
3242
3243        // Chunk 1: error (subject-too-long) + warning (body-required) + suggestion
3244        let chunk1_yaml = format!(
3245            concat!(
3246                "checks:\n",
3247                "  - commit: \"{hash}\"\n",
3248                "    passes: false\n",
3249                "    issues:\n",
3250                "      - severity: error\n",
3251                "        section: \"Subject Line\"\n",
3252                "        rule: \"subject-too-long\"\n",
3253                "        explanation: \"Subject exceeds 72 characters\"\n",
3254                "      - severity: warning\n",
3255                "        section: \"Content\"\n",
3256                "        rule: \"body-required\"\n",
3257                "        explanation: \"Large change needs body\"\n",
3258                "    suggestion:\n",
3259                "      message: \"feat(a): shorter subject for a\"\n",
3260                "      explanation: \"Shortened subject for file a\"\n",
3261                "    summary: \"Adds module a\"\n",
3262            ),
3263            hash = hash,
3264        );
3265
3266        // Chunk 2: same error (different explanation) + new info issue + suggestion
3267        let chunk2_yaml = format!(
3268            concat!(
3269                "checks:\n",
3270                "  - commit: \"{hash}\"\n",
3271                "    passes: false\n",
3272                "    issues:\n",
3273                "      - severity: error\n",
3274                "        section: \"Subject Line\"\n",
3275                "        rule: \"subject-too-long\"\n",
3276                "        explanation: \"Subject line is way too long\"\n",
3277                "      - severity: info\n",
3278                "        section: \"Style\"\n",
3279                "        rule: \"scope-suggestion\"\n",
3280                "        explanation: \"Consider more specific scope\"\n",
3281                "    suggestion:\n",
3282                "      message: \"feat(b): shorter subject for b\"\n",
3283                "      explanation: \"Shortened subject for file b\"\n",
3284                "    summary: \"Adds module b\"\n",
3285            ),
3286            hash = hash,
3287        );
3288
3289        // Merge pass (called because suggestions exist)
3290        let merge_yaml = format!(
3291            concat!(
3292                "checks:\n",
3293                "  - commit: \"{hash}\"\n",
3294                "    passes: false\n",
3295                "    issues: []\n",
3296                "    suggestion:\n",
3297                "      message: \"feat(test): add modules a and b\"\n",
3298                "      explanation: \"Combined suggestion\"\n",
3299                "    summary: \"Adds modules a and b\"\n",
3300            ),
3301            hash = hash,
3302        );
3303
3304        let (client, response_handle, prompt_handle) =
3305            make_small_context_client_with_prompts(vec![
3306                Ok(chunk1_yaml),
3307                Ok(chunk2_yaml),
3308                Ok(merge_yaml),
3309            ]);
3310
3311        let result = client
3312            .check_commits_with_scopes(&repo_view, None, &[], true)
3313            .await;
3314
3315        assert!(result.is_ok(), "split dispatch failed: {:?}", result.err());
3316        let report = result.unwrap();
3317        assert_eq!(report.commits.len(), 1);
3318        assert!(!report.commits[0].passes);
3319        assert_eq!(response_handle.remaining(), 0);
3320
3321        // Dedup: 3 unique (rule, severity, section) tuples
3322        //  - subject-too-long / error / Subject Line   (appears in both → deduped)
3323        //  - body-required    / warning / Content
3324        //  - scope-suggestion / info / Style
3325        assert_eq!(
3326            report.commits[0].issues.len(),
3327            3,
3328            "expected 3 unique issues after dedup, got {:?}",
3329            report.commits[0]
3330                .issues
3331                .iter()
3332                .map(|i| &i.rule)
3333                .collect::<Vec<_>>()
3334        );
3335
3336        // Suggestion should come from the merge pass
3337        assert!(report.commits[0].suggestion.is_some());
3338        assert!(
3339            report.commits[0]
3340                .suggestion
3341                .as_ref()
3342                .unwrap()
3343                .message
3344                .contains("add modules a and b"),
3345            "suggestion should come from the merge pass"
3346        );
3347
3348        // Prompt content assertions
3349        let prompts = prompt_handle.prompts();
3350        assert_eq!(prompts.len(), 3, "expected 2 chunks + 1 merge");
3351
3352        // Chunk prompts should collectively cover both files
3353        let (_, chunk1_user) = &prompts[0];
3354        let (_, chunk2_user) = &prompts[1];
3355        let combined_chunk_prompts = format!("{chunk1_user}{chunk2_user}");
3356        assert!(
3357            combined_chunk_prompts.contains("src/a.rs")
3358                && combined_chunk_prompts.contains("src/b.rs"),
3359            "chunk prompts should collectively cover both files"
3360        );
3361
3362        // Merge pass prompt should contain partial suggestions
3363        let (merge_sys, merge_user) = &prompts[2];
3364        assert!(
3365            merge_sys.contains("synthesiz") || merge_sys.contains("reviewer"),
3366            "merge system prompt should be the check chunk merge prompt"
3367        );
3368        assert!(
3369            merge_user.contains("feat(a): shorter subject for a")
3370                && merge_user.contains("feat(b): shorter subject for b"),
3371            "merge user prompt should contain both partial suggestions"
3372        );
3373        // Merge prompt should contain the diff_summary
3374        assert!(
3375            merge_user.contains("src/a.rs") && merge_user.contains("src/b.rs"),
3376            "merge user prompt should contain the diff_summary"
3377        );
3378    }
3379
3380    // ── Amendment retry tests ──────────────────────────────────────────
3381
3382    #[tokio::test]
3383    async fn amendment_retry_parse_failure_then_success() {
3384        let dir = tempfile::tempdir().unwrap();
3385        let repo_view = make_test_repo_view(&dir);
3386        let hash = format!("{:0>40}", 0);
3387
3388        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3389            Ok("not valid yaml {{[".to_string()),
3390            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3391        ]);
3392
3393        let result = client
3394            .generate_amendments_with_options(&repo_view, false)
3395            .await;
3396
3397        assert!(
3398            result.is_ok(),
3399            "should succeed after retry: {:?}",
3400            result.err()
3401        );
3402        assert_eq!(result.unwrap().amendments.len(), 1);
3403        assert_eq!(response_handle.remaining(), 0, "both responses consumed");
3404        assert_eq!(prompt_handle.request_count(), 2, "exactly 2 AI requests");
3405    }
3406
3407    #[tokio::test]
3408    async fn amendment_retry_request_failure_then_success() {
3409        let dir = tempfile::tempdir().unwrap();
3410        let repo_view = make_test_repo_view(&dir);
3411        let hash = format!("{:0>40}", 0);
3412
3413        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3414            Err(anyhow::anyhow!("rate limit")),
3415            Ok(valid_amendment_yaml(&hash, "feat(test): improved")),
3416        ]);
3417
3418        let result = client
3419            .generate_amendments_with_options(&repo_view, false)
3420            .await;
3421
3422        assert!(
3423            result.is_ok(),
3424            "should succeed after retry: {:?}",
3425            result.err()
3426        );
3427        assert_eq!(result.unwrap().amendments.len(), 1);
3428        assert_eq!(response_handle.remaining(), 0);
3429        assert_eq!(prompt_handle.request_count(), 2);
3430    }
3431
3432    #[tokio::test]
3433    async fn amendment_retry_all_attempts_exhausted() {
3434        let dir = tempfile::tempdir().unwrap();
3435        let repo_view = make_test_repo_view(&dir);
3436
3437        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3438            Ok("bad yaml 1".to_string()),
3439            Ok("bad yaml 2".to_string()),
3440            Ok("bad yaml 3".to_string()),
3441        ]);
3442
3443        let result = client
3444            .generate_amendments_with_options(&repo_view, false)
3445            .await;
3446
3447        assert!(result.is_err(), "should fail after all retries exhausted");
3448        assert_eq!(response_handle.remaining(), 0, "all 3 responses consumed");
3449        assert_eq!(
3450            prompt_handle.request_count(),
3451            3,
3452            "exactly 3 AI requests (1 + 2 retries)"
3453        );
3454    }
3455
3456    #[tokio::test]
3457    async fn amendment_retry_success_first_attempt() {
3458        let dir = tempfile::tempdir().unwrap();
3459        let repo_view = make_test_repo_view(&dir);
3460        let hash = format!("{:0>40}", 0);
3461
3462        let (client, response_handle, prompt_handle) =
3463            make_configurable_client_with_prompts(vec![Ok(valid_amendment_yaml(
3464                &hash,
3465                "feat(test): works first time",
3466            ))]);
3467
3468        let result = client
3469            .generate_amendments_with_options(&repo_view, false)
3470            .await;
3471
3472        assert!(result.is_ok());
3473        assert_eq!(response_handle.remaining(), 0);
3474        assert_eq!(prompt_handle.request_count(), 1, "only 1 request, no retry");
3475    }
3476
3477    #[tokio::test]
3478    async fn amendment_retry_mixed_request_and_parse_failures() {
3479        let dir = tempfile::tempdir().unwrap();
3480        let repo_view = make_test_repo_view(&dir);
3481        let hash = format!("{:0>40}", 0);
3482
3483        let (client, response_handle, prompt_handle) = make_configurable_client_with_prompts(vec![
3484            Err(anyhow::anyhow!("network error")),
3485            Ok("invalid yaml {{".to_string()),
3486            Ok(valid_amendment_yaml(&hash, "feat(test): third time")),
3487        ]);
3488
3489        let result = client
3490            .generate_amendments_with_options(&repo_view, false)
3491            .await;
3492
3493        assert!(
3494            result.is_ok(),
3495            "should succeed on third attempt: {:?}",
3496            result.err()
3497        );
3498        assert_eq!(result.unwrap().amendments.len(), 1);
3499        assert_eq!(response_handle.remaining(), 0);
3500        assert_eq!(prompt_handle.request_count(), 3, "all 3 attempts used");
3501    }
3502}