Skip to main content

krabka_protocol_codegen/
out.rs

1//! The output ledger: every path one generator run writes, and the sweep that
2//! deletes the generated files it did not.
3//!
4//! The generator overwrites its outputs in place. That leaves a hole. When a
5//! schema leaves the schema set, or a message renames a common struct, the
6//! file from the previous run stays on disk. It is still tracked and still
7//! unchanged, so `git status` reports nothing and the `codegen drift` CI job
8//! passes over a `generated` tree that no longer matches the schemas.
9//!
10//! [`Written`] closes the hole. Each write records its path. [`Written::prune`]
11//! then walks the output directories and removes every generated file that the
12//! run did not write.
13
14use std::{
15    collections::BTreeSet,
16    fs::{self, File},
17    io::{self, BufRead, BufReader},
18    path::{Path, PathBuf},
19};
20
21use crate::{emit::common::BANNER_PREFIX, fmt};
22
23/// An error from writing or pruning a generated file.
24#[derive(Debug, thiserror::Error)]
25pub enum WriteError {
26    #[error(transparent)]
27    Io(#[from] io::Error),
28    #[error(transparent)]
29    Fmt(#[from] fmt::FmtError),
30}
31
32/// The set of paths one generator run wrote.
33#[derive(Debug, Default)]
34pub struct Written(BTreeSet<PathBuf>);
35
36impl Written {
37    /// Write a file verbatim and record the path.
38    ///
39    /// # Errors
40    /// Returns an error when the file cannot be written.
41    pub fn write(&mut self, path: PathBuf, body: &str) -> io::Result<()> {
42        fs::write(&path, body)?;
43        self.0.insert(path);
44        Ok(())
45    }
46
47    /// Format generated Rust source with `format`, write it, and record the
48    /// path.
49    ///
50    /// The quote-based emitters return unformatted token text. The generator
51    /// passes [`fmt::rustfmt`], which turns it into the canonical committed
52    /// form.
53    ///
54    /// # Errors
55    /// Returns an error when `format` rejects the source or the file cannot be
56    /// written.
57    pub fn write_rs(
58        &mut self,
59        path: PathBuf,
60        body: &str,
61        format: fmt::Formatter,
62    ) -> Result<(), WriteError> {
63        let body = format(body)?;
64        self.write(path, &body)?;
65        Ok(())
66    }
67
68    /// True when this run wrote `path`.
69    #[must_use]
70    pub fn contains(&self, path: &Path) -> bool {
71        self.0.contains(path)
72    }
73
74    /// Delete every generated file under `dir` that this run did not write.
75    ///
76    /// A file counts as generated when it opens with [`BANNER_PREFIX`]. The
77    /// sweep leaves every other file alone, so a hand-written file in an
78    /// output directory, or a wrong output path on the command line, does not
79    /// lose data.
80    ///
81    /// `recursive` descends into subdirectories and removes the ones it leaves
82    /// empty. A flat sweep stops at the first level, which is what
83    /// `crates/protocol/generated` needs: it holds a namespace subdirectory
84    /// that a separate run owns.
85    ///
86    /// # Errors
87    /// Returns an error when a directory cannot be read or a file cannot be
88    /// removed.
89    pub fn prune(&self, dir: &Path, recursive: bool) -> io::Result<()> {
90        if !dir.is_dir() {
91            return Ok(());
92        }
93        for entry in fs::read_dir(dir)? {
94            let path = entry?.path();
95            if path.is_dir() {
96                if recursive {
97                    self.prune(&path, true)?;
98                    if fs::read_dir(&path)?.next().is_none() {
99                        fs::remove_dir(&path)?;
100                    }
101                }
102            } else if !self.0.contains(&path) && is_generated(&path)? {
103                fs::remove_file(&path)?;
104            }
105        }
106        Ok(())
107    }
108}
109
110/// True when `path` opens with the generated-file banner.
111fn is_generated(path: &Path) -> io::Result<bool> {
112    let file = File::open(path)?;
113    let mut first = String::new();
114    BufReader::new(file).read_line(&mut first)?;
115    Ok(first.starts_with(BANNER_PREFIX))
116}