Skip to main content

poolster_core/
customization.rs

1//! Explicit author-owned source overlays, applied after package finalization.
2use crate::{GeneratedFile, GeneratedTree};
3use anyhow::{Context, Result, bail};
4use std::path::{Component, Path, PathBuf};
5
6/// SDK-author middleware source shipped and registered by the language renderer.
7/// The symbol's native ABI is language-specific; consumers need no registration.
8#[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/// Code supplied by the SDK author. Contents are data; Poolster never executes them.
33#[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}
103/// Apply the entire sequence atomically in memory. Normal ownership protection
104/// still applies when writing: edits in output are never silently overwritten.
105pub 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}