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