dropshot_api_manager/cmd/
dispatch.rs

1// Copyright 2025 Oxide Computer Company
2
3use 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    git::GitRevision,
11    output::OutputOpts,
12};
13use anyhow::Result;
14use camino::Utf8PathBuf;
15use clap::{Args, Parser, Subcommand};
16use std::process::ExitCode;
17
18/// Manage OpenAPI documents for this repository.
19///
20/// For more information, see <https://crates.io/crates/dropshot-api-manager>.
21#[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 {
32        let result = match self.command {
33            Command::Debug(args) => args.exec(env, apis, &self.output_opts),
34            Command::List(args) => args.exec(apis, &self.output_opts),
35            Command::Generate(args) => args.exec(env, apis, &self.output_opts),
36            Command::Check(args) => args.exec(env, apis, &self.output_opts),
37        };
38
39        match result {
40            Ok(exit_code) => exit_code,
41            Err(error) => {
42                eprintln!("failure: {:#}", error);
43                ExitCode::FAILURE
44            }
45        }
46    }
47}
48
49#[derive(Debug, Subcommand)]
50pub enum Command {
51    /// Dump debug information about everything the tool knows
52    Debug(DebugArgs),
53
54    /// List managed APIs.
55    ///
56    /// Returns information purely from code without consulting JSON files on
57    /// disk. To compare against files on disk, use the `check` command.
58    List(ListArgs),
59
60    /// Generate latest OpenAPI documents and validate the results.
61    Generate(GenerateArgs),
62
63    /// Check that OpenAPI documents are up-to-date and valid.
64    Check(CheckArgs),
65}
66
67#[derive(Debug, Args)]
68pub struct BlessedSourceArgs {
69    /// Loads blessed OpenAPI documents from path PATH in the given Git
70    /// REVISION.
71    ///
72    /// The REVISION is not used as-is; instead, the tool always looks at the
73    /// merge-base between HEAD and REVISION. So if you provide `main:openapi`,
74    /// then it will look at the merge-base of HEAD and `main`, in directory
75    /// "openapi" in that commit.
76    ///
77    /// REVISION is optional and defaults to the `default_git_branch` provided
78    /// by the OpenAPI manager binary (typically `origin/main`).
79    ///
80    /// PATH is optional and defaults to `default_openapi_dir` provided by the
81    /// OpenAPI manager binary.
82    #[clap(
83        long,
84        env("OPENAPI_MGR_BLESSED_FROM_GIT"),
85        value_name("REVISION[:PATH]")
86    )]
87    pub blessed_from_git: Option<String>,
88
89    /// Loads blessed OpenAPI documents from a local directory (instead of the
90    /// default, from Git).
91    ///
92    /// This is intended for testing and debugging this tool.
93    #[clap(
94        long,
95        conflicts_with("blessed_from_git"),
96        env("OPENAPI_MGR_BLESSED_FROM_DIR"),
97        value_name("DIRECTORY")
98    )]
99    pub blessed_from_dir: Option<Utf8PathBuf>,
100}
101
102impl BlessedSourceArgs {
103    pub(crate) fn to_blessed_source(
104        &self,
105        env: &ResolvedEnv,
106    ) -> Result<BlessedSource, anyhow::Error> {
107        assert!(
108            self.blessed_from_dir.is_none() || self.blessed_from_git.is_none()
109        );
110
111        if let Some(local_directory) = &self.blessed_from_dir {
112            return Ok(BlessedSource::Directory {
113                local_directory: local_directory.clone(),
114            });
115        }
116
117        let (revision_str, maybe_directory) = match &self.blessed_from_git {
118            None => (env.default_git_branch.as_str(), None),
119            Some(arg) => match arg.split_once(":") {
120                Some((r, d)) => (r, Some(d)),
121                None => (arg.as_str(), None),
122            },
123        };
124        let revision = GitRevision::from(String::from(revision_str));
125        let directory = Utf8PathBuf::from(maybe_directory.map_or(
126            // We must use the relative directory path for Git commands.
127            env.openapi_rel_dir(),
128            |d| d.as_ref(),
129        ));
130        Ok(BlessedSource::GitRevisionMergeBase { revision, directory })
131    }
132}
133
134#[derive(Debug, Args)]
135pub struct GeneratedSourceArgs {
136    /// Instead of generating OpenAPI documents directly from the API
137    /// implementation, load OpenAPI documents from this directory.
138    #[clap(long, value_name("DIRECTORY"))]
139    pub generated_from_dir: Option<Utf8PathBuf>,
140}
141
142impl From<GeneratedSourceArgs> for GeneratedSource {
143    fn from(value: GeneratedSourceArgs) -> Self {
144        match value.generated_from_dir {
145            Some(local_directory) => {
146                GeneratedSource::Directory { local_directory }
147            }
148            None => GeneratedSource::Generated,
149        }
150    }
151}
152
153#[derive(Debug, Args)]
154pub struct LocalSourceArgs {
155    /// Loads this workspace's OpenAPI documents from local path DIRECTORY.
156    #[clap(long, env("OPENAPI_MGR_DIR"), value_name("DIRECTORY"))]
157    dir: Option<Utf8PathBuf>,
158}
159
160#[derive(Debug, Args)]
161pub struct DebugArgs {
162    #[clap(flatten)]
163    local: LocalSourceArgs,
164    #[clap(flatten)]
165    blessed: BlessedSourceArgs,
166    #[clap(flatten)]
167    generated: GeneratedSourceArgs,
168}
169
170impl DebugArgs {
171    fn exec(
172        self,
173        env: &Environment,
174        apis: &ManagedApis,
175        output: &OutputOpts,
176    ) -> anyhow::Result<ExitCode> {
177        let env = env.resolve(self.local.dir)?;
178        let blessed_source = self.blessed.to_blessed_source(&env)?;
179        let generated_source = GeneratedSource::from(self.generated);
180        debug_impl(apis, &env, &blessed_source, &generated_source, output)?;
181        Ok(ExitCode::SUCCESS)
182    }
183}
184
185#[derive(Debug, Args)]
186pub struct ListArgs {
187    /// Show verbose output including descriptions.
188    #[clap(long, short)]
189    verbose: bool,
190}
191
192impl ListArgs {
193    fn exec(
194        self,
195        apis: &ManagedApis,
196        output: &OutputOpts,
197    ) -> anyhow::Result<ExitCode> {
198        list_impl(apis, self.verbose, output)?;
199        Ok(ExitCode::SUCCESS)
200    }
201}
202
203#[derive(Debug, Args)]
204pub struct GenerateArgs {
205    #[clap(flatten)]
206    local: LocalSourceArgs,
207    #[clap(flatten)]
208    blessed: BlessedSourceArgs,
209    #[clap(flatten)]
210    generated: GeneratedSourceArgs,
211}
212
213impl GenerateArgs {
214    fn exec(
215        self,
216        env: &Environment,
217        apis: &ManagedApis,
218        output: &OutputOpts,
219    ) -> anyhow::Result<ExitCode> {
220        let env = env.resolve(self.local.dir)?;
221        let blessed_source = self.blessed.to_blessed_source(&env)?;
222        let generated_source = GeneratedSource::from(self.generated);
223        Ok(generate_impl(
224            apis,
225            &env,
226            &blessed_source,
227            &generated_source,
228            output,
229        )?
230        .to_exit_code())
231    }
232}
233
234#[derive(Debug, Args)]
235pub struct CheckArgs {
236    #[clap(flatten)]
237    local: LocalSourceArgs,
238    #[clap(flatten)]
239    blessed: BlessedSourceArgs,
240    #[clap(flatten)]
241    generated: GeneratedSourceArgs,
242}
243
244impl CheckArgs {
245    fn exec(
246        self,
247        env: &Environment,
248        apis: &ManagedApis,
249        output: &OutputOpts,
250    ) -> anyhow::Result<ExitCode> {
251        let env = env.resolve(self.local.dir)?;
252        let blessed_source = self.blessed.to_blessed_source(&env)?;
253        let generated_source = GeneratedSource::from(self.generated);
254        Ok(check_impl(apis, &env, &blessed_source, &generated_source, output)?
255            .to_exit_code())
256    }
257}
258
259// This code is not 0 or 1 (general anyhow errors) and indicates out-of-date.
260pub(crate) const NEEDS_UPDATE_EXIT_CODE: u8 = 4;
261
262// This code indicates failures during generation, e.g. validation errors.
263pub(crate) const FAILURE_EXIT_CODE: u8 = 100;
264
265#[cfg(test)]
266mod test {
267    use super::{
268        App, BlessedSourceArgs, CheckArgs, Command, GeneratedSourceArgs,
269        LocalSourceArgs,
270    };
271    use crate::environment::{BlessedSource, Environment, GeneratedSource};
272    use assert_matches::assert_matches;
273    use camino::{Utf8Path, Utf8PathBuf};
274    use clap::Parser;
275
276    #[test]
277    fn test_arg_parsing() {
278        // Default case
279        let app = App::parse_from(["dummy", "check"]);
280        assert_matches!(
281            app.command,
282            Command::Check(CheckArgs {
283                local: LocalSourceArgs { dir: None },
284                blessed: BlessedSourceArgs {
285                    blessed_from_git: None,
286                    blessed_from_dir: None
287                },
288                generated: GeneratedSourceArgs { generated_from_dir: None },
289            })
290        );
291
292        // Override local dir
293        let app = App::parse_from(["dummy", "check", "--dir", "foo"]);
294        assert_matches!(app.command, Command::Check(CheckArgs {
295            local: LocalSourceArgs { dir: Some(local_dir) },
296            blessed:
297                BlessedSourceArgs { blessed_from_git: None, blessed_from_dir: None },
298            generated: GeneratedSourceArgs { generated_from_dir: None },
299        }) if local_dir == "foo");
300
301        // Override generated dir differently
302        let app = App::parse_from([
303            "dummy",
304            "check",
305            "--dir",
306            "foo",
307            "--generated-from-dir",
308            "bar",
309        ]);
310        assert_matches!(app.command, Command::Check(CheckArgs {
311            local: LocalSourceArgs { dir: Some(local_dir) },
312            blessed:
313                BlessedSourceArgs { blessed_from_git: None, blessed_from_dir: None },
314            generated: GeneratedSourceArgs { generated_from_dir: Some(generated_dir) },
315        }) if local_dir == "foo" && generated_dir == "bar");
316
317        // Override blessed with a local directory.
318        let app = App::parse_from([
319            "dummy",
320            "check",
321            "--dir",
322            "foo",
323            "--generated-from-dir",
324            "bar",
325            "--blessed-from-dir",
326            "baz",
327        ]);
328        assert_matches!(app.command, Command::Check(CheckArgs {
329            local: LocalSourceArgs { dir: Some(local_dir) },
330            blessed:
331                BlessedSourceArgs { blessed_from_git: None, blessed_from_dir: Some(blessed_dir) },
332            generated: GeneratedSourceArgs { generated_from_dir: Some(generated_dir) },
333        }) if local_dir == "foo" && generated_dir == "bar" && blessed_dir == "baz");
334
335        // Override blessed from Git.
336        let app = App::parse_from([
337            "dummy",
338            "check",
339            "--blessed-from-git",
340            "some/other/upstream",
341        ]);
342        assert_matches!(app.command, Command::Check(CheckArgs {
343            local: LocalSourceArgs { dir: None },
344            blessed:
345                BlessedSourceArgs { blessed_from_git: Some(git), blessed_from_dir: None },
346            generated: GeneratedSourceArgs { generated_from_dir: None },
347        }) if git == "some/other/upstream");
348
349        // Error case: specifying both --blessed-from-git and --blessed-from-dir
350        let error = App::try_parse_from([
351            "dummy",
352            "check",
353            "--blessed-from-git",
354            "git_revision",
355            "--blessed-from-dir",
356            "dir",
357        ])
358        .unwrap_err();
359        assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
360        assert!(error.to_string().contains(
361            "error: the argument '--blessed-from-git <REVISION[:PATH]>' cannot \
362             be used with '--blessed-from-dir <DIRECTORY>"
363        ));
364    }
365
366    // Test how we turn `LocalSourceArgs` into `Environment`.
367    #[test]
368    fn test_local_args() {
369        #[cfg(unix)]
370        const ABS_DIR: &str = "/tmp";
371        #[cfg(windows)]
372        const ABS_DIR: &str = "C:\\tmp";
373
374        {
375            let env = Environment::new(
376                "cargo openapi".to_owned(),
377                Utf8PathBuf::from(ABS_DIR),
378                Utf8PathBuf::from("foo"),
379            )
380            .expect("loading environment");
381            let env = env.resolve(None).expect("resolving environment");
382            assert_eq!(
383                env.openapi_abs_dir(),
384                Utf8Path::new(ABS_DIR).join("foo")
385            );
386        }
387
388        {
389            let error = Environment::new(
390                "cargo openapi".to_owned(),
391                Utf8PathBuf::from(ABS_DIR),
392                Utf8PathBuf::from(ABS_DIR),
393            )
394            .unwrap_err();
395            assert_eq!(
396                error.to_string(),
397                format!(
398                    "default_openapi_dir must be a relative path with \
399                     normal components, found: {}",
400                    ABS_DIR
401                )
402            );
403        }
404
405        {
406            let current_dir =
407                Utf8PathBuf::try_from(std::env::current_dir().unwrap())
408                    .unwrap();
409            let env = Environment::new(
410                "cargo openapi".to_owned(),
411                current_dir.clone(),
412                Utf8PathBuf::from("foo"),
413            )
414            .expect("loading environment");
415            let env = env
416                .resolve(Some(Utf8PathBuf::from("bar")))
417                .expect("resolving environment");
418            assert_eq!(env.openapi_abs_dir(), current_dir.join("bar"));
419        }
420    }
421
422    // Test how we convert `GeneratedSourceArgs` into `GeneratedSource`.
423    #[test]
424    fn test_generated_args() {
425        let source = GeneratedSource::from(GeneratedSourceArgs {
426            generated_from_dir: None,
427        });
428        assert_matches!(source, GeneratedSource::Generated);
429
430        let source = GeneratedSource::from(GeneratedSourceArgs {
431            generated_from_dir: Some(Utf8PathBuf::from("/tmp")),
432        });
433        assert_matches!(
434            source,
435            GeneratedSource::Directory { local_directory }
436                if local_directory == "/tmp"
437        );
438    }
439
440    // Test how we convert `BlessedSourceArgs` into `BlessedSource`.
441    #[test]
442    fn test_blessed_args() {
443        #[cfg(unix)]
444        const ABS_DIR: &str = "/tmp";
445        #[cfg(windows)]
446        const ABS_DIR: &str = "C:\\tmp";
447
448        let env = Environment::new(
449            "cargo openapi".to_owned(),
450            ABS_DIR.into(),
451            "foo-openapi".into(),
452        )
453        .unwrap()
454        .with_default_git_branch("upstream/dev".to_owned());
455        let env = env.resolve(None).unwrap();
456
457        let source = BlessedSourceArgs {
458            blessed_from_git: None,
459            blessed_from_dir: None,
460        }
461        .to_blessed_source(&env)
462        .unwrap();
463        assert_matches!(
464            source,
465            BlessedSource::GitRevisionMergeBase { revision, directory }
466                if *revision == "upstream/dev" && directory == "foo-openapi"
467        );
468
469        // Override branch only
470        let source = BlessedSourceArgs {
471            blessed_from_git: Some(String::from("my/other/main")),
472            blessed_from_dir: None,
473        }
474        .to_blessed_source(&env)
475        .unwrap();
476        assert_matches!(
477            source,
478            BlessedSource::GitRevisionMergeBase { revision, directory}
479                if *revision == "my/other/main" && directory == "foo-openapi"
480        );
481
482        // Override branch and directory
483        let source = BlessedSourceArgs {
484            blessed_from_git: Some(String::from(
485                "my/other/main:other_openapi/bar",
486            )),
487            blessed_from_dir: None,
488        }
489        .to_blessed_source(&env)
490        .unwrap();
491        assert_matches!(
492            source,
493            BlessedSource::GitRevisionMergeBase { revision, directory}
494                if *revision == "my/other/main" &&
495                     directory == "other_openapi/bar"
496        );
497
498        // Override with a local directory
499        let source = BlessedSourceArgs {
500            blessed_from_git: None,
501            blessed_from_dir: Some(Utf8PathBuf::from("/tmp")),
502        }
503        .to_blessed_source(&env)
504        .unwrap();
505        assert_matches!(
506            source,
507            BlessedSource::Directory { local_directory }
508                if local_directory == "/tmp"
509        );
510    }
511}