Skip to main content

git_stub_vcs/
materialize.rs

1// Copyright 2026 Oxide Computer Company
2
3//! Materialization logic for git stubs.
4
5use crate::{MaterializeError, Vcs};
6use atomicwrites::AtomicFile;
7use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
8use fs_err as fs;
9use git_stub::GitStub;
10use std::io::Write;
11
12/// Returns the first non-normal component in the path, if any.
13///
14/// A normal component is a plain file or directory name (not `..`, `.`,
15/// root `/`, or a Windows prefix).
16fn find_non_normal_component(path: &Utf8Path) -> Option<String> {
17    path.components().find_map(|component| match component {
18        Utf8Component::Normal(_) => None,
19        Utf8Component::Prefix(_)
20        | Utf8Component::RootDir
21        | Utf8Component::CurDir
22        | Utf8Component::ParentDir => Some(component.as_str().to_owned()),
23    })
24}
25
26/// Returns an error if `path` contains any non-normal component.
27fn check_path(path: &Utf8Path) -> Result<(), MaterializeError> {
28    if let Some(component) = find_non_normal_component(path) {
29        return Err(MaterializeError::InvalidPathComponent {
30            path: path.to_owned(),
31            component,
32        });
33    }
34    Ok(())
35}
36
37/// Materializes git stubs into actual file content.
38///
39/// Reads `.gitstub` files, fetches the referenced content from Git history,
40/// and writes the content to an output directory.
41#[derive(Debug, Clone)]
42pub struct Materializer {
43    repo_root: Utf8PathBuf,
44    output_dir: Utf8PathBuf,
45    emit_cargo_directives: bool,
46    vcs: Vcs,
47}
48
49impl Materializer {
50    /// Creates a new materializer for general use.
51    ///
52    /// `repo_root` must be the repository root, and it is treated as relative
53    /// to the current working directory. (It is also allowed to be absolute.)
54    ///
55    /// `output_dir` is an output directory relative to the current working
56    /// directory. (It is also allowed to be absolute.)
57    ///
58    /// Returns an error if no VCS (`.git` or `.jj`) is detected at
59    /// `repo_root`, or if the repository is a shallow clone.
60    pub fn standard(
61        repo_root: impl Into<Utf8PathBuf>,
62        output_dir: impl Into<Utf8PathBuf>,
63    ) -> Result<Self, MaterializeError> {
64        let repo_root = repo_root.into();
65        let vcs = Vcs::detect(&repo_root)?;
66        Self::check_shallow(&vcs, &repo_root)?;
67        Ok(Materializer {
68            repo_root,
69            output_dir: output_dir.into(),
70            emit_cargo_directives: false,
71            vcs,
72        })
73    }
74
75    /// Creates a new materializer for use in Cargo build scripts.
76    ///
77    /// This constructor reads `OUT_DIR` from the environment for the output
78    /// directory, and writes files to the `git-stub-vcs` directory
79    /// within `OUT_DIR`. It also emits `cargo::rerun-if-changed` directives
80    /// for each materialized file.
81    ///
82    /// `repo_root` is relative to `CARGO_MANIFEST_DIR` (the directory
83    /// containing the crate's `Cargo.toml`), and is typically a relative
84    /// path.
85    ///
86    /// # Panics
87    ///
88    /// Panics if the `OUT_DIR` or `CARGO_MANIFEST_DIR` environment variables
89    /// are not set. Both these environment variables are expected to be set
90    /// in a Cargo build script context.
91    pub fn for_build_script(
92        repo_root: impl Into<Utf8PathBuf>,
93    ) -> Result<Self, MaterializeError> {
94        let out_dir = std::env::var("OUT_DIR").expect(
95            "OUT_DIR is set \
96             (must be called from a Cargo build script)",
97        );
98        let out_dir = Utf8PathBuf::from(out_dir).join("git-stub-vcs");
99
100        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect(
101            "CARGO_MANIFEST_DIR is set \
102                 (must be called from a Cargo build script)",
103        );
104        let manifest_dir = Utf8PathBuf::from(manifest_dir);
105        let repo_root = manifest_dir.join(repo_root.into());
106
107        let vcs = Vcs::detect(&repo_root)?;
108        Self::check_shallow(&vcs, &repo_root)?;
109        Ok(Materializer {
110            repo_root,
111            output_dir: out_dir,
112            emit_cargo_directives: true,
113            vcs,
114        })
115    }
116
117    /// Overrides the detected VCS.
118    ///
119    /// Use this when you want to force a specific VCS instead of relying on
120    /// automatic detection.
121    ///
122    /// Returns an error if shallow-clone checking fails, or if the repository
123    /// is shallow under the new VCS.
124    pub fn with_vcs(mut self, vcs: Vcs) -> Result<Self, MaterializeError> {
125        Self::check_shallow(&vcs, &self.repo_root)?;
126        self.vcs = vcs;
127        Ok(self)
128    }
129
130    /// Returns the VCS that will be used for materialization.
131    pub fn vcs(&self) -> &Vcs {
132        &self.vcs
133    }
134
135    /// Checks whether the repository is a shallow clone and returns an
136    /// error if so. Called once at construction time rather than on every
137    /// `materialize()` call.
138    fn check_shallow(
139        vcs: &Vcs,
140        repo_root: &Utf8Path,
141    ) -> Result<(), MaterializeError> {
142        if vcs.is_shallow_clone(repo_root).map_err(|error| {
143            MaterializeError::ShallowCloneCheck {
144                repo_root: repo_root.to_owned(),
145                error,
146            }
147        })? {
148            return Err(MaterializeError::ShallowClone {
149                vcs: vcs.name(),
150                repo_root: repo_root.to_owned(),
151            });
152        }
153        Ok(())
154    }
155
156    /// Materializes a git stub.
157    ///
158    /// Reads the file at `git_stub_path` (relative to the repository root),
159    /// fetches the referenced content from history, and writes it to the
160    /// output directory.
161    ///
162    /// Returns the path to the materialized file.
163    ///
164    /// # Examples
165    ///
166    /// ```no_run
167    /// // In build.rs:
168    /// let materializer = git_stub_vcs::Materializer::for_build_script("../..")
169    ///     .expect("VCS detected at repo root");
170    /// let spec_path = materializer
171    ///     .materialize("openapi/my-api/my-api-1.0.0-abc123.json.gitstub")
172    ///     .expect("materialized successfully");
173    /// ```
174    pub fn materialize(
175        &self,
176        git_stub_path: impl AsRef<Utf8Path>,
177    ) -> Result<Utf8PathBuf, MaterializeError> {
178        let git_stub_path = git_stub_path.as_ref();
179
180        check_path(git_stub_path)?;
181
182        if git_stub_path.extension() != Some("gitstub") {
183            return Err(MaterializeError::NotGitStub {
184                path: git_stub_path.to_owned(),
185            });
186        }
187
188        // Preserve directory structure, stripping only the .gitstub
189        // extension.
190        let output_path =
191            self.output_dir.join(git_stub_path.with_extension(""));
192        self.materialize_inner(git_stub_path, &output_path)?;
193        Ok(output_path)
194    }
195
196    /// Materializes a git stub to a specific path.
197    ///
198    /// Like [`materialize`](Self::materialize), but writes to `output_path`
199    /// (relative to the output directory, or absolute) instead of deriving the
200    /// path from the Git stub file name.
201    ///
202    /// `git_stub_path` is relative to the repository root.
203    pub fn materialize_to(
204        &self,
205        git_stub_path: impl AsRef<Utf8Path>,
206        output_path: impl AsRef<Utf8Path>,
207    ) -> Result<(), MaterializeError> {
208        let git_stub_path = git_stub_path.as_ref();
209        let output_path = output_path.as_ref();
210
211        check_path(git_stub_path)?;
212
213        if git_stub_path.extension() != Some("gitstub") {
214            return Err(MaterializeError::NotGitStub {
215                path: git_stub_path.to_owned(),
216            });
217        }
218
219        let output_path = self.output_dir.join(output_path);
220        self.materialize_inner(git_stub_path, &output_path)
221    }
222
223    /// Assumes `git_stub_path` has already been validated to have a
224    /// `.gitstub` extension.
225    fn materialize_inner(
226        &self,
227        git_stub_path: &Utf8Path,
228        output_path: &Utf8Path,
229    ) -> Result<(), MaterializeError> {
230        let full_git_stub_path = self.repo_root.join(git_stub_path);
231
232        if self.emit_cargo_directives {
233            println!("cargo::rerun-if-changed={}", full_git_stub_path);
234        }
235
236        let git_stub_contents = fs::read_to_string(&full_git_stub_path)
237            .map_err(|error| MaterializeError::ReadGitStub {
238                path: full_git_stub_path.clone(),
239                error,
240            })?;
241
242        let git_stub: GitStub = git_stub_contents.parse().map_err(|error| {
243            MaterializeError::InvalidGitStub { path: full_git_stub_path, error }
244        })?;
245
246        let content =
247            self.vcs.read_git_stub_contents(&git_stub, &self.repo_root)?;
248
249        if let Some(parent) = output_path.parent() {
250            fs::create_dir_all(parent).map_err(|error| {
251                MaterializeError::CreateDir { path: parent.to_owned(), error }
252            })?;
253        }
254
255        AtomicFile::new(
256            output_path,
257            atomicwrites::OverwriteBehavior::AllowOverwrite,
258        )
259        .write(|f| f.write_all(&content))
260        .map_err(|error| {
261            use crate::errors::AtomicWriteError;
262            let error = match error {
263                atomicwrites::Error::Internal(e) => AtomicWriteError::Rename(e),
264                atomicwrites::Error::User(e) => AtomicWriteError::Write(e),
265            };
266            MaterializeError::WriteOutput {
267                path: output_path.to_owned(),
268                error,
269            }
270        })?;
271
272        Ok(())
273    }
274}
275
276// Tests are in tests/integration/materialize.rs.