1use camino::{Utf8Path, Utf8PathBuf};
14use serde::{Deserialize, Serialize};
15
16use crate::error::{NewgitError, Result};
17use crate::exports::{RenderContext, render as render_template};
18
19#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
21pub struct RenderSpec {
22 pub path: Utf8PathBuf,
24 #[serde(default)]
25 pub replace: Vec<Replacement>,
26}
27
28#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
32pub struct Replacement {
33 pub find: String,
34 pub with: String,
35 #[serde(default = "one")]
40 pub count: usize,
41}
42
43fn one() -> usize {
44 1
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct RenderRecord {
52 pub path: Utf8PathBuf,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub tracker: Option<String>,
57 pub applied: Vec<AppliedReplacement>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct AppliedReplacement {
63 pub find: String,
64 pub value: String,
66 pub count: usize,
67}
68
69struct Located {
71 start: usize,
72 end: usize,
73 rule: usize,
74}
75
76fn locate(content: &str, finds: &[&str]) -> Vec<Located> {
79 let mut found = Vec::new();
80 for (rule, find) in finds.iter().enumerate() {
81 let mut from = 0;
82 while let Some(offset) = content[from..].find(find) {
83 let start = from + offset;
84 found.push(Located {
85 start,
86 end: start + find.len(),
87 rule,
88 });
89 from = start + find.len();
92 }
93 }
94 found.sort_by_key(|located| (located.start, located.end));
95 found
96}
97
98pub fn apply(
116 resource: &str,
117 spec: &RenderSpec,
118 committed: &str,
119 context: &RenderContext,
120) -> Result<(String, Vec<AppliedReplacement>)> {
121 let mut applied = Vec::with_capacity(spec.replace.len());
122 for replacement in &spec.replace {
123 let value = render_template(&replacement.with, context);
124 if let Some(unresolved) = crate::exports::unresolved_placeholder(&value) {
125 return Err(NewgitError::RenderUnresolved {
126 resource: resource.to_owned(),
127 path: spec.path.clone(),
128 placeholder: unresolved.to_owned(),
129 });
130 }
131 applied.push(AppliedReplacement {
132 find: replacement.find.clone(),
133 value,
134 count: replacement.count,
135 });
136 }
137
138 let content = substitute(resource, &spec.path, committed, &applied)?;
139
140 for replacement in &applied {
146 let back = content.matches(replacement.value.as_str()).count();
147 if back != replacement.count {
148 return Err(NewgitError::RenderNotInvertible {
149 resource: resource.to_owned(),
150 path: spec.path.clone(),
151 value: replacement.value.clone(),
152 expected: replacement.count,
153 found: back,
154 });
155 }
156 }
157
158 Ok((content, applied))
159}
160
161pub fn substitute(
168 resource: &str,
169 path: &Utf8Path,
170 committed: &str,
171 applied: &[AppliedReplacement],
172) -> Result<String> {
173 let finds: Vec<&str> = applied
174 .iter()
175 .map(|replacement| replacement.find.as_str())
176 .collect();
177 let located = locate(committed, &finds);
178
179 for (rule, replacement) in applied.iter().enumerate() {
180 let found = located.iter().filter(|hit| hit.rule == rule).count();
181 if found != replacement.count {
182 return Err(NewgitError::RenderMatchCount {
183 resource: resource.to_owned(),
184 path: path.to_path_buf(),
185 find: replacement.find.clone(),
186 expected: replacement.count,
187 found,
188 });
189 }
190 }
191
192 let mut output = String::with_capacity(committed.len());
196 let mut cursor = 0;
197 for hit in &located {
198 if hit.start < cursor {
199 return Err(NewgitError::RenderOverlappingFinds {
200 resource: resource.to_owned(),
201 path: path.to_path_buf(),
202 left: applied[hit.rule].find.clone(),
203 right: located
204 .iter()
205 .find(|other| other.end > hit.start && other.rule != hit.rule)
206 .map(|other| applied[other.rule].find.clone())
207 .unwrap_or_else(|| applied[hit.rule].find.clone()),
208 });
209 }
210 output.push_str(&committed[cursor..hit.start]);
211 output.push_str(&applied[hit.rule].value);
212 cursor = hit.end;
213 }
214 output.push_str(&committed[cursor..]);
215 Ok(output)
216}
217
218pub fn reverse(rendered: &str, applied: &[AppliedReplacement]) -> String {
232 let values: Vec<&str> = applied
233 .iter()
234 .map(|replacement| replacement.value.as_str())
235 .collect();
236 let located = locate(rendered, &values);
237
238 let mut output = String::with_capacity(rendered.len());
239 let mut cursor = 0;
240 for hit in &located {
241 if hit.start < cursor {
244 continue;
245 }
246 output.push_str(&rendered[cursor..hit.start]);
247 output.push_str(&applied[hit.rule].find);
248 cursor = hit.end;
249 }
250 output.push_str(&rendered[cursor..]);
251 output
252}
253
254pub fn validate_disjoint(specs: &[(&str, &RenderSpec)]) -> Result<()> {
258 for (index, (left, left_spec)) in specs.iter().enumerate() {
259 for (right, right_spec) in &specs[index + 1..] {
260 if left_spec.path == right_spec.path {
261 return Err(NewgitError::RenderPathConflict {
262 left: (*left).to_owned(),
263 right: (*right).to_owned(),
264 path: left_spec.path.clone(),
265 });
266 }
267 }
268 }
269 Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274 use std::collections::BTreeMap;
275
276 use super::{RenderSpec, Replacement, apply, reverse, validate_disjoint};
277 use crate::error::NewgitError;
278 use crate::exports::RenderContext;
279
280 fn ports() -> BTreeMap<String, u16> {
281 BTreeMap::from([("api".to_owned(), 54400), ("db".to_owned(), 54500)])
282 }
283
284 fn spec(replace: Vec<Replacement>) -> RenderSpec {
285 RenderSpec {
286 path: "supabase/config.toml".into(),
287 replace,
288 }
289 }
290
291 fn replacement(find: &str, with: &str) -> Replacement {
292 Replacement {
293 find: find.to_owned(),
294 with: with.to_owned(),
295 count: 1,
296 }
297 }
298
299 #[test]
300 fn substitutes_into_committed_content() {
301 let ports = ports();
302 let context = RenderContext {
303 branch_slug: "feature-a",
304 ports: Some(&ports),
305 ..RenderContext::default()
306 };
307 let committed = "[api]\nport = 54321\n[db]\nport = 54322\n";
308 let spec = spec(vec![
309 replacement("port = 54321", "port = {{ports.api}}"),
310 replacement("port = 54322", "port = {{ports.db}}"),
311 ]);
312
313 let (rendered, applied) = apply("supabase", &spec, committed, &context).expect("renders");
314 assert_eq!(rendered, "[api]\nport = 54400\n[db]\nport = 54500\n");
315 assert_eq!(applied.len(), 2);
316 }
317
318 #[test]
319 fn rendering_is_idempotent_because_it_reads_committed_content() {
320 let ports = ports();
321 let context = RenderContext {
322 ports: Some(&ports),
323 ..RenderContext::default()
324 };
325 let committed = "port = 54321\n";
326 let spec = spec(vec![replacement("port = 54321", "port = {{ports.api}}")]);
327
328 let (once, _) = apply("supabase", &spec, committed, &context).expect("renders");
329 let (twice, _) = apply("supabase", &spec, committed, &context).expect("renders");
330 assert_eq!(once, twice);
331 }
332
333 #[test]
334 fn a_find_that_matches_twice_is_refused() {
335 let ports = ports();
336 let context = RenderContext {
337 ports: Some(&ports),
338 ..RenderContext::default()
339 };
340 let committed = "port = 54321\nport = 54321\n";
341 let spec = spec(vec![replacement("port = 54321", "port = {{ports.api}}")]);
342
343 assert!(matches!(
344 apply("supabase", &spec, committed, &context),
345 Err(NewgitError::RenderMatchCount {
346 expected: 1,
347 found: 2,
348 ..
349 })
350 ));
351 }
352
353 #[test]
356 fn a_find_that_stopped_matching_is_refused() {
357 let ports = ports();
358 let context = RenderContext {
359 ports: Some(&ports),
360 ..RenderContext::default()
361 };
362 let committed = "port = 55555\n";
363 let spec = spec(vec![replacement("port = 54321", "port = {{ports.api}}")]);
364
365 assert!(matches!(
366 apply("supabase", &spec, committed, &context),
367 Err(NewgitError::RenderMatchCount { found: 0, .. })
368 ));
369 }
370
371 #[test]
372 fn a_declared_count_permits_exactly_that_many() {
373 let ports = ports();
374 let context = RenderContext {
375 ports: Some(&ports),
376 ..RenderContext::default()
377 };
378 let committed = "- \"3000:3000\"\n- \"3000:3000\"\n";
379 let spec = spec(vec![Replacement {
380 find: "\"3000:3000\"".to_owned(),
381 with: "\"{{ports.api}}:3000\"".to_owned(),
382 count: 2,
383 }]);
384
385 let (rendered, _) = apply("app", &spec, committed, &context).expect("renders");
386 assert_eq!(rendered, "- \"54400:3000\"\n- \"54400:3000\"\n");
387 }
388
389 #[test]
390 fn a_declared_count_still_fails_when_the_file_gains_one() {
391 let ports = ports();
392 let context = RenderContext {
393 ports: Some(&ports),
394 ..RenderContext::default()
395 };
396 let committed = "x\nx\nx\n";
397 let spec = spec(vec![Replacement {
398 find: "x".to_owned(),
399 with: "{{ports.api}}".to_owned(),
400 count: 2,
401 }]);
402
403 assert!(matches!(
404 apply("app", &spec, committed, &context),
405 Err(NewgitError::RenderMatchCount {
406 expected: 2,
407 found: 3,
408 ..
409 })
410 ));
411 }
412
413 #[test]
416 fn multiline_find_disambiguates_identical_defaults() {
417 let ports = ports();
418 let context = RenderContext {
419 ports: Some(&ports),
420 ..RenderContext::default()
421 };
422 let committed = "[api]\nport = 54321\n\n[studio]\nport = 54321\n";
423 let spec = spec(vec![replacement(
424 "[api]\nport = 54321",
425 "[api]\nport = {{ports.api}}",
426 )]);
427
428 let (rendered, _) = apply("supabase", &spec, committed, &context).expect("renders");
429 assert_eq!(rendered, "[api]\nport = 54400\n\n[studio]\nport = 54321\n");
430 }
431
432 #[test]
438 fn a_find_that_equals_an_earlier_rules_output_is_not_a_match_target() {
439 let ports = ports();
440 let context = RenderContext {
441 ports: Some(&ports),
442 ..RenderContext::default()
443 };
444 let committed = "port = 54321\nport = 54400\n";
448 let spec = spec(vec![
449 replacement("port = 54321", "port = {{ports.api}}"),
450 replacement("port = 54400", "port = {{ports.db}}"),
451 ]);
452
453 let (rendered, _) = apply("supabase", &spec, committed, &context).expect("renders");
454 assert_eq!(rendered, "port = 54400\nport = 54500\n");
455 }
456
457 #[test]
460 fn declaration_order_does_not_change_the_render() {
461 let ports = ports();
462 let context = RenderContext {
463 ports: Some(&ports),
464 ..RenderContext::default()
465 };
466 let committed = "port = 54321\nport = 54400\n";
467 let forwards = spec(vec![
468 replacement("port = 54321", "port = {{ports.api}}"),
469 replacement("port = 54400", "port = {{ports.db}}"),
470 ]);
471 let backwards = spec(vec![
472 replacement("port = 54400", "port = {{ports.db}}"),
473 replacement("port = 54321", "port = {{ports.api}}"),
474 ]);
475
476 let (one, _) = apply("supabase", &forwards, committed, &context).expect("renders");
477 let (two, _) = apply("supabase", &backwards, committed, &context).expect("renders");
478 assert_eq!(one, two);
479 }
480
481 #[test]
482 fn two_finds_claiming_overlapping_text_are_refused() {
483 let ports = ports();
484 let context = RenderContext {
485 ports: Some(&ports),
486 ..RenderContext::default()
487 };
488 let committed = "port = 54321\n";
489 let spec = spec(vec![
490 replacement("port = 54321", "port = {{ports.api}}"),
491 replacement("= 54321", "= {{ports.db}}"),
492 ]);
493
494 assert!(matches!(
495 apply("supabase", &spec, committed, &context),
496 Err(NewgitError::RenderOverlappingFinds { .. })
497 ));
498 }
499
500 #[test]
501 fn round_trips_through_reverse() {
502 let ports = ports();
503 let context = RenderContext {
504 branch_slug: "feature-a",
505 ports: Some(&ports),
506 ..RenderContext::default()
507 };
508 let committed = "SUPABASE_URL=http://127.0.0.1:54321\nAPI_KEY=local\n";
509 let spec = spec(vec![replacement(
510 "SUPABASE_URL=http://127.0.0.1:54321",
511 "SUPABASE_URL=http://127.0.0.1:{{ports.api}}",
512 )]);
513
514 let (rendered, applied) = apply("supabase", &spec, committed, &context).expect("renders");
515 assert_eq!(reverse(&rendered, &applied), committed);
516 }
517
518 #[test]
521 fn reverse_keeps_edits_made_beside_the_rendered_value() {
522 let ports = ports();
523 let context = RenderContext {
524 ports: Some(&ports),
525 ..RenderContext::default()
526 };
527 let committed = "SUPABASE_URL=http://127.0.0.1:54321\n";
528 let spec = spec(vec![replacement(
529 "SUPABASE_URL=http://127.0.0.1:54321",
530 "SUPABASE_URL=http://127.0.0.1:{{ports.api}}",
531 )]);
532
533 let (rendered, applied) = apply("supabase", &spec, committed, &context).expect("renders");
534 let edited = format!("{rendered}STRIPE_KEY=sk_test_123\n");
535 assert_eq!(
536 reverse(&edited, &applied),
537 "SUPABASE_URL=http://127.0.0.1:54321\nSTRIPE_KEY=sk_test_123\n"
538 );
539 }
540
541 #[test]
542 fn a_value_that_cannot_be_reversed_unambiguously_is_refused() {
543 let ports = ports();
544 let context = RenderContext {
545 ports: Some(&ports),
546 ..RenderContext::default()
547 };
548 let committed = "port = 54321\nother = 54400\n";
551 let spec = spec(vec![replacement("54321", "{{ports.api}}")]);
552
553 assert!(matches!(
554 apply("supabase", &spec, committed, &context),
555 Err(NewgitError::RenderNotInvertible { .. })
556 ));
557 }
558
559 #[test]
560 fn an_unresolved_placeholder_is_refused() {
561 let context = RenderContext::default();
562 let spec = spec(vec![replacement("port = 54321", "port = {{ports.api}}")]);
563
564 assert!(matches!(
565 apply("supabase", &spec, "port = 54321\n", &context),
566 Err(NewgitError::RenderUnresolved { .. })
567 ));
568 }
569
570 #[test]
571 fn two_resources_rendering_one_path_is_refused() {
572 let left = spec(vec![]);
573 let right = spec(vec![]);
574 assert!(matches!(
575 validate_disjoint(&[("supabase", &left), ("app", &right)]),
576 Err(NewgitError::RenderPathConflict { .. })
577 ));
578 }
579}