1use std::collections::BTreeMap;
9
10use super::error::DefError;
11use crate::types::DnsName;
12
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub enum Reference {
16 StackName,
18 InstanceName,
20 ServiceOrigin(String),
22 DatastoreUrl(String),
26 Secret(String),
28 IntegrationOutput { integration: String, output: String },
30}
31
32pub fn references(value: &str, location: &str) -> Result<Vec<Reference>, DefError> {
34 Reference::collect_in(value, location)
35}
36
37impl Reference {
38 pub fn collect_in(value: &str, location: &str) -> Result<Vec<Self>, DefError> {
43 let mut refs = Vec::new();
44 let mut rest = value;
45 while let Some(start) = rest.find("${") {
46 let after = &rest[start + 2..];
47 let Some(end) = after.find('}') else {
48 return Err(DefError::ReferenceSyntax {
49 location: location.to_owned(),
50 reference: rest[start..].to_owned(),
51 detail: "unterminated ${...}".into(),
52 });
53 };
54 refs.push(Self::parse(&after[..end], location)?);
55 rest = &after[end + 1..];
56 }
57 Ok(refs)
58 }
59
60 fn parse(inner: &str, location: &str) -> Result<Self, DefError> {
61 Self::parse_reference(inner, location)
62 }
63
64 fn parse_reference(inner: &str, location: &str) -> Result<Self, DefError> {
65 let parts: Vec<&str> = inner.split('.').collect();
66 let reference = match parts.as_slice() {
67 ["instance", "name"] => Reference::InstanceName,
68 ["stack", "name"] => Reference::StackName,
69 ["services", name, "origin"] => Reference::ServiceOrigin((*name).to_owned()),
70 ["datastores", name, "url"] => Reference::DatastoreUrl((*name).to_owned()),
71 ["secrets", key] => Reference::Secret((*key).to_owned()),
72 ["integrations", name, output] => Reference::IntegrationOutput {
73 integration: (*name).to_owned(),
74 output: (*output).to_owned(),
75 },
76 _ => {
77 return Err(DefError::ReferenceSyntax {
78 location: location.to_owned(),
79 reference: format!("${{{inner}}}"),
80 detail: "not a recognized namespace form".into(),
81 });
82 }
83 };
84 Ok(reference)
85 }
86}
87
88#[derive(Debug)]
91pub struct Namespace {
92 pub stack_name: DnsName,
93 pub instance_name: DnsName,
94 pub service_origins: BTreeMap<String, String>,
95 pub datastore_urls: BTreeMap<String, String>,
98 pub secrets: BTreeMap<String, String>,
99 pub integrations: BTreeMap<String, BTreeMap<String, String>>,
100}
101
102impl Default for Namespace {
103 fn default() -> Self {
104 Self {
105 stack_name: DnsName::from_stored("stack"),
106 instance_name: DnsName::from_stored("instance"),
107 service_origins: BTreeMap::new(),
108 datastore_urls: BTreeMap::new(),
109 secrets: BTreeMap::new(),
110 integrations: BTreeMap::new(),
111 }
112 }
113}
114
115impl Namespace {
116 fn lookup(&self, reference: &Reference, location: &str) -> Result<String, DefError> {
117 match reference {
118 Reference::StackName => Ok(self.stack_name.as_str().to_owned()),
119 Reference::InstanceName => Ok(self.instance_name.as_str().to_owned()),
120 Reference::ServiceOrigin(name) => {
121 self.service_origins.get(name).cloned().ok_or_else(|| {
122 DefError::UndeclaredReference {
123 location: location.to_owned(),
124 kind: "service",
125 name: name.clone(),
126 }
127 })
128 }
129 Reference::DatastoreUrl(name) => {
130 self.datastore_urls.get(name).cloned().ok_or_else(|| {
131 DefError::UndeclaredReference {
132 location: location.to_owned(),
133 kind: "datastore",
134 name: name.clone(),
135 }
136 })
137 }
138 Reference::Secret(key) => {
139 self.secrets
140 .get(key)
141 .cloned()
142 .ok_or_else(|| DefError::UndeclaredReference {
143 location: location.to_owned(),
144 kind: "secret",
145 name: key.clone(),
146 })
147 }
148 Reference::IntegrationOutput {
149 integration,
150 output,
151 } => self
152 .integrations
153 .get(integration)
154 .and_then(|outputs| outputs.get(output))
155 .cloned()
156 .ok_or_else(|| DefError::UndeclaredReference {
157 location: location.to_owned(),
158 kind: "integration output",
159 name: format!("{integration}.{output}"),
160 }),
161 }
162 }
163
164 pub fn add_integration_checkpoints(&mut self, checkpoints: &[crate::state::Checkpoint]) {
169 for checkpoint in checkpoints {
170 let Some(name) = checkpoint.step_id.strip_prefix("integration:") else {
171 continue;
172 };
173 let Ok(payload) = serde_json::from_str::<serde_json::Value>(&checkpoint.payload) else {
174 continue;
175 };
176 let Some(outputs) = payload.get("outputs").and_then(|value| value.as_object()) else {
177 continue;
178 };
179 let entry = self.integrations.entry(name.to_owned()).or_default();
180 for (key, value) in outputs {
181 if let Some(value) = value.as_str() {
182 entry.insert(key.clone(), value.to_owned());
183 }
184 }
185 }
186 }
187
188 pub fn integration_outputs_from_checkpoints(
189 checkpoints: &[crate::state::Checkpoint],
190 ) -> BTreeMap<String, BTreeMap<String, String>> {
191 let mut ns = Self::default();
192 ns.add_integration_checkpoints(checkpoints);
193 ns.integrations
194 }
195
196 pub fn add_datastore_checkpoints(
203 &mut self,
204 checkpoints: &[crate::state::Checkpoint],
205 prefer_external: bool,
206 ) {
207 for checkpoint in checkpoints {
208 let Some(name) = checkpoint.step_id.strip_prefix("provision:") else {
209 continue;
210 };
211 let Ok(payload) = serde_json::from_str::<serde_json::Value>(&checkpoint.payload) else {
212 continue;
213 };
214 let url = payload
215 .get("url")
216 .and_then(|value| value.as_str())
217 .or_else(|| {
218 let primary = if prefer_external {
219 "external_url"
220 } else {
221 "internal_url"
222 };
223 let fallback = if prefer_external {
224 "internal_url"
225 } else {
226 "external_url"
227 };
228 payload
229 .get(primary)
230 .and_then(|value| value.as_str())
231 .or_else(|| payload.get(fallback).and_then(|value| value.as_str()))
232 });
233 if let Some(url) = url {
234 self.datastore_urls.insert(name.to_owned(), url.to_owned());
235 }
236 }
237 }
238
239 pub fn resolve(&self, value: &str, location: &str) -> Result<String, DefError> {
241 let mut out = String::with_capacity(value.len());
242 let mut rest = value;
243 while let Some(start) = rest.find("${") {
244 out.push_str(&rest[..start]);
245 let after = &rest[start + 2..];
246 let Some(end) = after.find('}') else {
247 return Err(DefError::ReferenceSyntax {
248 location: location.to_owned(),
249 reference: rest[start..].to_owned(),
250 detail: "unterminated ${...}".into(),
251 });
252 };
253 let reference = Reference::parse_reference(&after[..end], location)?;
254 out.push_str(&self.lookup(&reference, location)?);
255 rest = &after[end + 1..];
256 }
257 out.push_str(rest);
258 Ok(out)
259 }
260}
261
262pub fn resolve(value: &str, namespace: &Namespace, location: &str) -> Result<String, DefError> {
264 namespace.resolve(value, location)
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn tokenizes_all_namespace_forms() {
273 let refs = references(
274 "${stack.name} ${instance.name} ${services.web.origin} ${datastores.db.url} ${secrets.KEY} ${integrations.clerk.secret_key}",
275 "test",
276 )
277 .unwrap();
278 assert_eq!(
279 refs,
280 vec![
281 Reference::StackName,
282 Reference::InstanceName,
283 Reference::ServiceOrigin("web".into()),
284 Reference::DatastoreUrl("db".into()),
285 Reference::Secret("KEY".into()),
286 Reference::IntegrationOutput {
287 integration: "clerk".into(),
288 output: "secret_key".into(),
289 },
290 ]
291 );
292 }
293
294 #[test]
295 fn port_is_not_a_reference() {
296 assert!(references("vite --port $PORT", "test").unwrap().is_empty());
297 }
298
299 #[test]
300 fn unterminated_reference_fails() {
301 assert!(references("${services.web.origin", "test").is_err());
302 }
303
304 #[test]
305 fn unknown_form_fails() {
306 assert!(references("${services.web.port}", "test").is_err());
307 }
308
309 #[test]
310 fn resolves_mixed_text() {
311 let mut namespace = Namespace {
312 stack_name: DnsName::try_new("atto").unwrap(),
313 instance_name: DnsName::try_new("demo").unwrap(),
314 ..Namespace::default()
315 };
316 namespace
317 .service_origins
318 .insert("web".into(), "http://web.demo.localhost:4444".into());
319 let resolved = resolve(
320 "origin=${services.web.origin};i=${instance.name}",
321 &namespace,
322 "t",
323 )
324 .unwrap();
325 assert_eq!(resolved, "origin=http://web.demo.localhost:4444;i=demo");
326 }
327}