1use 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;
21use std::io;
22
23const DEFAULT_GIT_BRANCH: &str = "origin/main";
25
26const DEFAULT_JJ_REVSET: &str = "trunk()";
28
29#[derive(Clone, Debug)]
36pub struct Environment {
37 pub(crate) command: String,
39
40 pub(crate) repo_root: Utf8PathBuf,
42
43 pub(crate) default_openapi_dir: Utf8PathBuf,
45
46 pub(crate) default_git_branch: String,
49
50 pub(crate) default_jj_revset: String,
53
54 pub(crate) vcs: RepoVcs,
56}
57
58impl Environment {
59 pub fn new(
75 command: impl Into<String>,
76 repo_root: impl Into<Utf8PathBuf>,
77 default_openapi_dir: impl Into<Utf8PathBuf>,
78 ) -> anyhow::Result<Self> {
79 let command = command.into();
80 let repo_root = repo_root.into();
81 let default_openapi_dir = default_openapi_dir.into();
82
83 validate_paths(&repo_root, &default_openapi_dir)?;
84
85 let vcs = RepoVcs::detect(&repo_root)?;
86
87 Ok(Self {
88 repo_root,
89 default_openapi_dir,
90 default_git_branch: DEFAULT_GIT_BRANCH.to_owned(),
91 default_jj_revset: DEFAULT_JJ_REVSET.to_owned(),
92 command,
93 vcs,
94 })
95 }
96
97 pub fn with_default_git_branch(
108 mut self,
109 branch: impl Into<String>,
110 ) -> Self {
111 self.default_git_branch = branch.into();
112 self
113 }
114
115 pub fn with_default_jj_revset(mut self, revset: impl Into<String>) -> Self {
124 self.default_jj_revset = revset.into();
125 self
126 }
127
128 #[cfg(test)]
134 pub(crate) fn new_for_test(
135 command: impl Into<String>,
136 repo_root: impl Into<Utf8PathBuf>,
137 default_openapi_dir: impl Into<Utf8PathBuf>,
138 ) -> anyhow::Result<Self> {
139 let command = command.into();
140 let repo_root = repo_root.into();
141 let default_openapi_dir = default_openapi_dir.into();
142
143 validate_paths(&repo_root, &default_openapi_dir)?;
144
145 let vcs = RepoVcs::git()?;
146
147 Ok(Self {
148 repo_root,
149 default_openapi_dir,
150 default_git_branch: DEFAULT_GIT_BRANCH.to_owned(),
151 default_jj_revset: DEFAULT_JJ_REVSET.to_owned(),
152 command,
153 vcs,
154 })
155 }
156
157 pub(crate) fn resolve(
158 &self,
159 openapi_dir: Option<Utf8PathBuf>,
160 ) -> anyhow::Result<ResolvedEnv> {
161 let (abs_dir, rel_dir) = match &openapi_dir {
171 Some(provided_dir) => {
172 let abs_dir = camino::absolute_utf8(provided_dir)
174 .with_context(|| {
175 format!(
176 "error making provided OpenAPI directory \
177 absolute: {}",
178 provided_dir
179 )
180 })?;
181
182 let rel_dir = abs_dir
184 .strip_prefix(&self.repo_root)
185 .with_context(|| {
186 format!(
187 "provided OpenAPI directory {} is not a \
188 subdirectory of repository root {}",
189 abs_dir, self.repo_root
190 )
191 })?
192 .to_path_buf();
193
194 (abs_dir, rel_dir)
195 }
196 None => {
197 let rel_dir = self.default_openapi_dir.clone();
198 let abs_dir = self.repo_root.join(&rel_dir);
199 (abs_dir, rel_dir)
200 }
201 };
202
203 let default_blessed_branch = match self.vcs.kind() {
206 RepoVcsKind::Git => self.default_git_branch.clone(),
207 RepoVcsKind::Jj => self.default_jj_revset.clone(),
208 };
209
210 Ok(ResolvedEnv {
211 command: self.command.clone(),
212 repo_root: self.repo_root.clone(),
213 local_source: LocalSource::Directory { abs_dir, rel_dir },
214 default_blessed_branch,
215 vcs: self.vcs.clone(),
216 })
217 }
218}
219
220fn validate_paths(
223 repo_root: &Utf8Path,
224 default_openapi_dir: &Utf8Path,
225) -> anyhow::Result<()> {
226 if !repo_root.is_absolute() {
227 return Err(anyhow::anyhow!(
228 "repo_root must be an absolute path, found: {}",
229 repo_root
230 ));
231 }
232
233 if !is_normal_relative(default_openapi_dir) {
234 return Err(anyhow::anyhow!(
235 "default_openapi_dir must be a relative path with \
236 normal components, found: {}",
237 default_openapi_dir
238 ));
239 }
240
241 Ok(())
242}
243
244fn is_normal_relative(default_openapi_dir: &Utf8Path) -> bool {
245 default_openapi_dir
246 .components()
247 .all(|c| matches!(c, Utf8Component::Normal(_) | Utf8Component::CurDir))
248}
249
250#[derive(Debug)]
252pub(crate) struct ResolvedEnv {
253 pub(crate) command: String,
254 pub(crate) repo_root: Utf8PathBuf,
255 pub(crate) local_source: LocalSource,
256 pub(crate) default_blessed_branch: String,
257 pub(crate) vcs: RepoVcs,
258}
259
260impl ResolvedEnv {
261 pub(crate) fn openapi_abs_dir(&self) -> &Utf8Path {
262 match &self.local_source {
263 LocalSource::Directory { abs_dir, .. } => abs_dir,
264 }
265 }
266
267 pub(crate) fn openapi_rel_dir(&self) -> &Utf8Path {
268 match &self.local_source {
269 LocalSource::Directory { rel_dir, .. } => rel_dir,
270 }
271 }
272}
273
274#[derive(Debug, Eq, PartialEq)]
277pub enum BlessedSource {
278 VcsRevisionMergeBase { revision: VcsRevision, directory: Utf8PathBuf },
282
283 Directory { local_directory: Utf8PathBuf },
287}
288
289impl BlessedSource {
290 pub fn load(
292 &self,
293 writer: &mut dyn io::Write,
294 repo_root: &Utf8Path,
295 apis: &ManagedApis,
296 styles: &Styles,
297 vcs: &RepoVcs,
298 ) -> anyhow::Result<(BlessedFiles, ErrorAccumulator)> {
299 let mut errors = ErrorAccumulator::new();
300 match self {
301 BlessedSource::Directory { local_directory } => {
302 writeln!(
303 writer,
304 "{:>HEADER_WIDTH$} blessed OpenAPI documents from {:?}",
305 "Loading".style(styles.success_header),
306 local_directory,
307 )?;
308 let api_files: ApiSpecFilesBuilder<'_, BlessedApiSpecFile> =
309 walk_local_directory(
310 local_directory,
311 apis,
312 &mut errors,
313 repo_root,
314 vcs,
315 )?;
316 Ok((BlessedFiles::from(api_files), errors))
317 }
318 BlessedSource::VcsRevisionMergeBase { revision, directory } => {
319 writeln!(
320 writer,
321 "{:>HEADER_WIDTH$} blessed OpenAPI documents from VCS \
322 revision {:?} path {:?}",
323 "Loading".style(styles.success_header),
324 revision,
325 directory
326 )?;
327 Ok((
328 BlessedFiles::load_from_vcs_parent_branch(
329 repo_root,
330 revision,
331 directory,
332 apis,
333 &mut errors,
334 vcs,
335 )?,
336 errors,
337 ))
338 }
339 }
340 }
341}
342
343#[derive(Debug)]
345pub enum GeneratedSource {
346 Generated,
348
349 Directory { local_directory: Utf8PathBuf },
353}
354
355impl GeneratedSource {
356 pub fn load(
358 &self,
359 writer: &mut dyn io::Write,
360 apis: &ManagedApis,
361 styles: &Styles,
362 repo_root: &Utf8Path,
363 vcs: &RepoVcs,
364 ) -> anyhow::Result<(GeneratedFiles, ErrorAccumulator)> {
365 let mut errors = ErrorAccumulator::new();
366 match self {
367 GeneratedSource::Generated => {
368 writeln!(
369 writer,
370 "{:>HEADER_WIDTH$} OpenAPI documents from API \
371 definitions ... ",
372 GENERATING.style(styles.success_header)
373 )?;
374 Ok((GeneratedFiles::generate(apis, &mut errors)?, errors))
375 }
376 GeneratedSource::Directory { local_directory } => {
377 writeln!(
378 writer,
379 "{:>HEADER_WIDTH$} \"generated\" OpenAPI documents from \
380 {:?} ... ",
381 "Loading".style(styles.success_header),
382 local_directory,
383 )?;
384 let api_files = walk_local_directory(
385 local_directory,
386 apis,
387 &mut errors,
388 repo_root,
389 vcs,
390 )?;
391 Ok((GeneratedFiles::from(api_files), errors))
392 }
393 }
394 }
395}
396
397#[derive(Debug)]
399pub enum LocalSource {
400 Directory {
402 abs_dir: Utf8PathBuf,
404 rel_dir: Utf8PathBuf,
407 },
408}
409
410impl LocalSource {
411 pub fn load(
415 &self,
416 writer: &mut dyn io::Write,
417 apis: &ManagedApis,
418 styles: &Styles,
419 repo_root: &Utf8Path,
420 vcs: &RepoVcs,
421 ) -> anyhow::Result<(LocalFiles, ErrorAccumulator)> {
422 let mut errors = ErrorAccumulator::new();
423
424 let any_uses_git_stub =
426 apis.iter_apis().any(|a| apis.uses_git_stub_storage(a));
427 if any_uses_git_stub && vcs.is_shallow_clone(writer, repo_root) {
428 errors.error(anyhow::anyhow!(
429 "this repository is a shallow clone, but Git stub storage is \
430 enabled for some APIs. Git stubs cannot be resolved in a \
431 shallow clone because the referenced commits may not be \
432 available. To fix this, fetch complete history (e.g. \
433 `git fetch --unshallow`) or make a fresh clone without \
434 --depth."
435 ));
436 return Ok((LocalFiles::default(), errors));
437 }
438
439 match self {
440 LocalSource::Directory { abs_dir, .. } => {
441 writeln!(
442 writer,
443 "{:>HEADER_WIDTH$} local OpenAPI documents from \
444 {:?} ... ",
445 "Loading".style(styles.success_header),
446 abs_dir,
447 )?;
448 Ok((
449 LocalFiles::load_from_directory(
450 abs_dir,
451 apis,
452 &mut errors,
453 repo_root,
454 vcs,
455 )?,
456 errors,
457 ))
458 }
459 }
460 }
461}
462
463pub struct ErrorAccumulator {
465 errors: Vec<anyhow::Error>,
467 warnings: Vec<anyhow::Error>,
469}
470
471impl ErrorAccumulator {
472 pub fn new() -> ErrorAccumulator {
473 ErrorAccumulator { errors: Vec::new(), warnings: Vec::new() }
474 }
475
476 pub fn error(&mut self, error: anyhow::Error) {
478 self.errors.push(error);
479 }
480
481 pub fn warning(&mut self, error: anyhow::Error) {
483 self.warnings.push(error);
484 }
485
486 pub fn iter_errors(&self) -> impl Iterator<Item = &'_ anyhow::Error> + '_ {
487 self.errors.iter()
488 }
489
490 pub fn iter_warnings(
491 &self,
492 ) -> impl Iterator<Item = &'_ anyhow::Error> + '_ {
493 self.warnings.iter()
494 }
495}