1use crate::git::{CommitInfo, CommitInfoForAI, RemoteInfo};
4use serde::{Deserialize, Serialize};
5
6pub mod amendments;
7pub mod check;
8pub mod context;
9pub mod yaml;
10
11pub use amendments::*;
12pub use check::*;
13pub use context::*;
14pub use yaml::*;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RepositoryView {
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub versions: Option<VersionInfo>,
22 pub explanation: FieldExplanation,
24 pub working_directory: WorkingDirectoryInfo,
26 pub remotes: Vec<RemoteInfo>,
28 pub ai: AiInfo,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub branch_info: Option<BranchInfo>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub pr_template: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub pr_template_location: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub branch_prs: Option<Vec<PullRequest>>,
42 pub commits: Vec<CommitInfo>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct RepositoryViewForAI {
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub versions: Option<VersionInfo>,
52 pub explanation: FieldExplanation,
54 pub working_directory: WorkingDirectoryInfo,
56 pub remotes: Vec<RemoteInfo>,
58 pub ai: AiInfo,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub branch_info: Option<BranchInfo>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub pr_template: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub pr_template_location: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub branch_prs: Option<Vec<PullRequest>>,
72 pub commits: Vec<CommitInfoForAI>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct FieldExplanation {
79 pub text: String,
81 pub fields: Vec<FieldDocumentation>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct FieldDocumentation {
88 pub name: String,
90 pub text: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub command: Option<String>,
95 pub present: bool,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct WorkingDirectoryInfo {
102 pub clean: bool,
104 pub untracked_changes: Vec<FileStatusInfo>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct FileStatusInfo {
111 pub status: String,
113 pub file: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct VersionInfo {
120 pub omni_dev: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct AiInfo {
127 pub scratch: String,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct BranchInfo {
134 pub branch: String,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PullRequest {
141 pub number: u64,
143 pub title: String,
145 pub state: String,
147 pub url: String,
149 pub body: String,
151 #[serde(default)]
153 pub base: String,
154}
155
156impl RepositoryView {
157 pub fn update_field_presence(&mut self) {
159 for field in &mut self.explanation.fields {
160 field.present = match field.name.as_str() {
161 "working_directory.clean" => true, "working_directory.untracked_changes" => true, "remotes" => true, "commits[].hash" => !self.commits.is_empty(),
165 "commits[].author" => !self.commits.is_empty(),
166 "commits[].date" => !self.commits.is_empty(),
167 "commits[].original_message" => !self.commits.is_empty(),
168 "commits[].in_main_branches" => !self.commits.is_empty(),
169 "commits[].analysis.detected_type" => !self.commits.is_empty(),
170 "commits[].analysis.detected_scope" => !self.commits.is_empty(),
171 "commits[].analysis.proposed_message" => !self.commits.is_empty(),
172 "commits[].analysis.file_changes.total_files" => !self.commits.is_empty(),
173 "commits[].analysis.file_changes.files_added" => !self.commits.is_empty(),
174 "commits[].analysis.file_changes.files_deleted" => !self.commits.is_empty(),
175 "commits[].analysis.file_changes.file_list" => !self.commits.is_empty(),
176 "commits[].analysis.diff_summary" => !self.commits.is_empty(),
177 "commits[].analysis.diff_file" => !self.commits.is_empty(),
178 "versions.omni_dev" => self.versions.is_some(),
179 "ai.scratch" => true,
180 "branch_info.branch" => self.branch_info.is_some(),
181 "pr_template" => self.pr_template.is_some(),
182 "pr_template_location" => self.pr_template_location.is_some(),
183 "branch_prs" => self.branch_prs.is_some(),
184 "branch_prs[].number" => {
185 self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty())
186 }
187 "branch_prs[].title" => self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty()),
188 "branch_prs[].state" => self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty()),
189 "branch_prs[].url" => self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty()),
190 "branch_prs[].body" => self.branch_prs.as_ref().is_some_and(|prs| !prs.is_empty()),
191 _ => false, }
193 }
194 }
195}
196
197impl Default for FieldExplanation {
198 fn default() -> Self {
200 Self {
201 text: [
202 "Field documentation for the YAML output format. Each entry describes the purpose and content of fields returned by the view command.",
203 "",
204 "Field structure:",
205 "- name: Specifies the YAML field path",
206 "- text: Provides a description of what the field contains",
207 "- command: Shows the corresponding command used to obtain that data (if applicable)",
208 "- present: Indicates whether this field is present in the current output",
209 "",
210 "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."
211 ].join("\n"),
212 fields: vec![
213 FieldDocumentation {
214 name: "working_directory.clean".to_string(),
215 text: "Boolean indicating if the working directory has no uncommitted changes".to_string(),
216 command: Some("git status".to_string()),
217 present: false, },
219 FieldDocumentation {
220 name: "working_directory.untracked_changes".to_string(),
221 text: "Array of files with uncommitted changes, showing git status and file path".to_string(),
222 command: Some("git status --porcelain".to_string()),
223 present: false,
224 },
225 FieldDocumentation {
226 name: "remotes".to_string(),
227 text: "Array of git remotes with their URLs and detected main branch names".to_string(),
228 command: Some("git remote -v".to_string()),
229 present: false,
230 },
231 FieldDocumentation {
232 name: "commits[].hash".to_string(),
233 text: "Full SHA-1 hash of the commit".to_string(),
234 command: Some("git log --format=%H".to_string()),
235 present: false,
236 },
237 FieldDocumentation {
238 name: "commits[].author".to_string(),
239 text: "Commit author name and email address".to_string(),
240 command: Some("git log --format=%an <%ae>".to_string()),
241 present: false,
242 },
243 FieldDocumentation {
244 name: "commits[].date".to_string(),
245 text: "Commit date in ISO format with timezone".to_string(),
246 command: Some("git log --format=%aI".to_string()),
247 present: false,
248 },
249 FieldDocumentation {
250 name: "commits[].original_message".to_string(),
251 text: "The original commit message as written by the author".to_string(),
252 command: Some("git log --format=%B".to_string()),
253 present: false,
254 },
255 FieldDocumentation {
256 name: "commits[].in_main_branches".to_string(),
257 text: "Array of remote main branches that contain this commit (empty if not pushed)".to_string(),
258 command: Some("git branch -r --contains <commit>".to_string()),
259 present: false,
260 },
261 FieldDocumentation {
262 name: "commits[].analysis.detected_type".to_string(),
263 text: "Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.)".to_string(),
264 command: None,
265 present: false,
266 },
267 FieldDocumentation {
268 name: "commits[].analysis.detected_scope".to_string(),
269 text: "Automatically detected scope based on file paths (commands, config, tests, etc.)".to_string(),
270 command: None,
271 present: false,
272 },
273 FieldDocumentation {
274 name: "commits[].analysis.proposed_message".to_string(),
275 text: "AI-generated conventional commit message based on file changes".to_string(),
276 command: None,
277 present: false,
278 },
279 FieldDocumentation {
280 name: "commits[].analysis.file_changes.total_files".to_string(),
281 text: "Total number of files modified in this commit".to_string(),
282 command: Some("git show --name-only <commit>".to_string()),
283 present: false,
284 },
285 FieldDocumentation {
286 name: "commits[].analysis.file_changes.files_added".to_string(),
287 text: "Number of new files added in this commit".to_string(),
288 command: Some("git show --name-status <commit> | grep '^A'".to_string()),
289 present: false,
290 },
291 FieldDocumentation {
292 name: "commits[].analysis.file_changes.files_deleted".to_string(),
293 text: "Number of files deleted in this commit".to_string(),
294 command: Some("git show --name-status <commit> | grep '^D'".to_string()),
295 present: false,
296 },
297 FieldDocumentation {
298 name: "commits[].analysis.file_changes.file_list".to_string(),
299 text: "Array of files changed with their git status (M=modified, A=added, D=deleted)".to_string(),
300 command: Some("git show --name-status <commit>".to_string()),
301 present: false,
302 },
303 FieldDocumentation {
304 name: "commits[].analysis.diff_summary".to_string(),
305 text: "Git diff --stat output showing lines changed per file".to_string(),
306 command: Some("git show --stat <commit>".to_string()),
307 present: false,
308 },
309 FieldDocumentation {
310 name: "commits[].analysis.diff_file".to_string(),
311 text: "Path to file containing full diff content showing line-by-line changes with added, removed, and context lines.\n\
312 AI assistants should read this file to understand the specific changes made in the commit.".to_string(),
313 command: Some("git show <commit>".to_string()),
314 present: false,
315 },
316 FieldDocumentation {
317 name: "versions.omni_dev".to_string(),
318 text: "Version of the omni-dev tool".to_string(),
319 command: Some("omni-dev --version".to_string()),
320 present: false,
321 },
322 FieldDocumentation {
323 name: "ai.scratch".to_string(),
324 text: "Path to AI scratch directory (controlled by AI_SCRATCH environment variable)".to_string(),
325 command: Some("echo $AI_SCRATCH".to_string()),
326 present: false,
327 },
328 FieldDocumentation {
329 name: "branch_info.branch".to_string(),
330 text: "Current branch name (only present in branch commands)".to_string(),
331 command: Some("git branch --show-current".to_string()),
332 present: false,
333 },
334 FieldDocumentation {
335 name: "pr_template".to_string(),
336 text: "Pull request template content from .github/pull_request_template.md (only present in branch commands when file exists)".to_string(),
337 command: None,
338 present: false,
339 },
340 FieldDocumentation {
341 name: "pr_template_location".to_string(),
342 text: "Location of the pull request template file (only present when pr_template exists)".to_string(),
343 command: None,
344 present: false,
345 },
346 FieldDocumentation {
347 name: "branch_prs".to_string(),
348 text: "Pull requests created from the current branch (only present in branch commands)".to_string(),
349 command: None,
350 present: false,
351 },
352 FieldDocumentation {
353 name: "branch_prs[].number".to_string(),
354 text: "Pull request number".to_string(),
355 command: None,
356 present: false,
357 },
358 FieldDocumentation {
359 name: "branch_prs[].title".to_string(),
360 text: "Pull request title".to_string(),
361 command: None,
362 present: false,
363 },
364 FieldDocumentation {
365 name: "branch_prs[].state".to_string(),
366 text: "Pull request state (open, closed, merged)".to_string(),
367 command: None,
368 present: false,
369 },
370 FieldDocumentation {
371 name: "branch_prs[].url".to_string(),
372 text: "Pull request URL".to_string(),
373 command: None,
374 present: false,
375 },
376 FieldDocumentation {
377 name: "branch_prs[].body".to_string(),
378 text: "Pull request description/body content".to_string(),
379 command: None,
380 present: false,
381 },
382 ],
383 }
384 }
385}
386
387impl RepositoryViewForAI {
388 pub fn from_repository_view(repo_view: RepositoryView) -> anyhow::Result<Self> {
390 Self::from_repository_view_with_options(repo_view, false)
391 }
392
393 pub fn from_repository_view_with_options(
398 repo_view: RepositoryView,
399 fresh: bool,
400 ) -> anyhow::Result<Self> {
401 let commits: anyhow::Result<Vec<_>> = repo_view
403 .commits
404 .into_iter()
405 .map(|commit| {
406 let mut ai_commit = CommitInfoForAI::from_commit_info(commit)?;
407 if fresh {
408 ai_commit.original_message =
409 "(Original message hidden - generate fresh message from diff)".to_string();
410 }
411 Ok(ai_commit)
412 })
413 .collect();
414
415 Ok(Self {
416 versions: repo_view.versions,
417 explanation: repo_view.explanation,
418 working_directory: repo_view.working_directory,
419 remotes: repo_view.remotes,
420 ai: repo_view.ai,
421 branch_info: repo_view.branch_info,
422 pr_template: repo_view.pr_template,
423 pr_template_location: repo_view.pr_template_location,
424 branch_prs: repo_view.branch_prs,
425 commits: commits?,
426 })
427 }
428}