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/// Commit analysis stripped of all diff-related content.
60///
61/// Used by the `--from-commits` PR generation path, which drives the AI
62/// from commit messages alone. Carries only pre-computed metadata that
63/// does not require reading any diff content from disk.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CommitAnalysisFromCommits {
66    /// Automatically detected conventional commit type (feat, fix, docs, etc.).
67    pub detected_type: String,
68    /// Automatically detected scope based on file paths (cli, git, data, etc.).
69    pub detected_scope: String,
70}
71
72/// Commit information for the commit-message-driven PR path.
73pub type CommitInfoFromCommits = CommitInfo<CommitAnalysisFromCommits>;
74
75/// Repository view used by `--from-commits`.
76///
77/// Never reads diff content from disk. Each commit carries only its
78/// metadata and the curated commit message.
79pub type RepositoryViewForAiFromCommits = RepositoryView<CommitInfoFromCommits>;
80
81/// Self-describing schema metadata embedded under [`RepositoryView::explanation`].
82///
83/// Always present. Carries prose intro text plus a per-field list so an AI consumer can
84/// read a single YAML document and know what every field means and whether it is
85/// populated in this serialization. See [ADR-0013](../../docs/adrs/adr-0013.md) for the
86/// rationale.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct FieldExplanation {
89    /// Descriptive text explaining the overall structure.
90    pub text: String,
91    /// Documentation for individual fields in the output.
92    pub fields: Vec<FieldDocumentation>,
93}
94
95/// Single entry inside [`FieldExplanation::fields`].
96///
97/// Names one YAML field path (e.g. `commits[].analysis.diff_file`), explains it, optionally
98/// links a `git` command that produces the underlying data, and carries a runtime
99/// `present` flag set by [`RepositoryView::update_field_presence`] before serialization.
100/// See [ADR-0013](../../docs/adrs/adr-0013.md).
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct FieldDocumentation {
103    /// Name of the field being documented.
104    pub name: String,
105    /// Descriptive text explaining what the field contains.
106    pub text: String,
107    /// Git command that corresponds to this field (if applicable).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub command: Option<String>,
110    /// Whether this field is present in the current output.
111    pub present: bool,
112}
113
114/// Working-tree status nested under [`RepositoryView::working_directory`].
115///
116/// Always present. Mirrors `git status` at invocation time: a `clean` flag plus the list of
117/// modified or untracked files. Used by AI consumers to decide whether staged changes
118/// should influence the proposed commit message.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct WorkingDirectoryInfo {
121    /// Whether the working directory has no changes.
122    pub clean: bool,
123    /// List of files with uncommitted changes.
124    pub untracked_changes: Vec<FileStatusInfo>,
125}
126
127/// Entry in [`WorkingDirectoryInfo::untracked_changes`].
128///
129/// One per file with uncommitted or untracked changes, carrying the porcelain status
130/// flags (e.g. `"AM"`, `"??"`, `"M "`) and the repository-relative path. Sourced from
131/// `git status --porcelain`.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct FileStatusInfo {
134    /// Git status flags (e.g., "AM", "??", "M ").
135    pub status: String,
136    /// Path to the file relative to repository root.
137    pub file: String,
138}
139
140/// Tool version metadata nested under optional [`RepositoryView::versions`].
141///
142/// Present only when the producing command opts to embed version data (the `view`
143/// command does; lightweight commands omit it). Absent in `single_commit_view` /
144/// `multi_commit_view` projections used for AI dispatch.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct VersionInfo {
147    /// Version of the omni-dev tool.
148    pub omni_dev: String,
149}
150
151/// AI integration metadata nested under [`RepositoryView::ai`].
152///
153/// Always present. Exposes the scratch directory path (controlled by the `AI_SCRATCH`
154/// environment variable) so downstream prompts and agents can resolve the per-commit
155/// diff files referenced from `commits[].analysis.diff_file` and `file_diffs[].diff_file`.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct AiInfo {
158    /// Path to AI scratch directory.
159    pub scratch: String,
160}
161
162/// Current-branch context nested under optional [`RepositoryView::branch_info`].
163///
164/// Present only for branch-aware commands (e.g. branch analysis / PR-message
165/// generation); absent on plain `view`. Preserved by `single_commit_view` projections
166/// because the branch name carries useful scope information for per-commit AI dispatch.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct BranchInfo {
169    /// Current branch name.
170    pub branch: String,
171}
172
173/// GitHub pull-request metadata. Appears as an entry in optional
174/// [`RepositoryView::branch_prs`].
175///
176/// Populated by branch-aware commands when the current branch has been pushed and the
177/// GitHub API resolves one or more PRs against it; absent on local-only branches or when
178/// the lookup fails. Field presence for the `branch_prs[].*` paths is tracked per
179/// [ADR-0013](../../docs/adrs/adr-0013.md).
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct PullRequest {
182    /// PR number.
183    pub number: u64,
184    /// PR title.
185    pub title: String,
186    /// PR state (open, closed, merged).
187    pub state: String,
188    /// PR URL.
189    pub url: String,
190    /// PR description/body content.
191    pub body: String,
192    /// Base branch the PR targets.
193    #[serde(default)]
194    pub base: String,
195}
196
197impl RepositoryView {
198    /// Updates the present field for all field documentation entries based on actual data.
199    pub fn update_field_presence(&mut self) {
200        for field in &mut self.explanation.fields {
201            field.present = match field.name.as_str() {
202                "working_directory.clean"
203                | "working_directory.untracked_changes"
204                | "remotes"
205                | "ai.scratch" => true, // Always present
206                "commits[].hash"
207                | "commits[].author"
208                | "commits[].date"
209                | "commits[].original_message"
210                | "commits[].in_main_branches"
211                | "commits[].analysis.detected_type"
212                | "commits[].analysis.detected_scope"
213                | "commits[].analysis.proposed_message"
214                | "commits[].analysis.file_changes.total_files"
215                | "commits[].analysis.file_changes.files_added"
216                | "commits[].analysis.file_changes.files_deleted"
217                | "commits[].analysis.file_changes.file_list"
218                | "commits[].analysis.diff_summary"
219                | "commits[].analysis.diff_file"
220                | "commits[].analysis.file_diffs"
221                | "commits[].analysis.file_diffs[].path"
222                | "commits[].analysis.file_diffs[].diff_file"
223                | "commits[].analysis.file_diffs[].byte_len" => !self.commits.is_empty(),
224                "versions.omni_dev" => self.versions.is_some(),
225                "branch_info.branch" => self.branch_info.is_some(),
226                "pr_template" => self.pr_template.is_some(),
227                "pr_template_location" => self.pr_template_location.is_some(),
228                "branch_prs" => self.branch_prs.is_some(),
229                "branch_prs[].number"
230                | "branch_prs[].title"
231                | "branch_prs[].state"
232                | "branch_prs[].url"
233                | "branch_prs[].body"
234                | "branch_prs[].base" => {
235                    self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty())
236                }
237                _ => false, // Unknown fields are not present
238            }
239        }
240    }
241
242    /// Serializes this view to YAML, calling [`update_field_presence`] first.
243    ///
244    /// Use this instead of calling `update_field_presence` followed by
245    /// `crate::data::to_yaml` separately.  Keeping the two steps together
246    /// prevents the explanation section from being stale in the output.
247    ///
248    /// [`update_field_presence`]: Self::update_field_presence
249    pub fn to_yaml_output(&mut self) -> anyhow::Result<String> {
250        self.update_field_presence();
251        yaml::to_yaml(self)
252    }
253
254    /// Creates a minimal view containing a single commit for parallel dispatch.
255    ///
256    /// Strips metadata not relevant to per-commit AI analysis (versions,
257    /// working directory status, remotes, PR templates) to reduce prompt size.
258    /// Only retains `branch_info` (for scope context) and the single commit.
259    #[must_use]
260    pub fn single_commit_view(&self, commit: &CommitInfo) -> Self {
261        Self {
262            versions: None,
263            explanation: FieldExplanation {
264                text: String::new(),
265                fields: Vec::new(),
266            },
267            working_directory: WorkingDirectoryInfo {
268                clean: true,
269                untracked_changes: Vec::new(),
270            },
271            remotes: Vec::new(),
272            ai: AiInfo {
273                scratch: String::new(),
274            },
275            branch_info: self.branch_info.clone(),
276            pr_template: None,
277            pr_template_location: None,
278            branch_prs: None,
279            commits: vec![commit.clone()],
280        }
281    }
282
283    /// Creates a minimal view containing multiple commits for batched dispatch.
284    ///
285    /// Same metadata stripping as [`Self::single_commit_view`] but with N commits.
286    /// Used by the batching system to group commits into a single AI request.
287    #[must_use]
288    pub(crate) fn multi_commit_view(&self, commits: &[&CommitInfo]) -> Self {
289        Self {
290            versions: None,
291            explanation: FieldExplanation {
292                text: String::new(),
293                fields: Vec::new(),
294            },
295            working_directory: WorkingDirectoryInfo {
296                clean: true,
297                untracked_changes: Vec::new(),
298            },
299            remotes: Vec::new(),
300            ai: AiInfo {
301                scratch: String::new(),
302            },
303            branch_info: self.branch_info.clone(),
304            pr_template: None,
305            pr_template_location: None,
306            branch_prs: None,
307            commits: commits.iter().map(|c| (*c).clone()).collect(),
308        }
309    }
310}
311
312impl Default for FieldExplanation {
313    /// Creates default field explanation.
314    fn default() -> Self {
315        Self {
316            text: [
317                "Field documentation for the YAML output format. Each entry describes the purpose and content of fields returned by the view command.",
318                "",
319                "Field structure:",
320                "- name: Specifies the YAML field path",
321                "- text: Provides a description of what the field contains",
322                "- command: Shows the corresponding command used to obtain that data (if applicable)",
323                "- present: Indicates whether this field is present in the current output",
324                "",
325                "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."
326            ].join("\n"),
327            fields: vec![
328                FieldDocumentation {
329                    name: "working_directory.clean".to_string(),
330                    text: "Boolean indicating if the working directory has no uncommitted changes".to_string(),
331                    command: Some("git status".to_string()),
332                    present: false, // Will be set dynamically when creating output
333                },
334                FieldDocumentation {
335                    name: "working_directory.untracked_changes".to_string(),
336                    text: "Array of files with uncommitted changes, showing git status and file path".to_string(),
337                    command: Some("git status --porcelain".to_string()),
338                    present: false,
339                },
340                FieldDocumentation {
341                    name: "remotes".to_string(),
342                    text: "Array of git remotes with their URLs and detected main branch names".to_string(),
343                    command: Some("git remote -v".to_string()),
344                    present: false,
345                },
346                FieldDocumentation {
347                    name: "commits[].hash".to_string(),
348                    text: "Full SHA-1 hash of the commit".to_string(),
349                    command: Some("git log --format=%H".to_string()),
350                    present: false,
351                },
352                FieldDocumentation {
353                    name: "commits[].author".to_string(),
354                    text: "Commit author name and email address".to_string(),
355                    command: Some("git log --format=%an <%ae>".to_string()),
356                    present: false,
357                },
358                FieldDocumentation {
359                    name: "commits[].date".to_string(),
360                    text: "Commit date in ISO format with timezone".to_string(),
361                    command: Some("git log --format=%aI".to_string()),
362                    present: false,
363                },
364                FieldDocumentation {
365                    name: "commits[].original_message".to_string(),
366                    text: "The original commit message as written by the author".to_string(),
367                    command: Some("git log --format=%B".to_string()),
368                    present: false,
369                },
370                FieldDocumentation {
371                    name: "commits[].in_main_branches".to_string(),
372                    text: "Array of remote main branches that contain this commit (empty if not pushed)".to_string(),
373                    command: Some("git branch -r --contains <commit>".to_string()),
374                    present: false,
375                },
376                FieldDocumentation {
377                    name: "commits[].analysis.detected_type".to_string(),
378                    text: "Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.)".to_string(),
379                    command: None,
380                    present: false,
381                },
382                FieldDocumentation {
383                    name: "commits[].analysis.detected_scope".to_string(),
384                    text: "Automatically detected scope based on file paths (commands, config, tests, etc.)".to_string(),
385                    command: None,
386                    present: false,
387                },
388                FieldDocumentation {
389                    name: "commits[].analysis.proposed_message".to_string(),
390                    text: "AI-generated conventional commit message based on file changes".to_string(),
391                    command: None,
392                    present: false,
393                },
394                FieldDocumentation {
395                    name: "commits[].analysis.file_changes.total_files".to_string(),
396                    text: "Total number of files modified in this commit".to_string(),
397                    command: Some("git show --name-only <commit>".to_string()),
398                    present: false,
399                },
400                FieldDocumentation {
401                    name: "commits[].analysis.file_changes.files_added".to_string(),
402                    text: "Number of new files added in this commit".to_string(),
403                    command: Some("git show --name-status <commit> | grep '^A'".to_string()),
404                    present: false,
405                },
406                FieldDocumentation {
407                    name: "commits[].analysis.file_changes.files_deleted".to_string(),
408                    text: "Number of files deleted in this commit".to_string(),
409                    command: Some("git show --name-status <commit> | grep '^D'".to_string()),
410                    present: false,
411                },
412                FieldDocumentation {
413                    name: "commits[].analysis.file_changes.file_list".to_string(),
414                    text: "Array of files changed with their git status (M=modified, A=added, D=deleted)".to_string(),
415                    command: Some("git show --name-status <commit>".to_string()),
416                    present: false,
417                },
418                FieldDocumentation {
419                    name: "commits[].analysis.diff_summary".to_string(),
420                    text: "Git diff --stat output showing lines changed per file".to_string(),
421                    command: Some("git show --stat <commit>".to_string()),
422                    present: false,
423                },
424                FieldDocumentation {
425                    name: "commits[].analysis.diff_file".to_string(),
426                    text: "Path to file containing full diff content showing line-by-line changes with added, removed, and context lines.\n\
427                           AI assistants should read this file to understand the specific changes made in the commit.".to_string(),
428                    command: Some("git show <commit>".to_string()),
429                    present: false,
430                },
431                FieldDocumentation {
432                    name: "commits[].analysis.file_diffs".to_string(),
433                    text: "Array of per-file diff references, each containing the file path, \
434                           absolute path to the diff file on disk, and byte length of the diff content.\n\
435                           AI assistants can use these to analyze individual file changes without loading the full diff."
436                        .to_string(),
437                    command: None,
438                    present: false,
439                },
440                FieldDocumentation {
441                    name: "commits[].analysis.file_diffs[].path".to_string(),
442                    text: "Repository-relative path of the changed file.".to_string(),
443                    command: None,
444                    present: false,
445                },
446                FieldDocumentation {
447                    name: "commits[].analysis.file_diffs[].diff_file".to_string(),
448                    text: "Absolute path to the per-file diff file on disk.".to_string(),
449                    command: None,
450                    present: false,
451                },
452                FieldDocumentation {
453                    name: "commits[].analysis.file_diffs[].byte_len".to_string(),
454                    text: "Byte length of the per-file diff content.".to_string(),
455                    command: None,
456                    present: false,
457                },
458                FieldDocumentation {
459                    name: "versions.omni_dev".to_string(),
460                    text: "Version of the omni-dev tool".to_string(),
461                    command: Some("omni-dev --version".to_string()),
462                    present: false,
463                },
464                FieldDocumentation {
465                    name: "ai.scratch".to_string(),
466                    text: "Path to AI scratch directory (controlled by AI_SCRATCH environment variable)".to_string(),
467                    command: Some("echo $AI_SCRATCH".to_string()),
468                    present: false,
469                },
470                FieldDocumentation {
471                    name: "branch_info.branch".to_string(),
472                    text: "Current branch name (only present in branch commands)".to_string(),
473                    command: Some("git branch --show-current".to_string()),
474                    present: false,
475                },
476                FieldDocumentation {
477                    name: "pr_template".to_string(),
478                    text: "Pull request template content from .github/pull_request_template.md (only present in branch commands when file exists)".to_string(),
479                    command: None,
480                    present: false,
481                },
482                FieldDocumentation {
483                    name: "pr_template_location".to_string(),
484                    text: "Location of the pull request template file (only present when pr_template exists)".to_string(),
485                    command: None,
486                    present: false,
487                },
488                FieldDocumentation {
489                    name: "branch_prs".to_string(),
490                    text: "Pull requests created from the current branch (only present in branch commands)".to_string(),
491                    command: None,
492                    present: false,
493                },
494                FieldDocumentation {
495                    name: "branch_prs[].number".to_string(),
496                    text: "Pull request number".to_string(),
497                    command: None,
498                    present: false,
499                },
500                FieldDocumentation {
501                    name: "branch_prs[].title".to_string(),
502                    text: "Pull request title".to_string(),
503                    command: None,
504                    present: false,
505                },
506                FieldDocumentation {
507                    name: "branch_prs[].state".to_string(),
508                    text: "Pull request state (open, closed, merged)".to_string(),
509                    command: None,
510                    present: false,
511                },
512                FieldDocumentation {
513                    name: "branch_prs[].url".to_string(),
514                    text: "Pull request URL".to_string(),
515                    command: None,
516                    present: false,
517                },
518                FieldDocumentation {
519                    name: "branch_prs[].body".to_string(),
520                    text: "Pull request description/body content".to_string(),
521                    command: None,
522                    present: false,
523                },
524                FieldDocumentation {
525                    name: "branch_prs[].base".to_string(),
526                    text: "Base branch the pull request targets".to_string(),
527                    command: None,
528                    present: false,
529                },
530            ],
531        }
532    }
533}
534
535impl<C> RepositoryView<C> {
536    /// Transforms commits while preserving all other fields.
537    pub fn map_commits<D>(
538        self,
539        f: impl FnMut(C) -> anyhow::Result<D>,
540    ) -> anyhow::Result<RepositoryView<D>> {
541        let commits: anyhow::Result<Vec<D>> = self.commits.into_iter().map(f).collect();
542        Ok(RepositoryView {
543            versions: self.versions,
544            explanation: self.explanation,
545            working_directory: self.working_directory,
546            remotes: self.remotes,
547            ai: self.ai,
548            branch_info: self.branch_info,
549            pr_template: self.pr_template,
550            pr_template_location: self.pr_template_location,
551            branch_prs: self.branch_prs,
552            commits: commits?,
553        })
554    }
555}
556
557impl RepositoryViewForAI {
558    /// Converts from basic RepositoryView by loading diff content for all commits.
559    pub fn from_repository_view(repo_view: RepositoryView) -> anyhow::Result<Self> {
560        Self::from_repository_view_with_options(repo_view, false)
561    }
562
563    /// Converts from basic RepositoryView with options.
564    ///
565    /// If `fresh` is true, clears original commit messages to force AI to generate
566    /// new messages based solely on the diff content.
567    pub fn from_repository_view_with_options(
568        repo_view: RepositoryView,
569        fresh: bool,
570    ) -> anyhow::Result<Self> {
571        repo_view.map_commits(|commit| {
572            let mut ai_commit = CommitInfoForAI::from_commit_info(commit)?;
573            if fresh {
574                ai_commit.base.original_message =
575                    "(Original message hidden - generate fresh message from diff)".to_string();
576            }
577            Ok(ai_commit)
578        })
579    }
580
581    /// Creates a minimal AI view containing a single commit for split dispatch.
582    ///
583    /// Analogous to [`RepositoryView::single_commit_view`] but operates on
584    /// the AI-enhanced type. Strips metadata not relevant to per-commit
585    /// analysis to reduce prompt size.
586    #[must_use]
587    pub(crate) fn single_commit_view_for_ai(&self, commit: &CommitInfoForAI) -> Self {
588        Self {
589            versions: None,
590            explanation: FieldExplanation {
591                text: String::new(),
592                fields: Vec::new(),
593            },
594            working_directory: WorkingDirectoryInfo {
595                clean: true,
596                untracked_changes: Vec::new(),
597            },
598            remotes: Vec::new(),
599            ai: AiInfo {
600                scratch: String::new(),
601            },
602            branch_info: self.branch_info.clone(),
603            pr_template: None,
604            pr_template_location: None,
605            branch_prs: None,
606            commits: vec![commit.clone()],
607        }
608    }
609}
610
611impl RepositoryViewForAiFromCommits {
612    /// Converts a `RepositoryView` into the commit-message-only view.
613    ///
614    /// No diff content is read; only pre-computed analysis metadata
615    /// (`detected_type`, `detected_scope`) is retained alongside the
616    /// commit hash, author, date, and message.
617    ///
618    /// The schema-documentation [`FieldExplanation`] is stripped here:
619    /// the default explanation lists diff-related field names (e.g.
620    /// `commits[].analysis.diff_file`) that this view never carries,
621    /// and the user prompt explicitly tells the AI what shape the
622    /// payload has — leaving the documentation in would be misleading.
623    #[must_use]
624    pub fn from_repository_view(repo_view: RepositoryView) -> Self {
625        #[allow(clippy::unwrap_used)] // Conversion is infallible.
626        let mut view: Self = repo_view
627            .map_commits(|c| {
628                Ok(CommitInfo {
629                    hash: c.hash,
630                    author: c.author,
631                    date: c.date,
632                    original_message: c.original_message,
633                    in_main_branches: c.in_main_branches,
634                    analysis: CommitAnalysisFromCommits {
635                        detected_type: c.analysis.detected_type,
636                        detected_scope: c.analysis.detected_scope,
637                    },
638                })
639            })
640            .unwrap();
641        view.explanation = FieldExplanation {
642            text: String::new(),
643            fields: Vec::new(),
644        };
645        view
646    }
647
648    /// Creates a minimal view containing a single commit for split dispatch.
649    ///
650    /// Mirrors [`RepositoryView::single_commit_view`] but for the
651    /// commit-message-only payload used by `--from-commits`.
652    #[must_use]
653    pub(crate) fn single_commit_view_from_commits(&self, commit: &CommitInfoFromCommits) -> Self {
654        Self {
655            versions: None,
656            explanation: FieldExplanation {
657                text: String::new(),
658                fields: Vec::new(),
659            },
660            working_directory: WorkingDirectoryInfo {
661                clean: true,
662                untracked_changes: Vec::new(),
663            },
664            remotes: Vec::new(),
665            ai: AiInfo {
666                scratch: String::new(),
667            },
668            branch_info: self.branch_info.clone(),
669            pr_template: None,
670            pr_template_location: None,
671            branch_prs: None,
672            commits: vec![commit.clone()],
673        }
674    }
675}
676
677#[cfg(test)]
678#[allow(clippy::unwrap_used, clippy::expect_used)]
679mod tests {
680    use super::*;
681    use crate::git::commit::FileChanges;
682    use crate::git::{CommitAnalysis, CommitInfo};
683    use chrono::Utc;
684
685    // ── update_field_presence ────────────────────────────────────────
686
687    fn make_repo_view(commits: Vec<crate::git::CommitInfo>) -> RepositoryView {
688        RepositoryView {
689            versions: None,
690            explanation: FieldExplanation::default(),
691            working_directory: WorkingDirectoryInfo {
692                clean: true,
693                untracked_changes: Vec::new(),
694            },
695            remotes: Vec::new(),
696            ai: AiInfo {
697                scratch: String::new(),
698            },
699            branch_info: None,
700            pr_template: None,
701            pr_template_location: None,
702            branch_prs: None,
703            commits,
704        }
705    }
706
707    fn field_present(view: &RepositoryView, name: &str) -> Option<bool> {
708        view.explanation
709            .fields
710            .iter()
711            .find(|f| f.name == name)
712            .map(|f| f.present)
713    }
714
715    #[test]
716    fn field_presence_no_commits() {
717        let mut view = make_repo_view(vec![]);
718        view.update_field_presence();
719
720        // Always-present fields
721        assert_eq!(field_present(&view, "working_directory.clean"), Some(true));
722        assert_eq!(field_present(&view, "remotes"), Some(true));
723        assert_eq!(field_present(&view, "ai.scratch"), Some(true));
724
725        // Commit-dependent fields
726        assert_eq!(field_present(&view, "commits[].hash"), Some(false));
727        assert_eq!(
728            field_present(&view, "commits[].analysis.detected_type"),
729            Some(false)
730        );
731
732        // Optional fields
733        assert_eq!(field_present(&view, "versions.omni_dev"), Some(false));
734        assert_eq!(field_present(&view, "branch_info.branch"), Some(false));
735        assert_eq!(field_present(&view, "pr_template"), Some(false));
736        assert_eq!(field_present(&view, "branch_prs"), Some(false));
737    }
738
739    #[test]
740    fn field_presence_with_versions() {
741        let mut view = make_repo_view(vec![]);
742        view.versions = Some(VersionInfo {
743            omni_dev: "1.0.0".to_string(),
744        });
745        view.update_field_presence();
746
747        assert_eq!(field_present(&view, "versions.omni_dev"), Some(true));
748    }
749
750    #[test]
751    fn field_presence_with_branch_info() {
752        let mut view = make_repo_view(vec![]);
753        view.branch_info = Some(BranchInfo {
754            branch: "main".to_string(),
755        });
756        view.update_field_presence();
757
758        assert_eq!(field_present(&view, "branch_info.branch"), Some(true));
759    }
760
761    #[test]
762    fn field_presence_with_pr_template() {
763        let mut view = make_repo_view(vec![]);
764        view.pr_template = Some("template content".to_string());
765        view.pr_template_location = Some(".github/pull_request_template.md".to_string());
766        view.update_field_presence();
767
768        assert_eq!(field_present(&view, "pr_template"), Some(true));
769        assert_eq!(field_present(&view, "pr_template_location"), Some(true));
770    }
771
772    #[test]
773    fn field_presence_with_branch_prs() {
774        let mut view = make_repo_view(vec![]);
775        view.branch_prs = Some(vec![PullRequest {
776            number: 42,
777            title: "Test PR".to_string(),
778            state: "open".to_string(),
779            url: "https://github.com/test/test/pull/42".to_string(),
780            body: "PR body".to_string(),
781            base: "main".to_string(),
782        }]);
783        view.update_field_presence();
784
785        assert_eq!(field_present(&view, "branch_prs"), Some(true));
786        assert_eq!(field_present(&view, "branch_prs[].number"), Some(true));
787        assert_eq!(field_present(&view, "branch_prs[].title"), Some(true));
788    }
789
790    #[test]
791    fn field_presence_empty_branch_prs() {
792        let mut view = make_repo_view(vec![]);
793        view.branch_prs = Some(vec![]);
794        view.update_field_presence();
795
796        assert_eq!(field_present(&view, "branch_prs"), Some(true));
797        assert_eq!(field_present(&view, "branch_prs[].number"), Some(false));
798    }
799
800    #[test]
801    fn field_presence_unknown_field_is_false() {
802        let mut view = make_repo_view(vec![]);
803        view.explanation.fields.push(FieldDocumentation {
804            name: "nonexistent.field".to_string(),
805            text: "should be false".to_string(),
806            command: None,
807            present: true, // Start true, should become false
808        });
809        view.update_field_presence();
810
811        assert_eq!(field_present(&view, "nonexistent.field"), Some(false));
812    }
813
814    #[test]
815    fn all_documented_fields_present_with_full_data() {
816        // Build a view where every optional field is populated and commits are
817        // non-empty.  After update_field_presence() every documented field must
818        // be present=true.  If a new FieldDocumentation entry is added without
819        // a corresponding match arm the catch-all arm returns false and this
820        // test fails, catching the drift at test time.
821        let commit = make_commit_info("abc123");
822        let mut view = make_repo_view(vec![commit]);
823        view.versions = Some(VersionInfo {
824            omni_dev: "1.0.0".to_string(),
825        });
826        view.branch_info = Some(BranchInfo {
827            branch: "main".to_string(),
828        });
829        view.pr_template = Some("template".to_string());
830        view.pr_template_location = Some(".github/pull_request_template.md".to_string());
831        view.branch_prs = Some(vec![PullRequest {
832            number: 1,
833            title: "Test".to_string(),
834            state: "open".to_string(),
835            url: "https://github.com/example/repo/pull/1".to_string(),
836            body: "body".to_string(),
837            base: "main".to_string(),
838        }]);
839        view.update_field_presence();
840
841        for field in &view.explanation.fields {
842            assert!(
843                field.present,
844                "Field '{}' is documented but not matched in update_field_presence()",
845                field.name
846            );
847        }
848    }
849
850    // ── single_commit_view / multi_commit_view ───────────────────────
851
852    fn make_commit_info(hash: &str) -> crate::git::CommitInfo {
853        crate::git::CommitInfo {
854            hash: hash.to_string(),
855            author: "Test <test@test.com>".to_string(),
856            date: chrono::Utc::now().fixed_offset(),
857            original_message: "test".to_string(),
858            in_main_branches: Vec::new(),
859            analysis: crate::git::CommitAnalysis {
860                detected_type: "feat".to_string(),
861                detected_scope: "test".to_string(),
862                proposed_message: String::new(),
863                file_changes: crate::git::commit::FileChanges {
864                    total_files: 0,
865                    files_added: 0,
866                    files_deleted: 0,
867                    file_list: Vec::new(),
868                },
869                diff_summary: String::new(),
870                diff_file: String::new(),
871                file_diffs: Vec::new(),
872            },
873        }
874    }
875
876    #[test]
877    fn single_commit_view_strips_metadata() {
878        let mut view = make_repo_view(vec![make_commit_info("aaa"), make_commit_info("bbb")]);
879        view.versions = Some(VersionInfo {
880            omni_dev: "1.0.0".to_string(),
881        });
882        view.branch_info = Some(BranchInfo {
883            branch: "feature/test".to_string(),
884        });
885        view.pr_template = Some("template".to_string());
886
887        let single = view.single_commit_view(&view.commits[0].clone());
888
889        assert!(single.versions.is_none());
890        assert!(single.pr_template.is_none());
891        assert!(single.remotes.is_empty());
892        assert_eq!(single.commits.len(), 1);
893        assert_eq!(single.commits[0].hash, "aaa");
894        // branch_info IS preserved
895        assert!(single.branch_info.is_some());
896        assert_eq!(single.branch_info.unwrap().branch, "feature/test");
897    }
898
899    #[test]
900    fn multi_commit_view_preserves_order() {
901        let commits = vec![
902            make_commit_info("aaa"),
903            make_commit_info("bbb"),
904            make_commit_info("ccc"),
905        ];
906        let view = make_repo_view(commits.clone());
907
908        let refs: Vec<&crate::git::CommitInfo> = commits.iter().collect();
909        let multi = view.multi_commit_view(&refs);
910
911        assert_eq!(multi.commits.len(), 3);
912        assert_eq!(multi.commits[0].hash, "aaa");
913        assert_eq!(multi.commits[1].hash, "bbb");
914        assert_eq!(multi.commits[2].hash, "ccc");
915    }
916
917    #[test]
918    fn multi_commit_view_empty() {
919        let view = make_repo_view(vec![]);
920        let multi = view.multi_commit_view(&[]);
921
922        assert!(multi.commits.is_empty());
923        assert!(multi.versions.is_none());
924    }
925
926    // ── single_commit_view_for_ai ──────────────────────────────────
927
928    #[test]
929    fn single_commit_view_for_ai_strips_metadata() {
930        use crate::git::commit::CommitInfoForAI;
931
932        let commit_info = make_commit_info("aaa");
933        let ai_commit = CommitInfoForAI {
934            base: crate::git::CommitInfo {
935                hash: commit_info.hash,
936                author: commit_info.author,
937                date: commit_info.date,
938                original_message: commit_info.original_message,
939                in_main_branches: commit_info.in_main_branches,
940                analysis: crate::git::commit::CommitAnalysisForAI {
941                    base: commit_info.analysis,
942                    diff_content: "diff content".to_string(),
943                },
944            },
945            pre_validated_checks: Vec::new(),
946        };
947
948        let ai_view = RepositoryViewForAI {
949            versions: Some(VersionInfo {
950                omni_dev: "1.0.0".to_string(),
951            }),
952            explanation: FieldExplanation::default(),
953            working_directory: WorkingDirectoryInfo {
954                clean: true,
955                untracked_changes: Vec::new(),
956            },
957            remotes: vec![RemoteInfo {
958                name: "origin".to_string(),
959                uri: "https://example.com".to_string(),
960                main_branch: "main".to_string(),
961            }],
962            ai: AiInfo {
963                scratch: String::new(),
964            },
965            branch_info: Some(BranchInfo {
966                branch: "feature/test".to_string(),
967            }),
968            pr_template: Some("template".to_string()),
969            pr_template_location: Some(".github/PULL_REQUEST_TEMPLATE.md".to_string()),
970            branch_prs: None,
971            commits: vec![ai_commit.clone()],
972        };
973
974        let single = ai_view.single_commit_view_for_ai(&ai_commit);
975
976        assert!(single.versions.is_none());
977        assert!(single.pr_template.is_none());
978        assert!(single.remotes.is_empty());
979        assert_eq!(single.commits.len(), 1);
980        assert_eq!(single.commits[0].base.hash, "aaa");
981        // branch_info IS preserved (for scope context)
982        assert!(single.branch_info.is_some());
983        assert_eq!(single.branch_info.unwrap().branch, "feature/test");
984    }
985
986    // ── RepositoryViewForAiFromCommits ───────────────────────────────
987
988    #[test]
989    fn from_commits_view_preserves_commit_messages() {
990        let mut view = make_repo_view(vec![make_commit_info("aaa"), make_commit_info("bbb")]);
991        view.commits[0].original_message = "feat: alpha\n\nbody line".to_string();
992        view.commits[1].original_message = "fix: beta".to_string();
993
994        let commits_view = RepositoryViewForAiFromCommits::from_repository_view(view);
995
996        assert_eq!(commits_view.commits.len(), 2);
997        assert_eq!(commits_view.commits[0].hash, "aaa");
998        assert_eq!(
999            commits_view.commits[0].original_message,
1000            "feat: alpha\n\nbody line"
1001        );
1002        assert_eq!(commits_view.commits[1].original_message, "fix: beta");
1003        assert_eq!(commits_view.commits[0].analysis.detected_type, "feat");
1004        assert_eq!(commits_view.commits[1].analysis.detected_type, "feat"); // make_commit_info hard-codes feat
1005    }
1006
1007    #[test]
1008    fn from_commits_view_serialization_contains_no_diff_content() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let diff_path = dir.path().join("0.diff");
1011        std::fs::write(&diff_path, "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+new\n").unwrap();
1012
1013        let mut commit = make_commit_info("aaa");
1014        commit.original_message = "feat(test): unique-commit-subject-marker".to_string();
1015        commit.analysis.diff_file = diff_path.to_string_lossy().to_string();
1016        commit.analysis.diff_summary = "x | 1 +".to_string();
1017
1018        let view = make_repo_view(vec![commit]);
1019        let commits_view = RepositoryViewForAiFromCommits::from_repository_view(view);
1020        let yaml = yaml::to_yaml(&commits_view).unwrap();
1021
1022        // Commit narrative IS present
1023        assert!(yaml.contains("unique-commit-subject-marker"));
1024        // Diff-related fields are NOT
1025        assert!(!yaml.contains("diff --git"));
1026        assert!(!yaml.contains("diff_content"));
1027        assert!(!yaml.contains("diff_file"));
1028        assert!(!yaml.contains("diff_summary"));
1029        assert!(!yaml.contains("file_changes"));
1030        assert!(!yaml.contains("file_diffs"));
1031    }
1032
1033    // ── FieldExplanation::default ────────────────────────────────────
1034
1035    #[test]
1036    fn field_explanation_default_has_all_expected_fields() {
1037        let explanation = FieldExplanation::default();
1038
1039        let field_names: Vec<&str> = explanation.fields.iter().map(|f| f.name.as_str()).collect();
1040
1041        // Core fields that must be documented
1042        assert!(field_names.contains(&"working_directory.clean"));
1043        assert!(field_names.contains(&"remotes"));
1044        assert!(field_names.contains(&"commits[].hash"));
1045        assert!(field_names.contains(&"commits[].author"));
1046        assert!(field_names.contains(&"commits[].date"));
1047        assert!(field_names.contains(&"commits[].original_message"));
1048        assert!(field_names.contains(&"commits[].analysis.detected_type"));
1049        assert!(field_names.contains(&"commits[].analysis.diff_file"));
1050        assert!(field_names.contains(&"ai.scratch"));
1051        assert!(field_names.contains(&"versions.omni_dev"));
1052        assert!(field_names.contains(&"branch_info.branch"));
1053        assert!(field_names.contains(&"pr_template"));
1054        assert!(field_names.contains(&"branch_prs"));
1055    }
1056
1057    #[test]
1058    fn field_explanation_default_all_start_not_present() {
1059        let explanation = FieldExplanation::default();
1060        for field in &explanation.fields {
1061            assert!(
1062                !field.present,
1063                "field '{}' should start as present=false",
1064                field.name
1065            );
1066        }
1067    }
1068
1069    // ── map_commits / from_repository_view ──────────────────────────
1070
1071    fn make_human_view_with_diff_files(
1072        dir: &tempfile::TempDir,
1073        messages: &[&str],
1074    ) -> RepositoryView {
1075        let commits = messages
1076            .iter()
1077            .enumerate()
1078            .map(|(i, msg)| {
1079                let diff_path = dir.path().join(format!("{i}.diff"));
1080                std::fs::write(&diff_path, format!("+line from commit {i}\n")).unwrap();
1081                CommitInfo {
1082                    hash: format!("{i:0>40}"),
1083                    author: "Test <test@test.com>".to_string(),
1084                    date: Utc::now().fixed_offset(),
1085                    original_message: (*msg).to_string(),
1086                    in_main_branches: Vec::new(),
1087                    analysis: CommitAnalysis {
1088                        detected_type: "feat".to_string(),
1089                        detected_scope: "test".to_string(),
1090                        proposed_message: format!("feat(test): {msg}"),
1091                        file_changes: FileChanges {
1092                            total_files: 1,
1093                            files_added: 0,
1094                            files_deleted: 0,
1095                            file_list: Vec::new(),
1096                        },
1097                        diff_summary: "file.rs | 1 +".to_string(),
1098                        diff_file: diff_path.to_string_lossy().to_string(),
1099                        file_diffs: Vec::new(),
1100                    },
1101                }
1102            })
1103            .collect();
1104
1105        RepositoryView {
1106            versions: None,
1107            explanation: FieldExplanation::default(),
1108            working_directory: WorkingDirectoryInfo {
1109                clean: true,
1110                untracked_changes: Vec::new(),
1111            },
1112            remotes: Vec::new(),
1113            ai: AiInfo {
1114                scratch: String::new(),
1115            },
1116            branch_info: None,
1117            pr_template: None,
1118            pr_template_location: None,
1119            branch_prs: None,
1120            commits,
1121        }
1122    }
1123
1124    #[test]
1125    fn map_commits_transforms_all_commits() {
1126        let dir = tempfile::tempdir().unwrap();
1127        let view = make_human_view_with_diff_files(&dir, &["first", "second"]);
1128        assert_eq!(view.commits.len(), 2);
1129
1130        let mapped: RepositoryView<String> = view.map_commits(|c| Ok(c.original_message)).unwrap();
1131        assert_eq!(
1132            mapped.commits,
1133            vec!["first".to_string(), "second".to_string()]
1134        );
1135    }
1136
1137    #[test]
1138    fn from_repository_view_loads_diffs() {
1139        let dir = tempfile::tempdir().unwrap();
1140        let view = make_human_view_with_diff_files(&dir, &["commit one"]);
1141
1142        let ai_view = RepositoryViewForAI::from_repository_view(view).unwrap();
1143        assert_eq!(ai_view.commits.len(), 1);
1144        assert_eq!(
1145            ai_view.commits[0].base.analysis.diff_content,
1146            "+line from commit 0\n"
1147        );
1148        assert_eq!(ai_view.commits[0].base.original_message, "commit one");
1149    }
1150
1151    #[test]
1152    fn from_repository_view_fresh_hides_messages() {
1153        let dir = tempfile::tempdir().unwrap();
1154        let view = make_human_view_with_diff_files(&dir, &["original msg"]);
1155
1156        let ai_view = RepositoryViewForAI::from_repository_view_with_options(view, true).unwrap();
1157        assert!(ai_view.commits[0].base.original_message.contains("hidden"));
1158    }
1159}