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