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)]
66pub struct FieldExplanation {
67 pub text: String,
69 pub fields: Vec<FieldDocumentation>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct FieldDocumentation {
81 pub name: String,
83 pub text: String,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub command: Option<String>,
88 pub present: bool,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct WorkingDirectoryInfo {
99 pub clean: bool,
101 pub untracked_changes: Vec<FileStatusInfo>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FileStatusInfo {
112 pub status: String,
114 pub file: String,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct VersionInfo {
125 pub omni_dev: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct AiInfo {
136 pub scratch: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct BranchInfo {
147 pub branch: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct PullRequest {
160 pub number: u64,
162 pub title: String,
164 pub state: String,
166 pub url: String,
168 pub body: String,
170 #[serde(default)]
172 pub base: String,
173}
174
175impl RepositoryView {
176 pub fn update_field_presence(&mut self) {
178 for field in &mut self.explanation.fields {
179 field.present = match field.name.as_str() {
180 "working_directory.clean"
181 | "working_directory.untracked_changes"
182 | "remotes"
183 | "ai.scratch" => true, "commits[].hash"
185 | "commits[].author"
186 | "commits[].date"
187 | "commits[].original_message"
188 | "commits[].in_main_branches"
189 | "commits[].analysis.detected_type"
190 | "commits[].analysis.detected_scope"
191 | "commits[].analysis.proposed_message"
192 | "commits[].analysis.file_changes.total_files"
193 | "commits[].analysis.file_changes.files_added"
194 | "commits[].analysis.file_changes.files_deleted"
195 | "commits[].analysis.file_changes.file_list"
196 | "commits[].analysis.diff_summary"
197 | "commits[].analysis.diff_file"
198 | "commits[].analysis.file_diffs"
199 | "commits[].analysis.file_diffs[].path"
200 | "commits[].analysis.file_diffs[].diff_file"
201 | "commits[].analysis.file_diffs[].byte_len" => !self.commits.is_empty(),
202 "versions.omni_dev" => self.versions.is_some(),
203 "branch_info.branch" => self.branch_info.is_some(),
204 "pr_template" => self.pr_template.is_some(),
205 "pr_template_location" => self.pr_template_location.is_some(),
206 "branch_prs" => self.branch_prs.is_some(),
207 "branch_prs[].number"
208 | "branch_prs[].title"
209 | "branch_prs[].state"
210 | "branch_prs[].url"
211 | "branch_prs[].body"
212 | "branch_prs[].base" => {
213 self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty())
214 }
215 _ => false, }
217 }
218 }
219
220 pub fn to_yaml_output(&mut self) -> anyhow::Result<String> {
228 self.update_field_presence();
229 yaml::to_yaml(self)
230 }
231
232 #[must_use]
238 pub fn single_commit_view(&self, commit: &CommitInfo) -> Self {
239 Self {
240 versions: None,
241 explanation: FieldExplanation {
242 text: String::new(),
243 fields: Vec::new(),
244 },
245 working_directory: WorkingDirectoryInfo {
246 clean: true,
247 untracked_changes: Vec::new(),
248 },
249 remotes: Vec::new(),
250 ai: AiInfo {
251 scratch: String::new(),
252 },
253 branch_info: self.branch_info.clone(),
254 pr_template: None,
255 pr_template_location: None,
256 branch_prs: None,
257 commits: vec![commit.clone()],
258 }
259 }
260
261 #[must_use]
266 pub(crate) fn multi_commit_view(&self, commits: &[&CommitInfo]) -> Self {
267 Self {
268 versions: None,
269 explanation: FieldExplanation {
270 text: String::new(),
271 fields: Vec::new(),
272 },
273 working_directory: WorkingDirectoryInfo {
274 clean: true,
275 untracked_changes: Vec::new(),
276 },
277 remotes: Vec::new(),
278 ai: AiInfo {
279 scratch: String::new(),
280 },
281 branch_info: self.branch_info.clone(),
282 pr_template: None,
283 pr_template_location: None,
284 branch_prs: None,
285 commits: commits.iter().map(|c| (*c).clone()).collect(),
286 }
287 }
288}
289
290impl Default for FieldExplanation {
291 fn default() -> Self {
293 Self {
294 text: [
295 "Field documentation for the YAML output format. Each entry describes the purpose and content of fields returned by the view command.",
296 "",
297 "Field structure:",
298 "- name: Specifies the YAML field path",
299 "- text: Provides a description of what the field contains",
300 "- command: Shows the corresponding command used to obtain that data (if applicable)",
301 "- present: Indicates whether this field is present in the current output",
302 "",
303 "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."
304 ].join("\n"),
305 fields: vec![
306 FieldDocumentation {
307 name: "working_directory.clean".to_string(),
308 text: "Boolean indicating if the working directory has no uncommitted changes".to_string(),
309 command: Some("git status".to_string()),
310 present: false, },
312 FieldDocumentation {
313 name: "working_directory.untracked_changes".to_string(),
314 text: "Array of files with uncommitted changes, showing git status and file path".to_string(),
315 command: Some("git status --porcelain".to_string()),
316 present: false,
317 },
318 FieldDocumentation {
319 name: "remotes".to_string(),
320 text: "Array of git remotes with their URLs and detected main branch names".to_string(),
321 command: Some("git remote -v".to_string()),
322 present: false,
323 },
324 FieldDocumentation {
325 name: "commits[].hash".to_string(),
326 text: "Full SHA-1 hash of the commit".to_string(),
327 command: Some("git log --format=%H".to_string()),
328 present: false,
329 },
330 FieldDocumentation {
331 name: "commits[].author".to_string(),
332 text: "Commit author name and email address".to_string(),
333 command: Some("git log --format=%an <%ae>".to_string()),
334 present: false,
335 },
336 FieldDocumentation {
337 name: "commits[].date".to_string(),
338 text: "Commit date in ISO format with timezone".to_string(),
339 command: Some("git log --format=%aI".to_string()),
340 present: false,
341 },
342 FieldDocumentation {
343 name: "commits[].original_message".to_string(),
344 text: "The original commit message as written by the author".to_string(),
345 command: Some("git log --format=%B".to_string()),
346 present: false,
347 },
348 FieldDocumentation {
349 name: "commits[].in_main_branches".to_string(),
350 text: "Array of remote main branches that contain this commit (empty if not pushed)".to_string(),
351 command: Some("git branch -r --contains <commit>".to_string()),
352 present: false,
353 },
354 FieldDocumentation {
355 name: "commits[].analysis.detected_type".to_string(),
356 text: "Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.)".to_string(),
357 command: None,
358 present: false,
359 },
360 FieldDocumentation {
361 name: "commits[].analysis.detected_scope".to_string(),
362 text: "Automatically detected scope based on file paths (commands, config, tests, etc.)".to_string(),
363 command: None,
364 present: false,
365 },
366 FieldDocumentation {
367 name: "commits[].analysis.proposed_message".to_string(),
368 text: "AI-generated conventional commit message based on file changes".to_string(),
369 command: None,
370 present: false,
371 },
372 FieldDocumentation {
373 name: "commits[].analysis.file_changes.total_files".to_string(),
374 text: "Total number of files modified in this commit".to_string(),
375 command: Some("git show --name-only <commit>".to_string()),
376 present: false,
377 },
378 FieldDocumentation {
379 name: "commits[].analysis.file_changes.files_added".to_string(),
380 text: "Number of new files added in this commit".to_string(),
381 command: Some("git show --name-status <commit> | grep '^A'".to_string()),
382 present: false,
383 },
384 FieldDocumentation {
385 name: "commits[].analysis.file_changes.files_deleted".to_string(),
386 text: "Number of files deleted in this commit".to_string(),
387 command: Some("git show --name-status <commit> | grep '^D'".to_string()),
388 present: false,
389 },
390 FieldDocumentation {
391 name: "commits[].analysis.file_changes.file_list".to_string(),
392 text: "Array of files changed with their git status (M=modified, A=added, D=deleted)".to_string(),
393 command: Some("git show --name-status <commit>".to_string()),
394 present: false,
395 },
396 FieldDocumentation {
397 name: "commits[].analysis.diff_summary".to_string(),
398 text: "Git diff --stat output showing lines changed per file".to_string(),
399 command: Some("git show --stat <commit>".to_string()),
400 present: false,
401 },
402 FieldDocumentation {
403 name: "commits[].analysis.diff_file".to_string(),
404 text: "Path to file containing full diff content showing line-by-line changes with added, removed, and context lines.\n\
405 AI assistants should read this file to understand the specific changes made in the commit.".to_string(),
406 command: Some("git show <commit>".to_string()),
407 present: false,
408 },
409 FieldDocumentation {
410 name: "commits[].analysis.file_diffs".to_string(),
411 text: "Array of per-file diff references, each containing the file path, \
412 absolute path to the diff file on disk, and byte length of the diff content.\n\
413 AI assistants can use these to analyze individual file changes without loading the full diff."
414 .to_string(),
415 command: None,
416 present: false,
417 },
418 FieldDocumentation {
419 name: "commits[].analysis.file_diffs[].path".to_string(),
420 text: "Repository-relative path of the changed file.".to_string(),
421 command: None,
422 present: false,
423 },
424 FieldDocumentation {
425 name: "commits[].analysis.file_diffs[].diff_file".to_string(),
426 text: "Absolute path to the per-file diff file on disk.".to_string(),
427 command: None,
428 present: false,
429 },
430 FieldDocumentation {
431 name: "commits[].analysis.file_diffs[].byte_len".to_string(),
432 text: "Byte length of the per-file diff content.".to_string(),
433 command: None,
434 present: false,
435 },
436 FieldDocumentation {
437 name: "versions.omni_dev".to_string(),
438 text: "Version of the omni-dev tool".to_string(),
439 command: Some("omni-dev --version".to_string()),
440 present: false,
441 },
442 FieldDocumentation {
443 name: "ai.scratch".to_string(),
444 text: "Path to AI scratch directory (controlled by AI_SCRATCH environment variable)".to_string(),
445 command: Some("echo $AI_SCRATCH".to_string()),
446 present: false,
447 },
448 FieldDocumentation {
449 name: "branch_info.branch".to_string(),
450 text: "Current branch name (only present in branch commands)".to_string(),
451 command: Some("git branch --show-current".to_string()),
452 present: false,
453 },
454 FieldDocumentation {
455 name: "pr_template".to_string(),
456 text: "Pull request template content from .github/pull_request_template.md (only present in branch commands when file exists)".to_string(),
457 command: None,
458 present: false,
459 },
460 FieldDocumentation {
461 name: "pr_template_location".to_string(),
462 text: "Location of the pull request template file (only present when pr_template exists)".to_string(),
463 command: None,
464 present: false,
465 },
466 FieldDocumentation {
467 name: "branch_prs".to_string(),
468 text: "Pull requests created from the current branch (only present in branch commands)".to_string(),
469 command: None,
470 present: false,
471 },
472 FieldDocumentation {
473 name: "branch_prs[].number".to_string(),
474 text: "Pull request number".to_string(),
475 command: None,
476 present: false,
477 },
478 FieldDocumentation {
479 name: "branch_prs[].title".to_string(),
480 text: "Pull request title".to_string(),
481 command: None,
482 present: false,
483 },
484 FieldDocumentation {
485 name: "branch_prs[].state".to_string(),
486 text: "Pull request state (open, closed, merged)".to_string(),
487 command: None,
488 present: false,
489 },
490 FieldDocumentation {
491 name: "branch_prs[].url".to_string(),
492 text: "Pull request URL".to_string(),
493 command: None,
494 present: false,
495 },
496 FieldDocumentation {
497 name: "branch_prs[].body".to_string(),
498 text: "Pull request description/body content".to_string(),
499 command: None,
500 present: false,
501 },
502 FieldDocumentation {
503 name: "branch_prs[].base".to_string(),
504 text: "Base branch the pull request targets".to_string(),
505 command: None,
506 present: false,
507 },
508 ],
509 }
510 }
511}
512
513impl<C> RepositoryView<C> {
514 pub fn map_commits<D>(
516 self,
517 f: impl FnMut(C) -> anyhow::Result<D>,
518 ) -> anyhow::Result<RepositoryView<D>> {
519 let commits: anyhow::Result<Vec<D>> = self.commits.into_iter().map(f).collect();
520 Ok(RepositoryView {
521 versions: self.versions,
522 explanation: self.explanation,
523 working_directory: self.working_directory,
524 remotes: self.remotes,
525 ai: self.ai,
526 branch_info: self.branch_info,
527 pr_template: self.pr_template,
528 pr_template_location: self.pr_template_location,
529 branch_prs: self.branch_prs,
530 commits: commits?,
531 })
532 }
533}
534
535impl RepositoryViewForAI {
536 pub fn from_repository_view(repo_view: RepositoryView) -> anyhow::Result<Self> {
538 Self::from_repository_view_with_options(repo_view, false)
539 }
540
541 pub fn from_repository_view_with_options(
546 repo_view: RepositoryView,
547 fresh: bool,
548 ) -> anyhow::Result<Self> {
549 repo_view.map_commits(|commit| {
550 let mut ai_commit = CommitInfoForAI::from_commit_info(commit)?;
551 if fresh {
552 ai_commit.base.original_message =
553 "(Original message hidden - generate fresh message from diff)".to_string();
554 }
555 Ok(ai_commit)
556 })
557 }
558
559 #[must_use]
565 pub(crate) fn single_commit_view_for_ai(&self, commit: &CommitInfoForAI) -> Self {
566 Self {
567 versions: None,
568 explanation: FieldExplanation {
569 text: String::new(),
570 fields: Vec::new(),
571 },
572 working_directory: WorkingDirectoryInfo {
573 clean: true,
574 untracked_changes: Vec::new(),
575 },
576 remotes: Vec::new(),
577 ai: AiInfo {
578 scratch: String::new(),
579 },
580 branch_info: self.branch_info.clone(),
581 pr_template: None,
582 pr_template_location: None,
583 branch_prs: None,
584 commits: vec![commit.clone()],
585 }
586 }
587}
588
589#[cfg(test)]
590#[allow(clippy::unwrap_used, clippy::expect_used)]
591mod tests {
592 use super::*;
593 use crate::git::commit::FileChanges;
594 use crate::git::{CommitAnalysis, CommitInfo};
595 use chrono::Utc;
596
597 fn make_repo_view(commits: Vec<crate::git::CommitInfo>) -> RepositoryView {
600 RepositoryView {
601 versions: None,
602 explanation: FieldExplanation::default(),
603 working_directory: WorkingDirectoryInfo {
604 clean: true,
605 untracked_changes: Vec::new(),
606 },
607 remotes: Vec::new(),
608 ai: AiInfo {
609 scratch: String::new(),
610 },
611 branch_info: None,
612 pr_template: None,
613 pr_template_location: None,
614 branch_prs: None,
615 commits,
616 }
617 }
618
619 fn field_present(view: &RepositoryView, name: &str) -> Option<bool> {
620 view.explanation
621 .fields
622 .iter()
623 .find(|f| f.name == name)
624 .map(|f| f.present)
625 }
626
627 #[test]
628 fn field_presence_no_commits() {
629 let mut view = make_repo_view(vec![]);
630 view.update_field_presence();
631
632 assert_eq!(field_present(&view, "working_directory.clean"), Some(true));
634 assert_eq!(field_present(&view, "remotes"), Some(true));
635 assert_eq!(field_present(&view, "ai.scratch"), Some(true));
636
637 assert_eq!(field_present(&view, "commits[].hash"), Some(false));
639 assert_eq!(
640 field_present(&view, "commits[].analysis.detected_type"),
641 Some(false)
642 );
643
644 assert_eq!(field_present(&view, "versions.omni_dev"), Some(false));
646 assert_eq!(field_present(&view, "branch_info.branch"), Some(false));
647 assert_eq!(field_present(&view, "pr_template"), Some(false));
648 assert_eq!(field_present(&view, "branch_prs"), Some(false));
649 }
650
651 #[test]
652 fn field_presence_with_versions() {
653 let mut view = make_repo_view(vec![]);
654 view.versions = Some(VersionInfo {
655 omni_dev: "1.0.0".to_string(),
656 });
657 view.update_field_presence();
658
659 assert_eq!(field_present(&view, "versions.omni_dev"), Some(true));
660 }
661
662 #[test]
663 fn field_presence_with_branch_info() {
664 let mut view = make_repo_view(vec![]);
665 view.branch_info = Some(BranchInfo {
666 branch: "main".to_string(),
667 });
668 view.update_field_presence();
669
670 assert_eq!(field_present(&view, "branch_info.branch"), Some(true));
671 }
672
673 #[test]
674 fn field_presence_with_pr_template() {
675 let mut view = make_repo_view(vec![]);
676 view.pr_template = Some("template content".to_string());
677 view.pr_template_location = Some(".github/pull_request_template.md".to_string());
678 view.update_field_presence();
679
680 assert_eq!(field_present(&view, "pr_template"), Some(true));
681 assert_eq!(field_present(&view, "pr_template_location"), Some(true));
682 }
683
684 #[test]
685 fn field_presence_with_branch_prs() {
686 let mut view = make_repo_view(vec![]);
687 view.branch_prs = Some(vec![PullRequest {
688 number: 42,
689 title: "Test PR".to_string(),
690 state: "open".to_string(),
691 url: "https://github.com/test/test/pull/42".to_string(),
692 body: "PR body".to_string(),
693 base: "main".to_string(),
694 }]);
695 view.update_field_presence();
696
697 assert_eq!(field_present(&view, "branch_prs"), Some(true));
698 assert_eq!(field_present(&view, "branch_prs[].number"), Some(true));
699 assert_eq!(field_present(&view, "branch_prs[].title"), Some(true));
700 }
701
702 #[test]
703 fn field_presence_empty_branch_prs() {
704 let mut view = make_repo_view(vec![]);
705 view.branch_prs = Some(vec![]);
706 view.update_field_presence();
707
708 assert_eq!(field_present(&view, "branch_prs"), Some(true));
709 assert_eq!(field_present(&view, "branch_prs[].number"), Some(false));
710 }
711
712 #[test]
713 fn field_presence_unknown_field_is_false() {
714 let mut view = make_repo_view(vec![]);
715 view.explanation.fields.push(FieldDocumentation {
716 name: "nonexistent.field".to_string(),
717 text: "should be false".to_string(),
718 command: None,
719 present: true, });
721 view.update_field_presence();
722
723 assert_eq!(field_present(&view, "nonexistent.field"), Some(false));
724 }
725
726 #[test]
727 fn all_documented_fields_present_with_full_data() {
728 let commit = make_commit_info("abc123");
734 let mut view = make_repo_view(vec![commit]);
735 view.versions = Some(VersionInfo {
736 omni_dev: "1.0.0".to_string(),
737 });
738 view.branch_info = Some(BranchInfo {
739 branch: "main".to_string(),
740 });
741 view.pr_template = Some("template".to_string());
742 view.pr_template_location = Some(".github/pull_request_template.md".to_string());
743 view.branch_prs = Some(vec![PullRequest {
744 number: 1,
745 title: "Test".to_string(),
746 state: "open".to_string(),
747 url: "https://github.com/example/repo/pull/1".to_string(),
748 body: "body".to_string(),
749 base: "main".to_string(),
750 }]);
751 view.update_field_presence();
752
753 for field in &view.explanation.fields {
754 assert!(
755 field.present,
756 "Field '{}' is documented but not matched in update_field_presence()",
757 field.name
758 );
759 }
760 }
761
762 fn make_commit_info(hash: &str) -> crate::git::CommitInfo {
765 crate::git::CommitInfo {
766 hash: hash.to_string(),
767 author: "Test <test@test.com>".to_string(),
768 date: chrono::Utc::now().fixed_offset(),
769 original_message: "test".to_string(),
770 in_main_branches: Vec::new(),
771 analysis: crate::git::CommitAnalysis {
772 detected_type: "feat".to_string(),
773 detected_scope: "test".to_string(),
774 proposed_message: String::new(),
775 file_changes: crate::git::commit::FileChanges {
776 total_files: 0,
777 files_added: 0,
778 files_deleted: 0,
779 file_list: Vec::new(),
780 },
781 diff_summary: String::new(),
782 diff_file: String::new(),
783 file_diffs: Vec::new(),
784 },
785 }
786 }
787
788 #[test]
789 fn single_commit_view_strips_metadata() {
790 let mut view = make_repo_view(vec![make_commit_info("aaa"), make_commit_info("bbb")]);
791 view.versions = Some(VersionInfo {
792 omni_dev: "1.0.0".to_string(),
793 });
794 view.branch_info = Some(BranchInfo {
795 branch: "feature/test".to_string(),
796 });
797 view.pr_template = Some("template".to_string());
798
799 let single = view.single_commit_view(&view.commits[0].clone());
800
801 assert!(single.versions.is_none());
802 assert!(single.pr_template.is_none());
803 assert!(single.remotes.is_empty());
804 assert_eq!(single.commits.len(), 1);
805 assert_eq!(single.commits[0].hash, "aaa");
806 assert!(single.branch_info.is_some());
808 assert_eq!(single.branch_info.unwrap().branch, "feature/test");
809 }
810
811 #[test]
812 fn multi_commit_view_preserves_order() {
813 let commits = vec![
814 make_commit_info("aaa"),
815 make_commit_info("bbb"),
816 make_commit_info("ccc"),
817 ];
818 let view = make_repo_view(commits.clone());
819
820 let refs: Vec<&crate::git::CommitInfo> = commits.iter().collect();
821 let multi = view.multi_commit_view(&refs);
822
823 assert_eq!(multi.commits.len(), 3);
824 assert_eq!(multi.commits[0].hash, "aaa");
825 assert_eq!(multi.commits[1].hash, "bbb");
826 assert_eq!(multi.commits[2].hash, "ccc");
827 }
828
829 #[test]
830 fn multi_commit_view_empty() {
831 let view = make_repo_view(vec![]);
832 let multi = view.multi_commit_view(&[]);
833
834 assert!(multi.commits.is_empty());
835 assert!(multi.versions.is_none());
836 }
837
838 #[test]
841 fn single_commit_view_for_ai_strips_metadata() {
842 use crate::git::commit::CommitInfoForAI;
843
844 let commit_info = make_commit_info("aaa");
845 let ai_commit = CommitInfoForAI {
846 base: crate::git::CommitInfo {
847 hash: commit_info.hash,
848 author: commit_info.author,
849 date: commit_info.date,
850 original_message: commit_info.original_message,
851 in_main_branches: commit_info.in_main_branches,
852 analysis: crate::git::commit::CommitAnalysisForAI {
853 base: commit_info.analysis,
854 diff_content: "diff content".to_string(),
855 },
856 },
857 pre_validated_checks: Vec::new(),
858 };
859
860 let ai_view = RepositoryViewForAI {
861 versions: Some(VersionInfo {
862 omni_dev: "1.0.0".to_string(),
863 }),
864 explanation: FieldExplanation::default(),
865 working_directory: WorkingDirectoryInfo {
866 clean: true,
867 untracked_changes: Vec::new(),
868 },
869 remotes: vec![RemoteInfo {
870 name: "origin".to_string(),
871 uri: "https://example.com".to_string(),
872 main_branch: "main".to_string(),
873 }],
874 ai: AiInfo {
875 scratch: String::new(),
876 },
877 branch_info: Some(BranchInfo {
878 branch: "feature/test".to_string(),
879 }),
880 pr_template: Some("template".to_string()),
881 pr_template_location: Some(".github/PULL_REQUEST_TEMPLATE.md".to_string()),
882 branch_prs: None,
883 commits: vec![ai_commit.clone()],
884 };
885
886 let single = ai_view.single_commit_view_for_ai(&ai_commit);
887
888 assert!(single.versions.is_none());
889 assert!(single.pr_template.is_none());
890 assert!(single.remotes.is_empty());
891 assert_eq!(single.commits.len(), 1);
892 assert_eq!(single.commits[0].base.hash, "aaa");
893 assert!(single.branch_info.is_some());
895 assert_eq!(single.branch_info.unwrap().branch, "feature/test");
896 }
897
898 #[test]
901 fn field_explanation_default_has_all_expected_fields() {
902 let explanation = FieldExplanation::default();
903
904 let field_names: Vec<&str> = explanation.fields.iter().map(|f| f.name.as_str()).collect();
905
906 assert!(field_names.contains(&"working_directory.clean"));
908 assert!(field_names.contains(&"remotes"));
909 assert!(field_names.contains(&"commits[].hash"));
910 assert!(field_names.contains(&"commits[].author"));
911 assert!(field_names.contains(&"commits[].date"));
912 assert!(field_names.contains(&"commits[].original_message"));
913 assert!(field_names.contains(&"commits[].analysis.detected_type"));
914 assert!(field_names.contains(&"commits[].analysis.diff_file"));
915 assert!(field_names.contains(&"ai.scratch"));
916 assert!(field_names.contains(&"versions.omni_dev"));
917 assert!(field_names.contains(&"branch_info.branch"));
918 assert!(field_names.contains(&"pr_template"));
919 assert!(field_names.contains(&"branch_prs"));
920 }
921
922 #[test]
923 fn field_explanation_default_all_start_not_present() {
924 let explanation = FieldExplanation::default();
925 for field in &explanation.fields {
926 assert!(
927 !field.present,
928 "field '{}' should start as present=false",
929 field.name
930 );
931 }
932 }
933
934 fn make_human_view_with_diff_files(
937 dir: &tempfile::TempDir,
938 messages: &[&str],
939 ) -> RepositoryView {
940 let commits = messages
941 .iter()
942 .enumerate()
943 .map(|(i, msg)| {
944 let diff_path = dir.path().join(format!("{i}.diff"));
945 std::fs::write(&diff_path, format!("+line from commit {i}\n")).unwrap();
946 CommitInfo {
947 hash: format!("{i:0>40}"),
948 author: "Test <test@test.com>".to_string(),
949 date: Utc::now().fixed_offset(),
950 original_message: (*msg).to_string(),
951 in_main_branches: Vec::new(),
952 analysis: CommitAnalysis {
953 detected_type: "feat".to_string(),
954 detected_scope: "test".to_string(),
955 proposed_message: format!("feat(test): {msg}"),
956 file_changes: FileChanges {
957 total_files: 1,
958 files_added: 0,
959 files_deleted: 0,
960 file_list: Vec::new(),
961 },
962 diff_summary: "file.rs | 1 +".to_string(),
963 diff_file: diff_path.to_string_lossy().to_string(),
964 file_diffs: Vec::new(),
965 },
966 }
967 })
968 .collect();
969
970 RepositoryView {
971 versions: None,
972 explanation: FieldExplanation::default(),
973 working_directory: WorkingDirectoryInfo {
974 clean: true,
975 untracked_changes: Vec::new(),
976 },
977 remotes: Vec::new(),
978 ai: AiInfo {
979 scratch: String::new(),
980 },
981 branch_info: None,
982 pr_template: None,
983 pr_template_location: None,
984 branch_prs: None,
985 commits,
986 }
987 }
988
989 #[test]
990 fn map_commits_transforms_all_commits() {
991 let dir = tempfile::tempdir().unwrap();
992 let view = make_human_view_with_diff_files(&dir, &["first", "second"]);
993 assert_eq!(view.commits.len(), 2);
994
995 let mapped: RepositoryView<String> = view.map_commits(|c| Ok(c.original_message)).unwrap();
996 assert_eq!(
997 mapped.commits,
998 vec!["first".to_string(), "second".to_string()]
999 );
1000 }
1001
1002 #[test]
1003 fn from_repository_view_loads_diffs() {
1004 let dir = tempfile::tempdir().unwrap();
1005 let view = make_human_view_with_diff_files(&dir, &["commit one"]);
1006
1007 let ai_view = RepositoryViewForAI::from_repository_view(view).unwrap();
1008 assert_eq!(ai_view.commits.len(), 1);
1009 assert_eq!(
1010 ai_view.commits[0].base.analysis.diff_content,
1011 "+line from commit 0\n"
1012 );
1013 assert_eq!(ai_view.commits[0].base.original_message, "commit one");
1014 }
1015
1016 #[test]
1017 fn from_repository_view_fresh_hides_messages() {
1018 let dir = tempfile::tempdir().unwrap();
1019 let view = make_human_view_with_diff_files(&dir, &["original msg"]);
1020
1021 let ai_view = RepositoryViewForAI::from_repository_view_with_options(view, true).unwrap();
1022 assert!(ai_view.commits[0].base.original_message.contains("hidden"));
1023 }
1024}