1use clap::{Args, Subcommand, ValueEnum};
2use std::fs;
3use std::io::{self, Write as _};
4use std::path::{Path, PathBuf};
5
6const DEFAULT_RISK_NOTES: &str = "- N/A";
7
8#[derive(Args, Debug)]
9pub struct PrBodyArgs {
10 #[command(subcommand)]
11 pub command: PrBodyCommand,
12}
13
14#[derive(Subcommand, Debug)]
15pub enum PrBodyCommand {
16 Render(PrBodyRenderArgs),
18}
19
20#[derive(Args, Debug)]
21pub struct PrBodyRenderArgs {
22 #[arg(long, value_enum)]
27 pub kind: PrBodyKind,
28 #[arg(long)]
30 pub summary_file: PathBuf,
31 #[arg(long, required_if_eq("kind", "feature"))]
33 pub changes_file: Option<PathBuf>,
34 #[arg(long, required_if_eq("kind", "bug"))]
36 pub problem_file: Option<PathBuf>,
37 #[arg(long, required_if_eq("kind", "bug"))]
39 pub reproduction_file: Option<PathBuf>,
40 #[arg(long, required_if_eq("kind", "bug"))]
44 pub issues_file: Option<PathBuf>,
45 #[arg(long, required_if_eq("kind", "bug"))]
47 pub fix_approach_file: Option<PathBuf>,
48 #[arg(long)]
50 pub test_first_file: PathBuf,
51 #[arg(long)]
53 pub test_plan_file: PathBuf,
54 #[arg(long)]
56 pub risk_file: Option<PathBuf>,
57 #[arg(long)]
59 pub out: Option<PathBuf>,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
63#[clap(rename_all = "lower")]
64pub enum PrBodyKind {
65 Feature,
66 Bug,
67 Chore,
68 Docs,
69 Ci,
70 Refactor,
71}
72
73pub fn run(args: PrBodyArgs) -> anyhow::Result<u8> {
74 match args.command {
75 PrBodyCommand::Render(render_args) => render(render_args),
76 }
77}
78
79fn render(args: PrBodyRenderArgs) -> anyhow::Result<u8> {
80 reject_kind_mismatched_files(&args)?;
81
82 let summary = read_required("summary", &args.summary_file)?;
83 let test_first = read_required("test-first", &args.test_first_file)?;
84 let test_plan = read_required("test-plan", &args.test_plan_file)?;
85 let risk = match args.risk_file.as_deref() {
86 Some(path) => read_required("risk", path)?,
87 None => DEFAULT_RISK_NOTES.to_string(),
88 };
89 let optional_issues = match (args.kind, args.issues_file.as_deref()) {
90 (PrBodyKind::Bug, _) | (_, None) => None,
91 (_, Some(path)) => Some(read_required("issues", path)?),
92 };
93
94 let body = match args.kind {
95 PrBodyKind::Feature => {
96 let changes = read_required(
97 "changes",
98 required_path("changes", args.changes_file.as_deref())?,
99 )?;
100 render_feature(
101 &summary,
102 optional_issues.as_deref(),
103 &changes,
104 &test_first,
105 &test_plan,
106 &risk,
107 )
108 }
109 PrBodyKind::Bug => {
110 let problem = read_required(
111 "problem",
112 required_path("problem", args.problem_file.as_deref())?,
113 )?;
114 let reproduction = read_required(
115 "reproduction",
116 required_path("reproduction", args.reproduction_file.as_deref())?,
117 )?;
118 let issues = read_required(
119 "issues",
120 required_path("issues", args.issues_file.as_deref())?,
121 )?;
122 let fix_approach = read_required(
123 "fix-approach",
124 required_path("fix-approach", args.fix_approach_file.as_deref())?,
125 )?;
126 render_bug(
127 &summary,
128 &problem,
129 &reproduction,
130 &issues,
131 &fix_approach,
132 &test_first,
133 &test_plan,
134 &risk,
135 )
136 }
137 PrBodyKind::Chore | PrBodyKind::Docs | PrBodyKind::Ci | PrBodyKind::Refactor => {
138 render_generic(
139 &summary,
140 optional_issues.as_deref(),
141 &test_first,
142 &test_plan,
143 &risk,
144 )
145 }
146 };
147
148 validate_forge_sections(&body)?;
149 write_output(args.out.as_deref(), &body)?;
150 Ok(0)
151}
152
153fn reject_kind_mismatched_files(args: &PrBodyRenderArgs) -> anyhow::Result<()> {
157 let mismatches = [
158 (
159 args.changes_file.is_some() && args.kind != PrBodyKind::Feature,
160 "changes",
161 "feature",
162 ),
163 (
164 args.problem_file.is_some() && args.kind != PrBodyKind::Bug,
165 "problem",
166 "bug",
167 ),
168 (
169 args.reproduction_file.is_some() && args.kind != PrBodyKind::Bug,
170 "reproduction",
171 "bug",
172 ),
173 (
174 args.fix_approach_file.is_some() && args.kind != PrBodyKind::Bug,
175 "fix-approach",
176 "bug",
177 ),
178 ];
179 for (mismatched, label, owner) in mismatches {
180 if mismatched {
181 anyhow::bail!("--{label}-file is only rendered by --kind {owner}");
182 }
183 }
184 Ok(())
185}
186
187fn read_required(label: &str, path: &Path) -> anyhow::Result<String> {
188 let raw = fs::read_to_string(path).map_err(|err| {
189 anyhow::anyhow!("failed to read --{label}-file {}: {err}", path.display())
190 })?;
191 let body = raw.trim();
192 if body.is_empty() {
193 anyhow::bail!("--{label}-file {} is empty", path.display());
194 }
195 Ok(body.to_string())
196}
197
198fn required_path<'a>(label: &str, path: Option<&'a Path>) -> anyhow::Result<&'a Path> {
199 path.ok_or_else(|| anyhow::anyhow!("--{label}-file is required"))
200}
201
202fn render_feature(
203 summary: &str,
204 issues: Option<&str>,
205 changes: &str,
206 test_first: &str,
207 test_plan: &str,
208 risk: &str,
209) -> String {
210 let issues = issues_section(issues);
211 format!(
212 "## Summary\n\n{summary}\n\n{issues}## Changes\n\n{changes}\n\n## Test-First Evidence\n\n{test_first}\n\n## Test plan\n\n{test_plan}\n\n## Risk / Notes\n\n{risk}\n"
213 )
214}
215
216fn render_generic(
221 summary: &str,
222 issues: Option<&str>,
223 test_first: &str,
224 test_plan: &str,
225 risk: &str,
226) -> String {
227 let issues = issues_section(issues);
228 format!(
229 "## Summary\n\n{summary}\n\n{issues}## Test-First Evidence\n\n{test_first}\n\n## Test plan\n\n{test_plan}\n\n## Risk / Notes\n\n{risk}\n"
230 )
231}
232
233fn issues_section(issues: Option<&str>) -> String {
237 issues
238 .map(|issues| format!("## Issues\n\n{issues}\n\n"))
239 .unwrap_or_default()
240}
241
242#[allow(clippy::too_many_arguments)]
243fn render_bug(
244 summary: &str,
245 problem: &str,
246 reproduction: &str,
247 issues: &str,
248 fix_approach: &str,
249 test_first: &str,
250 test_plan: &str,
251 risk: &str,
252) -> String {
253 format!(
254 "## Summary\n\n{summary}\n\n## Problem\n\n{problem}\n\n## Reproduction\n\n{reproduction}\n\n## Issues Found\n\n{issues}\n\n## Fix Approach\n\n{fix_approach}\n\n## Test-First Evidence\n\n{test_first}\n\n## Test plan\n\n{test_plan}\n\n## Risk / Notes\n\n{risk}\n"
255 )
256}
257
258fn validate_forge_sections(body: &str) -> anyhow::Result<()> {
259 for heading in ["## Summary", "## Test plan"] {
260 if !has_non_empty_h2_section(body, heading) {
261 anyhow::bail!("rendered body is missing non-empty {heading}");
262 }
263 }
264 Ok(())
265}
266
267fn has_non_empty_h2_section(body: &str, heading: &str) -> bool {
268 let mut in_section = false;
269 for line in body.lines() {
270 if line.starts_with("## ") {
271 if in_section {
272 return false;
273 }
274 in_section = line.trim_end() == heading;
275 continue;
276 }
277 if in_section && !line.trim().is_empty() {
278 return true;
279 }
280 }
281 false
282}
283
284fn write_output(out: Option<&Path>, body: &str) -> anyhow::Result<()> {
285 match out {
286 Some(path) => {
287 fs::write(path, body).map_err(|err| {
288 anyhow::anyhow!("failed to write --out {}: {err}", path.display())
289 })?;
290 }
291 None => {
292 let mut stdout = io::stdout().lock();
293 stdout.write_all(body.as_bytes())?;
294 }
295 }
296 Ok(())
297}