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    git::{GitRevision, is_shallow_clone},
9    output::{
10        Styles,
11        headers::{GENERATING, HEADER_WIDTH},
12    },
13    spec_files_blessed::{BlessedApiSpecFile, BlessedFiles},
14    spec_files_generated::GeneratedFiles,
15    spec_files_generic::ApiSpecFilesBuilder,
16    spec_files_local::{LocalFiles, walk_local_directory},
17};
18use anyhow::Context;
19use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
20use owo_colors::OwoColorize;
21
22/// Configuration for the Dropshot API manager.
23///
24/// This struct describes various properties of the environment the API manager
25/// is running within, such as the command to invoke the OpenAPI manager, and
26/// the repository root directory. For the full list of properties, see the
27/// methods on this struct.
28#[derive(Clone, Debug)]
29pub struct Environment {
30    /// The command to run the OpenAPI manager.
31    pub(crate) command: String,
32
33    /// Path to the root of this repository
34    pub(crate) repo_root: Utf8PathBuf,
35
36    /// The default OpenAPI directory.
37    pub(crate) default_openapi_dir: Utf8PathBuf,
38
39    /// The default Git upstream.
40    pub(crate) default_git_branch: String,
41}
42
43impl Environment {
44    /// Creates a new environment with:
45    ///
46    /// * the command to invoke the OpenAPI manager (e.g. `"cargo openapi"`
47    ///   or `"cargo xtask openapi"`)
48    /// * the provided Git repository root
49    /// * the default OpenAPI directory as a relative path within the
50    ///   repository root
51    ///
52    /// Returns an error if `repo_root` is not an absolute path or
53    /// `default_openapi_dir` is not a relative path.
54    pub fn new(
55        command: impl Into<String>,
56        repo_root: impl Into<Utf8PathBuf>,
57        default_openapi_dir: impl Into<Utf8PathBuf>,
58    ) -> anyhow::Result<Self> {
59        let command = command.into();
60        let repo_root = repo_root.into();
61        let default_openapi_dir = default_openapi_dir.into();
62
63        if !repo_root.is_absolute() {
64            return Err(anyhow::anyhow!(
65                "repo_root must be an absolute path, found: {}",
66                repo_root
67            ));
68        }
69
70        if !is_normal_relative(&default_openapi_dir) {
71            return Err(anyhow::anyhow!(
72                "default_openapi_dir must be a relative path with \
73                 normal components, found: {}",
74                default_openapi_dir
75            ));
76        }
77
78        Ok(Self {
79            repo_root,
80            default_openapi_dir,
81            default_git_branch: "origin/main".to_owned(),
82            command,
83        })
84    }
85
86    /// Sets the default Git upstream and branch name.
87    ///
88    /// By default, this is `origin/main`, but it can be set to any valid Git
89    /// remote and branch name separated by a forward slash, e.g.
90    /// `origin/master` or `upstream/dev`.
91    ///
92    /// For individual commands, this can be overridden through the
93    /// `--blessed-from-git` argument, or the `OPENAPI_MGR_BLESSED_FROM_GIT`
94    /// environment variable.
95    pub fn with_default_git_branch(
96        mut self,
97        branch: impl Into<String>,
98    ) -> Self {
99        self.default_git_branch = branch.into();
100        self
101    }
102
103    pub(crate) fn resolve(
104        &self,
105        openapi_dir: Option<Utf8PathBuf>,
106    ) -> anyhow::Result<ResolvedEnv> {
107        // This is a bit tricky:
108        //
109        // * if the openapi_dir is provided:
110        //   * first we determine the absolute path using `camino::absolute_utf8`
111        //   * then we determine the path relative to the workspace root (erroring
112        //     out if it is not a subdirectory)
113        // * if the openapi_dir is not provided, we use default_openapi_dir as
114        //   the relative directory, then join it with the workspace root to
115        //   obtain the absolute directory.
116        let (abs_dir, rel_dir) = match &openapi_dir {
117            Some(provided_dir) => {
118                // Determine the absolute path.
119                let abs_dir = camino::absolute_utf8(provided_dir)
120                    .with_context(|| {
121                        format!(
122                            "error making provided OpenAPI directory \
123                             absolute: {}",
124                            provided_dir
125                        )
126                    })?;
127
128                // Determine the path relative to the workspace root.
129                let rel_dir = abs_dir
130                    .strip_prefix(&self.repo_root)
131                    .with_context(|| {
132                        format!(
133                            "provided OpenAPI directory {} is not a \
134                             subdirectory of repository root {}",
135                            abs_dir, self.repo_root
136                        )
137                    })?
138                    .to_path_buf();
139
140                (abs_dir, rel_dir)
141            }
142            None => {
143                let rel_dir = self.default_openapi_dir.clone();
144                let abs_dir = self.repo_root.join(&rel_dir);
145                (abs_dir, rel_dir)
146            }
147        };
148
149        Ok(ResolvedEnv {
150            command: self.command.clone(),
151            repo_root: self.repo_root.clone(),
152            local_source: LocalSource::Directory { abs_dir, rel_dir },
153            default_git_branch: self.default_git_branch.clone(),
154        })
155    }
156}
157
158fn is_normal_relative(default_openapi_dir: &Utf8Path) -> bool {
159    default_openapi_dir
160        .components()
161        .all(|c| matches!(c, Utf8Component::Normal(_) | Utf8Component::CurDir))
162}
163
164/// Internal type for the environment where the OpenAPI directory is known.
165#[derive(Debug)]
166pub(crate) struct ResolvedEnv {
167    pub(crate) command: String,
168    pub(crate) repo_root: Utf8PathBuf,
169    pub(crate) local_source: LocalSource,
170    pub(crate) default_git_branch: String,
171}
172
173impl ResolvedEnv {
174    pub(crate) fn openapi_abs_dir(&self) -> &Utf8Path {
175        match &self.local_source {
176            LocalSource::Directory { abs_dir, .. } => abs_dir,
177        }
178    }
179
180    pub(crate) fn openapi_rel_dir(&self) -> &Utf8Path {
181        match &self.local_source {
182            LocalSource::Directory { rel_dir, .. } => rel_dir,
183        }
184    }
185}
186
187/// Specifies where to find blessed OpenAPI documents (the ones that are
188/// considered immutable because they've been committed-to upstream)
189#[derive(Debug)]
190pub enum BlessedSource {
191    /// Blessed OpenAPI documents come from the Git merge base between `HEAD`
192    /// and the specified revision (default "main"), in the specified directory.
193    GitRevisionMergeBase { revision: GitRevision, directory: Utf8PathBuf },
194
195    /// Blessed OpenAPI documents come from this directory
196    ///
197    /// This is basically just for testing and debugging this tool.
198    Directory { local_directory: Utf8PathBuf },
199}
200
201impl BlessedSource {
202    /// Load the blessed OpenAPI documents.
203    pub fn load(
204        &self,
205        repo_root: &Utf8Path,
206        apis: &ManagedApis,
207        styles: &Styles,
208    ) -> anyhow::Result<(BlessedFiles, ErrorAccumulator)> {
209        let mut errors = ErrorAccumulator::new();
210        match self {
211            BlessedSource::Directory { local_directory } => {
212                eprintln!(
213                    "{:>HEADER_WIDTH$} blessed OpenAPI documents from {:?}",
214                    "Loading".style(styles.success_header),
215                    local_directory,
216                );
217                let api_files: ApiSpecFilesBuilder<'_, BlessedApiSpecFile> =
218                    walk_local_directory(
219                        local_directory,
220                        apis,
221                        &mut errors,
222                        repo_root,
223                    )?;
224                Ok((BlessedFiles::from(api_files), errors))
225            }
226            BlessedSource::GitRevisionMergeBase { revision, directory } => {
227                eprintln!(
228                    "{:>HEADER_WIDTH$} blessed OpenAPI documents from git \
229                     revision {:?} path {:?}",
230                    "Loading".style(styles.success_header),
231                    revision,
232                    directory
233                );
234                Ok((
235                    BlessedFiles::load_from_git_parent_branch(
236                        repo_root,
237                        revision,
238                        directory,
239                        apis,
240                        &mut errors,
241                    )?,
242                    errors,
243                ))
244            }
245        }
246    }
247}
248
249/// Specifies how to find generated OpenAPI documents
250#[derive(Debug)]
251pub enum GeneratedSource {
252    /// Generate OpenAPI documents from the API implementation (default)
253    Generated,
254
255    /// Load "generated" OpenAPI documents from the specified directory
256    ///
257    /// This is basically just for testing and debugging this tool.
258    Directory { local_directory: Utf8PathBuf },
259}
260
261impl GeneratedSource {
262    /// Load the generated OpenAPI documents (i.e., generating them as needed).
263    pub fn load(
264        &self,
265        apis: &ManagedApis,
266        styles: &Styles,
267        repo_root: &Utf8Path,
268    ) -> anyhow::Result<(GeneratedFiles, ErrorAccumulator)> {
269        let mut errors = ErrorAccumulator::new();
270        match self {
271            GeneratedSource::Generated => {
272                eprintln!(
273                    "{:>HEADER_WIDTH$} OpenAPI documents from API \
274                     definitions ... ",
275                    GENERATING.style(styles.success_header)
276                );
277                Ok((GeneratedFiles::generate(apis, &mut errors)?, errors))
278            }
279            GeneratedSource::Directory { local_directory } => {
280                eprintln!(
281                    "{:>HEADER_WIDTH$} \"generated\" OpenAPI documents from \
282                     {:?} ... ",
283                    "Loading".style(styles.success_header),
284                    local_directory,
285                );
286                let api_files = walk_local_directory(
287                    local_directory,
288                    apis,
289                    &mut errors,
290                    repo_root,
291                )?;
292                Ok((GeneratedFiles::from(api_files), errors))
293            }
294        }
295    }
296}
297
298/// Specifies where to find local OpenAPI documents
299#[derive(Debug)]
300pub enum LocalSource {
301    /// Local OpenAPI documents come from this directory
302    Directory {
303        /// The absolute directory path.
304        abs_dir: Utf8PathBuf,
305        /// The directory path relative to the repo root. Used for Git commands
306        /// that read contents of other commits.
307        rel_dir: Utf8PathBuf,
308    },
309}
310
311impl LocalSource {
312    /// Load the local OpenAPI documents.
313    ///
314    /// The `repo_root` parameter is needed to resolve `.gitstub` files.
315    pub fn load(
316        &self,
317        apis: &ManagedApis,
318        styles: &Styles,
319        repo_root: &Utf8Path,
320    ) -> anyhow::Result<(LocalFiles, ErrorAccumulator)> {
321        let mut errors = ErrorAccumulator::new();
322
323        // Shallow clones and Git stub storage are incompatible.
324        let any_uses_git_stub =
325            apis.iter_apis().any(|a| apis.uses_git_stub_storage(a));
326        if any_uses_git_stub && is_shallow_clone(repo_root) {
327            errors.error(anyhow::anyhow!(
328                "this repository is a shallow clone, but Git stub storage is \
329                 enabled for some APIs. Git stubs cannot be resolved in a \
330                 shallow clone because the referenced commits may not be \
331                 available. To fix this, run `git fetch --unshallow` to \
332                 fetch complete history, or make a fresh clone without --depth."
333            ));
334            return Ok((LocalFiles::default(), errors));
335        }
336
337        match self {
338            LocalSource::Directory { abs_dir, .. } => {
339                eprintln!(
340                    "{:>HEADER_WIDTH$} local OpenAPI documents from \
341                     {:?} ... ",
342                    "Loading".style(styles.success_header),
343                    abs_dir,
344                );
345                Ok((
346                    LocalFiles::load_from_directory(
347                        abs_dir,
348                        apis,
349                        &mut errors,
350                        repo_root,
351                    )?,
352                    errors,
353                ))
354            }
355        }
356    }
357}
358
359/// Stores errors and warnings accumulated during loading
360pub struct ErrorAccumulator {
361    /// errors that reflect incorrectness or incompleteness of the loaded data
362    errors: Vec<anyhow::Error>,
363    /// problems that do not affect the correctness or completeness of the data
364    warnings: Vec<anyhow::Error>,
365}
366
367impl ErrorAccumulator {
368    pub fn new() -> ErrorAccumulator {
369        ErrorAccumulator { errors: Vec::new(), warnings: Vec::new() }
370    }
371
372    /// Record an error
373    pub fn error(&mut self, error: anyhow::Error) {
374        self.errors.push(error);
375    }
376
377    /// Record a warning
378    pub fn warning(&mut self, error: anyhow::Error) {
379        self.warnings.push(error);
380    }
381
382    pub fn iter_errors(&self) -> impl Iterator<Item = &'_ anyhow::Error> + '_ {
383        self.errors.iter()
384    }
385
386    pub fn iter_warnings(
387        &self,
388    ) -> impl Iterator<Item = &'_ anyhow::Error> + '_ {
389        self.warnings.iter()
390    }
391}