Skip to main content

omni_dev/
data.rs

1//! Data processing and serialization.
2
3use serde::{Deserialize, Serialize};
4
5use crate::git::{CommitInfo, CommitInfoForAI, RemoteInfo};
6
7pub mod amendments;
8pub mod check;
9pub mod context;
10pub mod yaml;
11
12pub use amendments::*;
13pub use check::*;
14pub use context::*;
15pub use yaml::*;
16
17/// Complete repository view output structure.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct RepositoryView {
20    /// Version information for the omni-dev tool.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub versions: Option<VersionInfo>,
23    /// Explanation of field meanings and structure.
24    pub explanation: FieldExplanation,
25    /// Working directory status information.
26    pub working_directory: WorkingDirectoryInfo,
27    /// List of remote repositories and their main branches.
28    pub remotes: Vec<RemoteInfo>,
29    /// AI-related information.
30    pub ai: AiInfo,
31    /// Branch information (only present when using branch commands).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub branch_info: Option<BranchInfo>,
34    /// Pull request template content (only present in branch commands when template exists).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub pr_template: Option<String>,
37    /// Location of the pull request template file (only present when pr_template exists).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub pr_template_location: Option<String>,
40    /// Pull requests created from the current branch (only present in branch commands).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub branch_prs: Option<Vec<PullRequest>>,
43    /// List of analyzed commits with metadata and analysis.
44    pub commits: Vec<CommitInfo>,
45}
46
47/// Enhanced repository view for AI processing with full diff content.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct RepositoryViewForAI {
50    /// Version information for the omni-dev tool.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub versions: Option<VersionInfo>,
53    /// Explanation of field meanings and structure.
54    pub explanation: FieldExplanation,
55    /// Working directory status information.
56    pub working_directory: WorkingDirectoryInfo,
57    /// List of remote repositories and their main branches.
58    pub remotes: Vec<RemoteInfo>,
59    /// AI-related information.
60    pub ai: AiInfo,
61    /// Branch information (only present when using branch commands).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub branch_info: Option<BranchInfo>,
64    /// Pull request template content (only present in branch commands when template exists).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub pr_template: Option<String>,
67    /// Location of the pull request template file (only present when pr_template exists).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub pr_template_location: Option<String>,
70    /// Pull requests created from the current branch (only present in branch commands).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub branch_prs: Option<Vec<PullRequest>>,
73    /// List of analyzed commits with enhanced metadata including full diff content.
74    pub commits: Vec<CommitInfoForAI>,
75}
76
77/// Field explanation for the YAML output.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct FieldExplanation {
80    /// Descriptive text explaining the overall structure.
81    pub text: String,
82    /// Documentation for individual fields in the output.
83    pub fields: Vec<FieldDocumentation>,
84}
85
86/// Individual field documentation.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct FieldDocumentation {
89    /// Name of the field being documented.
90    pub name: String,
91    /// Descriptive text explaining what the field contains.
92    pub text: String,
93    /// Git command that corresponds to this field (if applicable).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub command: Option<String>,
96    /// Whether this field is present in the current output.
97    pub present: bool,
98}
99
100/// Working directory information.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct WorkingDirectoryInfo {
103    /// Whether the working directory has no changes.
104    pub clean: bool,
105    /// List of files with uncommitted changes.
106    pub untracked_changes: Vec<FileStatusInfo>,
107}
108
109/// File status information for working directory.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FileStatusInfo {
112    /// Git status flags (e.g., "AM", "??", "M ").
113    pub status: String,
114    /// Path to the file relative to repository root.
115    pub file: String,
116}
117
118/// Version information for tools and environment.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct VersionInfo {
121    /// Version of the omni-dev tool.
122    pub omni_dev: String,
123}
124
125/// AI-related information.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct AiInfo {
128    /// Path to AI scratch directory.
129    pub scratch: String,
130}
131
132/// Branch information for branch-specific commands.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct BranchInfo {
135    /// Current branch name.
136    pub branch: String,
137}
138
139/// Pull request information.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct PullRequest {
142    /// PR number.
143    pub number: u64,
144    /// PR title.
145    pub title: String,
146    /// PR state (open, closed, merged).
147    pub state: String,
148    /// PR URL.
149    pub url: String,
150    /// PR description/body content.
151    pub body: String,
152    /// Base branch the PR targets.
153    #[serde(default)]
154    pub base: String,
155}
156
157/// Level of diff detail included in the AI prompt after budget fitting.
158///
159/// When a commit's diff exceeds the model's context window, the system
160/// progressively reduces detail through these levels until the prompt fits.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub(crate) enum DiffDetail {
163    /// Full diff content included.
164    Full,
165    /// Diff content truncated to fit budget.
166    Truncated,
167    /// Only `diff --stat` summary included (no line-level diff).
168    StatOnly,
169    /// Only file list included (no diff content or summary).
170    FileListOnly,
171}
172
173impl std::fmt::Display for DiffDetail {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            Self::Full => write!(f, "full diff"),
177            Self::Truncated => write!(f, "truncated diff"),
178            Self::StatOnly => write!(f, "stat summary only"),
179            Self::FileListOnly => write!(f, "file list only"),
180        }
181    }
182}
183
184impl RepositoryView {
185    /// Updates the present field for all field documentation entries based on actual data.
186    pub fn update_field_presence(&mut self) {
187        for field in &mut self.explanation.fields {
188            field.present = match field.name.as_str() {
189                "working_directory.clean" => true,             // Always present
190                "working_directory.untracked_changes" => true, // Always present
191                "remotes" => true,                             // Always present
192                "commits[].hash" => !self.commits.is_empty(),
193                "commits[].author" => !self.commits.is_empty(),
194                "commits[].date" => !self.commits.is_empty(),
195                "commits[].original_message" => !self.commits.is_empty(),
196                "commits[].in_main_branches" => !self.commits.is_empty(),
197                "commits[].analysis.detected_type" => !self.commits.is_empty(),
198                "commits[].analysis.detected_scope" => !self.commits.is_empty(),
199                "commits[].analysis.proposed_message" => !self.commits.is_empty(),
200                "commits[].analysis.file_changes.total_files" => !self.commits.is_empty(),
201                "commits[].analysis.file_changes.files_added" => !self.commits.is_empty(),
202                "commits[].analysis.file_changes.files_deleted" => !self.commits.is_empty(),
203                "commits[].analysis.file_changes.file_list" => !self.commits.is_empty(),
204                "commits[].analysis.diff_summary" => !self.commits.is_empty(),
205                "commits[].analysis.diff_file" => !self.commits.is_empty(),
206                "versions.omni_dev" => self.versions.is_some(),
207                "ai.scratch" => true,
208                "branch_info.branch" => self.branch_info.is_some(),
209                "pr_template" => self.pr_template.is_some(),
210                "pr_template_location" => self.pr_template_location.is_some(),
211                "branch_prs" => self.branch_prs.is_some(),
212                "branch_prs[].number" => {
213                    self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty())
214                }
215                "branch_prs[].title" => self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty()),
216                "branch_prs[].state" => self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty()),
217                "branch_prs[].url" => self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty()),
218                "branch_prs[].body" => self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty()),
219                _ => false, // Unknown fields are not present
220            }
221        }
222    }
223
224    /// Creates a minimal view containing a single commit for parallel dispatch.
225    ///
226    /// Strips metadata not relevant to per-commit AI analysis (versions,
227    /// working directory status, remotes, PR templates) to reduce prompt size.
228    /// Only retains `branch_info` (for scope context) and the single commit.
229    #[must_use]
230    pub fn single_commit_view(&self, commit: &CommitInfo) -> Self {
231        Self {
232            versions: None,
233            explanation: FieldExplanation {
234                text: String::new(),
235                fields: Vec::new(),
236            },
237            working_directory: WorkingDirectoryInfo {
238                clean: true,
239                untracked_changes: Vec::new(),
240            },
241            remotes: Vec::new(),
242            ai: AiInfo {
243                scratch: String::new(),
244            },
245            branch_info: self.branch_info.clone(),
246            pr_template: None,
247            pr_template_location: None,
248            branch_prs: None,
249            commits: vec![commit.clone()],
250        }
251    }
252
253    /// Creates a minimal view containing multiple commits for batched dispatch.
254    ///
255    /// Same metadata stripping as [`single_commit_view`] but with N commits.
256    /// Used by the batching system to group commits into a single AI request.
257    #[must_use]
258    pub(crate) fn multi_commit_view(&self, commits: &[&CommitInfo]) -> Self {
259        Self {
260            versions: None,
261            explanation: FieldExplanation {
262                text: String::new(),
263                fields: Vec::new(),
264            },
265            working_directory: WorkingDirectoryInfo {
266                clean: true,
267                untracked_changes: Vec::new(),
268            },
269            remotes: Vec::new(),
270            ai: AiInfo {
271                scratch: String::new(),
272            },
273            branch_info: self.branch_info.clone(),
274            pr_template: None,
275            pr_template_location: None,
276            branch_prs: None,
277            commits: commits.iter().map(|c| (*c).clone()).collect(),
278        }
279    }
280}
281
282impl Default for FieldExplanation {
283    /// Creates default field explanation.
284    fn default() -> Self {
285        Self {
286            text: [
287                "Field documentation for the YAML output format. Each entry describes the purpose and content of fields returned by the view command.",
288                "",
289                "Field structure:",
290                "- name: Specifies the YAML field path",
291                "- text: Provides a description of what the field contains",
292                "- command: Shows the corresponding command used to obtain that data (if applicable)",
293                "- present: Indicates whether this field is present in the current output",
294                "",
295                "IMPORTANT FOR AI ASSISTANTS: If a field shows present=true, it is guaranteed to be somewhere in this document. AI assistants should search the entire document thoroughly for any field marked as present=true, as it is definitely included in the output."
296            ].join("\n"),
297            fields: vec![
298                FieldDocumentation {
299                    name: "working_directory.clean".to_string(),
300                    text: "Boolean indicating if the working directory has no uncommitted changes".to_string(),
301                    command: Some("git status".to_string()),
302                    present: false, // Will be set dynamically when creating output
303                },
304                FieldDocumentation {
305                    name: "working_directory.untracked_changes".to_string(),
306                    text: "Array of files with uncommitted changes, showing git status and file path".to_string(),
307                    command: Some("git status --porcelain".to_string()),
308                    present: false,
309                },
310                FieldDocumentation {
311                    name: "remotes".to_string(),
312                    text: "Array of git remotes with their URLs and detected main branch names".to_string(),
313                    command: Some("git remote -v".to_string()),
314                    present: false,
315                },
316                FieldDocumentation {
317                    name: "commits[].hash".to_string(),
318                    text: "Full SHA-1 hash of the commit".to_string(),
319                    command: Some("git log --format=%H".to_string()),
320                    present: false,
321                },
322                FieldDocumentation {
323                    name: "commits[].author".to_string(),
324                    text: "Commit author name and email address".to_string(),
325                    command: Some("git log --format=%an <%ae>".to_string()),
326                    present: false,
327                },
328                FieldDocumentation {
329                    name: "commits[].date".to_string(),
330                    text: "Commit date in ISO format with timezone".to_string(),
331                    command: Some("git log --format=%aI".to_string()),
332                    present: false,
333                },
334                FieldDocumentation {
335                    name: "commits[].original_message".to_string(),
336                    text: "The original commit message as written by the author".to_string(),
337                    command: Some("git log --format=%B".to_string()),
338                    present: false,
339                },
340                FieldDocumentation {
341                    name: "commits[].in_main_branches".to_string(),
342                    text: "Array of remote main branches that contain this commit (empty if not pushed)".to_string(),
343                    command: Some("git branch -r --contains <commit>".to_string()),
344                    present: false,
345                },
346                FieldDocumentation {
347                    name: "commits[].analysis.detected_type".to_string(),
348                    text: "Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.)".to_string(),
349                    command: None,
350                    present: false,
351                },
352                FieldDocumentation {
353                    name: "commits[].analysis.detected_scope".to_string(),
354                    text: "Automatically detected scope based on file paths (commands, config, tests, etc.)".to_string(),
355                    command: None,
356                    present: false,
357                },
358                FieldDocumentation {
359                    name: "commits[].analysis.proposed_message".to_string(),
360                    text: "AI-generated conventional commit message based on file changes".to_string(),
361                    command: None,
362                    present: false,
363                },
364                FieldDocumentation {
365                    name: "commits[].analysis.file_changes.total_files".to_string(),
366                    text: "Total number of files modified in this commit".to_string(),
367                    command: Some("git show --name-only <commit>".to_string()),
368                    present: false,
369                },
370                FieldDocumentation {
371                    name: "commits[].analysis.file_changes.files_added".to_string(),
372                    text: "Number of new files added in this commit".to_string(),
373                    command: Some("git show --name-status <commit> | grep '^A'".to_string()),
374                    present: false,
375                },
376                FieldDocumentation {
377                    name: "commits[].analysis.file_changes.files_deleted".to_string(),
378                    text: "Number of files deleted in this commit".to_string(),
379                    command: Some("git show --name-status <commit> | grep '^D'".to_string()),
380                    present: false,
381                },
382                FieldDocumentation {
383                    name: "commits[].analysis.file_changes.file_list".to_string(),
384                    text: "Array of files changed with their git status (M=modified, A=added, D=deleted)".to_string(),
385                    command: Some("git show --name-status <commit>".to_string()),
386                    present: false,
387                },
388                FieldDocumentation {
389                    name: "commits[].analysis.diff_summary".to_string(),
390                    text: "Git diff --stat output showing lines changed per file".to_string(),
391                    command: Some("git show --stat <commit>".to_string()),
392                    present: false,
393                },
394                FieldDocumentation {
395                    name: "commits[].analysis.diff_file".to_string(),
396                    text: "Path to file containing full diff content showing line-by-line changes with added, removed, and context lines.\n\
397                           AI assistants should read this file to understand the specific changes made in the commit.".to_string(),
398                    command: Some("git show <commit>".to_string()),
399                    present: false,
400                },
401                FieldDocumentation {
402                    name: "versions.omni_dev".to_string(),
403                    text: "Version of the omni-dev tool".to_string(),
404                    command: Some("omni-dev --version".to_string()),
405                    present: false,
406                },
407                FieldDocumentation {
408                    name: "ai.scratch".to_string(),
409                    text: "Path to AI scratch directory (controlled by AI_SCRATCH environment variable)".to_string(),
410                    command: Some("echo $AI_SCRATCH".to_string()),
411                    present: false,
412                },
413                FieldDocumentation {
414                    name: "branch_info.branch".to_string(),
415                    text: "Current branch name (only present in branch commands)".to_string(),
416                    command: Some("git branch --show-current".to_string()),
417                    present: false,
418                },
419                FieldDocumentation {
420                    name: "pr_template".to_string(),
421                    text: "Pull request template content from .github/pull_request_template.md (only present in branch commands when file exists)".to_string(),
422                    command: None,
423                    present: false,
424                },
425                FieldDocumentation {
426                    name: "pr_template_location".to_string(),
427                    text: "Location of the pull request template file (only present when pr_template exists)".to_string(),
428                    command: None,
429                    present: false,
430                },
431                FieldDocumentation {
432                    name: "branch_prs".to_string(),
433                    text: "Pull requests created from the current branch (only present in branch commands)".to_string(),
434                    command: None,
435                    present: false,
436                },
437                FieldDocumentation {
438                    name: "branch_prs[].number".to_string(),
439                    text: "Pull request number".to_string(),
440                    command: None,
441                    present: false,
442                },
443                FieldDocumentation {
444                    name: "branch_prs[].title".to_string(),
445                    text: "Pull request title".to_string(),
446                    command: None,
447                    present: false,
448                },
449                FieldDocumentation {
450                    name: "branch_prs[].state".to_string(),
451                    text: "Pull request state (open, closed, merged)".to_string(),
452                    command: None,
453                    present: false,
454                },
455                FieldDocumentation {
456                    name: "branch_prs[].url".to_string(),
457                    text: "Pull request URL".to_string(),
458                    command: None,
459                    present: false,
460                },
461                FieldDocumentation {
462                    name: "branch_prs[].body".to_string(),
463                    text: "Pull request description/body content".to_string(),
464                    command: None,
465                    present: false,
466                },
467            ],
468        }
469    }
470}
471
472/// Truncation marker appended when diff content is shortened to fit budget.
473const DIFF_TRUNCATION_MARKER: &str = "\n\n[... diff truncated to fit model context window ...]\n";
474
475/// Minimum characters to retain when truncating a diff.
476/// Below this threshold, stat-only is preferred.
477const MIN_TRUNCATED_DIFF_LEN: usize = 500;
478
479impl RepositoryViewForAI {
480    /// Converts from basic RepositoryView by loading diff content for all commits.
481    pub fn from_repository_view(repo_view: RepositoryView) -> anyhow::Result<Self> {
482        Self::from_repository_view_with_options(repo_view, false)
483    }
484
485    /// Converts from basic RepositoryView with options.
486    ///
487    /// If `fresh` is true, clears original commit messages to force AI to generate
488    /// new messages based solely on the diff content.
489    pub fn from_repository_view_with_options(
490        repo_view: RepositoryView,
491        fresh: bool,
492    ) -> anyhow::Result<Self> {
493        // Convert all commits to AI-enhanced versions
494        let commits: anyhow::Result<Vec<_>> = repo_view
495            .commits
496            .into_iter()
497            .map(|commit| {
498                let mut ai_commit = CommitInfoForAI::from_commit_info(commit)?;
499                if fresh {
500                    ai_commit.original_message =
501                        "(Original message hidden - generate fresh message from diff)".to_string();
502                }
503                Ok(ai_commit)
504            })
505            .collect();
506
507        Ok(Self {
508            versions: repo_view.versions,
509            explanation: repo_view.explanation,
510            working_directory: repo_view.working_directory,
511            remotes: repo_view.remotes,
512            ai: repo_view.ai,
513            branch_info: repo_view.branch_info,
514            pr_template: repo_view.pr_template,
515            pr_template_location: repo_view.pr_template_location,
516            branch_prs: repo_view.branch_prs,
517            commits: commits?,
518        })
519    }
520
521    /// Truncates diff content across all commits to remove approximately
522    /// `excess_chars` characters total.
523    ///
524    /// Distributes cuts proportionally by each commit's diff size. Cuts at
525    /// newline boundaries and appends a truncation marker. Commits whose
526    /// remaining content would be below [`MIN_TRUNCATED_DIFF_LEN`] are skipped
527    /// (the caller should fall through to [`replace_diffs_with_stat`]).
528    pub(crate) fn truncate_diffs(&mut self, excess_chars: usize) {
529        let total_diff_len: usize = self
530            .commits
531            .iter()
532            .map(|c| c.analysis.diff_content.len())
533            .sum();
534
535        if total_diff_len == 0 {
536            return;
537        }
538
539        for commit in &mut self.commits {
540            let diff_len = commit.analysis.diff_content.len();
541            if diff_len == 0 {
542                continue;
543            }
544
545            // Proportional share of excess for this commit
546            let share =
547                ((diff_len as f64 / total_diff_len as f64) * excess_chars as f64).ceil() as usize;
548            let target_len = diff_len.saturating_sub(share + DIFF_TRUNCATION_MARKER.len());
549
550            if target_len < MIN_TRUNCATED_DIFF_LEN {
551                // Would leave too little; caller should try stat-only instead.
552                continue;
553            }
554
555            // Find nearest newline boundary at or before target_len (include the newline)
556            let cut_point = commit.analysis.diff_content[..target_len]
557                .rfind('\n')
558                .map(|p| p + 1)
559                .unwrap_or(target_len);
560
561            commit.analysis.diff_content.truncate(cut_point);
562            commit
563                .analysis
564                .diff_content
565                .push_str(DIFF_TRUNCATION_MARKER);
566        }
567    }
568
569    /// Replaces full diff content with the `diff --stat` summary for all commits.
570    pub(crate) fn replace_diffs_with_stat(&mut self) {
571        for commit in &mut self.commits {
572            commit.analysis.diff_content = format!(
573                "[diff replaced with stat summary to fit model context window]\n\n{}",
574                commit.analysis.diff_summary
575            );
576        }
577    }
578
579    /// Removes all diff content, keeping only file list metadata.
580    pub(crate) fn remove_diffs(&mut self) {
581        for commit in &mut self.commits {
582            commit.analysis.diff_content =
583                "[diff content removed to fit model context window — only file list available]"
584                    .to_string();
585            commit.analysis.diff_summary = String::new();
586        }
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::git::commit::FileChanges;
594    use crate::git::{CommitAnalysisForAI, CommitInfoForAI};
595    use chrono::Utc;
596
597    fn make_commit(hash: &str, diff_content: &str, diff_summary: &str) -> CommitInfoForAI {
598        CommitInfoForAI {
599            hash: hash.to_string(),
600            author: "Test <test@test.com>".to_string(),
601            date: Utc::now().fixed_offset(),
602            original_message: "test commit".to_string(),
603            in_main_branches: Vec::new(),
604            analysis: CommitAnalysisForAI {
605                detected_type: "feat".to_string(),
606                detected_scope: "test".to_string(),
607                proposed_message: "feat(test): test".to_string(),
608                file_changes: FileChanges {
609                    total_files: 1,
610                    files_added: 0,
611                    files_deleted: 0,
612                    file_list: Vec::new(),
613                },
614                diff_summary: diff_summary.to_string(),
615                diff_file: "/tmp/test.diff".to_string(),
616                diff_content: diff_content.to_string(),
617            },
618            pre_validated_checks: Vec::new(),
619        }
620    }
621
622    fn make_view(commits: Vec<CommitInfoForAI>) -> RepositoryViewForAI {
623        RepositoryViewForAI {
624            versions: None,
625            explanation: FieldExplanation::default(),
626            working_directory: WorkingDirectoryInfo {
627                clean: true,
628                untracked_changes: Vec::new(),
629            },
630            remotes: Vec::new(),
631            ai: AiInfo {
632                scratch: String::new(),
633            },
634            branch_info: None,
635            pr_template: None,
636            pr_template_location: None,
637            branch_prs: None,
638            commits,
639        }
640    }
641
642    #[test]
643    fn truncate_diffs_at_newline_boundary() {
644        // Create a diff with ~2000 chars across many lines (well above MIN_TRUNCATED_DIFF_LEN)
645        let lines: Vec<String> = (0..100)
646            .map(|i| format!("+line {:03} with some padding content here\n", i))
647            .collect();
648        let diff_content = lines.join("");
649        let original_len = diff_content.len();
650        assert!(
651            original_len > 2000,
652            "test diff should be large: {original_len}"
653        );
654
655        let commit = make_commit("abc123", &diff_content, "file.rs | 100 +++");
656        let mut view = make_view(vec![commit]);
657
658        // Remove ~500 chars
659        view.truncate_diffs(500);
660
661        let result = &view.commits[0].analysis.diff_content;
662        // Should be shorter than original
663        assert!(result.len() < original_len);
664        // Should end with truncation marker
665        assert!(result.contains("[... diff truncated to fit model context window ...]"));
666        // Content before marker should end at a newline boundary
667        let before_marker = result.split("\n\n[...").next().unwrap();
668        assert!(before_marker.ends_with('\n'));
669    }
670
671    #[test]
672    fn truncate_diffs_skips_when_remainder_too_small() {
673        // Create a diff with exactly 600 chars
674        let diff_content = "x".repeat(600);
675
676        let commit = make_commit("abc123", &diff_content, "file.rs | 1 +");
677        let mut view = make_view(vec![commit]);
678
679        // Try to remove 500 chars — would leave only ~100 chars < MIN_TRUNCATED_DIFF_LEN
680        view.truncate_diffs(500);
681
682        // Should be left unchanged since remainder < 500
683        assert_eq!(view.commits[0].analysis.diff_content.len(), 600);
684    }
685
686    #[test]
687    fn truncate_diffs_proportional_multi_commit() {
688        // Two commits: one with 1000 chars, one with 3000 chars
689        let small_diff = "a\n".repeat(500); // ~1000 chars
690        let large_diff = "b\n".repeat(1500); // ~3000 chars
691
692        let c1 = make_commit("aaa", &small_diff, "small.rs | 1 +");
693        let c2 = make_commit("bbb", &large_diff, "large.rs | 3 +++");
694        let mut view = make_view(vec![c1, c2]);
695
696        let orig_small = view.commits[0].analysis.diff_content.len();
697        let orig_large = view.commits[1].analysis.diff_content.len();
698
699        // Remove 1000 chars total — should remove ~250 from small, ~750 from large
700        view.truncate_diffs(1000);
701
702        let new_small = view.commits[0].analysis.diff_content.len();
703        let new_large = view.commits[1].analysis.diff_content.len();
704
705        // Both should be reduced
706        assert!(new_small < orig_small);
707        assert!(new_large < orig_large);
708        // Large commit should be reduced more
709        assert!(orig_large - new_large > orig_small - new_small);
710    }
711
712    #[test]
713    fn replace_diffs_with_stat_preserves_summary() {
714        let commit = make_commit(
715            "abc123",
716            "full diff content here",
717            " file.rs | 10 +++++++---",
718        );
719        let mut view = make_view(vec![commit]);
720
721        view.replace_diffs_with_stat();
722
723        let result = &view.commits[0].analysis.diff_content;
724        assert!(result.contains("stat summary"));
725        assert!(result.contains("file.rs | 10 +++++++---"));
726        assert!(!result.contains("full diff content here"));
727    }
728
729    #[test]
730    fn remove_diffs_clears_content_and_summary() {
731        let commit = make_commit("abc123", "full diff", "file.rs | 1 +");
732        let mut view = make_view(vec![commit]);
733
734        view.remove_diffs();
735
736        let result = &view.commits[0].analysis.diff_content;
737        assert!(result.contains("only file list available"));
738        assert!(!result.contains("full diff"));
739        assert!(view.commits[0].analysis.diff_summary.is_empty());
740    }
741
742    #[test]
743    fn truncate_diffs_empty_diff_noop() {
744        let commit = make_commit("abc123", "", "");
745        let mut view = make_view(vec![commit]);
746
747        view.truncate_diffs(1000);
748
749        assert!(view.commits[0].analysis.diff_content.is_empty());
750    }
751
752    #[test]
753    fn diff_detail_display() {
754        assert_eq!(DiffDetail::Full.to_string(), "full diff");
755        assert_eq!(DiffDetail::Truncated.to_string(), "truncated diff");
756        assert_eq!(DiffDetail::StatOnly.to_string(), "stat summary only");
757        assert_eq!(DiffDetail::FileListOnly.to_string(), "file list only");
758    }
759}