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