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::{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        apis: &ManagedApis,
206        styles: &Styles,
207    ) -> anyhow::Result<(BlessedFiles, ErrorAccumulator)> {
208        let mut errors = ErrorAccumulator::new();
209        match self {
210            BlessedSource::Directory { local_directory } => {
211                eprintln!(
212                    "{:>HEADER_WIDTH$} blessed OpenAPI documents from {:?}",
213                    "Loading".style(styles.success_header),
214                    local_directory,
215                );
216                let api_files: ApiSpecFilesBuilder<'_, BlessedApiSpecFile> =
217                    walk_local_directory(local_directory, apis, &mut errors)?;
218                Ok((BlessedFiles::from(api_files), errors))
219            }
220            BlessedSource::GitRevisionMergeBase { revision, directory } => {
221                eprintln!(
222                    "{:>HEADER_WIDTH$} blessed OpenAPI documents from git \
223                     revision {:?} path {:?}",
224                    "Loading".style(styles.success_header),
225                    revision,
226                    directory
227                );
228                Ok((
229                    BlessedFiles::load_from_git_parent_branch(
230                        revision,
231                        directory,
232                        apis,
233                        &mut errors,
234                    )?,
235                    errors,
236                ))
237            }
238        }
239    }
240}
241
242/// Specifies how to find generated OpenAPI documents
243#[derive(Debug)]
244pub enum GeneratedSource {
245    /// Generate OpenAPI documents from the API implementation (default)
246    Generated,
247
248    /// Load "generated" OpenAPI documents from the specified directory
249    ///
250    /// This is basically just for testing and debugging this tool.
251    Directory { local_directory: Utf8PathBuf },
252}
253
254impl GeneratedSource {
255    /// Load the generated OpenAPI documents (i.e., generating them as needed)
256    pub fn load(
257        &self,
258        apis: &ManagedApis,
259        styles: &Styles,
260    ) -> anyhow::Result<(GeneratedFiles, ErrorAccumulator)> {
261        let mut errors = ErrorAccumulator::new();
262        match self {
263            GeneratedSource::Generated => {
264                eprintln!(
265                    "{:>HEADER_WIDTH$} OpenAPI documents from API \
266                     definitions ... ",
267                    GENERATING.style(styles.success_header)
268                );
269                Ok((GeneratedFiles::generate(apis, &mut errors)?, errors))
270            }
271            GeneratedSource::Directory { local_directory } => {
272                eprintln!(
273                    "{:>HEADER_WIDTH$} \"generated\" OpenAPI documents from \
274                     {:?} ... ",
275                    "Loading".style(styles.success_header),
276                    local_directory,
277                );
278                let api_files =
279                    walk_local_directory(local_directory, apis, &mut errors)?;
280                Ok((GeneratedFiles::from(api_files), errors))
281            }
282        }
283    }
284}
285
286/// Specifies where to find local OpenAPI documents
287#[derive(Debug)]
288pub enum LocalSource {
289    /// Local OpenAPI documents come from this directory
290    Directory {
291        /// The absolute directory path.
292        abs_dir: Utf8PathBuf,
293        /// The directory path relative to the repo root. Used for Git commands
294        /// that read contents of other commits.
295        rel_dir: Utf8PathBuf,
296    },
297}
298
299impl LocalSource {
300    /// Load the local OpenAPI documents
301    pub fn load(
302        &self,
303        apis: &ManagedApis,
304        styles: &Styles,
305    ) -> anyhow::Result<(LocalFiles, ErrorAccumulator)> {
306        let mut errors = ErrorAccumulator::new();
307        match self {
308            LocalSource::Directory { abs_dir, .. } => {
309                eprintln!(
310                    "{:>HEADER_WIDTH$} local OpenAPI documents from \
311                     {:?} ... ",
312                    "Loading".style(styles.success_header),
313                    abs_dir,
314                );
315                Ok((
316                    LocalFiles::load_from_directory(
317                        abs_dir,
318                        apis,
319                        &mut errors,
320                    )?,
321                    errors,
322                ))
323            }
324        }
325    }
326}
327
328/// Stores errors and warnings accumulated during loading
329pub struct ErrorAccumulator {
330    /// errors that reflect incorrectness or incompleteness of the loaded data
331    errors: Vec<anyhow::Error>,
332    /// problems that do not affect the correctness or completeness of the data
333    warnings: Vec<anyhow::Error>,
334}
335
336impl ErrorAccumulator {
337    pub fn new() -> ErrorAccumulator {
338        ErrorAccumulator { errors: Vec::new(), warnings: Vec::new() }
339    }
340
341    /// Record an error
342    pub fn error(&mut self, error: anyhow::Error) {
343        self.errors.push(error);
344    }
345
346    /// Record a warning
347    pub fn warning(&mut self, error: anyhow::Error) {
348        self.warnings.push(error);
349    }
350
351    pub fn iter_errors(&self) -> impl Iterator<Item = &'_ anyhow::Error> + '_ {
352        self.errors.iter()
353    }
354
355    pub fn iter_warnings(
356        &self,
357    ) -> impl Iterator<Item = &'_ anyhow::Error> + '_ {
358        self.warnings.iter()
359    }
360}