dropshot_api_manager/
environment.rs

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