1use std::{
2 env,
3 fmt::{Display, Write as FmtWrite},
4 fs::{self, OpenOptions},
5 io::{BufWriter, Write as IoWrite},
6 path::PathBuf,
7 str::FromStr,
8};
9
10use blue_build_process_management::drivers::{
11 CiDriver, Driver, DriverArgs, GitlabDriver, SigningDriver, opts::GenerateKeyPairOpts,
12};
13use blue_build_template::{GitlabCiTemplate, InitReadmeTemplate, Template};
14use blue_build_utils::constants::{COSIGN_PUB_PATH, RECIPE_FILE, RECIPE_PATH, TEMPLATE_REPO_URL};
15use bon::Builder;
16use clap::{Args, ValueEnum, crate_version};
17use comlexr::cmd;
18use log::{debug, info, trace};
19use miette::{Context, IntoDiagnostic, Report, Result, bail, miette};
20use requestty::{Answer, Answers, OnEsc, questions};
21use semver::Version;
22
23use crate::commands::BlueBuildCommand;
24
25#[derive(Debug, Default, Clone, Copy, ValueEnum)]
26pub enum CiProvider {
27 #[default]
28 Github,
29 Gitlab,
30 None,
31}
32
33impl CiProvider {
34 fn default_ci_file_path(self) -> std::path::PathBuf {
35 match self {
36 Self::Gitlab => GitlabDriver::default_ci_file_path(),
37 Self::None | Self::Github => unimplemented!(),
38 }
39 }
40
41 fn render_file(self) -> Result<String> {
42 match self {
43 Self::Gitlab => GitlabCiTemplate::builder()
44 .version({
45 let version = crate_version!();
46 let version: Version = version.parse().into_diagnostic()?;
47
48 format!("v{}.{}", version.major, version.minor)
49 })
50 .build()
51 .render()
52 .into_diagnostic(),
53 Self::None | Self::Github => unimplemented!(),
54 }
55 }
56}
57
58impl TryFrom<&str> for CiProvider {
59 type Error = Report;
60
61 fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
62 Ok(match value {
63 "Gitlab" => Self::Gitlab,
64 "Github" => Self::Github,
65 "None" => Self::None,
66 _ => bail!("Unable to parse for CiProvider"),
67 })
68 }
69}
70
71impl TryFrom<&String> for CiProvider {
72 type Error = Report;
73
74 fn try_from(value: &String) -> std::result::Result<Self, Self::Error> {
75 Self::try_from(value.as_str())
76 }
77}
78
79impl FromStr for CiProvider {
80 type Err = Report;
81
82 fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
83 Self::try_from(s)
84 }
85}
86
87impl Display for CiProvider {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 write!(
90 f,
91 "{}",
92 match *self {
93 Self::Github => "Github",
94 Self::Gitlab => "Gitlab",
95 Self::None => "None",
96 }
97 )
98 }
99}
100
101#[derive(Debug, Clone, Default, Args, Builder)]
102#[builder(on(String, into))]
103pub struct NewInitCommon {
104 #[arg(long)]
106 image_name: Option<String>,
107
108 #[arg(long)]
111 org_name: Option<String>,
112
113 #[arg(long)]
115 description: Option<String>,
116
117 #[arg(long)]
119 registry: Option<String>,
120
121 #[arg(long, short)]
126 ci_provider: Option<CiProvider>,
127
128 #[arg(long)]
130 no_git: bool,
131
132 #[clap(flatten)]
133 #[builder(default)]
134 drivers: DriverArgs,
135}
136
137#[derive(Debug, Clone, Args, Builder)]
138pub struct NewCommand {
139 #[arg()]
140 dir: PathBuf,
141
142 #[clap(flatten)]
143 common: NewInitCommon,
144}
145
146impl BlueBuildCommand for NewCommand {
147 fn try_run(&mut self) -> Result<()> {
148 InitCommand::builder()
149 .dir(self.dir.clone())
150 .common(self.common.clone())
151 .build()
152 .try_run()
153 }
154}
155
156#[derive(Debug, Clone, Args, Builder)]
157pub struct InitCommand {
158 #[clap(skip)]
159 #[builder(into)]
160 dir: Option<PathBuf>,
161
162 #[clap(flatten)]
163 common: NewInitCommon,
164}
165
166impl BlueBuildCommand for InitCommand {
167 fn try_run(&mut self) -> Result<()> {
168 Driver::init(self.common.drivers);
169
170 let base_dir = self
171 .dir
172 .get_or_insert(env::current_dir().into_diagnostic()?);
173
174 if base_dir.exists() && fs::read_dir(base_dir).is_ok_and(|dir| dir.count() != 0) {
175 bail!("Must be in an empty directory!");
176 }
177
178 self.start(&self.questions()?)
179 }
180}
181
182macro_rules! when {
183 ($check:expr) => {
184 |_answers: &::requestty::Answers| $check
185 };
186}
187
188impl InitCommand {
189 const CI_PROVIDER: &str = "ci_provider";
190 const REGISTRY: &str = "registry";
191 const IMAGE_NAME: &str = "image_name";
192 const ORG_NAME: &str = "org_name";
193 const DESCRIPTION: &str = "description";
194
195 fn questions(&self) -> Result<Answers> {
196 let questions = questions![
197 Input {
198 name: Self::IMAGE_NAME,
199 message: "What would you like to name your image?",
200 when: when!(self.common.image_name.is_none()),
201 on_esc: OnEsc::Terminate,
202 },
203 Input {
204 name: Self::REGISTRY,
205 message: "What is the registry for the image? (e.g. ghcr.io or registry.gitlab.com)",
206 when: when!(self.common.registry.is_none()),
207 on_esc: OnEsc::Terminate,
208 },
209 Input {
210 name: Self::ORG_NAME,
211 message: "What is the name of your org/username?",
212 when: when!(self.common.org_name.is_none()),
213 on_esc: OnEsc::Terminate,
214 },
215 Input {
216 name: Self::DESCRIPTION,
217 message: "Write a short description of your image:",
218 when: when!(self.common.description.is_none()),
219 on_esc: OnEsc::Terminate,
220 },
221 Select {
222 name: Self::CI_PROVIDER,
223 message: "Are you building on Github or Gitlab?",
224 when: when!(!self.common.no_git && self.common.ci_provider.is_none()),
225 on_esc: OnEsc::Terminate,
226 choices: vec!["Github", "Gitlab", "None"],
227 }
228 ];
229
230 requestty::prompt(questions).into_diagnostic()
231 }
232
233 fn start(&self, answers: &Answers) -> Result<()> {
234 self.clone_repository()?;
235 self.remove_git_directory()?;
236 self.template_readme(answers)?;
237 self.template_ci_file(answers)?;
238 self.update_recipe_file(answers)?;
239 self.generate_signing_files()?;
240
241 if !self.common.no_git {
242 self.initialize_git()?;
243 self.add_files()?;
244 self.initial_commit()?;
245 }
246
247 info!(
248 "Created new BlueBuild project in {}",
249 self.dir.as_ref().unwrap().display()
250 );
251
252 Ok(())
253 }
254
255 fn clone_repository(&self) -> Result<()> {
256 let dir = self.dir.as_ref().unwrap();
257 trace!("clone_repository()");
258
259 let mut command = cmd!("git", "clone", "-q", TEMPLATE_REPO_URL, dir);
260 trace!("{command:?}");
261
262 let status = command
263 .status()
264 .into_diagnostic()
265 .context("Failed to execute git clone")?;
266
267 if !status.success() {
268 bail!("Failed to clone template repo");
269 }
270
271 Ok(())
272 }
273
274 fn remove_git_directory(&self) -> Result<()> {
275 trace!("remove_git_directory()");
276
277 let dir = self.dir.as_ref().unwrap();
278 let git_path = dir.join(".git");
279
280 if git_path.exists() {
281 fs::remove_dir_all(&git_path)
282 .into_diagnostic()
283 .context("Failed to remove .git directory")?;
284 debug!(".git directory removed.");
285 }
286 Ok(())
287 }
288
289 fn initialize_git(&self) -> Result<()> {
290 trace!("initialize_git()");
291
292 let dir = self.dir.as_ref().unwrap();
293
294 let mut command = cmd!("git", "init", "-q", "-b", "main", dir);
295 trace!("{command:?}");
296
297 let status = command
298 .status()
299 .into_diagnostic()
300 .context("Failed to execute git init")?;
301
302 if !status.success() {
303 bail!("Error initializing git");
304 }
305
306 debug!("Initialized git in {}", dir.display());
307
308 Ok(())
309 }
310
311 fn initial_commit(&self) -> Result<()> {
312 trace!("initial_commit()");
313
314 let dir = self.dir.as_ref().unwrap();
315
316 let mut command = cmd!(cd dir; "git", "commit", "-a", "-m", "chore: Initial Commit");
317 trace!("{command:?}");
318
319 let status = command
320 .status()
321 .into_diagnostic()
322 .context("Failed to run git commit")?;
323
324 if !status.success() {
325 bail!("Failed to commit initial changes");
326 }
327
328 debug!("Created initial commit");
329
330 Ok(())
331 }
332
333 fn add_files(&self) -> Result<()> {
334 trace!("add_files()");
335
336 let dir = self.dir.as_ref().unwrap();
337
338 let mut command = cmd!(cd dir; "git", "add", ".");
339 trace!("{command:?}");
340
341 let status = command
342 .status()
343 .into_diagnostic()
344 .context("Failed to run git add")?;
345
346 if !status.success() {
347 bail!("Failed to add files to initial commit");
348 }
349
350 debug!("Added files for initial commit");
351
352 Ok(())
353 }
354
355 fn template_readme(&self, answers: &Answers) -> Result<()> {
356 trace!("template_readme()");
357
358 let readme_path = self.dir.as_ref().unwrap().join("README.md");
359
360 let readme = InitReadmeTemplate::builder()
361 .repo_name(
362 self.common
363 .org_name
364 .as_deref()
365 .or_else(|| answers.get(Self::ORG_NAME).and_then(Answer::as_string))
366 .ok_or_else(|| miette!("Failed to get organization name"))?,
367 )
368 .image_name(
369 self.common
370 .image_name
371 .as_deref()
372 .or_else(|| answers.get(Self::IMAGE_NAME).and_then(Answer::as_string))
373 .ok_or_else(|| miette!("Failed to get image name"))?,
374 )
375 .registry(
376 self.common
377 .registry
378 .as_deref()
379 .or_else(|| answers.get(Self::REGISTRY).and_then(Answer::as_string))
380 .ok_or_else(|| miette!("Failed to get registry"))?,
381 )
382 .build();
383
384 debug!("Templating README");
385 let readme = readme.render().into_diagnostic()?;
386
387 debug!("Writing README to {}", readme_path.display());
388 fs::write(readme_path, readme).into_diagnostic()
389 }
390
391 fn template_ci_file(&self, answers: &Answers) -> Result<()> {
392 trace!("template_ci_file()");
393
394 let ci_provider = self
395 .common
396 .ci_provider
397 .ok_or("CLI Arg not set")
398 .or_else(|e| {
399 answers
400 .get(Self::CI_PROVIDER)
401 .and_then(Answer::as_list_item)
402 .map(|li| &li.text)
403 .ok_or_else(|| miette!("Failed to get CI Provider answer:\n{e}"))
404 .and_then(CiProvider::try_from)
405 })?;
406
407 if matches!(ci_provider, CiProvider::Github) {
408 fs::remove_file(self.dir.as_ref().unwrap().join(".github/CODEOWNERS"))
409 .into_diagnostic()?;
410 return Ok(());
411 }
412
413 fs::remove_dir_all(self.dir.as_ref().unwrap().join(".github")).into_diagnostic()?;
414
415 if matches!(ci_provider, CiProvider::None) {
417 return Ok(());
418 }
419
420 let ci_file_path = self
421 .dir
422 .as_ref()
423 .unwrap()
424 .join(ci_provider.default_ci_file_path());
425 let parent_path = ci_file_path
426 .parent()
427 .ok_or_else(|| miette!("Couldn't get parent directory from {ci_file_path:?}"))?;
428 fs::create_dir_all(parent_path)
429 .into_diagnostic()
430 .with_context(|| format!("Couldn't create directory path {}", parent_path.display()))?;
431
432 let file = &mut BufWriter::new(
433 OpenOptions::new()
434 .truncate(true)
435 .create(true)
436 .write(true)
437 .open(&ci_file_path)
438 .into_diagnostic()
439 .with_context(|| format!("Failed to open file at {}", ci_file_path.display()))?,
440 );
441
442 let template = ci_provider.render_file()?;
443
444 writeln!(file, "{template}")
445 .into_diagnostic()
446 .with_context(|| format!("Failed to write CI file {}", ci_file_path.display()))
447 }
448
449 fn update_recipe_file(&self, answers: &Answers) -> Result<()> {
450 trace!("update_recipe_file()");
451
452 let recipe_path = self
453 .dir
454 .as_ref()
455 .unwrap()
456 .join(RECIPE_PATH)
457 .join(RECIPE_FILE);
458
459 debug!("Reading {}", recipe_path.display());
460 let file = fs::read_to_string(&recipe_path)
461 .into_diagnostic()
462 .with_context(|| format!("Failed to read {}", recipe_path.display()))?;
463
464 let description = self
465 .common
466 .description
467 .as_deref()
468 .ok_or("Description arg not set")
469 .or_else(|e| {
470 answers
471 .get(Self::DESCRIPTION)
472 .and_then(Answer::as_string)
473 .ok_or_else(|| miette!("Failed to get description:\n{e}"))
474 })?;
475 let name = self
476 .common
477 .image_name
478 .as_deref()
479 .ok_or("Description arg not set")
480 .or_else(|e| {
481 answers
482 .get(Self::IMAGE_NAME)
483 .and_then(Answer::as_string)
484 .ok_or_else(|| miette!("Failed to get description:\n{e}"))
485 })?;
486
487 let mut new_file_str = String::with_capacity(file.capacity());
488
489 for line in file.lines() {
490 if line.starts_with("description:") {
491 writeln!(&mut new_file_str, "description: {description}").into_diagnostic()?;
492 } else if line.starts_with("name: ") {
493 writeln!(&mut new_file_str, "name: {name}").into_diagnostic()?;
494 } else {
495 writeln!(&mut new_file_str, "{line}").into_diagnostic()?;
496 }
497 }
498
499 let file = &mut BufWriter::new(
500 OpenOptions::new()
501 .truncate(true)
502 .write(true)
503 .open(&recipe_path)
504 .into_diagnostic()
505 .with_context(|| format!("Failed to open {}", recipe_path.display()))?,
506 );
507 write!(file, "{new_file_str}")
508 .into_diagnostic()
509 .with_context(|| format!("Failed to write to file {}", recipe_path.display()))
510 }
511
512 fn generate_signing_files(&self) -> Result<()> {
513 trace!("generate_signing_files()");
514
515 debug!("Removing old cosign files {COSIGN_PUB_PATH}");
516 fs::remove_file(self.dir.as_ref().unwrap().join(COSIGN_PUB_PATH))
517 .into_diagnostic()
518 .with_context(|| format!("Failed to delete old public file {COSIGN_PUB_PATH}"))?;
519
520 Driver::generate_key_pair(
521 GenerateKeyPairOpts::builder()
522 .maybe_dir(self.dir.as_deref())
523 .build(),
524 )
525 }
526}