codegen/app/
output_tree.rs1use std::{collections::BTreeSet, process::ExitCode};
2
3use anyhow::{Context, bail};
4use wowlab_common::output;
5use wowlab_fs::{
6 artifact::{GeneratedTextFile, Status},
7 directory::{self, EntryKind},
8 path::{Path, PathBuf},
9};
10
11use super::ownership::{
12 OWNERSHIP_MANIFEST, adopt_legacy_generated_tree, ownership_manifest_source,
13 prune_empty_owned_parents, read_ownership_manifest, reject_unowned_files,
14 validate_owned_file_kinds, validate_owned_path, write_ownership_manifest,
15};
16
17pub(super) fn write_generated_files(
18 files: &[(String, String)],
19 output_dir: &Path,
20 quiet: bool,
21) -> anyhow::Result<()> {
22 let expected_paths = expected_path_set(files)?;
23 let actual_paths = actual_file_set(output_dir)?;
24
25 let previous_paths = match read_ownership_manifest(output_dir)? {
26 Some(paths) => {
27 reject_unowned_files(&actual_paths, &paths)?;
28 validate_owned_file_kinds(output_dir, &paths)?;
29
30 paths
31 }
32 None => adopt_legacy_generated_tree(output_dir, &actual_paths)?,
33 };
34
35 let transitional_paths = previous_paths
37 .union(&expected_paths)
38 .cloned()
39 .collect::<BTreeSet<_>>();
40
41 write_ownership_manifest(output_dir, &transitional_paths)?;
42
43 for obsolete in previous_paths.difference(&expected_paths) {
44 let path = output_dir.join(obsolete);
45
46 if !directory::remove_file_if_exists(&path)
47 .with_context(|| format!("failed to remove obsolete output {}", path.display()))?
48 {
49 continue;
50 }
51
52 if !quiet {
53 output::detail(&format!("removed obsolete {}", path.display()));
54 }
55
56 if let Some(parent) = path.parent() {
57 prune_empty_owned_parents(output_dir, parent)?;
58 }
59 }
60
61 for (filename, code) in files {
62 let output_file = output_dir.join(filename);
63
64 GeneratedTextFile::new(&output_file, code).persist()?;
65
66 if !quiet {
67 output::detail(&format!("wrote {}", output_file.display()));
68 }
69 }
70
71 write_ownership_manifest(output_dir, &expected_paths)?;
72
73 Ok(())
74}
75
76pub(super) fn check_freshness(
77 files: &[(String, String)],
78 output_dir: &Path,
79) -> anyhow::Result<ExitCode> {
80 let expected_paths = expected_path_set(files)?;
81 let actual_paths = actual_file_set(output_dir)?;
82 let mut stale = BTreeSet::new();
83 let ownership = read_ownership_manifest(output_dir)?;
84
85 if let Some(owned_paths) = &ownership {
86 reject_unowned_files(&actual_paths, owned_paths)?;
87 validate_owned_file_kinds(output_dir, owned_paths)?;
88 } else {
89 let _legacy_paths = adopt_legacy_generated_tree(output_dir, &actual_paths)?;
90
91 stale.insert(PathBuf::from(OWNERSHIP_MANIFEST));
92 }
93
94 for (filename, expected) in files {
95 let path = output_dir.join(filename);
96
97 if GeneratedTextFile::new(&path, expected).status()? != Status::Current {
98 stale.insert(PathBuf::from(filename.as_str()));
99 }
100 }
101
102 stale.extend(actual_paths.difference(&expected_paths).cloned());
103
104 let ownership_path = output_dir.join(OWNERSHIP_MANIFEST);
105
106 if ownership.is_some()
107 && GeneratedTextFile::new(
108 &ownership_path,
109 &ownership_manifest_source(&expected_paths)?,
110 )
111 .status()?
112 != Status::Current
113 {
114 stale.insert(PathBuf::from(OWNERSHIP_MANIFEST));
115 }
116
117 if stale.is_empty() {
118 output::success("generated code is up-to-date");
119
120 Ok(ExitCode::SUCCESS)
121 } else {
122 output::error(&format!(
123 "{} file(s) are stale, run `cargo codegen` to regenerate:",
124 stale.len()
125 ));
126
127 for file in &stale {
128 output::detail(&format!(" {}", file.display()));
129 }
130
131 Ok(ExitCode::FAILURE)
132 }
133}
134
135pub(super) fn expected_path_set(files: &[(String, String)]) -> anyhow::Result<BTreeSet<PathBuf>> {
136 let mut paths = BTreeSet::new();
137
138 for (filename, _) in files {
139 let path = PathBuf::from(filename.as_str());
140
141 validate_owned_path(&path)?;
142
143 if !paths.insert(path) {
144 bail!("generator produced duplicate output path {filename}");
145 }
146 }
147
148 Ok(paths)
149}
150
151pub(super) fn actual_file_set(output_dir: &Path) -> anyhow::Result<BTreeSet<PathBuf>> {
152 let mut paths = BTreeSet::new();
153
154 match directory::inspect_link(output_dir)? {
155 Some(entry) if entry.kind() == EntryKind::Symlink => {
156 bail!(
157 "generated output root must not be a symlink: {}",
158 output_dir.display()
159 );
160 }
161 Some(entry) if entry.kind() != EntryKind::Directory => {
162 bail!(
163 "generated output root is not a directory: {}",
164 output_dir.display()
165 );
166 }
167 Some(_) => {}
168 None => return Ok(paths),
169 }
170
171 collect_actual_files(output_dir, output_dir, &mut paths)?;
172
173 Ok(paths)
174}
175
176fn collect_actual_files(
177 root: &Path,
178 current_directory: &Path,
179 paths: &mut BTreeSet<PathBuf>,
180) -> anyhow::Result<()> {
181 for entry in directory::entries(current_directory).with_context(|| {
182 format!(
183 "failed to read output directory {}",
184 current_directory.display()
185 )
186 })? {
187 if entry.kind() == EntryKind::Symlink {
188 bail!(
189 "generated output tree contains a symlink: {}",
190 entry.path().display()
191 );
192 } else if entry.kind() == EntryKind::Directory {
193 collect_actual_files(root, entry.path(), paths)?;
194 } else if entry.kind() == EntryKind::File {
195 let relative = entry.path().strip_prefix(root).with_context(|| {
196 format!(
197 "generated output {} is outside {}",
198 entry.path().display(),
199 root.display()
200 )
201 })?;
202
203 if relative != Path::new(OWNERSHIP_MANIFEST) {
204 paths.insert(relative.to_path_buf());
205 }
206 } else {
207 bail!(
208 "generated output tree contains a non-regular entry: {}",
209 entry.path().display()
210 );
211 }
212 }
213
214 Ok(())
215}