Skip to main content

omni_dev/
data.rs

1//! Data processing and serialization.
2
3use 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/// Complete repository view output structure, generic over commit type.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct RepositoryView<C = CommitInfo> {
20    /// Version information for the omni-dev tool.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub versions: Option<VersionInfo>,
23    /// Explanation of field meanings and structure.
24    pub explanation: FieldExplanation,
25    /// Working directory status information.
26    pub working_directory: WorkingDirectoryInfo,
27    /// List of remote repositories and their main branches.
28    pub remotes: Vec<RemoteInfo>,
29    /// AI-related information.
30    pub ai: AiInfo,
31    /// Branch information (only present when using branch commands).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub branch_info: Option<BranchInfo>,
34    /// Pull request template content (only present in branch commands when template exists).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub pr_template: Option<String>,
37    /// Location of the pull request template file (only present when pr_template exists).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub pr_template_location: Option<String>,
40    /// Pull requests created from the current branch (only present in branch commands).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub branch_prs: Option<Vec<PullRequest>>,
43    /// List of analyzed commits with metadata and analysis.
44    pub commits: Vec<C>,
45}
46
47/// Enhanced repository view for AI processing with full diff content.
48pub type RepositoryViewForAI = RepositoryView<CommitInfoForAI>;
49
50/// Field explanation for the YAML output.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct FieldExplanation {
53    /// Descriptive text explaining the overall structure.
54    pub text: String,
55    /// Documentation for individual fields in the output.
56    pub fields: Vec<FieldDocumentation>,
57}
58
59/// Individual field documentation.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct FieldDocumentation {
62    /// Name of the field being documented.
63    pub name: String,
64    /// Descriptive text explaining what the field contains.
65    pub text: String,
66    /// Git command that corresponds to this field (if applicable).
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub command: Option<String>,
69    /// Whether this field is present in the current output.
70    pub present: bool,
71}
72
73/// Working directory information.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct WorkingDirectoryInfo {
76    /// Whether the working directory has no changes.
77    pub clean: bool,
78    /// List of files with uncommitted changes.
79    pub untracked_changes: Vec<FileStatusInfo>,
80}
81
82/// File status information for working directory.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct FileStatusInfo {
85    /// Git status flags (e.g., "AM", "??", "M ").
86    pub status: String,
87    /// Path to the file relative to repository root.
88    pub file: String,
89}
90
91/// Version information for tools and environment.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct VersionInfo {
94    /// Version of the omni-dev tool.
95    pub omni_dev: String,
96}
97
98/// AI-related information.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AiInfo {
101    /// Path to AI scratch directory.
102    pub scratch: String,
103}
104
105/// Branch information for branch-specific commands.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct BranchInfo {
108    /// Current branch name.
109    pub branch: String,
110}
111
112/// Pull request information.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PullRequest {
115    /// PR number.
116    pub number: u64,
117    /// PR title.
118    pub title: String,
119    /// PR state (open, closed, merged).
120    pub state: String,
121    /// PR URL.
122    pub url: String,
123    /// PR description/body content.
124    pub body: String,
125    /// Base branch the PR targets.
126    #[serde(default)]
127    pub base: String,
128}
129
130impl RepositoryView {
131    /// Updates the present field for all field documentation entries based on actual data.
132    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, // Always present
139                "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, // Unknown fields are not present
171            }
172        }
173    }
174
175    /// Serializes this view to YAML, calling [`update_field_presence`] first.
176    ///
177    /// Use this instead of calling `update_field_presence` followed by
178    /// `crate::data::to_yaml` separately.  Keeping the two steps together
179    /// prevents the explanation section from being stale in the output.
180    ///
181    /// [`update_field_presence`]: Self::update_field_presence
182    pub fn to_yaml_output(&mut self) -> anyhow::Result<String> {
183        self.update_field_presence();
184        yaml::to_yaml(self)
185    }
186
187    /// Creates a minimal view containing a single commit for parallel dispatch.
188    ///
189    /// Strips metadata not relevant to per-commit AI analysis (versions,
190    /// working directory status, remotes, PR templates) to reduce prompt size.
191    /// Only retains `branch_info` (for scope context) and the single commit.
192    #[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    /// Creates a minimal view containing multiple commits for batched dispatch.
217    ///
218    /// Same metadata stripping as [`single_commit_view`] but with N commits.
219    /// Used by the batching system to group commits into a single AI request.
220    #[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    /// Creates default field explanation.
247    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, // Will be set dynamically when creating output
266                },
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    /// Transforms commits while preserving all other fields.
470    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    /// Converts from basic RepositoryView by loading diff content for all commits.
492    pub fn from_repository_view(repo_view: RepositoryView) -> anyhow::Result<Self> {
493        Self::from_repository_view_with_options(repo_view, false)
494    }
495
496    /// Converts from basic RepositoryView with options.
497    ///
498    /// If `fresh` is true, clears original commit messages to force AI to generate
499    /// new messages based solely on the diff content.
500    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    /// Creates a minimal AI view containing a single commit for split dispatch.
515    ///
516    /// Analogous to [`RepositoryView::single_commit_view`] but operates on
517    /// the AI-enhanced type. Strips metadata not relevant to per-commit
518    /// analysis to reduce prompt size.
519    #[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    // ── update_field_presence ────────────────────────────────────────
553
554    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        // Always-present fields
588        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        // Commit-dependent fields
593        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        // Optional fields
600        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, // Start true, should become false
675        });
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        // Build a view where every optional field is populated and commits are
684        // non-empty.  After update_field_presence() every documented field must
685        // be present=true.  If a new FieldDocumentation entry is added without
686        // a corresponding match arm the catch-all arm returns false and this
687        // test fails, catching the drift at test time.
688        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    // ── single_commit_view / multi_commit_view ───────────────────────
718
719    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        // branch_info IS preserved
762        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    // ── single_commit_view_for_ai ──────────────────────────────────
794
795    #[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        // branch_info IS preserved (for scope context)
849        assert!(single.branch_info.is_some());
850        assert_eq!(single.branch_info.unwrap().branch, "feature/test");
851    }
852
853    // ── FieldExplanation::default ────────────────────────────────────
854
855    #[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        // Core fields that must be documented
862        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    // ── map_commits / from_repository_view ──────────────────────────
890
891    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}