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 add_datastore_checkpoints(
195 &mut self,
196 checkpoints: &[crate::state::Checkpoint],
197 prefer_external: bool,
198 ) {
199 for checkpoint in checkpoints {
200 let Some(name) = checkpoint.step_id.strip_prefix("provision:") else {
201 continue;
202 };
203 let Ok(payload) = serde_json::from_str::<serde_json::Value>(&checkpoint.payload) else {
204 continue;
205 };
206 let url = payload
207 .get("url")
208 .and_then(|value| value.as_str())
209 .or_else(|| {
210 let primary = if prefer_external {
211 "external_url"
212 } else {
213 "internal_url"
214 };
215 let fallback = if prefer_external {
216 "internal_url"
217 } else {
218 "external_url"
219 };
220 payload
221 .get(primary)
222 .and_then(|value| value.as_str())
223 .or_else(|| payload.get(fallback).and_then(|value| value.as_str()))
224 });
225 if let Some(url) = url {
226 self.datastore_urls.insert(name.to_owned(), url.to_owned());
227 }
228 }
229 }
230
231 pub fn resolve(&self, value: &str, location: &str) -> Result<String, DefError> {
233 let mut out = String::with_capacity(value.len());
234 let mut rest = value;
235 while let Some(start) = rest.find("${") {
236 out.push_str(&rest[..start]);
237 let after = &rest[start + 2..];
238 let Some(end) = after.find('}') else {
239 return Err(DefError::ReferenceSyntax {
240 location: location.to_owned(),
241 reference: rest[start..].to_owned(),
242 detail: "unterminated ${...}".into(),
243 });
244 };
245 let reference = Reference::parse_reference(&after[..end], location)?;
246 out.push_str(&self.lookup(&reference, location)?);
247 rest = &after[end + 1..];
248 }
249 out.push_str(rest);
250 Ok(out)
251 }
252}
253
254pub fn resolve(value: &str, namespace: &Namespace, location: &str) -> Result<String, DefError> {
256 namespace.resolve(value, location)
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn tokenizes_all_namespace_forms() {
265 let refs = references(
266 "${stack.name} ${instance.name} ${services.web.origin} ${datastores.db.url} ${secrets.KEY} ${integrations.clerk.secret_key}",
267 "test",
268 )
269 .unwrap();
270 assert_eq!(
271 refs,
272 vec![
273 Reference::StackName,
274 Reference::InstanceName,
275 Reference::ServiceOrigin("web".into()),
276 Reference::DatastoreUrl("db".into()),
277 Reference::Secret("KEY".into()),
278 Reference::IntegrationOutput {
279 integration: "clerk".into(),
280 output: "secret_key".into(),
281 },
282 ]
283 );
284 }
285
286 #[test]
287 fn port_is_not_a_reference() {
288 assert!(references("vite --port $PORT", "test").unwrap().is_empty());
289 }
290
291 #[test]
292 fn unterminated_reference_fails() {
293 assert!(references("${services.web.origin", "test").is_err());
294 }
295
296 #[test]
297 fn unknown_form_fails() {
298 assert!(references("${services.web.port}", "test").is_err());
299 }
300
301 #[test]
302 fn resolves_mixed_text() {
303 let mut namespace = Namespace {
304 stack_name: DnsName::try_new("atto").unwrap(),
305 instance_name: DnsName::try_new("demo").unwrap(),
306 ..Namespace::default()
307 };
308 namespace
309 .service_origins
310 .insert("web".into(), "http://web.demo.localhost:4444".into());
311 let resolved = resolve(
312 "origin=${services.web.origin};i=${instance.name}",
313 &namespace,
314 "t",
315 )
316 .unwrap();
317 assert_eq!(resolved, "origin=http://web.demo.localhost:4444;i=demo");
318 }
319}