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)]
28pub struct RepositoryView<C = CommitInfo> {
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub versions: Option<VersionInfo>,
32 pub explanation: FieldExplanation,
34 pub working_directory: WorkingDirectoryInfo,
36 pub remotes: Vec<RemoteInfo>,
38 pub ai: AiInfo,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub branch_info: Option<BranchInfo>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub pr_template: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub pr_template_location: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub branch_prs: Option<Vec<PullRequest>>,
52 pub commits: Vec<C>,
54}
55
56pub type RepositoryViewForAI = RepositoryView<CommitInfoForAI>;
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CommitAnalysisFromCommits {
66 pub detected_type: String,
68 pub detected_scope: String,
70}
71
72pub type CommitInfoFromCommits = CommitInfo<CommitAnalysisFromCommits>;
74
75pub type RepositoryViewForAiFromCommits = RepositoryView<CommitInfoFromCommits>;
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct FieldExplanation {
89 pub text: String,
91 pub fields: Vec<FieldDocumentation>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct FieldDocumentation {
103 pub name: String,
105 pub text: String,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub command: Option<String>,
110 pub present: bool,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct WorkingDirectoryInfo {
121 pub clean: bool,
123 pub untracked_changes: Vec<FileStatusInfo>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct FileStatusInfo {
134 pub status: String,
136 pub file: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct VersionInfo {
147 pub omni_dev: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct AiInfo {
158 pub scratch: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct BranchInfo {
169 pub branch: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct PullRequest {
182 pub number: u64,
184 pub title: String,
186 pub state: String,
188 pub url: String,
190 pub body: String,
192 #[serde(default)]
194 pub base: String,
195}
196
197impl RepositoryView {
198 pub fn update_field_presence(&mut self) {
200 for field in &mut self.explanation.fields {
201 field.present = match field.name.as_str() {
202 "working_directory.clean"
203 | "working_directory.untracked_changes"
204 | "remotes"
205 | "ai.scratch" => true, "commits[].hash"
207 | "commits[].author"
208 | "commits[].date"
209 | "commits[].original_message"
210 | "commits[].in_main_branches"
211 | "commits[].analysis.detected_type"
212 | "commits[].analysis.detected_scope"
213 | "commits[].analysis.proposed_message"
214 | "commits[].analysis.file_changes.total_files"
215 | "commits[].analysis.file_changes.files_added"
216 | "commits[].analysis.file_changes.files_deleted"
217 | "commits[].analysis.file_changes.file_list"
218 | "commits[].analysis.diff_summary"
219 | "commits[].analysis.diff_file"
220 | "commits[].analysis.file_diffs"
221 | "commits[].analysis.file_diffs[].path"
222 | "commits[].analysis.file_diffs[].diff_file"
223 | "commits[].analysis.file_diffs[].byte_len" => !self.commits.is_empty(),
224 "versions.omni_dev" => self.versions.is_some(),
225 "branch_info.branch" => self.branch_info.is_some(),
226 "pr_template" => self.pr_template.is_some(),
227 "pr_template_location" => self.pr_template_location.is_some(),
228 "branch_prs" => self.branch_prs.is_some(),
229 "branch_prs[].number"
230 | "branch_prs[].title"
231 | "branch_prs[].state"
232 | "branch_prs[].url"
233 | "branch_prs[].body"
234 | "branch_prs[].base" => {
235 self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty())
236 }
237 _ => false, }
239 }
240 }
241
242 pub fn to_yaml_output(&mut self) -> anyhow::Result<String> {
250 self.update_field_presence();
251 yaml::to_yaml(self)
252 }
253
254 #[must_use]
260 pub fn single_commit_view(&self, commit: &CommitInfo) -> Self {
261 Self {
262 versions: None,
263 explanation: FieldExplanation {
264 text: String::new(),
265 fields: Vec::new(),
266 },
267 working_directory: WorkingDirectoryInfo {
268 clean: true,
269 untracked_changes: Vec::new(),
270 },
271 remotes: Vec::new(),
272 ai: AiInfo {
273 scratch: String::new(),
274 },
275 branch_info: self.branch_info.clone(),
276 pr_template: None,
277 pr_template_location: None,
278 branch_prs: None,
279 commits: vec![commit.clone()],
280 }
281 }
282
283 #[must_use]
288 pub(crate) fn multi_commit_view(&self, commits: &[&CommitInfo]) -> Self {
289 Self {
290 versions: None,
291 explanation: FieldExplanation {
292 text: String::new(),
293 fields: Vec::new(),
294 },
295 working_directory: WorkingDirectoryInfo {
296 clean: true,
297 untracked_changes: Vec::new(),
298 },
299 remotes: Vec::new(),
300 ai: AiInfo {
301 scratch: String::new(),
302 },
303 branch_info: self.branch_info.clone(),
304 pr_template: None,
305 pr_template_location: None,
306 branch_prs: None,
307 commits: commits.iter().map(|c| (*c).clone()).collect(),
308 }
309 }
310}
311
312impl Default for FieldExplanation {
313 fn default() -> Self {
315 Self {
316 text: [
317 "Field documentation for the YAML output format. Each entry describes the purpose and content of fields returned by the view command.",
318 "",
319 "Field structure:",
320 "- name: Specifies the YAML field path",
321 "- text: Provides a description of what the field contains",
322 "- command: Shows the corresponding command used to obtain that data (if applicable)",
323 "- present: Indicates whether this field is present in the current output",
324 "",
325 "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."
326 ].join("\n"),
327 fields: vec![
328 FieldDocumentation {
329 name: "working_directory.clean".to_string(),
330 text: "Boolean indicating if the working directory has no uncommitted changes".to_string(),
331 command: Some("git status".to_string()),
332 present: false, },
334 FieldDocumentation {
335 name: "working_directory.untracked_changes".to_string(),
336 text: "Array of files with uncommitted changes, showing git status and file path".to_string(),
337 command: Some("git status --porcelain".to_string()),
338 present: false,
339 },
340 FieldDocumentation {
341 name: "remotes".to_string(),
342 text: "Array of git remotes with their URLs and detected main branch names".to_string(),
343 command: Some("git remote -v".to_string()),
344 present: false,
345 },
346 FieldDocumentation {
347 name: "commits[].hash".to_string(),
348 text: "Full SHA-1 hash of the commit".to_string(),
349 command: Some("git log --format=%H".to_string()),
350 present: false,
351 },
352 FieldDocumentation {
353 name: "commits[].author".to_string(),
354 text: "Commit author name and email address".to_string(),
355 command: Some("git log --format=%an <%ae>".to_string()),
356 present: false,
357 },
358 FieldDocumentation {
359 name: "commits[].date".to_string(),
360 text: "Commit date in ISO format with timezone".to_string(),
361 command: Some("git log --format=%aI".to_string()),
362 present: false,
363 },
364 FieldDocumentation {
365 name: "commits[].original_message".to_string(),
366 text: "The original commit message as written by the author".to_string(),
367 command: Some("git log --format=%B".to_string()),
368 present: false,
369 },
370 FieldDocumentation {
371 name: "commits[].in_main_branches".to_string(),
372 text: "Array of remote main branches that contain this commit (empty if not pushed)".to_string(),
373 command: Some("git branch -r --contains <commit>".to_string()),
374 present: false,
375 },
376 FieldDocumentation {
377 name: "commits[].analysis.detected_type".to_string(),
378 text: "Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.)".to_string(),
379 command: None,
380 present: false,
381 },
382 FieldDocumentation {
383 name: "commits[].analysis.detected_scope".to_string(),
384 text: "Automatically detected scope based on file paths (commands, config, tests, etc.)".to_string(),
385 command: None,
386 present: false,
387 },
388 FieldDocumentation {
389 name: "commits[].analysis.proposed_message".to_string(),
390 text: "AI-generated conventional commit message based on file changes".to_string(),
391 command: None,
392 present: false,
393 },
394 FieldDocumentation {
395 name: "commits[].analysis.file_changes.total_files".to_string(),
396 text: "Total number of files modified in this commit".to_string(),
397 command: Some("git show --name-only <commit>".to_string()),
398 present: false,
399 },
400 FieldDocumentation {
401 name: "commits[].analysis.file_changes.files_added".to_string(),
402 text: "Number of new files added in this commit".to_string(),
403 command: Some("git show --name-status <commit> | grep '^A'".to_string()),
404 present: false,
405 },
406 FieldDocumentation {
407 name: "commits[].analysis.file_changes.files_deleted".to_string(),
408 text: "Number of files deleted in this commit".to_string(),
409 command: Some("git show --name-status <commit> | grep '^D'".to_string()),
410 present: false,
411 },
412 FieldDocumentation {
413 name: "commits[].analysis.file_changes.file_list".to_string(),
414 text: "Array of files changed with their git status (M=modified, A=added, D=deleted)".to_string(),
415 command: Some("git show --name-status <commit>".to_string()),
416 present: false,
417 },
418 FieldDocumentation {
419 name: "commits[].analysis.diff_summary".to_string(),
420 text: "Git diff --stat output showing lines changed per file".to_string(),
421 command: Some("git show --stat <commit>".to_string()),
422 present: false,
423 },
424 FieldDocumentation {
425 name: "commits[].analysis.diff_file".to_string(),
426 text: "Path to file containing full diff content showing line-by-line changes with added, removed, and context lines.\n\
427 AI assistants should read this file to understand the specific changes made in the commit.".to_string(),
428 command: Some("git show <commit>".to_string()),
429 present: false,
430 },
431 FieldDocumentation {
432 name: "commits[].analysis.file_diffs".to_string(),
433 text: "Array of per-file diff references, each containing the file path, \
434 absolute path to the diff file on disk, and byte length of the diff content.\n\
435 AI assistants can use these to analyze individual file changes without loading the full diff."
436 .to_string(),
437 command: None,
438 present: false,
439 },
440 FieldDocumentation {
441 name: "commits[].analysis.file_diffs[].path".to_string(),
442 text: "Repository-relative path of the changed file.".to_string(),
443 command: None,
444 present: false,
445 },
446 FieldDocumentation {
447 name: "commits[].analysis.file_diffs[].diff_file".to_string(),
448 text: "Absolute path to the per-file diff file on disk.".to_string(),
449 command: None,
450 present: false,
451 },
452 FieldDocumentation {
453 name: "commits[].analysis.file_diffs[].byte_len".to_string(),
454 text: "Byte length of the per-file diff content.".to_string(),
455 command: None,
456 present: false,
457 },
458 FieldDocumentation {
459 name: "versions.omni_dev".to_string(),
460 text: "Version of the omni-dev tool".to_string(),
461 command: Some("omni-dev --version".to_string()),
462 present: false,
463 },
464 FieldDocumentation {
465 name: "ai.scratch".to_string(),
466 text: "Path to AI scratch directory (controlled by AI_SCRATCH environment variable)".to_string(),
467 command: Some("echo $AI_SCRATCH".to_string()),
468 present: false,
469 },
470 FieldDocumentation {
471 name: "branch_info.branch".to_string(),
472 text: "Current branch name (only present in branch commands)".to_string(),
473 command: Some("git branch --show-current".to_string()),
474 present: false,
475 },
476 FieldDocumentation {
477 name: "pr_template".to_string(),
478 text: "Pull request template content from .github/pull_request_template.md (only present in branch commands when file exists)".to_string(),
479 command: None,
480 present: false,
481 },
482 FieldDocumentation {
483 name: "pr_template_location".to_string(),
484 text: "Location of the pull request template file (only present when pr_template exists)".to_string(),
485 command: None,
486 present: false,
487 },
488 FieldDocumentation {
489 name: "branch_prs".to_string(),
490 text: "Pull requests created from the current branch (only present in branch commands)".to_string(),
491 command: None,
492 present: false,
493 },
494 FieldDocumentation {
495 name: "branch_prs[].number".to_string(),
496 text: "Pull request number".to_string(),
497 command: None,
498 present: false,
499 },
500 FieldDocumentation {
501 name: "branch_prs[].title".to_string(),
502 text: "Pull request title".to_string(),
503 command: None,
504 present: false,
505 },
506 FieldDocumentation {
507 name: "branch_prs[].state".to_string(),
508 text: "Pull request state (open, closed, merged)".to_string(),
509 command: None,
510 present: false,
511 },
512 FieldDocumentation {
513 name: "branch_prs[].url".to_string(),
514 text: "Pull request URL".to_string(),
515 command: None,
516 present: false,
517 },
518 FieldDocumentation {
519 name: "branch_prs[].body".to_string(),
520 text: "Pull request description/body content".to_string(),
521 command: None,
522 present: false,
523 },
524 FieldDocumentation {
525 name: "branch_prs[].base".to_string(),
526 text: "Base branch the pull request targets".to_string(),
527 command: None,
528 present: false,
529 },
530 ],
531 }
532 }
533}
534
535impl<C> RepositoryView<C> {
536 pub fn map_commits<D>(
538 self,
539 f: impl FnMut(C) -> anyhow::Result<D>,
540 ) -> anyhow::Result<RepositoryView<D>> {
541 let commits: anyhow::Result<Vec<D>> = self.commits.into_iter().map(f).collect();
542 Ok(RepositoryView {
543 versions: self.versions,
544 explanation: self.explanation,
545 working_directory: self.working_directory,
546 remotes: self.remotes,
547 ai: self.ai,
548 branch_info: self.branch_info,
549 pr_template: self.pr_template,
550 pr_template_location: self.pr_template_location,
551 branch_prs: self.branch_prs,
552 commits: commits?,
553 })
554 }
555}
556
557impl RepositoryViewForAI {
558 pub fn from_repository_view(repo_view: RepositoryView) -> anyhow::Result<Self> {
560 Self::from_repository_view_with_options(repo_view, false)
561 }
562
563 pub fn from_repository_view_with_options(
568 repo_view: RepositoryView,
569 fresh: bool,
570 ) -> anyhow::Result<Self> {
571 repo_view.map_commits(|commit| {
572 let mut ai_commit = CommitInfoForAI::from_commit_info(commit)?;
573 if fresh {
574 ai_commit.base.original_message =
575 "(Original message hidden - generate fresh message from diff)".to_string();
576 }
577 Ok(ai_commit)
578 })
579 }
580
581 #[must_use]
587 pub(crate) fn single_commit_view_for_ai(&self, commit: &CommitInfoForAI) -> Self {
588 Self {
589 versions: None,
590 explanation: FieldExplanation {
591 text: String::new(),
592 fields: Vec::new(),
593 },
594 working_directory: WorkingDirectoryInfo {
595 clean: true,
596 untracked_changes: Vec::new(),
597 },
598 remotes: Vec::new(),
599 ai: AiInfo {
600 scratch: String::new(),
601 },
602 branch_info: self.branch_info.clone(),
603 pr_template: None,
604 pr_template_location: None,
605 branch_prs: None,
606 commits: vec![commit.clone()],
607 }
608 }
609}
610
611impl RepositoryViewForAiFromCommits {
612 #[must_use]
624 pub fn from_repository_view(repo_view: RepositoryView) -> Self {
625 #[allow(clippy::unwrap_used)] let mut view: Self = repo_view
627 .map_commits(|c| {
628 Ok(CommitInfo {
629 hash: c.hash,
630 author: c.author,
631 date: c.date,
632 original_message: c.original_message,
633 in_main_branches: c.in_main_branches,
634 analysis: CommitAnalysisFromCommits {
635 detected_type: c.analysis.detected_type,
636 detected_scope: c.analysis.detected_scope,
637 },
638 })
639 })
640 .unwrap();
641 view.explanation = FieldExplanation {
642 text: String::new(),
643 fields: Vec::new(),
644 };
645 view
646 }
647
648 #[must_use]
653 pub(crate) fn single_commit_view_from_commits(&self, commit: &CommitInfoFromCommits) -> Self {
654 Self {
655 versions: None,
656 explanation: FieldExplanation {
657 text: String::new(),
658 fields: Vec::new(),
659 },
660 working_directory: WorkingDirectoryInfo {
661 clean: true,
662 untracked_changes: Vec::new(),
663 },
664 remotes: Vec::new(),
665 ai: AiInfo {
666 scratch: String::new(),
667 },
668 branch_info: self.branch_info.clone(),
669 pr_template: None,
670 pr_template_location: None,
671 branch_prs: None,
672 commits: vec![commit.clone()],
673 }
674 }
675}
676
677#[cfg(test)]
678#[allow(clippy::unwrap_used, clippy::expect_used)]
679mod tests {
680 use super::*;
681 use crate::git::commit::FileChanges;
682 use crate::git::{CommitAnalysis, CommitInfo};
683 use chrono::Utc;
684
685 fn make_repo_view(commits: Vec<crate::git::CommitInfo>) -> RepositoryView {
688 RepositoryView {
689 versions: None,
690 explanation: FieldExplanation::default(),
691 working_directory: WorkingDirectoryInfo {
692 clean: true,
693 untracked_changes: Vec::new(),
694 },
695 remotes: Vec::new(),
696 ai: AiInfo {
697 scratch: String::new(),
698 },
699 branch_info: None,
700 pr_template: None,
701 pr_template_location: None,
702 branch_prs: None,
703 commits,
704 }
705 }
706
707 fn field_present(view: &RepositoryView, name: &str) -> Option<bool> {
708 view.explanation
709 .fields
710 .iter()
711 .find(|f| f.name == name)
712 .map(|f| f.present)
713 }
714
715 #[test]
716 fn field_presence_no_commits() {
717 let mut view = make_repo_view(vec![]);
718 view.update_field_presence();
719
720 assert_eq!(field_present(&view, "working_directory.clean"), Some(true));
722 assert_eq!(field_present(&view, "remotes"), Some(true));
723 assert_eq!(field_present(&view, "ai.scratch"), Some(true));
724
725 assert_eq!(field_present(&view, "commits[].hash"), Some(false));
727 assert_eq!(
728 field_present(&view, "commits[].analysis.detected_type"),
729 Some(false)
730 );
731
732 assert_eq!(field_present(&view, "versions.omni_dev"), Some(false));
734 assert_eq!(field_present(&view, "branch_info.branch"), Some(false));
735 assert_eq!(field_present(&view, "pr_template"), Some(false));
736 assert_eq!(field_present(&view, "branch_prs"), Some(false));
737 }
738
739 #[test]
740 fn field_presence_with_versions() {
741 let mut view = make_repo_view(vec![]);
742 view.versions = Some(VersionInfo {
743 omni_dev: "1.0.0".to_string(),
744 });
745 view.update_field_presence();
746
747 assert_eq!(field_present(&view, "versions.omni_dev"), Some(true));
748 }
749
750 #[test]
751 fn field_presence_with_branch_info() {
752 let mut view = make_repo_view(vec![]);
753 view.branch_info = Some(BranchInfo {
754 branch: "main".to_string(),
755 });
756 view.update_field_presence();
757
758 assert_eq!(field_present(&view, "branch_info.branch"), Some(true));
759 }
760
761 #[test]
762 fn field_presence_with_pr_template() {
763 let mut view = make_repo_view(vec![]);
764 view.pr_template = Some("template content".to_string());
765 view.pr_template_location = Some(".github/pull_request_template.md".to_string());
766 view.update_field_presence();
767
768 assert_eq!(field_present(&view, "pr_template"), Some(true));
769 assert_eq!(field_present(&view, "pr_template_location"), Some(true));
770 }
771
772 #[test]
773 fn field_presence_with_branch_prs() {
774 let mut view = make_repo_view(vec![]);
775 view.branch_prs = Some(vec![PullRequest {
776 number: 42,
777 title: "Test PR".to_string(),
778 state: "open".to_string(),
779 url: "https://github.com/test/test/pull/42".to_string(),
780 body: "PR body".to_string(),
781 base: "main".to_string(),
782 }]);
783 view.update_field_presence();
784
785 assert_eq!(field_present(&view, "branch_prs"), Some(true));
786 assert_eq!(field_present(&view, "branch_prs[].number"), Some(true));
787 assert_eq!(field_present(&view, "branch_prs[].title"), Some(true));
788 }
789
790 #[test]
791 fn field_presence_empty_branch_prs() {
792 let mut view = make_repo_view(vec![]);
793 view.branch_prs = Some(vec![]);
794 view.update_field_presence();
795
796 assert_eq!(field_present(&view, "branch_prs"), Some(true));
797 assert_eq!(field_present(&view, "branch_prs[].number"), Some(false));
798 }
799
800 #[test]
801 fn field_presence_unknown_field_is_false() {
802 let mut view = make_repo_view(vec![]);
803 view.explanation.fields.push(FieldDocumentation {
804 name: "nonexistent.field".to_string(),
805 text: "should be false".to_string(),
806 command: None,
807 present: true, });
809 view.update_field_presence();
810
811 assert_eq!(field_present(&view, "nonexistent.field"), Some(false));
812 }
813
814 #[test]
815 fn all_documented_fields_present_with_full_data() {
816 let commit = make_commit_info("abc123");
822 let mut view = make_repo_view(vec![commit]);
823 view.versions = Some(VersionInfo {
824 omni_dev: "1.0.0".to_string(),
825 });
826 view.branch_info = Some(BranchInfo {
827 branch: "main".to_string(),
828 });
829 view.pr_template = Some("template".to_string());
830 view.pr_template_location = Some(".github/pull_request_template.md".to_string());
831 view.branch_prs = Some(vec![PullRequest {
832 number: 1,
833 title: "Test".to_string(),
834 state: "open".to_string(),
835 url: "https://github.com/example/repo/pull/1".to_string(),
836 body: "body".to_string(),
837 base: "main".to_string(),
838 }]);
839 view.update_field_presence();
840
841 for field in &view.explanation.fields {
842 assert!(
843 field.present,
844 "Field '{}' is documented but not matched in update_field_presence()",
845 field.name
846 );
847 }
848 }
849
850 fn make_commit_info(hash: &str) -> crate::git::CommitInfo {
853 crate::git::CommitInfo {
854 hash: hash.to_string(),
855 author: "Test <test@test.com>".to_string(),
856 date: chrono::Utc::now().fixed_offset(),
857 original_message: "test".to_string(),
858 in_main_branches: Vec::new(),
859 analysis: crate::git::CommitAnalysis {
860 detected_type: "feat".to_string(),
861 detected_scope: "test".to_string(),
862 proposed_message: String::new(),
863 file_changes: crate::git::commit::FileChanges {
864 total_files: 0,
865 files_added: 0,
866 files_deleted: 0,
867 file_list: Vec::new(),
868 },
869 diff_summary: String::new(),
870 diff_file: String::new(),
871 file_diffs: Vec::new(),
872 },
873 }
874 }
875
876 #[test]
877 fn single_commit_view_strips_metadata() {
878 let mut view = make_repo_view(vec![make_commit_info("aaa"), make_commit_info("bbb")]);
879 view.versions = Some(VersionInfo {
880 omni_dev: "1.0.0".to_string(),
881 });
882 view.branch_info = Some(BranchInfo {
883 branch: "feature/test".to_string(),
884 });
885 view.pr_template = Some("template".to_string());
886
887 let single = view.single_commit_view(&view.commits[0].clone());
888
889 assert!(single.versions.is_none());
890 assert!(single.pr_template.is_none());
891 assert!(single.remotes.is_empty());
892 assert_eq!(single.commits.len(), 1);
893 assert_eq!(single.commits[0].hash, "aaa");
894 assert!(single.branch_info.is_some());
896 assert_eq!(single.branch_info.unwrap().branch, "feature/test");
897 }
898
899 #[test]
900 fn multi_commit_view_preserves_order() {
901 let commits = vec![
902 make_commit_info("aaa"),
903 make_commit_info("bbb"),
904 make_commit_info("ccc"),
905 ];
906 let view = make_repo_view(commits.clone());
907
908 let refs: Vec<&crate::git::CommitInfo> = commits.iter().collect();
909 let multi = view.multi_commit_view(&refs);
910
911 assert_eq!(multi.commits.len(), 3);
912 assert_eq!(multi.commits[0].hash, "aaa");
913 assert_eq!(multi.commits[1].hash, "bbb");
914 assert_eq!(multi.commits[2].hash, "ccc");
915 }
916
917 #[test]
918 fn multi_commit_view_empty() {
919 let view = make_repo_view(vec![]);
920 let multi = view.multi_commit_view(&[]);
921
922 assert!(multi.commits.is_empty());
923 assert!(multi.versions.is_none());
924 }
925
926 #[test]
929 fn single_commit_view_for_ai_strips_metadata() {
930 use crate::git::commit::CommitInfoForAI;
931
932 let commit_info = make_commit_info("aaa");
933 let ai_commit = CommitInfoForAI {
934 base: crate::git::CommitInfo {
935 hash: commit_info.hash,
936 author: commit_info.author,
937 date: commit_info.date,
938 original_message: commit_info.original_message,
939 in_main_branches: commit_info.in_main_branches,
940 analysis: crate::git::commit::CommitAnalysisForAI {
941 base: commit_info.analysis,
942 diff_content: "diff content".to_string(),
943 },
944 },
945 pre_validated_checks: Vec::new(),
946 };
947
948 let ai_view = RepositoryViewForAI {
949 versions: Some(VersionInfo {
950 omni_dev: "1.0.0".to_string(),
951 }),
952 explanation: FieldExplanation::default(),
953 working_directory: WorkingDirectoryInfo {
954 clean: true,
955 untracked_changes: Vec::new(),
956 },
957 remotes: vec![RemoteInfo {
958 name: "origin".to_string(),
959 uri: "https://example.com".to_string(),
960 main_branch: "main".to_string(),
961 }],
962 ai: AiInfo {
963 scratch: String::new(),
964 },
965 branch_info: Some(BranchInfo {
966 branch: "feature/test".to_string(),
967 }),
968 pr_template: Some("template".to_string()),
969 pr_template_location: Some(".github/PULL_REQUEST_TEMPLATE.md".to_string()),
970 branch_prs: None,
971 commits: vec![ai_commit.clone()],
972 };
973
974 let single = ai_view.single_commit_view_for_ai(&ai_commit);
975
976 assert!(single.versions.is_none());
977 assert!(single.pr_template.is_none());
978 assert!(single.remotes.is_empty());
979 assert_eq!(single.commits.len(), 1);
980 assert_eq!(single.commits[0].base.hash, "aaa");
981 assert!(single.branch_info.is_some());
983 assert_eq!(single.branch_info.unwrap().branch, "feature/test");
984 }
985
986 #[test]
989 fn from_commits_view_preserves_commit_messages() {
990 let mut view = make_repo_view(vec![make_commit_info("aaa"), make_commit_info("bbb")]);
991 view.commits[0].original_message = "feat: alpha\n\nbody line".to_string();
992 view.commits[1].original_message = "fix: beta".to_string();
993
994 let commits_view = RepositoryViewForAiFromCommits::from_repository_view(view);
995
996 assert_eq!(commits_view.commits.len(), 2);
997 assert_eq!(commits_view.commits[0].hash, "aaa");
998 assert_eq!(
999 commits_view.commits[0].original_message,
1000 "feat: alpha\n\nbody line"
1001 );
1002 assert_eq!(commits_view.commits[1].original_message, "fix: beta");
1003 assert_eq!(commits_view.commits[0].analysis.detected_type, "feat");
1004 assert_eq!(commits_view.commits[1].analysis.detected_type, "feat"); }
1006
1007 #[test]
1008 fn from_commits_view_serialization_contains_no_diff_content() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let diff_path = dir.path().join("0.diff");
1011 std::fs::write(&diff_path, "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+new\n").unwrap();
1012
1013 let mut commit = make_commit_info("aaa");
1014 commit.original_message = "feat(test): unique-commit-subject-marker".to_string();
1015 commit.analysis.diff_file = diff_path.to_string_lossy().to_string();
1016 commit.analysis.diff_summary = "x | 1 +".to_string();
1017
1018 let view = make_repo_view(vec![commit]);
1019 let commits_view = RepositoryViewForAiFromCommits::from_repository_view(view);
1020 let yaml = yaml::to_yaml(&commits_view).unwrap();
1021
1022 assert!(yaml.contains("unique-commit-subject-marker"));
1024 assert!(!yaml.contains("diff --git"));
1026 assert!(!yaml.contains("diff_content"));
1027 assert!(!yaml.contains("diff_file"));
1028 assert!(!yaml.contains("diff_summary"));
1029 assert!(!yaml.contains("file_changes"));
1030 assert!(!yaml.contains("file_diffs"));
1031 }
1032
1033 #[test]
1036 fn field_explanation_default_has_all_expected_fields() {
1037 let explanation = FieldExplanation::default();
1038
1039 let field_names: Vec<&str> = explanation.fields.iter().map(|f| f.name.as_str()).collect();
1040
1041 assert!(field_names.contains(&"working_directory.clean"));
1043 assert!(field_names.contains(&"remotes"));
1044 assert!(field_names.contains(&"commits[].hash"));
1045 assert!(field_names.contains(&"commits[].author"));
1046 assert!(field_names.contains(&"commits[].date"));
1047 assert!(field_names.contains(&"commits[].original_message"));
1048 assert!(field_names.contains(&"commits[].analysis.detected_type"));
1049 assert!(field_names.contains(&"commits[].analysis.diff_file"));
1050 assert!(field_names.contains(&"ai.scratch"));
1051 assert!(field_names.contains(&"versions.omni_dev"));
1052 assert!(field_names.contains(&"branch_info.branch"));
1053 assert!(field_names.contains(&"pr_template"));
1054 assert!(field_names.contains(&"branch_prs"));
1055 }
1056
1057 #[test]
1058 fn field_explanation_default_all_start_not_present() {
1059 let explanation = FieldExplanation::default();
1060 for field in &explanation.fields {
1061 assert!(
1062 !field.present,
1063 "field '{}' should start as present=false",
1064 field.name
1065 );
1066 }
1067 }
1068
1069 fn make_human_view_with_diff_files(
1072 dir: &tempfile::TempDir,
1073 messages: &[&str],
1074 ) -> RepositoryView {
1075 let commits = messages
1076 .iter()
1077 .enumerate()
1078 .map(|(i, msg)| {
1079 let diff_path = dir.path().join(format!("{i}.diff"));
1080 std::fs::write(&diff_path, format!("+line from commit {i}\n")).unwrap();
1081 CommitInfo {
1082 hash: format!("{i:0>40}"),
1083 author: "Test <test@test.com>".to_string(),
1084 date: Utc::now().fixed_offset(),
1085 original_message: (*msg).to_string(),
1086 in_main_branches: Vec::new(),
1087 analysis: CommitAnalysis {
1088 detected_type: "feat".to_string(),
1089 detected_scope: "test".to_string(),
1090 proposed_message: format!("feat(test): {msg}"),
1091 file_changes: FileChanges {
1092 total_files: 1,
1093 files_added: 0,
1094 files_deleted: 0,
1095 file_list: Vec::new(),
1096 },
1097 diff_summary: "file.rs | 1 +".to_string(),
1098 diff_file: diff_path.to_string_lossy().to_string(),
1099 file_diffs: Vec::new(),
1100 },
1101 }
1102 })
1103 .collect();
1104
1105 RepositoryView {
1106 versions: None,
1107 explanation: FieldExplanation::default(),
1108 working_directory: WorkingDirectoryInfo {
1109 clean: true,
1110 untracked_changes: Vec::new(),
1111 },
1112 remotes: Vec::new(),
1113 ai: AiInfo {
1114 scratch: String::new(),
1115 },
1116 branch_info: None,
1117 pr_template: None,
1118 pr_template_location: None,
1119 branch_prs: None,
1120 commits,
1121 }
1122 }
1123
1124 #[test]
1125 fn map_commits_transforms_all_commits() {
1126 let dir = tempfile::tempdir().unwrap();
1127 let view = make_human_view_with_diff_files(&dir, &["first", "second"]);
1128 assert_eq!(view.commits.len(), 2);
1129
1130 let mapped: RepositoryView<String> = view.map_commits(|c| Ok(c.original_message)).unwrap();
1131 assert_eq!(
1132 mapped.commits,
1133 vec!["first".to_string(), "second".to_string()]
1134 );
1135 }
1136
1137 #[test]
1138 fn from_repository_view_loads_diffs() {
1139 let dir = tempfile::tempdir().unwrap();
1140 let view = make_human_view_with_diff_files(&dir, &["commit one"]);
1141
1142 let ai_view = RepositoryViewForAI::from_repository_view(view).unwrap();
1143 assert_eq!(ai_view.commits.len(), 1);
1144 assert_eq!(
1145 ai_view.commits[0].base.analysis.diff_content,
1146 "+line from commit 0\n"
1147 );
1148 assert_eq!(ai_view.commits[0].base.original_message, "commit one");
1149 }
1150
1151 #[test]
1152 fn from_repository_view_fresh_hides_messages() {
1153 let dir = tempfile::tempdir().unwrap();
1154 let view = make_human_view_with_diff_files(&dir, &["original msg"]);
1155
1156 let ai_view = RepositoryViewForAI::from_repository_view_with_options(view, true).unwrap();
1157 assert!(ai_view.commits[0].base.original_message.contains("hidden"));
1158 }
1159}