1use std::collections::BTreeSet;
2
3use anyhow::{Context, bail};
4use wowlab_fs::{
5 artifact::GeneratedTextFile,
6 directory::{self, EntryKind},
7 file,
8 path::{Component, Path, PathBuf},
9};
10
11pub(super) const OWNERSHIP_MANIFEST: &str = ".wowlab-codegen-ownership";
12const OWNERSHIP_MAGIC: &str = "# wowlab-codegen-output-v1";
13pub(super) const GENERATED_HEADER: &str = "//! @generated by codegen-cli";
14
15pub(super) fn validate_owned_path(path: &Path) -> anyhow::Result<()> {
16 let valid = !path.as_os_str().is_empty()
17 && path != Path::new(OWNERSHIP_MANIFEST)
18 && path
19 .components()
20 .all(|component| matches!(component, Component::Normal(_)));
21
22 if valid {
23 Ok(())
24 } else {
25 bail!("invalid generated output path {}", path.display())
26 }
27}
28
29pub(super) fn ownership_manifest_source(paths: &BTreeSet<PathBuf>) -> anyhow::Result<String> {
30 let mut source = String::from(OWNERSHIP_MAGIC);
31
32 source.push('\n');
33
34 for path in paths {
35 validate_owned_path(path)?;
36 let path = path
37 .to_str()
38 .with_context(|| format!("generated output path is not UTF-8: {}", path.display()))?;
39
40 source.push_str(path);
41 source.push('\n');
42 }
43
44 Ok(source)
45}
46
47pub(super) fn write_ownership_manifest(
48 output_dir: &Path,
49 paths: &BTreeSet<PathBuf>,
50) -> anyhow::Result<()> {
51 let source = ownership_manifest_source(paths)?;
52
53 directory::ensure(output_dir).with_context(|| {
54 format!(
55 "failed to create generated output directory {}",
56 output_dir.display()
57 )
58 })?;
59 let path = output_dir.join(OWNERSHIP_MANIFEST);
60
61 GeneratedTextFile::new(&path, &source)
62 .persist()
63 .with_context(|| {
64 format!(
65 "failed to atomically replace ownership manifest {}",
66 path.display()
67 )
68 })
69}
70
71pub(super) fn read_ownership_manifest(
72 output_dir: &Path,
73) -> anyhow::Result<Option<BTreeSet<PathBuf>>> {
74 let path = output_dir.join(OWNERSHIP_MANIFEST);
75 let Some(source) = file::read_text_if_exists(&path)
76 .with_context(|| format!("failed to read ownership manifest {}", path.display()))?
77 else {
78 return Ok(None);
79 };
80 let mut lines = source.lines();
81
82 if lines.next() != Some(OWNERSHIP_MAGIC) {
83 bail!(
84 "invalid codegen ownership manifest header: {}",
85 path.display()
86 );
87 }
88
89 let mut paths = BTreeSet::new();
90
91 for line in lines {
92 let owned_path = PathBuf::from(line);
93
94 validate_owned_path(&owned_path)
95 .with_context(|| format!("invalid entry in ownership manifest {}", path.display()))?;
96
97 if !paths.insert(owned_path) {
98 bail!(
99 "duplicate entry `{line}` in ownership manifest {}",
100 path.display()
101 );
102 }
103 }
104
105 Ok(Some(paths))
106}
107
108pub(super) fn reject_unowned_files(
109 actual_paths: &BTreeSet<PathBuf>,
110 owned_paths: &BTreeSet<PathBuf>,
111) -> anyhow::Result<()> {
112 if let Some(unowned) = actual_paths.difference(owned_paths).next() {
113 bail!(
114 "refusing to modify generated output tree containing unowned file {}; move it outside the output directory or add it through codegen",
115 unowned.display()
116 );
117 }
118
119 Ok(())
120}
121
122pub(super) fn validate_owned_file_kinds(
123 output_dir: &Path,
124 owned_paths: &BTreeSet<PathBuf>,
125) -> anyhow::Result<()> {
126 for owned in owned_paths {
127 let path = output_dir.join(owned);
128
129 match directory::inspect_link(&path)
130 .with_context(|| format!("failed to inspect owned output {}", path.display()))?
131 .map(|entry| entry.kind())
132 {
133 Some(EntryKind::File) | None => {}
134 Some(_) => bail!(
135 "owned generated output is not a regular file: {}",
136 path.display()
137 ),
138 }
139 }
140
141 Ok(())
142}
143
144pub(super) fn adopt_legacy_generated_tree(
145 output_dir: &Path,
146 actual_paths: &BTreeSet<PathBuf>,
147) -> anyhow::Result<BTreeSet<PathBuf>> {
148 for relative in actual_paths {
149 let path = output_dir.join(relative);
150 let source = file::read_text(&path).with_context(|| {
151 format!(
152 "failed to inspect legacy generated output {}",
153 path.display()
154 )
155 })?;
156
157 if !source.starts_with(GENERATED_HEADER) {
158 bail!(
159 "refusing to adopt output tree without an ownership manifest because {} is not a recognizable codegen artifact",
160 path.display()
161 );
162 }
163 }
164
165 Ok(actual_paths.clone())
166}
167
168pub(super) fn prune_empty_owned_parents(root: &Path, start_directory: &Path) -> anyhow::Result<()> {
169 let mut current = start_directory;
170
171 while current != root {
172 match directory::remove_if_empty(current) {
173 Ok(()) => {}
174 Err(error) if error.is_not_found() || error.is_directory_not_empty() => {
175 break;
176 }
177 Err(error) => {
178 return Err(error).with_context(|| {
179 format!("failed to prune output directory {}", current.display())
180 });
181 }
182 }
183
184 current = current.parent().with_context(|| {
185 format!(
186 "generated output directory escaped ownership root {}",
187 root.display()
188 )
189 })?;
190 }
191
192 Ok(())
193}