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::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 eprintln!("failure: {:#}", error);
45 ExitCode::FAILURE
46 }
47 }
48 }
49}
50
51#[derive(Debug, Subcommand)]
52pub enum Command {
53 Debug(DebugArgs),
55
56 List(ListArgs),
61
62 Generate(GenerateArgs),
64
65 Check(CheckArgs),
67}
68
69#[derive(Debug, Args)]
70pub struct BlessedSourceArgs {
71 #[clap(
92 long = "blessed-from-vcs",
93 alias = "blessed-from-git",
94 env(BLESSED_FROM_VCS_ENV),
95 value_name("REVISION")
96 )]
97 pub blessed_from_vcs: Option<String>,
98
99 #[clap(long, env(BLESSED_FROM_VCS_PATH_ENV), value_name("PATH"))]
102 pub blessed_from_vcs_path: Option<Utf8PathBuf>,
103
104 #[clap(
109 long,
110 conflicts_with("blessed_from_vcs"),
111 env("OPENAPI_MGR_BLESSED_FROM_DIR"),
112 value_name("DIRECTORY")
113 )]
114 pub blessed_from_dir: Option<Utf8PathBuf>,
115}
116
117const BLESSED_FROM_VCS_ENV: &str = "OPENAPI_MGR_BLESSED_FROM_VCS";
119
120const BLESSED_FROM_VCS_PATH_ENV: &str = "OPENAPI_MGR_BLESSED_FROM_VCS_PATH";
122
123const BLESSED_FROM_GIT_ENV: &str = "OPENAPI_MGR_BLESSED_FROM_GIT";
125
126impl BlessedSourceArgs {
127 pub(crate) fn to_blessed_source(
128 &self,
129 env: &ResolvedEnv,
130 ) -> Result<BlessedSource, anyhow::Error> {
131 assert!(
132 self.blessed_from_dir.is_none() || self.blessed_from_vcs.is_none()
133 );
134
135 if let Some(local_directory) = &self.blessed_from_dir {
136 return Ok(BlessedSource::Directory {
137 local_directory: local_directory.clone(),
138 });
139 }
140
141 let resolved =
142 resolve_blessed_from_vcs(self.blessed_from_vcs.as_deref());
143 let revision_str = match &resolved {
144 Some(revision) => revision.as_str(),
145 None => env.default_blessed_branch.as_str(),
146 };
147 let revision = VcsRevision::from(String::from(revision_str));
148 let directory = match &self.blessed_from_vcs_path {
149 Some(path) => path.clone(),
150 None => Utf8PathBuf::from(env.openapi_rel_dir()),
153 };
154 Ok(BlessedSource::VcsRevisionMergeBase { revision, directory })
155 }
156}
157
158fn resolve_blessed_from_vcs(cli_value: Option<&str>) -> Option<String> {
172 if let Some(v) = cli_value {
173 return Some(v.to_owned());
174 }
175
176 if let Ok(v) = std::env::var(BLESSED_FROM_GIT_ENV) {
177 return Some(v);
178 }
179
180 None
181}
182
183#[derive(Debug, Args)]
184pub struct GeneratedSourceArgs {
185 #[clap(long, value_name("DIRECTORY"))]
188 pub generated_from_dir: Option<Utf8PathBuf>,
189}
190
191impl From<GeneratedSourceArgs> for GeneratedSource {
192 fn from(value: GeneratedSourceArgs) -> Self {
193 match value.generated_from_dir {
194 Some(local_directory) => {
195 GeneratedSource::Directory { local_directory }
196 }
197 None => GeneratedSource::Generated,
198 }
199 }
200}
201
202#[derive(Debug, Args)]
203pub struct LocalSourceArgs {
204 #[clap(long, env("OPENAPI_MGR_DIR"), value_name("DIRECTORY"))]
206 dir: Option<Utf8PathBuf>,
207}
208
209#[derive(Debug, Args)]
210pub struct DebugArgs {
211 #[clap(flatten)]
212 local: LocalSourceArgs,
213 #[clap(flatten)]
214 blessed: BlessedSourceArgs,
215 #[clap(flatten)]
216 generated: GeneratedSourceArgs,
217}
218
219impl DebugArgs {
220 fn exec(
221 self,
222 env: &Environment,
223 apis: &ManagedApis,
224 output: &OutputOpts,
225 ) -> anyhow::Result<ExitCode> {
226 let env = env.resolve(self.local.dir)?;
227 let blessed_source = self.blessed.to_blessed_source(&env)?;
228 let generated_source = GeneratedSource::from(self.generated);
229 debug_impl(apis, &env, &blessed_source, &generated_source, output)?;
230 Ok(ExitCode::SUCCESS)
231 }
232}
233
234#[derive(Debug, Args)]
235pub struct ListArgs {
236 #[clap(long, short)]
238 verbose: bool,
239}
240
241impl ListArgs {
242 fn exec(
243 self,
244 apis: &ManagedApis,
245 output: &OutputOpts,
246 ) -> anyhow::Result<ExitCode> {
247 list_impl(apis, self.verbose, output)?;
248 Ok(ExitCode::SUCCESS)
249 }
250}
251
252#[derive(Debug, Args)]
253pub struct GenerateArgs {
254 #[clap(flatten)]
255 local: LocalSourceArgs,
256 #[clap(flatten)]
257 blessed: BlessedSourceArgs,
258 #[clap(flatten)]
259 generated: GeneratedSourceArgs,
260}
261
262impl GenerateArgs {
263 fn exec(
264 self,
265 env: &Environment,
266 apis: &ManagedApis,
267 output: &OutputOpts,
268 ) -> anyhow::Result<ExitCode> {
269 let env = env.resolve(self.local.dir)?;
270 let blessed_source = self.blessed.to_blessed_source(&env)?;
271 let generated_source = GeneratedSource::from(self.generated);
272 Ok(generate_impl(
273 apis,
274 &env,
275 &blessed_source,
276 &generated_source,
277 output,
278 )?
279 .to_exit_code())
280 }
281}
282
283#[derive(Debug, Args)]
284pub struct CheckArgs {
285 #[clap(flatten)]
286 local: LocalSourceArgs,
287 #[clap(flatten)]
288 blessed: BlessedSourceArgs,
289 #[clap(flatten)]
290 generated: GeneratedSourceArgs,
291}
292
293impl CheckArgs {
294 fn exec(
295 self,
296 env: &Environment,
297 apis: &ManagedApis,
298 output: &OutputOpts,
299 ) -> anyhow::Result<ExitCode> {
300 let env = env.resolve(self.local.dir)?;
301 let blessed_source = self.blessed.to_blessed_source(&env)?;
302 let generated_source = GeneratedSource::from(self.generated);
303 Ok(check_impl(apis, &env, &blessed_source, &generated_source, output)?
304 .to_exit_code())
305 }
306}
307
308pub const NEEDS_UPDATE_EXIT_CODE: u8 = 4;
313
314pub const FAILURE_EXIT_CODE: u8 = 100;
319
320#[cfg(test)]
321mod test {
322 use super::*;
323 use crate::{
324 environment::{
325 BlessedSource, Environment, GeneratedSource, ResolvedEnv,
326 },
327 vcs::VcsRevision,
328 };
329 use assert_matches::assert_matches;
330 use camino::{Utf8Path, Utf8PathBuf};
331 use clap::Parser;
332
333 #[test]
334 fn test_arg_parsing() {
335 let app = App::parse_from(["dummy", "check"]);
337 assert_matches!(
338 app.command,
339 Command::Check(CheckArgs {
340 local: LocalSourceArgs { dir: None },
341 blessed: BlessedSourceArgs {
342 blessed_from_vcs: None,
343 blessed_from_vcs_path: None,
344 blessed_from_dir: None
345 },
346 generated: GeneratedSourceArgs { generated_from_dir: None },
347 })
348 );
349
350 let app = App::parse_from(["dummy", "check", "--dir", "foo"]);
352 assert_matches!(app.command, Command::Check(CheckArgs {
353 local: LocalSourceArgs { dir: Some(local_dir) },
354 blessed:
355 BlessedSourceArgs { blessed_from_vcs: None, blessed_from_vcs_path: None, blessed_from_dir: None },
356 generated: GeneratedSourceArgs { generated_from_dir: None },
357 }) if local_dir == "foo");
358
359 let app = App::parse_from([
361 "dummy",
362 "check",
363 "--dir",
364 "foo",
365 "--generated-from-dir",
366 "bar",
367 ]);
368 assert_matches!(app.command, Command::Check(CheckArgs {
369 local: LocalSourceArgs { dir: Some(local_dir) },
370 blessed:
371 BlessedSourceArgs { blessed_from_vcs: None, blessed_from_vcs_path: None, blessed_from_dir: None },
372 generated: GeneratedSourceArgs { generated_from_dir: Some(generated_dir) },
373 }) if local_dir == "foo" && generated_dir == "bar");
374
375 let app = App::parse_from([
377 "dummy",
378 "check",
379 "--dir",
380 "foo",
381 "--generated-from-dir",
382 "bar",
383 "--blessed-from-dir",
384 "baz",
385 ]);
386 assert_matches!(app.command, Command::Check(CheckArgs {
387 local: LocalSourceArgs { dir: Some(local_dir) },
388 blessed:
389 BlessedSourceArgs { blessed_from_vcs: None, blessed_from_vcs_path: None, blessed_from_dir: Some(blessed_dir) },
390 generated: GeneratedSourceArgs { generated_from_dir: Some(generated_dir) },
391 }) if local_dir == "foo" && generated_dir == "bar" && blessed_dir == "baz");
392
393 let app = App::parse_from([
395 "dummy",
396 "check",
397 "--blessed-from-git",
398 "some/other/upstream",
399 ]);
400 assert_matches!(app.command, Command::Check(CheckArgs {
401 local: LocalSourceArgs { dir: None },
402 blessed:
403 BlessedSourceArgs { blessed_from_vcs: Some(git), blessed_from_vcs_path: None, blessed_from_dir: None },
404 generated: GeneratedSourceArgs { generated_from_dir: None },
405 }) if git == "some/other/upstream");
406
407 let error = App::try_parse_from([
409 "dummy",
410 "check",
411 "--blessed-from-vcs",
412 "vcs_revision",
413 "--blessed-from-dir",
414 "dir",
415 ])
416 .unwrap_err();
417 assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
418 assert!(error.to_string().contains(
419 "error: the argument '--blessed-from-vcs <REVISION>' \
420 cannot be used with '--blessed-from-dir <DIRECTORY>"
421 ));
422 }
423
424 #[test]
426 fn test_local_args() {
427 #[cfg(unix)]
428 const ABS_DIR: &str = "/tmp";
429 #[cfg(windows)]
430 const ABS_DIR: &str = "C:\\tmp";
431
432 {
433 let env = Environment::new_for_test(
434 "cargo openapi".to_owned(),
435 Utf8PathBuf::from(ABS_DIR),
436 Utf8PathBuf::from("foo"),
437 )
438 .expect("loading environment");
439 let env = env.resolve(None).expect("resolving environment");
440 assert_eq!(
441 env.openapi_abs_dir(),
442 Utf8Path::new(ABS_DIR).join("foo")
443 );
444 }
445
446 {
447 let error = Environment::new_for_test(
448 "cargo openapi".to_owned(),
449 Utf8PathBuf::from(ABS_DIR),
450 Utf8PathBuf::from(ABS_DIR),
451 )
452 .unwrap_err();
453 assert_eq!(
454 error.to_string(),
455 format!(
456 "default_openapi_dir must be a relative path with \
457 normal components, found: {}",
458 ABS_DIR
459 )
460 );
461 }
462
463 {
464 let current_dir =
465 Utf8PathBuf::try_from(std::env::current_dir().unwrap())
466 .unwrap();
467 let env = Environment::new_for_test(
468 "cargo openapi".to_owned(),
469 current_dir.clone(),
470 Utf8PathBuf::from("foo"),
471 )
472 .expect("loading environment");
473 let env = env
474 .resolve(Some(Utf8PathBuf::from("bar")))
475 .expect("resolving environment");
476 assert_eq!(env.openapi_abs_dir(), current_dir.join("bar"));
477 }
478 }
479
480 #[test]
482 fn test_generated_args() {
483 let source = GeneratedSource::from(GeneratedSourceArgs {
484 generated_from_dir: None,
485 });
486 assert_matches!(source, GeneratedSource::Generated);
487
488 let source = GeneratedSource::from(GeneratedSourceArgs {
489 generated_from_dir: Some(Utf8PathBuf::from("/tmp")),
490 });
491 assert_matches!(
492 source,
493 GeneratedSource::Directory { local_directory }
494 if local_directory == "/tmp"
495 );
496 }
497
498 #[test]
500 fn test_blessed_args() {
501 #[cfg(unix)]
502 const ABS_DIR: &str = "/tmp";
503 #[cfg(windows)]
504 const ABS_DIR: &str = "C:\\tmp";
505
506 unsafe {
511 std::env::remove_var(BLESSED_FROM_VCS_ENV);
512 std::env::remove_var(BLESSED_FROM_VCS_PATH_ENV);
513 std::env::remove_var(BLESSED_FROM_GIT_ENV);
514 }
515
516 let env =
517 Environment::new_for_test("cargo openapi", ABS_DIR, "foo-openapi")
518 .unwrap()
519 .with_default_git_branch("upstream/dev".to_owned());
520 let env = env.resolve(None).unwrap();
521
522 let source = BlessedSourceArgs {
523 blessed_from_vcs: None,
524 blessed_from_vcs_path: None,
525 blessed_from_dir: None,
526 }
527 .to_blessed_source(&env)
528 .unwrap();
529 assert_matches!(
530 source,
531 BlessedSource::VcsRevisionMergeBase { revision, directory }
532 if *revision == "upstream/dev" && directory == "foo-openapi"
533 );
534
535 let source = BlessedSourceArgs {
537 blessed_from_vcs: Some(String::from("my/other/main")),
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 == "my/other/main" && directory == "foo-openapi"
547 );
548
549 let source = BlessedSourceArgs {
551 blessed_from_vcs: Some(String::from("my/other/main")),
552 blessed_from_vcs_path: Some(Utf8PathBuf::from("other_openapi/bar")),
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" &&
561 directory == "other_openapi/bar"
562 );
563
564 let source = BlessedSourceArgs {
566 blessed_from_vcs: None,
567 blessed_from_vcs_path: None,
568 blessed_from_dir: Some(Utf8PathBuf::from("/tmp")),
569 }
570 .to_blessed_source(&env)
571 .unwrap();
572 assert_matches!(
573 source,
574 BlessedSource::Directory { local_directory }
575 if local_directory == "/tmp"
576 );
577 }
578
579 fn parse_blessed_source(
585 env: &ResolvedEnv,
586 extra_args: &[&str],
587 ) -> BlessedSource {
588 let mut args = vec!["dummy", "check"];
589 args.extend_from_slice(extra_args);
590 let app = App::parse_from(args);
591 match app.command {
592 Command::Check(check_args) => {
593 check_args.blessed.to_blessed_source(env).unwrap()
594 }
595 _ => panic!("expected Check command"),
596 }
597 }
598
599 #[test]
607 fn test_blessed_args_from_env_vars() {
608 #[cfg(unix)]
609 const ABS_DIR: &str = "/tmp";
610 #[cfg(windows)]
611 const ABS_DIR: &str = "C:\\tmp";
612
613 let env =
614 Environment::new_for_test("cargo openapi", ABS_DIR, "foo-openapi")
615 .unwrap()
616 .with_default_git_branch("upstream/dev".to_owned());
617 let env = env.resolve(None).unwrap();
618
619 unsafe {
622 std::env::remove_var(BLESSED_FROM_VCS_ENV);
623 std::env::remove_var(BLESSED_FROM_VCS_PATH_ENV);
624 std::env::remove_var(BLESSED_FROM_GIT_ENV);
625 }
626
627 unsafe {
629 std::env::set_var(BLESSED_FROM_VCS_ENV, "env-trunk");
630 }
631 assert_eq!(
632 parse_blessed_source(&env, &[]),
633 BlessedSource::VcsRevisionMergeBase {
634 revision: VcsRevision::from("env-trunk".to_owned()),
635 directory: Utf8PathBuf::from("foo-openapi"),
636 },
637 );
638
639 unsafe {
641 std::env::set_var(BLESSED_FROM_VCS_ENV, "env-trunk");
642 std::env::set_var(BLESSED_FROM_VCS_PATH_ENV, "custom-dir");
643 }
644 assert_eq!(
645 parse_blessed_source(&env, &[]),
646 BlessedSource::VcsRevisionMergeBase {
647 revision: VcsRevision::from("env-trunk".to_owned()),
648 directory: Utf8PathBuf::from("custom-dir"),
649 },
650 );
651
652 unsafe {
654 std::env::remove_var(BLESSED_FROM_VCS_PATH_ENV);
655 }
656
657 unsafe {
659 std::env::remove_var(BLESSED_FROM_VCS_ENV);
660 std::env::set_var(BLESSED_FROM_GIT_ENV, "origin/dev");
661 }
662 assert_eq!(
663 parse_blessed_source(&env, &[]),
664 BlessedSource::VcsRevisionMergeBase {
665 revision: VcsRevision::from("origin/dev".to_owned()),
666 directory: Utf8PathBuf::from("foo-openapi"),
667 },
668 );
669
670 unsafe {
673 std::env::set_var(BLESSED_FROM_VCS_ENV, "env-vcs");
674 std::env::set_var(BLESSED_FROM_GIT_ENV, "env-git");
675 }
676 assert_eq!(
677 parse_blessed_source(&env, &[]),
678 BlessedSource::VcsRevisionMergeBase {
679 revision: VcsRevision::from("env-vcs".to_owned()),
680 directory: Utf8PathBuf::from("foo-openapi"),
681 },
682 );
683
684 assert_eq!(
686 parse_blessed_source(&env, &["--blessed-from-vcs", "cli-override"]),
687 BlessedSource::VcsRevisionMergeBase {
688 revision: VcsRevision::from("cli-override".to_owned()),
689 directory: Utf8PathBuf::from("foo-openapi"),
690 },
691 );
692 }
693}