1use crate::{GeneratedFile, GeneratedTree};
3use anyhow::{Context, Result, bail};
4use std::path::{Component, Path, PathBuf};
5
6#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct BundledMiddleware {
10 pub path: PathBuf,
11 pub contents: String,
12 pub symbol: String,
13 pub async_symbol: Option<String>,
14}
15impl BundledMiddleware {
16 pub fn validate(&self) -> Result<()> {
17 normalized(&self.path)?;
18 for symbol in std::iter::once(self.symbol.as_str()).chain(self.async_symbol.as_deref()) {
19 let mut chars = symbol.chars();
20 if !chars
21 .next()
22 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
23 || !chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
24 {
25 bail!("bundled middleware symbol must be a simple ASCII identifier: {symbol:?}");
26 }
27 }
28 Ok(())
29 }
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum CodeCustomization {
35 Add {
36 path: PathBuf,
37 contents: String,
38 },
39 Replace {
40 path: PathBuf,
41 contents: String,
42 },
43 Patch {
44 path: PathBuf,
45 find: String,
46 replacement: String,
47 },
48}
49impl CodeCustomization {
50 pub fn path(&self) -> &Path {
51 match self {
52 Self::Add { path, .. } | Self::Replace { path, .. } | Self::Patch { path, .. } => path,
53 }
54 }
55 pub fn prefixed(&self, prefix: &Path) -> Self {
56 let path = prefix.join(self.path());
57 match self {
58 Self::Add { contents, .. } => Self::Add {
59 path,
60 contents: contents.clone(),
61 },
62 Self::Replace { contents, .. } => Self::Replace {
63 path,
64 contents: contents.clone(),
65 },
66 Self::Patch {
67 find, replacement, ..
68 } => Self::Patch {
69 path,
70 find: find.clone(),
71 replacement: replacement.clone(),
72 },
73 }
74 }
75}
76fn normalized(path: &Path) -> Result<PathBuf> {
77 GeneratedFile::new(path, "")?;
78 let path: PathBuf = path
79 .components()
80 .filter(|p| !matches!(p, Component::CurDir))
81 .collect();
82 if path.as_os_str().is_empty() {
83 bail!("customization path must name a file");
84 }
85 if matches!(
86 path.to_str(),
87 Some(
88 ".poolster/ownership.json"
89 | ".poolster/generation.lock.json"
90 | ".poolster/package.json"
91 )
92 ) || path.ends_with(".poolster/ownership.json")
93 || path.ends_with(".poolster/generation.lock.json")
94 || path.ends_with(".poolster/package.json")
95 {
96 bail!(
97 "customization cannot replace Poolster bookkeeping: {}",
98 path.display()
99 );
100 }
101 Ok(path)
102}
103pub fn apply_code_customizations(
106 tree: &mut GeneratedTree,
107 changes: &[CodeCustomization],
108) -> Result<()> {
109 let mut staged = tree.clone();
110 for change in changes {
111 let path = normalized(change.path())?;
112 let existing = staged
113 .iter()
114 .find(|(candidate, _)| {
115 candidate
116 .components()
117 .filter(|p| !matches!(p, Component::CurDir))
118 .collect::<PathBuf>()
119 == path
120 })
121 .map(|(p, c)| (p.to_owned(), c.to_owned()));
122 let target = existing
123 .as_ref()
124 .map(|(p, _)| p.clone())
125 .unwrap_or(path.clone());
126 if staged.preserves_existing(&target) {
127 bail!(
128 "customization cannot overwrite create-once user file {}",
129 path.display()
130 );
131 }
132 let contents = match change {
133 CodeCustomization::Add { contents, .. } => {
134 if existing.is_some() {
135 bail!(
136 "customization add collides with generated file {}",
137 path.display()
138 );
139 }
140 contents.clone()
141 }
142 CodeCustomization::Replace { contents, .. } => {
143 existing.as_ref().with_context(|| {
144 format!("customization replace target missing: {}", path.display())
145 })?;
146 contents.clone()
147 }
148 CodeCustomization::Patch {
149 find, replacement, ..
150 } => {
151 let (_, contents) = existing.as_ref().with_context(|| {
152 format!("customization patch target missing: {}", path.display())
153 })?;
154 if find.is_empty() || contents.matches(find).count() != 1 {
155 bail!(
156 "customization patch must match exactly once in {}",
157 path.display()
158 );
159 }
160 contents.replacen(find, replacement, 1)
161 }
162 };
163 let file = GeneratedFile::new(&target, contents)?;
164 if existing.is_some() {
165 staged.replace(file)?;
166 } else {
167 staged.insert(file)?;
168 }
169 staged.set_owner(&target, format!("code-customization:{}", path.display()))?;
170 }
171 *tree = staged;
172 Ok(())
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 #[test]
179 fn scoped_overrides_are_transactional_and_reject_stale_patches() {
180 let mut tree = GeneratedTree::default();
181 for p in ["ts/client.ts", "go/client.go"] {
182 tree.insert(GeneratedFile::new(p, "original").unwrap())
183 .unwrap();
184 }
185 apply_code_customizations(
186 &mut tree,
187 &[CodeCustomization::Patch {
188 path: "ts/client.ts".into(),
189 find: "original".into(),
190 replacement: "custom".into(),
191 }],
192 )
193 .unwrap();
194 assert_eq!(tree.get("ts/client.ts"), Some("custom"));
195 assert_eq!(tree.get("go/client.go"), Some("original"));
196 let before = tree.clone();
197 assert!(
198 apply_code_customizations(
199 &mut tree,
200 &[
201 CodeCustomization::Add {
202 path: "helper.ts".into(),
203 contents: "helper".into()
204 },
205 CodeCustomization::Patch {
206 path: "ts/client.ts".into(),
207 find: "original".into(),
208 replacement: "bad".into()
209 },
210 ]
211 )
212 .is_err()
213 );
214 assert_eq!(tree, before);
215 }
216 #[test]
217 fn rejects_escape_collision_bookkeeping_and_user_owned_targets() {
218 let mut tree = GeneratedTree::default();
219 tree.insert_custom(GeneratedFile::new("custom.ts", "starter").unwrap())
220 .unwrap();
221 for path in [
222 "../escape",
223 ".",
224 ".poolster/package.json",
225 "ts/.poolster/ownership.json",
226 "custom.ts",
227 ] {
228 assert!(
229 apply_code_customizations(
230 &mut tree,
231 &[CodeCustomization::Replace {
232 path: path.into(),
233 contents: "bad".into()
234 }]
235 )
236 .is_err()
237 );
238 }
239 tree.insert(GeneratedFile::new("./client.ts", "x x").unwrap())
240 .unwrap();
241 assert!(
242 apply_code_customizations(
243 &mut tree,
244 &[CodeCustomization::Add {
245 path: "client.ts".into(),
246 contents: "bad".into()
247 }]
248 )
249 .is_err()
250 );
251 assert!(
252 apply_code_customizations(
253 &mut tree,
254 &[CodeCustomization::Patch {
255 path: "client.ts".into(),
256 find: "x".into(),
257 replacement: "y".into()
258 }]
259 )
260 .is_err()
261 );
262 }
263}