oapi_codegen/package.rs
1//! The generated output as a set of files, and the file operations over it.
2//!
3//! A run that emits operations produces a small module tree instead of one
4//! file: a root file the consumer mounts, and a companion directory beside it
5//! holding one module per concern. The root file keeps its path, so a consumer
6//! that already mounts it needs no change, and the root re-exports every child,
7//! so every generated name stays where it was.
8
9use std::path::Component;
10use std::path::Path;
11use std::path::PathBuf;
12
13use crate::emit::GENERATED_MARKER;
14use crate::emit::HEADER;
15use crate::error::Error;
16use crate::error::Result;
17
18/// One file of a [`GeneratedPackage`].
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct GeneratedFile {
21 /// Where the file goes, relative to the directory holding the root file.
22 path: PathBuf,
23 /// The complete source, header included.
24 source: String,
25}
26
27impl GeneratedFile {
28 /// Build a file from its relative path and its complete source.
29 pub fn new(path: impl Into<PathBuf>, source: impl Into<String>) -> Self {
30 return Self {
31 path: path.into(),
32 source: source.into(),
33 };
34 }
35
36 /// Where the file goes, relative to the directory holding the root file.
37 pub fn path(&self) -> &Path {
38 return &self.path;
39 }
40
41 /// The complete source, header included.
42 pub fn source(&self) -> &str {
43 return &self.source;
44 }
45
46 /// The source with the generated-file header removed.
47 fn body(&self) -> &str {
48 return self.source.strip_prefix(HEADER).unwrap_or(&self.source);
49 }
50}
51
52/// Everything one generator run produces.
53///
54/// A models-only run has no children and behaves exactly like the single file
55/// it has always written. A run with operations adds the children, and the root
56/// becomes a facade of `#[path]` module declarations and re-exports.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct GeneratedPackage {
59 /// The source of the file at the configured output path.
60 root: String,
61 /// The companion module files, relative to the root file's directory.
62 children: Vec<GeneratedFile>,
63}
64
65impl GeneratedPackage {
66 /// Build a package from the root source and its companion files.
67 pub fn new(root: impl Into<String>, children: Vec<GeneratedFile>) -> Self {
68 return Self {
69 root: root.into(),
70 children,
71 };
72 }
73
74 /// The source of the file at the configured output path.
75 pub fn root_source(&self) -> &str {
76 return &self.root;
77 }
78
79 /// The companion module files, relative to the root file's directory.
80 pub fn children(&self) -> &[GeneratedFile] {
81 return &self.children;
82 }
83
84 /// How many files a write produces.
85 pub fn file_count(&self) -> usize {
86 return self.children.len().saturating_add(1);
87 }
88
89 /// Every file's body behind one header, for a scan that has to see all of
90 /// the generated code at once.
91 ///
92 /// Dependency detection and the empty-output check both read the code as
93 /// text. Splitting the output across files must not change what either one
94 /// concludes, so both read this instead of any single file.
95 pub fn combined_source(&self) -> String {
96 let mut combined = String::from(HEADER);
97 combined.push_str(self.root.strip_prefix(HEADER).unwrap_or(&self.root));
98 for child in &self.children {
99 combined.push_str(child.body());
100 }
101 return combined;
102 }
103}
104
105/// What a comparison of a generated package against the files on disk found.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum PackageDrift {
108 /// Every file on disk holds the generated code, and no other file does.
109 None,
110 /// A file generation would write does not exist.
111 Absent(PathBuf),
112 /// A file exists and holds different content.
113 Differs(PathBuf),
114 /// A file an earlier run wrote is still there, and this run does not
115 /// produce it.
116 Stale(PathBuf),
117}
118
119/// The companion directory beside `output_path`, named after its file stem.
120///
121/// The root file declares each child with an explicit `#[path]` relative to its
122/// own directory, so this is where those declarations point.
123///
124/// Gives `None` when the path has no file stem. Use [`companion_of`] where the
125/// directory has to be usable: an extensionless path yields the output file
126/// itself, which cannot hold the children.
127fn companion_directory(output_path: &Path) -> Option<PathBuf> {
128 let stem = output_path.file_stem()?;
129 let parent = output_path.parent().unwrap_or_else(|| return Path::new(""));
130 return Some(parent.join(stem));
131}
132
133/// Write every file of `package`, then remove the files an earlier run left in
134/// the companion directory that this run does not produce.
135///
136/// The companion directory is audited before anything is written, so a run that
137/// refuses leaves every file it would have replaced intact.
138///
139/// # Errors
140///
141/// Returns [`Error::WriteOutput`] when a file cannot be written, and
142/// [`Error::UnownedOutput`] when the companion directory holds a file the
143/// generator did not write. Refusing is deliberate: the directory belongs to
144/// the generator, and deleting a hand-written file that landed in it would lose
145/// work.
146pub fn write_package(output_path: &Path, package: &GeneratedPackage) -> Result<()> {
147 let stale = audit(output_path, package)?;
148
149 crate::write_output(output_path, package.root_source())?;
150 let parent = output_path.parent().unwrap_or_else(|| return Path::new(""));
151 for child in package.children() {
152 crate::write_output(&parent.join(child.path()), child.source())?;
153 }
154 for path in stale {
155 std::fs::remove_file(&path).map_err(|source| {
156 return Error::WriteOutput {
157 path: path.display().to_string(),
158 source,
159 };
160 })?;
161 }
162 if let Some(directory) = companion_directory(output_path) {
163 remove_empty_directories(&directory);
164 }
165 return Ok(());
166}
167
168/// Compare `package` with the files on disk and report the first difference.
169///
170/// # Errors
171///
172/// Returns [`Error::ReadOutput`] when a file exists and cannot be read, and
173/// [`Error::UnownedOutput`] when the companion directory holds a file the
174/// generator did not write.
175pub fn check_package(output_path: &Path, package: &GeneratedPackage) -> Result<PackageDrift> {
176 // The audit comes first so that a file the generator does not own is
177 // reported as such, rather than as drift against what would replace it.
178 let stale = audit(output_path, package)?;
179 // The root comes next, because it is the file a consumer mounts and the
180 // one a reader looks at first.
181 if let Some(drift) = compare(output_path, package.root_source())? {
182 return Ok(drift);
183 }
184 let parent = output_path.parent().unwrap_or_else(|| return Path::new(""));
185 for child in package.children() {
186 if let Some(drift) = compare(&parent.join(child.path()), child.source())? {
187 return Ok(drift);
188 }
189 }
190 if let Some(path) = stale.into_iter().next() {
191 return Ok(PackageDrift::Stale(path));
192 }
193 return Ok(PackageDrift::None);
194}
195
196/// Compare one file, giving `None` when it already holds `source`.
197fn compare(path: &Path, source: &str) -> Result<Option<PackageDrift>> {
198 return match crate::check_output(path, source)? {
199 crate::Drift::None => Ok(None),
200 crate::Drift::Absent => Ok(Some(PackageDrift::Absent(path.to_path_buf()))),
201 crate::Drift::Differs => Ok(Some(PackageDrift::Differs(path.to_path_buf()))),
202 };
203}
204
205/// The companion directory a package with children needs beside `output_path`.
206///
207/// # Errors
208///
209/// Returns [`Error::UnsplittableOutput`] when the directory would be the output
210/// file itself, which is what an output path with no extension asks for.
211pub(crate) fn companion_of(output_path: &Path) -> Result<PathBuf> {
212 return companion_directory(output_path)
213 .filter(|directory| return directory != output_path)
214 .ok_or_else(|| {
215 return Error::UnsplittableOutput {
216 path: output_path.display().to_string(),
217 };
218 });
219}
220
221/// Check every existing file in the companion directory and return the
222/// generated ones `package` does not produce, sorted so a report names the same
223/// file on every run.
224///
225/// Auditing the whole directory up front is what lets a write be safe. Every
226/// file the generator would replace or delete is inspected first, so a run that
227/// meets somebody else's work stops before touching anything.
228fn audit(output_path: &Path, package: &GeneratedPackage) -> Result<Vec<PathBuf>> {
229 // A run without children still has to clear the companion directory an
230 // earlier run left behind, so only a path that cannot have one is skipped.
231 let directory = match companion_of(output_path) {
232 Ok(directory) => directory,
233 Err(_) if package.children().is_empty() => return Ok(Vec::new()),
234 Err(error) => return Err(error),
235 };
236 let parent = output_path.parent().unwrap_or_else(|| return Path::new(""));
237 for child in package.children() {
238 let path = child.path();
239 // Every step below writes or deletes under the companion directory and
240 // trusts that each child lands there. The emitter always builds such a
241 // path, but the package types are public and can be built by hand.
242 let contained = path
243 .components()
244 .all(|component| return matches!(component, Component::Normal(_)))
245 && parent.join(path).starts_with(&directory);
246 if !contained {
247 return Err(Error::OutsideOutput {
248 path: path.display().to_string(),
249 directory: directory.display().to_string(),
250 });
251 }
252 }
253 match std::fs::symlink_metadata(&directory) {
254 Ok(metadata) if metadata.is_dir() => {}
255 // A companion directory that is a file or a link is not the generator's
256 // work, and the run must not write children through it.
257 Ok(_) => {
258 return Err(Error::UnownedOutput {
259 path: directory.display().to_string(),
260 });
261 }
262 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
263 return Ok(Vec::new());
264 }
265 Err(source) => {
266 return Err(Error::ReadOutput {
267 path: directory.display().to_string(),
268 source,
269 });
270 }
271 }
272 let expected: Vec<PathBuf> = package
273 .children()
274 .iter()
275 .map(|child| return parent.join(child.path()))
276 .collect();
277 let mut stale = Vec::new();
278 collect_stale(&directory, &expected, &mut stale)?;
279 stale.sort();
280 return Ok(stale);
281}
282
283/// Walk `directory`, rejecting every file the generator does not own and adding
284/// the generated ones `expected` does not list.
285fn collect_stale(directory: &Path, expected: &[PathBuf], stale: &mut Vec<PathBuf>) -> Result<()> {
286 let entries = std::fs::read_dir(directory).map_err(|source| {
287 return Error::ReadOutput {
288 path: directory.display().to_string(),
289 source,
290 };
291 })?;
292 for entry in entries {
293 let entry = entry.map_err(|source| {
294 return Error::ReadOutput {
295 path: directory.display().to_string(),
296 source,
297 };
298 })?;
299 let path = entry.path();
300 let kind = entry.file_type().map_err(|source| {
301 return Error::ReadOutput {
302 path: path.display().to_string(),
303 source,
304 };
305 })?;
306 // The generator writes no link. Following one would take the walk out of
307 // the directory this run owns, so a link is somebody's work whatever it
308 // points at.
309 if kind.is_symlink() {
310 return Err(Error::UnownedOutput {
311 path: path.display().to_string(),
312 });
313 }
314 if kind.is_dir() {
315 collect_stale(&path, expected, stale)?;
316 continue;
317 }
318 // Reading a device or a pipe can block for ever, and the generator
319 // writes neither, so anything but a regular file is somebody else's.
320 //
321 // Ownership is settled before the expected set is consulted, so a
322 // hand-written file sitting where a generated one belongs stops the run
323 // instead of being overwritten.
324 if !kind.is_file() || !is_generated(&path)? {
325 return Err(Error::UnownedOutput {
326 path: path.display().to_string(),
327 });
328 }
329 if expected.iter().any(|candidate| return *candidate == path) {
330 continue;
331 }
332 stale.push(path);
333 }
334 return Ok(());
335}
336
337/// Whether `path` carries the marker every generated file opens with.
338///
339/// Only the marker is read. The file may be anything at all, and a run must not
340/// have to hold it in memory to decide it is not the generator's.
341fn is_generated(path: &Path) -> Result<bool> {
342 let read = |source| {
343 return Error::ReadOutput {
344 path: path.display().to_string(),
345 source,
346 };
347 };
348 let marker = GENERATED_MARKER.as_bytes();
349 let mut file = std::fs::File::open(path).map_err(read)?;
350 let mut opening = vec![0_u8; marker.len()];
351 return match std::io::Read::read_exact(&mut file, &mut opening) {
352 Ok(()) => Ok(opening == marker),
353 // A file shorter than the marker cannot carry it.
354 Err(source) if source.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
355 Err(source) => Err(read(source)),
356 };
357}
358
359/// Remove `directory` and every directory under it that holds nothing.
360///
361/// A removal that fails leaves an empty directory behind, which costs nothing
362/// and breaks no later run, so this reports no error.
363fn remove_empty_directories(directory: &Path) {
364 let Ok(entries) = std::fs::read_dir(directory) else {
365 return;
366 };
367 for entry in entries.flatten() {
368 // `file_type` does not follow links, so the walk stays inside the
369 // directory this run owns.
370 if entry.file_type().map(|kind| return kind.is_dir()).unwrap_or(false) {
371 remove_empty_directories(&entry.path());
372 }
373 }
374 let _ = std::fs::remove_dir(directory);
375}