1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct RepositoryView {
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub versions: Option<VersionInfo>,
23 pub explanation: FieldExplanation,
25 pub working_directory: WorkingDirectoryInfo,
27 pub remotes: Vec<RemoteInfo>,
29 pub ai: AiInfo,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub branch_info: Option<BranchInfo>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub pr_template: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub pr_template_location: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub branch_prs: Option<Vec<PullRequest>>,
43 pub commits: Vec<CommitInfo>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct RepositoryViewForAI {
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub versions: Option<VersionInfo>,
53 pub explanation: FieldExplanation,
55 pub working_directory: WorkingDirectoryInfo,
57 pub remotes: Vec<RemoteInfo>,
59 pub ai: AiInfo,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub branch_info: Option<BranchInfo>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub pr_template: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub pr_template_location: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub branch_prs: Option<Vec<PullRequest>>,
73 pub commits: Vec<CommitInfoForAI>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct FieldExplanation {
80 pub text: String,
82 pub fields: Vec<FieldDocumentation>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct FieldDocumentation {
89 pub name: String,
91 pub text: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub command: Option<String>,
96 pub present: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct WorkingDirectoryInfo {
103 pub clean: bool,
105 pub untracked_changes: Vec<FileStatusInfo>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FileStatusInfo {
112 pub status: String,
114 pub file: String,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct VersionInfo {
121 pub omni_dev: String,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct AiInfo {
128 pub scratch: String,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct BranchInfo {
135 pub branch: String,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct PullRequest {
142 pub number: u64,
144 pub title: String,
146 pub state: String,
148 pub url: String,
150 pub body: String,
152 #[serde(default)]
154 pub base: String,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub(crate) enum DiffDetail {
163 Full,
165 Truncated,
167 StatOnly,
169 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 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, "working_directory.untracked_changes" => true, "remotes" => true, "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, }
221 }
222 }
223
224 #[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 #[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 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, },
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
472const DIFF_TRUNCATION_MARKER: &str = "\n\n[... diff truncated to fit model context window ...]\n";
474
475const MIN_TRUNCATED_DIFF_LEN: usize = 500;
478
479impl RepositoryViewForAI {
480 pub fn from_repository_view(repo_view: RepositoryView) -> anyhow::Result<Self> {
482 Self::from_repository_view_with_options(repo_view, false)
483 }
484
485 pub fn from_repository_view_with_options(
490 repo_view: RepositoryView,
491 fresh: bool,
492 ) -> anyhow::Result<Self> {
493 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 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 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 continue;
553 }
554
555 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 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 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 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 view.truncate_diffs(500);
660
661 let result = &view.commits[0].analysis.diff_content;
662 assert!(result.len() < original_len);
664 assert!(result.contains("[... diff truncated to fit model context window ...]"));
666 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 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 view.truncate_diffs(500);
681
682 assert_eq!(view.commits[0].analysis.diff_content.len(), 600);
684 }
685
686 #[test]
687 fn truncate_diffs_proportional_multi_commit() {
688 let small_diff = "a\n".repeat(500); let large_diff = "b\n".repeat(1500); 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 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 assert!(new_small < orig_small);
707 assert!(new_large < orig_large);
708 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}