1use crate::{
4 apis::ManagedApis,
5 cmd::{
6 check::check_impl, debug::debug_impl, generate::generate_impl,
7 list::list_impl,
8 },
9 environment::{BlessedSource, Environment, GeneratedSource, ResolvedEnv},
10 output::OutputOpts,
11 vcs::VcsRevision,
12};
13use anyhow::Result;
14use camino::Utf8PathBuf;
15use clap::{Args, Parser, Subcommand};
16use std::{io::Write as _, process::ExitCode};
17
18#[derive(Debug, Parser)]
22pub struct App {
23 #[clap(flatten)]
24 output_opts: OutputOpts,
25
26 #[clap(subcommand)]
27 command: Command,
28}
29
30impl App {
31 pub fn exec(self, env: &Environment, apis: &ManagedApis) -> ExitCode {
34 let result = match self.command {
35 Command::Debug(args) => args.exec(env, apis, &self.output_opts),
36 Command::List(args) => args.exec(apis, &self.output_opts),
37 Command::Generate(args) => args.exec(env, apis, &self.output_opts),
38 Command::Check(args) => args.exec(env, apis, &self.output_opts),
39 };
40
41 match result {
42 Ok(exit_code) => exit_code,
43 Err(error) => {
44 let _ = writeln!(
47 &mut std::io::stderr().lock(),
48 "failure: {:#}",
49 error,
50 );
51 ExitCode::FAILURE
52 }
53 }
54 }
55}
56
57#[derive(Debug, Subcommand)]
58pub enum Command {
59 Debug(DebugArgs),
61
62 List(ListArgs),
67
68 Generate(GenerateArgs),
70
71 Check(CheckArgs),
73}
74
75#[derive(Debug, Args)]
76pub struct BlessedSourceArgs {
77 #[clap(
98 long = "blessed-from-vcs",
99 alias = "blessed-from-git",
100 env(BLESSED_FROM_VCS_ENV),
101 value_name("REVISION")
102 )]
103 pub blessed_from_vcs: Option<String>,
104
105 #[clap(long, env(BLESSED_FROM_VCS_PATH_ENV), value_name("PATH"))]
108 pub blessed_from_vcs_path: Option<Utf8PathBuf>,
109
110 #[clap(
115 long,
116 conflicts_with("blessed_from_vcs"),
117 env("OPENAPI_MGR_BLESSED_FROM_DIR"),
118 value_name("DIRECTORY")
119 )]
120 pub blessed_from_dir: Option<Utf8PathBuf>,
121}
122
123const BLESSED_FROM_VCS_ENV: &str = "OPENAPI_MGR_BLESSED_FROM_VCS";
125
126const BLESSED_FROM_VCS_PATH_ENV: &str = "OPENAPI_MGR_BLESSED_FROM_VCS_PATH";
128
129const BLESSED_FROM_GIT_ENV: &str = "OPENAPI_MGR_BLESSED_FROM_GIT";
131
132impl BlessedSourceArgs {
133 pub(crate) fn to_blessed_source(
134 &self,
135 env: &ResolvedEnv,
136 ) -> Result<BlessedSource, anyhow::Error> {
137 assert!(
138 self.blessed_from_dir.is_none() || self.blessed_from_vcs.is_none()
139 );
140
141 if let Some(local_directory) = &self.blessed_from_dir {
142 return Ok(BlessedSource::Directory {
143 local_directory: local_directory.clone(),
144 });
145 }
146
147 let resolved =
148 resolve_blessed_from_vcs(self.blessed_from_vcs.as_deref());
149 let revision_str = match &resolved {
150 Some(revision) => revision.as_str(),
151 None => env.default_blessed_branch.as_str(),
152 };
153 let revision = VcsRevision::from(String::from(revision_str));
154 let directory = match &self.blessed_from_vcs_path {
155 Some(path) => path.clone(),
156 None => Utf8PathBuf::from(env.openapi_rel_dir()),
159 };
160 Ok(BlessedSource::VcsRevisionMergeBase { revision, directory })
161 }
162}
163
164fn resolve_blessed_from_vcs(cli_value: Option<&str>) -> Option<String> {
178 if let Some(v) = cli_value {
179 return Some(v.to_owned());
180 }
181
182 if let Ok(v) = std::env::var(BLESSED_FROM_GIT_ENV) {
183 return Some(v);
184 }
185
186 None
187}
188
189#[derive(Debug, Args)]
190pub struct GeneratedSourceArgs {
191 #[clap(long, value_name("DIRECTORY"))]
194 pub generated_from_dir: Option<Utf8PathBuf>,
195}
196
197impl From<GeneratedSourceArgs> for GeneratedSource {
198 fn from(value: GeneratedSourceArgs) -> Self {
199 match value.generated_from_dir {
200 Some(local_directory) => {
201 GeneratedSource::Directory { local_directory }
202 }
203 None => GeneratedSource::Generated,
204 }
205 }
206}
207
208#[derive(Debug, Args)]
209pub struct LocalSourceArgs {
210 #[clap(long, env("OPENAPI_MGR_DIR"), value_name("DIRECTORY"))]
212 dir: Option<Utf8PathBuf>,
213}
214
215#[derive(Debug, Args)]
216pub struct DebugArgs {
217 #[clap(flatten)]
218 local: LocalSourceArgs,
219 #[clap(flatten)]
220 blessed: BlessedSourceArgs,
221 #[clap(flatten)]
222 generated: GeneratedSourceArgs,
223}
224
225impl DebugArgs {
226 fn exec(
227 self,
228 env: &Environment,
229 apis: &ManagedApis,
230 output: &OutputOpts,
231 ) -> anyhow::Result<ExitCode> {
232 let env = env.resolve(self.local.dir)?;
233 let blessed_source = self.blessed.to_blessed_source(&env)?;
234 let generated_source = GeneratedSource::from(self.generated);
235 debug_impl(apis, &env, &blessed_source, &generated_source, output)?;
236 Ok(ExitCode::SUCCESS)
237 }
238}
239
240#[derive(Debug, Args)]
241pub struct ListArgs {
242 #[clap(long, short)]
244 verbose: bool,
245}
246
247impl ListArgs {
248 fn exec(
249 self,
250 apis: &ManagedApis,
251 output: &OutputOpts,
252 ) -> anyhow::Result<ExitCode> {
253 list_impl(apis, self.verbose, output)?;
254 Ok(ExitCode::SUCCESS)
255 }
256}
257
258#[derive(Debug, Args)]
259pub struct GenerateArgs {
260 #[clap(flatten)]
261 local: LocalSourceArgs,
262 #[clap(flatten)]
263 blessed: BlessedSourceArgs,
264 #[clap(flatten)]
265 generated: GeneratedSourceArgs,
266}
267
268impl GenerateArgs {
269 fn exec(
270 self,
271 env: &Environment,
272 apis: &ManagedApis,
273 output: &OutputOpts,
274 ) -> anyhow::Result<ExitCode> {
275 let env = env.resolve(self.local.dir)?;
276 let blessed_source = self.blessed.to_blessed_source(&env)?;
277 let generated_source = GeneratedSource::from(self.generated);
278 Ok(generate_impl(
279 apis,
280 &env,
281 &blessed_source,
282 &generated_source,
283 output,
284 )?
285 .to_exit_code())
286 }
287}
288
289#[derive(Debug, Args)]
290pub struct CheckArgs {
291 #[clap(flatten)]
292 local: LocalSourceArgs,
293 #[clap(flatten)]
294 blessed: BlessedSourceArgs,
295 #[clap(flatten)]
296 generated: GeneratedSourceArgs,
297}
298
299impl CheckArgs {
300 fn exec(
301 self,
302 env: &Environment,
303 apis: &ManagedApis,
304 output: &OutputOpts,
305 ) -> anyhow::Result<ExitCode> {
306 let env = env.resolve(self.local.dir)?;
307 let blessed_source = self.blessed.to_blessed_source(&env)?;
308 let generated_source = GeneratedSource::from(self.generated);
309 let styles = output.styles(supports_color::Stream::Stderr);
310 Ok(check_impl(
311 &mut std::io::stderr().lock(),
312 apis,
313 &env,
314 &blessed_source,
315 &generated_source,
316 &styles,
317 )?
318 .to_exit_code())
319 }
320}
321
322pub const NEEDS_UPDATE_EXIT_CODE: u8 = 4;
327
328pub const FAILURE_EXIT_CODE: u8 = 100;
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::{
338 environment::{
339 BlessedSource, Environment, GeneratedSource, ResolvedEnv,
340 },
341 vcs::VcsRevision,
342 };
343 use assert_matches::assert_matches;
344 use camino::{Utf8Path, Utf8PathBuf};
345 use clap::Parser;
346
347 #[test]
348 fn test_arg_parsing() {
349 let app = App::parse_from(["dummy", "check"]);
351 assert_matches!(
352 app.command,
353 Command::Check(CheckArgs {
354 local: LocalSourceArgs { dir: None },
355 blessed: BlessedSourceArgs {
356 blessed_from_vcs: None,
357 blessed_from_vcs_path: None,
358 blessed_from_dir: None
359 },
360 generated: GeneratedSourceArgs { generated_from_dir: None },
361 })
362 );
363
364 let app = App::parse_from(["dummy", "check", "--dir", "foo"]);
366 assert_matches!(app.command, Command::Check(CheckArgs {
367 local: LocalSourceArgs { dir: Some(local_dir) },
368 blessed:
369 BlessedSourceArgs { blessed_from_vcs: None, blessed_from_vcs_path: None, blessed_from_dir: None },
370 generated: GeneratedSourceArgs { generated_from_dir: None },
371 }) if local_dir == "foo");
372
373 let app = App::parse_from([
375 "dummy",
376 "check",
377 "--dir",
378 "foo",
379 "--generated-from-dir",
380 "bar",
381 ]);
382 assert_matches!(app.command, Command::Check(CheckArgs {
383 local: LocalSourceArgs { dir: Some(local_dir) },
384 blessed:
385 BlessedSourceArgs { blessed_from_vcs: None, blessed_from_vcs_path: None, blessed_from_dir: None },
386 generated: GeneratedSourceArgs { generated_from_dir: Some(generated_dir) },
387 }) if local_dir == "foo" && generated_dir == "bar");
388
389 let app = App::parse_from([
391 "dummy",
392 "check",
393 "--dir",
394 "foo",
395 "--generated-from-dir",
396 "bar",
397 "--blessed-from-dir",
398 "baz",
399 ]);
400 assert_matches!(app.command, Command::Check(CheckArgs {
401 local: LocalSourceArgs { dir: Some(local_dir) },
402 blessed:
403 BlessedSourceArgs { blessed_from_vcs: None, blessed_from_vcs_path: None, blessed_from_dir: Some(blessed_dir) },
404 generated: GeneratedSourceArgs { generated_from_dir: Some(generated_dir) },
405 }) if local_dir == "foo" && generated_dir == "bar" && blessed_dir == "baz");
406
407 let app = App::parse_from([
409 "dummy",
410 "check",
411 "--blessed-from-git",
412 "some/other/upstream",
413 ]);
414 assert_matches!(app.command, Command::Check(CheckArgs {
415 local: LocalSourceArgs { dir: None },
416 blessed:
417 BlessedSourceArgs { blessed_from_vcs: Some(git), blessed_from_vcs_path: None, blessed_from_dir: None },
418 generated: GeneratedSourceArgs { generated_from_dir: None },
419 }) if git == "some/other/upstream");
420
421 let error = App::try_parse_from([
423 "dummy",
424 "check",
425 "--blessed-from-vcs",
426 "vcs_revision",
427 "--blessed-from-dir",
428 "dir",
429 ])
430 .unwrap_err();
431 assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
432 assert!(error.to_string().contains(
433 "error: the argument '--blessed-from-vcs <REVISION>' \
434 cannot be used with '--blessed-from-dir <DIRECTORY>"
435 ));
436 }
437
438 #[test]
440 fn test_local_args() {
441 #[cfg(unix)]
442 const ABS_DIR: &str = "/tmp";
443 #[cfg(windows)]
444 const ABS_DIR: &str = "C:\\tmp";
445
446 {
447 let env = Environment::new_for_test(
448 "cargo openapi".to_owned(),
449 Utf8PathBuf::from(ABS_DIR),
450 Utf8PathBuf::from("foo"),
451 )
452 .expect("loading environment");
453 let env = env.resolve(None).expect("resolving environment");
454 assert_eq!(
455 env.openapi_abs_dir(),
456 Utf8Path::new(ABS_DIR).join("foo")
457 );
458 }
459
460 {
461 let error = Environment::new_for_test(
462 "cargo openapi".to_owned(),
463 Utf8PathBuf::from(ABS_DIR),
464 Utf8PathBuf::from(ABS_DIR),
465 )
466 .unwrap_err();
467 assert_eq!(
468 error.to_string(),
469 format!(
470 "default_openapi_dir must be a relative path with \
471 normal components, found: {}",
472 ABS_DIR
473 )
474 );
475 }
476
477 {
478 let current_dir =
479 Utf8PathBuf::try_from(std::env::current_dir().unwrap())
480 .unwrap();
481 let env = Environment::new_for_test(
482 "cargo openapi".to_owned(),
483 current_dir.clone(),
484 Utf8PathBuf::from("foo"),
485 )
486 .expect("loading environment");
487 let env = env
488 .resolve(Some(Utf8PathBuf::from("bar")))
489 .expect("resolving environment");
490 assert_eq!(env.openapi_abs_dir(), current_dir.join("bar"));
491 }
492 }
493
494 #[test]
496 fn test_generated_args() {
497 let source = GeneratedSource::from(GeneratedSourceArgs {
498 generated_from_dir: None,
499 });
500 assert_matches!(source, GeneratedSource::Generated);
501
502 let source = GeneratedSource::from(GeneratedSourceArgs {
503 generated_from_dir: Some(Utf8PathBuf::from("/tmp")),
504 });
505 assert_matches!(
506 source,
507 GeneratedSource::Directory { local_directory }
508 if local_directory == "/tmp"
509 );
510 }
511
512 #[test]
514 fn test_blessed_args() {
515 #[cfg(unix)]
516 const ABS_DIR: &str = "/tmp";
517 #[cfg(windows)]
518 const ABS_DIR: &str = "C:\\tmp";
519
520 unsafe {
525 std::env::remove_var(BLESSED_FROM_VCS_ENV);
526 std::env::remove_var(BLESSED_FROM_VCS_PATH_ENV);
527 std::env::remove_var(BLESSED_FROM_GIT_ENV);
528 }
529
530 let env =
531 Environment::new_for_test("cargo openapi", ABS_DIR, "foo-openapi")
532 .unwrap()
533 .with_default_git_branch("upstream/dev".to_owned());
534 let env = env.resolve(None).unwrap();
535
536 let source = BlessedSourceArgs {
537 blessed_from_vcs: None,
538 blessed_from_vcs_path: None,
539 blessed_from_dir: None,
540 }
541 .to_blessed_source(&env)
542 .unwrap();
543 assert_matches!(
544 source,
545 BlessedSource::VcsRevisionMergeBase { revision, directory }
546 if *revision == "upstream/dev" && directory == "foo-openapi"
547 );
548
549 let source = BlessedSourceArgs {
551 blessed_from_vcs: Some(String::from("my/other/main")),
552 blessed_from_vcs_path: None,
553 blessed_from_dir: None,
554 }
555 .to_blessed_source(&env)
556 .unwrap();
557 assert_matches!(
558 source,
559 BlessedSource::VcsRevisionMergeBase { revision, directory}
560 if *revision == "my/other/main" && directory == "foo-openapi"
561 );
562
563 let source = BlessedSourceArgs {
565 blessed_from_vcs: Some(String::from("my/other/main")),
566 blessed_from_vcs_path: Some(Utf8PathBuf::from("other_openapi/bar")),
567 blessed_from_dir: None,
568 }
569 .to_blessed_source(&env)
570 .unwrap();
571 assert_matches!(
572 source,
573 BlessedSource::VcsRevisionMergeBase { revision, directory}
574 if *revision == "my/other/main" &&
575 directory == "other_openapi/bar"
576 );
577
578 let source = BlessedSourceArgs {
580 blessed_from_vcs: None,
581 blessed_from_vcs_path: None,
582 blessed_from_dir: Some(Utf8PathBuf::from("/tmp")),
583 }
584 .to_blessed_source(&env)
585 .unwrap();
586 assert_matches!(
587 source,
588 BlessedSource::Directory { local_directory }
589 if local_directory == "/tmp"
590 );
591 }
592
593 fn parse_blessed_source(
599 env: &ResolvedEnv,
600 extra_args: &[&str],
601 ) -> BlessedSource {
602 let mut args = vec!["dummy", "check"];
603 args.extend_from_slice(extra_args);
604 let app = App::parse_from(args);
605 match app.command {
606 Command::Check(check_args) => {
607 check_args.blessed.to_blessed_source(env).unwrap()
608 }
609 _ => panic!("expected Check command"),
610 }
611 }
612
613 #[test]
621 fn test_blessed_args_from_env_vars() {
622 #[cfg(unix)]
623 const ABS_DIR: &str = "/tmp";
624 #[cfg(windows)]
625 const ABS_DIR: &str = "C:\\tmp";
626
627 let env =
628 Environment::new_for_test("cargo openapi", ABS_DIR, "foo-openapi")
629 .unwrap()
630 .with_default_git_branch("upstream/dev".to_owned());
631 let env = env.resolve(None).unwrap();
632
633 unsafe {
636 std::env::remove_var(BLESSED_FROM_VCS_ENV);
637 std::env::remove_var(BLESSED_FROM_VCS_PATH_ENV);
638 std::env::remove_var(BLESSED_FROM_GIT_ENV);
639 }
640
641 unsafe {
643 std::env::set_var(BLESSED_FROM_VCS_ENV, "env-trunk");
644 }
645 assert_eq!(
646 parse_blessed_source(&env, &[]),
647 BlessedSource::VcsRevisionMergeBase {
648 revision: VcsRevision::from("env-trunk".to_owned()),
649 directory: Utf8PathBuf::from("foo-openapi"),
650 },
651 );
652
653 unsafe {
655 std::env::set_var(BLESSED_FROM_VCS_ENV, "env-trunk");
656 std::env::set_var(BLESSED_FROM_VCS_PATH_ENV, "custom-dir");
657 }
658 assert_eq!(
659 parse_blessed_source(&env, &[]),
660 BlessedSource::VcsRevisionMergeBase {
661 revision: VcsRevision::from("env-trunk".to_owned()),
662 directory: Utf8PathBuf::from("custom-dir"),
663 },
664 );
665
666 unsafe {
668 std::env::remove_var(BLESSED_FROM_VCS_PATH_ENV);
669 }
670
671 unsafe {
673 std::env::remove_var(BLESSED_FROM_VCS_ENV);
674 std::env::set_var(BLESSED_FROM_GIT_ENV, "origin/dev");
675 }
676 assert_eq!(
677 parse_blessed_source(&env, &[]),
678 BlessedSource::VcsRevisionMergeBase {
679 revision: VcsRevision::from("origin/dev".to_owned()),
680 directory: Utf8PathBuf::from("foo-openapi"),
681 },
682 );
683
684 unsafe {
687 std::env::set_var(BLESSED_FROM_VCS_ENV, "env-vcs");
688 std::env::set_var(BLESSED_FROM_GIT_ENV, "env-git");
689 }
690 assert_eq!(
691 parse_blessed_source(&env, &[]),
692 BlessedSource::VcsRevisionMergeBase {
693 revision: VcsRevision::from("env-vcs".to_owned()),
694 directory: Utf8PathBuf::from("foo-openapi"),
695 },
696 );
697
698 assert_eq!(
700 parse_blessed_source(&env, &["--blessed-from-vcs", "cli-override"]),
701 BlessedSource::VcsRevisionMergeBase {
702 revision: VcsRevision::from("cli-override".to_owned()),
703 directory: Utf8PathBuf::from("foo-openapi"),
704 },
705 );
706 }
707}