Skip to main content

dropshot_api_manager/cmd/
dispatch.rs

1// Copyright 2026 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    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/// 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                // Best-effort: if even the error report fails to write,
45                // we still want to exit with FAILURE.
46                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    /// Dump debug information about everything the tool knows
60    Debug(DebugArgs),
61
62    /// List managed APIs.
63    ///
64    /// Returns information purely from code without consulting JSON files on
65    /// disk. To compare against files on disk, use the `check` command.
66    List(ListArgs),
67
68    /// Generate latest OpenAPI documents and validate the results.
69    Generate(GenerateArgs),
70
71    /// Check that OpenAPI documents are up-to-date and valid.
72    Check(CheckArgs),
73}
74
75#[derive(Debug, Args)]
76pub struct BlessedSourceArgs {
77    /// Loads blessed OpenAPI documents from the given VCS REVISION.
78    ///
79    /// The REVISION is not used as-is; instead, the tool always looks at
80    /// the merge-base between the current working state and REVISION.
81    /// So if you provide `main`, then it will look at the merge-base
82    /// of the working copy with `main`.
83    ///
84    /// REVISION is optional and defaults to the `default_blessed_branch`
85    /// provided by the OpenAPI manager binary (typically `origin/main`
86    /// for Git, `trunk()` for Jujutsu).
87    ///
88    /// The path within the revision defaults to `default_openapi_dir`
89    /// provided by the OpenAPI manager binary. To override it, use
90    /// `--blessed-from-vcs-path`.
91    ///
92    /// As a fallback, the `OPENAPI_MGR_BLESSED_FROM_GIT` environment
93    /// variable can also be used.
94    // Environment variable handling is done manually in
95    // `resolve_blessed_from_vcs` because clap's `env()` only supports a
96    // single variable, and we need two.
97    #[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    /// Overrides the path within the VCS revision to load blessed
106    /// OpenAPI documents from.
107    #[clap(long, env(BLESSED_FROM_VCS_PATH_ENV), value_name("PATH"))]
108    pub blessed_from_vcs_path: Option<Utf8PathBuf>,
109
110    /// Loads blessed OpenAPI documents from a local directory (instead of
111    /// the default, from VCS).
112    ///
113    /// This is intended for testing and debugging this tool.
114    #[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
123/// Environment variable for the blessed VCS revision.
124const BLESSED_FROM_VCS_ENV: &str = "OPENAPI_MGR_BLESSED_FROM_VCS";
125
126/// Environment variable for the blessed VCS path within a revision.
127const BLESSED_FROM_VCS_PATH_ENV: &str = "OPENAPI_MGR_BLESSED_FROM_VCS_PATH";
128
129/// Environment variable for the blessed VCS revision (legacy fallback).
130const 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            // We must use the relative directory path for VCS
157            // commands.
158            None => Utf8PathBuf::from(env.openapi_rel_dir()),
159        };
160        Ok(BlessedSource::VcsRevisionMergeBase { revision, directory })
161    }
162}
163
164/// Resolve the blessed-from-vcs value from the CLI flag or environment
165/// variables.
166///
167/// Returns `Some` if a value was provided via the CLI flag or an
168/// environment variable. The priority is:
169///
170/// 1. CLI flag (`cli_value`)
171/// 2. `OPENAPI_MGR_BLESSED_FROM_VCS` (done automatically by clap and
172///    stored in `cli_value`)
173/// 3. `OPENAPI_MGR_BLESSED_FROM_GIT` (legacy fallback)
174///
175/// Returns `None` if none of these are set, meaning the caller should
176/// use the environment's default blessed branch or revset.
177fn 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    /// Instead of generating OpenAPI documents directly from the API
192    /// implementation, load OpenAPI documents from this directory.
193    #[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    /// Loads this workspace's OpenAPI documents from local path DIRECTORY.
211    #[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    /// Show verbose output including descriptions.
243    #[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
322/// Exit code which indicates that local files are out-of-date.
323///
324/// This is chosen to be 4 so that the exit code is not 0 or 1 (general anyhow
325/// errors).
326pub const NEEDS_UPDATE_EXIT_CODE: u8 = 4;
327
328/// Exit code which indicates that one or more failures occurred.
329///
330/// This exit code is returned for issues like validation errors, or blessed
331/// files being updated in an incompatible way.
332pub 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        // Default case
350        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        // Override local dir
365        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        // Override generated dir differently
374        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        // Override blessed with a local directory.
390        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        // Override blessed from Git.
408        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        // Error case: specifying both --blessed-from-vcs and --blessed-from-dir
422        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 how we turn `LocalSourceArgs` into `Environment`.
439    #[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 how we convert `GeneratedSourceArgs` into `GeneratedSource`.
495    #[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 how we convert `BlessedSourceArgs` into `BlessedSource`.
513    #[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        // Clear env vars so they don't interfere with these tests.
521        //
522        // SAFETY:
523        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
524        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        // Override branch only.
550        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        // Override branch and directory.
564        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        // Override with a local directory.
579        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    /// Helper: parse CLI args through clap and resolve the blessed
594    /// source.
595    ///
596    /// This exercises the full env var resolution path, including
597    /// clap's `env()` attribute on `blessed_from_vcs`.
598    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 that env vars flow through `to_blessed_source` correctly.
614    //
615    // Uses `parse_blessed_source` to route through clap parsing, so
616    // that clap's `env()` attribute on `blessed_from_vcs` is
617    // exercised. Constructing `BlessedSourceArgs` manually would
618    // bypass this and miss the `OPENAPI_MGR_BLESSED_FROM_VCS` env
619    // var.
620    #[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        // SAFETY:
634        // https://nexte.st/docs/configuration/env-vars/#altering-the-environment-within-tests
635        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        // OPENAPI_MGR_BLESSED_FROM_VCS overrides the default.
642        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        // OPENAPI_MGR_BLESSED_FROM_VCS_PATH overrides the path.
654        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        // Clean up path env var for remaining tests.
667        unsafe {
668            std::env::remove_var(BLESSED_FROM_VCS_PATH_ENV);
669        }
670
671        // OPENAPI_MGR_BLESSED_FROM_GIT as legacy fallback.
672        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        // Both env vars set: OPENAPI_MGR_BLESSED_FROM_VCS is preferred over
685        // OPENAPI_MGR_BLESSED_FROM_GIT.
686        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        // CLI flag overrides both env vars.
699        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}