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