Skip to main content

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