Skip to main content

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}