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}