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/// Root node of the YAML output produced by `view`, `info`, `check`, and the branch
18/// subcommands.
19///
20/// Field presence is runtime-dependent: optional fields are populated only when the
21/// active command and repository state require them, and the embedded
22/// [`FieldExplanation`] reports which fields are actually present in this serialization.
23/// See [ADR-0013](../../docs/adrs/adr-0013.md) for the field-presence contract.
24///
25/// Generic over the commit type so the same shape serves both human-facing output
26/// (`CommitInfo`) and AI-facing output ([`RepositoryViewForAI`], using `CommitInfoForAI`).
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RepositoryView<C = CommitInfo> {
29    /// Version information for the omni-dev tool.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub versions: Option<VersionInfo>,
32    /// Explanation of field meanings and structure.
33    pub explanation: FieldExplanation,
34    /// Working directory status information.
35    pub working_directory: WorkingDirectoryInfo,
36    /// List of remote repositories and their main branches.
37    pub remotes: Vec<RemoteInfo>,
38    /// AI-related information.
39    pub ai: AiInfo,
40    /// Branch information (only present when using branch commands).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub branch_info: Option<BranchInfo>,
43    /// Pull request template content (only present in branch commands when template exists).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub pr_template: Option<String>,
46    /// Location of the pull request template file (only present when pr_template exists).
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub pr_template_location: Option<String>,
49    /// Pull requests created from the current branch (only present in branch commands).
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub branch_prs: Option<Vec<PullRequest>>,
52    /// List of analyzed commits with metadata and analysis.
53    pub commits: Vec<C>,
54}
55
56/// Enhanced repository view for AI processing with full diff content.
57pub type RepositoryViewForAI = RepositoryView<CommitInfoForAI>;
58
59/// Self-describing schema metadata embedded under [`RepositoryView::explanation`].
60///
61/// Always present. Carries prose intro text plus a per-field list so an AI consumer can
62/// read a single YAML document and know what every field means and whether it is
63/// populated in this serialization. See [ADR-0013](../../docs/adrs/adr-0013.md) for the
64/// rationale.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct FieldExplanation {
67    /// Descriptive text explaining the overall structure.
68    pub text: String,
69    /// Documentation for individual fields in the output.
70    pub fields: Vec<FieldDocumentation>,
71}
72
73/// Single entry inside [`FieldExplanation::fields`].
74///
75/// Names one YAML field path (e.g. `commits[].analysis.diff_file`), explains it, optionally
76/// links a `git` command that produces the underlying data, and carries a runtime
77/// `present` flag set by [`RepositoryView::update_field_presence`] before serialization.
78/// See [ADR-0013](../../docs/adrs/adr-0013.md).
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct FieldDocumentation {
81    /// Name of the field being documented.
82    pub name: String,
83    /// Descriptive text explaining what the field contains.
84    pub text: String,
85    /// Git command that corresponds to this field (if applicable).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub command: Option<String>,
88    /// Whether this field is present in the current output.
89    pub present: bool,
90}
91
92/// Working-tree status nested under [`RepositoryView::working_directory`].
93///
94/// Always present. Mirrors `git status` at invocation time: a `clean` flag plus the list of
95/// modified or untracked files. Used by AI consumers to decide whether staged changes
96/// should influence the proposed commit message.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct WorkingDirectoryInfo {
99    /// Whether the working directory has no changes.
100    pub clean: bool,
101    /// List of files with uncommitted changes.
102    pub untracked_changes: Vec<FileStatusInfo>,
103}
104
105/// Entry in [`WorkingDirectoryInfo::untracked_changes`].
106///
107/// One per file with uncommitted or untracked changes, carrying the porcelain status
108/// flags (e.g. `"AM"`, `"??"`, `"M "`) and the repository-relative path. Sourced from
109/// `git status --porcelain`.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct FileStatusInfo {
112    /// Git status flags (e.g., "AM", "??", "M ").
113    pub status: String,
114    /// Path to the file relative to repository root.
115    pub file: String,
116}
117
118/// Tool version metadata nested under optional [`RepositoryView::versions`].
119///
120/// Present only when the producing command opts to embed version data (the `view`
121/// command does; lightweight commands omit it). Absent in `single_commit_view` /
122/// `multi_commit_view` projections used for AI dispatch.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct VersionInfo {
125    /// Version of the omni-dev tool.
126    pub omni_dev: String,
127}
128
129/// AI integration metadata nested under [`RepositoryView::ai`].
130///
131/// Always present. Exposes the scratch directory path (controlled by the `AI_SCRATCH`
132/// environment variable) so downstream prompts and agents can resolve the per-commit
133/// diff files referenced from `commits[].analysis.diff_file` and `file_diffs[].diff_file`.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct AiInfo {
136    /// Path to AI scratch directory.
137    pub scratch: String,
138}
139
140/// Current-branch context nested under optional [`RepositoryView::branch_info`].
141///
142/// Present only for branch-aware commands (e.g. branch analysis / PR-message
143/// generation); absent on plain `view`. Preserved by `single_commit_view` projections
144/// because the branch name carries useful scope information for per-commit AI dispatch.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct BranchInfo {
147    /// Current branch name.
148    pub branch: String,
149}
150
151/// GitHub pull-request metadata. Appears as an entry in optional
152/// [`RepositoryView::branch_prs`].
153///
154/// Populated by branch-aware commands when the current branch has been pushed and the
155/// GitHub API resolves one or more PRs against it; absent on local-only branches or when
156/// the lookup fails. Field presence for the `branch_prs[].*` paths is tracked per
157/// [ADR-0013](../../docs/adrs/adr-0013.md).
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct PullRequest {
160    /// PR number.
161    pub number: u64,
162    /// PR title.
163    pub title: String,
164    /// PR state (open, closed, merged).
165    pub state: String,
166    /// PR URL.
167    pub url: String,
168    /// PR description/body content.
169    pub body: String,
170    /// Base branch the PR targets.
171    #[serde(default)]
172    pub base: String,
173}
174
175impl RepositoryView {
176    /// Updates the present field for all field documentation entries based on actual data.
177    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, // Always present
184                "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, // Unknown fields are not present
216            }
217        }
218    }
219
220    /// Serializes this view to YAML, calling [`update_field_presence`] first.
221    ///
222    /// Use this instead of calling `update_field_presence` followed by
223    /// `crate::data::to_yaml` separately.  Keeping the two steps together
224    /// prevents the explanation section from being stale in the output.
225    ///
226    /// [`update_field_presence`]: Self::update_field_presence
227    pub fn to_yaml_output(&mut self) -> anyhow::Result<String> {
228        self.update_field_presence();
229        yaml::to_yaml(self)
230    }
231
232    /// Creates a minimal view containing a single commit for parallel dispatch.
233    ///
234    /// Strips metadata not relevant to per-commit AI analysis (versions,
235    /// working directory status, remotes, PR templates) to reduce prompt size.
236    /// Only retains `branch_info` (for scope context) and the single commit.
237    #[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    /// Creates a minimal view containing multiple commits for batched dispatch.
262    ///
263    /// Same metadata stripping as [`Self::single_commit_view`] but with N commits.
264    /// Used by the batching system to group commits into a single AI request.
265    #[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    /// Creates default field explanation.
292    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, // Will be set dynamically when creating output
311                },
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    /// Transforms commits while preserving all other fields.
515    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    /// Converts from basic RepositoryView by loading diff content for all commits.
537    pub fn from_repository_view(repo_view: RepositoryView) -> anyhow::Result<Self> {
538        Self::from_repository_view_with_options(repo_view, false)
539    }
540
541    /// Converts from basic RepositoryView with options.
542    ///
543    /// If `fresh` is true, clears original commit messages to force AI to generate
544    /// new messages based solely on the diff content.
545    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    /// Creates a minimal AI view containing a single commit for split dispatch.
560    ///
561    /// Analogous to [`RepositoryView::single_commit_view`] but operates on
562    /// the AI-enhanced type. Strips metadata not relevant to per-commit
563    /// analysis to reduce prompt size.
564    #[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    // ── update_field_presence ────────────────────────────────────────
598
599    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        // Always-present fields
633        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        // Commit-dependent fields
638        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        // Optional fields
645        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, // Start true, should become false
720        });
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        // Build a view where every optional field is populated and commits are
729        // non-empty.  After update_field_presence() every documented field must
730        // be present=true.  If a new FieldDocumentation entry is added without
731        // a corresponding match arm the catch-all arm returns false and this
732        // test fails, catching the drift at test time.
733        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    // ── single_commit_view / multi_commit_view ───────────────────────
763
764    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        // branch_info IS preserved
807        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    // ── single_commit_view_for_ai ──────────────────────────────────
839
840    #[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        // branch_info IS preserved (for scope context)
894        assert!(single.branch_info.is_some());
895        assert_eq!(single.branch_info.unwrap().branch, "feature/test");
896    }
897
898    // ── FieldExplanation::default ────────────────────────────────────
899
900    #[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        // Core fields that must be documented
907        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    // ── map_commits / from_repository_view ──────────────────────────
935
936    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}