Skip to main content

newgit_core/
render.rs

1//! Per-instance values substituted into a file the project commits.
2//!
3//! Ports reach a command as `{{ports.x}}` and as an env var. Most tools do
4//! not take them that way: Supabase reads `supabase/config.toml`, Expo reads
5//! `.env`, Compose reads `compose.yaml`. Without this, every project that
6//! hits it writes the same config rewriter inside its `prepare` hook.
7//!
8//! There is no template file. `port = 54321` is not a placeholder, it is the
9//! project's working default — a clone without newgit still starts on it.
10//! newgit substitutes into the committed content and writes the result into
11//! one workspace. See *Render* in newgit-v1-mvp.md.
12
13use camino::{Utf8Path, Utf8PathBuf};
14use serde::{Deserialize, Serialize};
15
16use crate::error::{NewgitError, Result};
17use crate::exports::{RenderContext, render as render_template};
18
19/// One file a resource renders per-instance values into.
20#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
21pub struct RenderSpec {
22    /// Workspace-relative path to the committed file.
23    pub path: Utf8PathBuf,
24    #[serde(default)]
25    pub replace: Vec<Replacement>,
26}
27
28/// A literal substitution. `find` is never a regex: a pattern language
29/// reintroduces the "did it match what I meant" doubt that [`Replacement::count`]
30/// exists to remove, and it would leave the inverse undefined.
31#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
32pub struct Replacement {
33    pub find: String,
34    pub with: String,
35    /// How many times `find` is expected to occur. Exactly one by default.
36    ///
37    /// Not an `all` flag: a declared number keeps failing when a file changes
38    /// from two occurrences to three, which is the property worth protecting.
39    #[serde(default = "one")]
40    pub count: usize,
41}
42
43fn one() -> usize {
44    1
45}
46
47/// What a render actually did, recorded on the binding record so capture can
48/// reverse it without re-deriving the values from a definition that may have
49/// been edited since.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct RenderRecord {
52    pub path: Utf8PathBuf,
53    /// The tracker owning this path, if any. Decides where committed content
54    /// is read from, and whether capture must reverse the substitution.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub tracker: Option<String>,
57    pub applied: Vec<AppliedReplacement>,
58}
59
60/// A replacement with its template already rendered, so the inverse is exact.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct AppliedReplacement {
63    pub find: String,
64    /// `with` after `{{ports.*}}` and friends were resolved.
65    pub value: String,
66    pub count: usize,
67}
68
69/// Where one rule matched in the committed content.
70struct Located {
71    start: usize,
72    end: usize,
73    rule: usize,
74}
75
76/// Every occurrence of every `find`, located in one pass over the *same*
77/// string. Returned sorted by position.
78fn 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            // Overlapping occurrences of one `find` are not matches Git or a
90            // human would count; advance past this one.
91            from = start + find.len();
92        }
93    }
94    found.sort_by_key(|located| (located.start, located.end));
95    found
96}
97
98/// Substitute into `committed`, the file's committed content — never what is
99/// currently on disk.
100///
101/// That is what makes a render idempotent: reading the working file would
102/// mean the second render looks for `port = 54321`, finds `port = 54400`, and
103/// fails. Reading committed content means `undo` re-renders off the binding
104/// record with no extra machinery, a `pull` that moves a lane head re-renders
105/// from the new content, and values can never compound.
106///
107/// **Every `find` is located in the committed content, and all replacements
108/// apply as one batch. A replacement's output is never a match target.**
109/// Rewriting in declaration order, re-matching each rule against the
110/// partially-rewritten text, would make a rule mean different things
111/// depending on what ran before it: a `find` that happens to equal an earlier
112/// rule's output would either report a spurious second match or silently
113/// rewrite that output. Declaration order then stops being cosmetic, which is
114/// not a property a config file should have.
115pub 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    // The inverse has to be as unambiguous as the forward pass, or
141    // `newgit capture` on a tracker-owned file would rewrite the wrong
142    // occurrence. Checked against the finished render, and here rather than
143    // at capture, so the failure lands where the definition is in front of
144    // you.
145    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
161/// Apply already-resolved replacements as one simultaneous batch, enforcing
162/// each rule's declared match count against the input.
163///
164/// Also the recomputation behind drift detection: the expected on-disk
165/// content of a rendered file is exactly this, run over the same committed
166/// content with the replacements the binding record remembers.
167pub 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    // Two rules claiming overlapping text have no batch answer — whichever
193    // won would be an accident of declaration order, the thing simultaneous
194    // application exists to remove.
195    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
218/// Undo a render: rewrite this instance's values back to the committed ones.
219///
220/// This is what lets a tracker-owned file be rendered at all. The lane is
221/// shared by every instance, so `newgit capture` reverses the substitution
222/// before recording — a key you add to `.env.local` reaches the lane and this
223/// instance's port does not. Only literal substitution can be run backwards;
224/// a whole-file template could not.
225///
226/// Simultaneous for the same reason [`apply`] is, and lenient where `apply`
227/// is strict: this runs against a file someone may have edited, so a value
228/// that no longer appears the declared number of times is not an error here.
229/// Whether those edits survive is [`crate::manager`]'s question, not this
230/// function's.
231pub 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        // Leftmost wins where two values overlap; nothing to decide between
242        // them, and the alternative is dropping text.
243        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
254/// Two resources rendering the same path is a config error, not a merge:
255/// they would race, and the second would render over the first's output and
256/// fail its own match check for reasons nothing in the definition explains.
257pub 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    /// The drift detector: upstream bumps its default and bind fails naming
354    /// the string, rather than silently doing nothing.
355    #[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    /// Multi-line `find` is how two sections sharing a default disambiguate,
414    /// without newgit ever learning what TOML is.
415    #[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    /// The collision case. Sequential rewriting would locate rule two
433    /// against text rule one had already produced: `port = 54400` would occur
434    /// twice, and the exactly-once check would report a match count the
435    /// committed file does not have. Locating everything up front makes
436    /// declaration order cosmetic, which is what a config file should be.
437    #[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        // The second rule's `find` is exactly what the first rule renders.
445        // Sequentially, rule two would see two occurrences of it — one of
446        // them rule one's own output — and fail the exactly-once check.
447        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    /// The same property stated the other way: reordering the rules cannot
458    /// change the result.
459    #[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    /// The point of reversing: edits made alongside the rendered value still
519    /// reach the lane.
520    #[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        // Rendering `54400` here would leave two occurrences of it, so a
549        // later capture could not know which one to put back.
550        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}