Skip to main content

dropshot_api_manager/
environment.rs

1// Copyright 2026 Oxide Computer Company
2
3//! Describes the environment the command is running in, and particularly where
4//! different sets of specifications are loaded from
5
6use crate::{
7    apis::ManagedApis,
8    output::{
9        Styles,
10        headers::{GENERATING, HEADER_WIDTH},
11    },
12    spec_files_blessed::{BlessedApiSpecFile, BlessedFiles},
13    spec_files_generated::GeneratedFiles,
14    spec_files_generic::ApiSpecFilesBuilder,
15    spec_files_local::{LocalFiles, walk_local_directory},
16    vcs::{RepoVcs, RepoVcsKind, VcsRevision},
17};
18use anyhow::Context;
19use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
20use owo_colors::OwoColorize;
21use std::io;
22
23/// Default Git branch for the blessed source.
24const DEFAULT_GIT_BRANCH: &str = "origin/main";
25
26/// Default Jujutsu revset for the blessed source.
27const DEFAULT_JJ_REVSET: &str = "trunk()";
28
29/// Configuration for the Dropshot API manager.
30///
31/// This struct describes various properties of the environment the API manager
32/// is running within, such as the command to invoke the OpenAPI manager, and
33/// the repository root directory. For the full list of properties, see the
34/// methods on this struct.
35#[derive(Clone, Debug)]
36pub struct Environment {
37    /// The command to run the OpenAPI manager.
38    pub(crate) command: String,
39
40    /// Path to the root of this repository.
41    pub(crate) repo_root: Utf8PathBuf,
42
43    /// The default OpenAPI directory.
44    pub(crate) default_openapi_dir: Utf8PathBuf,
45
46    /// The default Git branch for the blessed source (e.g.,
47    /// `"origin/main"`).
48    pub(crate) default_git_branch: String,
49
50    /// The default Jujutsu revset for the blessed source (e.g.,
51    /// `"trunk()"`).
52    pub(crate) default_jj_revset: String,
53
54    /// The detected VCS backend.
55    pub(crate) vcs: RepoVcs,
56}
57
58impl Environment {
59    /// Creates a new environment with:
60    ///
61    /// * the command to invoke the OpenAPI manager (e.g. `"cargo openapi"`
62    ///   or `"cargo xtask openapi"`)
63    /// * the provided repository root
64    /// * the default OpenAPI directory as a relative path within the
65    ///   repository root
66    ///
67    /// The VCS backend is auto-detected from the repository root. The
68    /// default blessed branch is `"origin/main"` for Git and `"trunk()"`
69    /// for Jujutsu; the appropriate default is selected based on the
70    /// detected VCS at resolution time.
71    ///
72    /// Returns an error if `repo_root` is not an absolute path or
73    /// `default_openapi_dir` is not a relative path.
74    pub fn new(
75        command: impl Into<String>,
76        repo_root: impl Into<Utf8PathBuf>,
77        default_openapi_dir: impl Into<Utf8PathBuf>,
78    ) -> anyhow::Result<Self> {
79        let command = command.into();
80        let repo_root = repo_root.into();
81        let default_openapi_dir = default_openapi_dir.into();
82
83        validate_paths(&repo_root, &default_openapi_dir)?;
84
85        let vcs = RepoVcs::detect(&repo_root)?;
86
87        Ok(Self {
88            repo_root,
89            default_openapi_dir,
90            default_git_branch: DEFAULT_GIT_BRANCH.to_owned(),
91            default_jj_revset: DEFAULT_JJ_REVSET.to_owned(),
92            command,
93            vcs,
94        })
95    }
96
97    /// Sets the default Git branch used as the blessed source.
98    ///
99    /// By default, this is `origin/main`. The value should be a valid
100    /// Git ref, e.g. `origin/main`, `upstream/dev`, or `main`.
101    ///
102    /// For individual commands, the revision can be overridden through
103    /// the `--blessed-from-vcs` argument (or
104    /// `OPENAPI_MGR_BLESSED_FROM_VCS`), and the path within the
105    /// revision can be overridden through `--blessed-from-vcs-path`
106    /// (or `OPENAPI_MGR_BLESSED_FROM_VCS_PATH`).
107    pub fn with_default_git_branch(
108        mut self,
109        branch: impl Into<String>,
110    ) -> Self {
111        self.default_git_branch = branch.into();
112        self
113    }
114
115    /// Sets the default Jujutsu revset used as the blessed source.
116    ///
117    /// By default, this is `trunk()`. The value should be a valid [jj
118    /// revset](https://docs.jj-vcs.dev/latest/revsets/) expression (e.g.
119    /// `trunk()`, `main`).
120    ///
121    /// For individual commands, this can be overridden through the command line
122    /// or environment variables.
123    pub fn with_default_jj_revset(mut self, revset: impl Into<String>) -> Self {
124        self.default_jj_revset = revset.into();
125        self
126    }
127
128    /// Creates a new environment without auto-detecting VCS.
129    ///
130    /// Uses the Git backend by default. This is intended for unit tests that
131    /// don't exercise VCS operations and only need a valid `Environment` object
132    /// for argument parsing tests.
133    #[cfg(test)]
134    pub(crate) fn new_for_test(
135        command: impl Into<String>,
136        repo_root: impl Into<Utf8PathBuf>,
137        default_openapi_dir: impl Into<Utf8PathBuf>,
138    ) -> anyhow::Result<Self> {
139        let command = command.into();
140        let repo_root = repo_root.into();
141        let default_openapi_dir = default_openapi_dir.into();
142
143        validate_paths(&repo_root, &default_openapi_dir)?;
144
145        let vcs = RepoVcs::git()?;
146
147        Ok(Self {
148            repo_root,
149            default_openapi_dir,
150            default_git_branch: DEFAULT_GIT_BRANCH.to_owned(),
151            default_jj_revset: DEFAULT_JJ_REVSET.to_owned(),
152            command,
153            vcs,
154        })
155    }
156
157    pub(crate) fn resolve(
158        &self,
159        openapi_dir: Option<Utf8PathBuf>,
160    ) -> anyhow::Result<ResolvedEnv> {
161        // This is a bit tricky:
162        //
163        // * if the openapi_dir is provided:
164        //   * first we determine the absolute path using `camino::absolute_utf8`
165        //   * then we determine the path relative to the workspace root (erroring
166        //     out if it is not a subdirectory)
167        // * if the openapi_dir is not provided, we use default_openapi_dir as
168        //   the relative directory, then join it with the workspace root to
169        //   obtain the absolute directory.
170        let (abs_dir, rel_dir) = match &openapi_dir {
171            Some(provided_dir) => {
172                // Determine the absolute path.
173                let abs_dir = camino::absolute_utf8(provided_dir)
174                    .with_context(|| {
175                        format!(
176                            "error making provided OpenAPI directory \
177                             absolute: {}",
178                            provided_dir
179                        )
180                    })?;
181
182                // Determine the path relative to the workspace root.
183                let rel_dir = abs_dir
184                    .strip_prefix(&self.repo_root)
185                    .with_context(|| {
186                        format!(
187                            "provided OpenAPI directory {} is not a \
188                             subdirectory of repository root {}",
189                            abs_dir, self.repo_root
190                        )
191                    })?
192                    .to_path_buf();
193
194                (abs_dir, rel_dir)
195            }
196            None => {
197                let rel_dir = self.default_openapi_dir.clone();
198                let abs_dir = self.repo_root.join(&rel_dir);
199                (abs_dir, rel_dir)
200            }
201        };
202
203        // Select the appropriate default blessed branch based on the
204        // detected VCS backend.
205        let default_blessed_branch = match self.vcs.kind() {
206            RepoVcsKind::Git => self.default_git_branch.clone(),
207            RepoVcsKind::Jj => self.default_jj_revset.clone(),
208        };
209
210        Ok(ResolvedEnv {
211            command: self.command.clone(),
212            repo_root: self.repo_root.clone(),
213            local_source: LocalSource::Directory { abs_dir, rel_dir },
214            default_blessed_branch,
215            vcs: self.vcs.clone(),
216        })
217    }
218}
219
220/// Validate that `repo_root` is absolute and `default_openapi_dir` is a
221/// normal relative path.
222fn validate_paths(
223    repo_root: &Utf8Path,
224    default_openapi_dir: &Utf8Path,
225) -> anyhow::Result<()> {
226    if !repo_root.is_absolute() {
227        return Err(anyhow::anyhow!(
228            "repo_root must be an absolute path, found: {}",
229            repo_root
230        ));
231    }
232
233    if !is_normal_relative(default_openapi_dir) {
234        return Err(anyhow::anyhow!(
235            "default_openapi_dir must be a relative path with \
236             normal components, found: {}",
237            default_openapi_dir
238        ));
239    }
240
241    Ok(())
242}
243
244fn is_normal_relative(default_openapi_dir: &Utf8Path) -> bool {
245    default_openapi_dir
246        .components()
247        .all(|c| matches!(c, Utf8Component::Normal(_) | Utf8Component::CurDir))
248}
249
250/// Internal type for the environment where the OpenAPI directory is known.
251#[derive(Debug)]
252pub(crate) struct ResolvedEnv {
253    pub(crate) command: String,
254    pub(crate) repo_root: Utf8PathBuf,
255    pub(crate) local_source: LocalSource,
256    pub(crate) default_blessed_branch: String,
257    pub(crate) vcs: RepoVcs,
258}
259
260impl ResolvedEnv {
261    pub(crate) fn openapi_abs_dir(&self) -> &Utf8Path {
262        match &self.local_source {
263            LocalSource::Directory { abs_dir, .. } => abs_dir,
264        }
265    }
266
267    pub(crate) fn openapi_rel_dir(&self) -> &Utf8Path {
268        match &self.local_source {
269            LocalSource::Directory { rel_dir, .. } => rel_dir,
270        }
271    }
272}
273
274/// Specifies where to find blessed OpenAPI documents (the ones that are
275/// considered immutable because they've been committed-to upstream).
276#[derive(Debug, Eq, PartialEq)]
277pub enum BlessedSource {
278    /// Blessed OpenAPI documents come from the VCS merge base between the
279    /// current working state and the specified revision, in the specified
280    /// directory.
281    VcsRevisionMergeBase { revision: VcsRevision, directory: Utf8PathBuf },
282
283    /// Blessed OpenAPI documents come from this directory.
284    ///
285    /// This is basically for testing and debugging this tool.
286    Directory { local_directory: Utf8PathBuf },
287}
288
289impl BlessedSource {
290    /// Load the blessed OpenAPI documents.
291    pub fn load(
292        &self,
293        writer: &mut dyn io::Write,
294        repo_root: &Utf8Path,
295        apis: &ManagedApis,
296        styles: &Styles,
297        vcs: &RepoVcs,
298    ) -> anyhow::Result<(BlessedFiles, ErrorAccumulator)> {
299        let mut errors = ErrorAccumulator::new();
300        match self {
301            BlessedSource::Directory { local_directory } => {
302                writeln!(
303                    writer,
304                    "{:>HEADER_WIDTH$} blessed OpenAPI documents from {:?}",
305                    "Loading".style(styles.success_header),
306                    local_directory,
307                )?;
308                let api_files: ApiSpecFilesBuilder<'_, BlessedApiSpecFile> =
309                    walk_local_directory(
310                        local_directory,
311                        apis,
312                        &mut errors,
313                        repo_root,
314                        vcs,
315                    )?;
316                Ok((BlessedFiles::from(api_files), errors))
317            }
318            BlessedSource::VcsRevisionMergeBase { revision, directory } => {
319                writeln!(
320                    writer,
321                    "{:>HEADER_WIDTH$} blessed OpenAPI documents from VCS \
322                     revision {:?} path {:?}",
323                    "Loading".style(styles.success_header),
324                    revision,
325                    directory
326                )?;
327                Ok((
328                    BlessedFiles::load_from_vcs_parent_branch(
329                        repo_root,
330                        revision,
331                        directory,
332                        apis,
333                        &mut errors,
334                        vcs,
335                    )?,
336                    errors,
337                ))
338            }
339        }
340    }
341}
342
343/// Specifies how to find generated OpenAPI documents
344#[derive(Debug)]
345pub enum GeneratedSource {
346    /// Generate OpenAPI documents from the API implementation (default)
347    Generated,
348
349    /// Load "generated" OpenAPI documents from the specified directory
350    ///
351    /// This is basically just for testing and debugging this tool.
352    Directory { local_directory: Utf8PathBuf },
353}
354
355impl GeneratedSource {
356    /// Load the generated OpenAPI documents (i.e., generating them as needed).
357    pub fn load(
358        &self,
359        writer: &mut dyn io::Write,
360        apis: &ManagedApis,
361        styles: &Styles,
362        repo_root: &Utf8Path,
363        vcs: &RepoVcs,
364    ) -> anyhow::Result<(GeneratedFiles, ErrorAccumulator)> {
365        let mut errors = ErrorAccumulator::new();
366        match self {
367            GeneratedSource::Generated => {
368                writeln!(
369                    writer,
370                    "{:>HEADER_WIDTH$} OpenAPI documents from API \
371                     definitions ... ",
372                    GENERATING.style(styles.success_header)
373                )?;
374                Ok((GeneratedFiles::generate(apis, &mut errors)?, errors))
375            }
376            GeneratedSource::Directory { local_directory } => {
377                writeln!(
378                    writer,
379                    "{:>HEADER_WIDTH$} \"generated\" OpenAPI documents from \
380                     {:?} ... ",
381                    "Loading".style(styles.success_header),
382                    local_directory,
383                )?;
384                let api_files = walk_local_directory(
385                    local_directory,
386                    apis,
387                    &mut errors,
388                    repo_root,
389                    vcs,
390                )?;
391                Ok((GeneratedFiles::from(api_files), errors))
392            }
393        }
394    }
395}
396
397/// Specifies where to find local OpenAPI documents
398#[derive(Debug)]
399pub enum LocalSource {
400    /// Local OpenAPI documents come from this directory
401    Directory {
402        /// The absolute directory path.
403        abs_dir: Utf8PathBuf,
404        /// The directory path relative to the repo root. Used for VCS commands
405        /// that read contents of other commits.
406        rel_dir: Utf8PathBuf,
407    },
408}
409
410impl LocalSource {
411    /// Load the local OpenAPI documents.
412    ///
413    /// The `repo_root` parameter is needed to resolve `.gitstub` files.
414    pub fn load(
415        &self,
416        writer: &mut dyn io::Write,
417        apis: &ManagedApis,
418        styles: &Styles,
419        repo_root: &Utf8Path,
420        vcs: &RepoVcs,
421    ) -> anyhow::Result<(LocalFiles, ErrorAccumulator)> {
422        let mut errors = ErrorAccumulator::new();
423
424        // Shallow clones and Git stub storage are incompatible.
425        let any_uses_git_stub =
426            apis.iter_apis().any(|a| apis.uses_git_stub_storage(a));
427        if any_uses_git_stub && vcs.is_shallow_clone(writer, repo_root) {
428            errors.error(anyhow::anyhow!(
429                "this repository is a shallow clone, but Git stub storage is \
430                 enabled for some APIs. Git stubs cannot be resolved in a \
431                 shallow clone because the referenced commits may not be \
432                 available. To fix this, fetch complete history (e.g. \
433                 `git fetch --unshallow`) or make a fresh clone without \
434                 --depth."
435            ));
436            return Ok((LocalFiles::default(), errors));
437        }
438
439        match self {
440            LocalSource::Directory { abs_dir, .. } => {
441                writeln!(
442                    writer,
443                    "{:>HEADER_WIDTH$} local OpenAPI documents from \
444                     {:?} ... ",
445                    "Loading".style(styles.success_header),
446                    abs_dir,
447                )?;
448                Ok((
449                    LocalFiles::load_from_directory(
450                        abs_dir,
451                        apis,
452                        &mut errors,
453                        repo_root,
454                        vcs,
455                    )?,
456                    errors,
457                ))
458            }
459        }
460    }
461}
462
463/// Stores errors and warnings accumulated during loading
464pub struct ErrorAccumulator {
465    /// errors that reflect incorrectness or incompleteness of the loaded data
466    errors: Vec<anyhow::Error>,
467    /// problems that do not affect the correctness or completeness of the data
468    warnings: Vec<anyhow::Error>,
469}
470
471impl ErrorAccumulator {
472    pub fn new() -> ErrorAccumulator {
473        ErrorAccumulator { errors: Vec::new(), warnings: Vec::new() }
474    }
475
476    /// Record an error
477    pub fn error(&mut self, error: anyhow::Error) {
478        self.errors.push(error);
479    }
480
481    /// Record a warning
482    pub fn warning(&mut self, error: anyhow::Error) {
483        self.warnings.push(error);
484    }
485
486    pub fn iter_errors(&self) -> impl Iterator<Item = &'_ anyhow::Error> + '_ {
487        self.errors.iter()
488    }
489
490    pub fn iter_warnings(
491        &self,
492    ) -> impl Iterator<Item = &'_ anyhow::Error> + '_ {
493        self.warnings.iter()
494    }
495}