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