use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{NewgitError, Result};
use crate::exports::{RenderContext, render as render_template};
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct RenderSpec {
pub path: Utf8PathBuf,
#[serde(default)]
pub replace: Vec<Replacement>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Replacement {
pub find: String,
pub with: String,
#[serde(default = "one")]
pub count: usize,
}
fn one() -> usize {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RenderRecord {
pub path: Utf8PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tracker: Option<String>,
pub applied: Vec<AppliedReplacement>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AppliedReplacement {
pub find: String,
pub value: String,
pub count: usize,
}
struct Located {
start: usize,
end: usize,
rule: usize,
}
fn locate(content: &str, finds: &[&str]) -> Vec<Located> {
let mut found = Vec::new();
for (rule, find) in finds.iter().enumerate() {
let mut from = 0;
while let Some(offset) = content[from..].find(find) {
let start = from + offset;
found.push(Located {
start,
end: start + find.len(),
rule,
});
from = start + find.len();
}
}
found.sort_by_key(|located| (located.start, located.end));
found
}
pub fn apply(
resource: &str,
spec: &RenderSpec,
committed: &str,
context: &RenderContext,
) -> Result<(String, Vec<AppliedReplacement>)> {
let mut applied = Vec::with_capacity(spec.replace.len());
for replacement in &spec.replace {
let value = render_template(&replacement.with, context);
if let Some(unresolved) = crate::exports::unresolved_placeholder(&value) {
return Err(NewgitError::RenderUnresolved {
resource: resource.to_owned(),
path: spec.path.clone(),
placeholder: unresolved.to_owned(),
});
}
applied.push(AppliedReplacement {
find: replacement.find.clone(),
value,
count: replacement.count,
});
}
let content = substitute(resource, &spec.path, committed, &applied)?;
for replacement in &applied {
let back = content.matches(replacement.value.as_str()).count();
if back != replacement.count {
return Err(NewgitError::RenderNotInvertible {
resource: resource.to_owned(),
path: spec.path.clone(),
value: replacement.value.clone(),
expected: replacement.count,
found: back,
});
}
}
Ok((content, applied))
}
pub fn substitute(
resource: &str,
path: &Utf8Path,
committed: &str,
applied: &[AppliedReplacement],
) -> Result<String> {
let finds: Vec<&str> = applied
.iter()
.map(|replacement| replacement.find.as_str())
.collect();
let located = locate(committed, &finds);
for (rule, replacement) in applied.iter().enumerate() {
let found = located.iter().filter(|hit| hit.rule == rule).count();
if found != replacement.count {
return Err(NewgitError::RenderMatchCount {
resource: resource.to_owned(),
path: path.to_path_buf(),
find: replacement.find.clone(),
expected: replacement.count,
found,
});
}
}
let mut output = String::with_capacity(committed.len());
let mut cursor = 0;
for hit in &located {
if hit.start < cursor {
return Err(NewgitError::RenderOverlappingFinds {
resource: resource.to_owned(),
path: path.to_path_buf(),
left: applied[hit.rule].find.clone(),
right: located
.iter()
.find(|other| other.end > hit.start && other.rule != hit.rule)
.map(|other| applied[other.rule].find.clone())
.unwrap_or_else(|| applied[hit.rule].find.clone()),
});
}
output.push_str(&committed[cursor..hit.start]);
output.push_str(&applied[hit.rule].value);
cursor = hit.end;
}
output.push_str(&committed[cursor..]);
Ok(output)
}
pub fn reverse(rendered: &str, applied: &[AppliedReplacement]) -> String {
let values: Vec<&str> = applied
.iter()
.map(|replacement| replacement.value.as_str())
.collect();
let located = locate(rendered, &values);
let mut output = String::with_capacity(rendered.len());
let mut cursor = 0;
for hit in &located {
if hit.start < cursor {
continue;
}
output.push_str(&rendered[cursor..hit.start]);
output.push_str(&applied[hit.rule].find);
cursor = hit.end;
}
output.push_str(&rendered[cursor..]);
output
}
pub fn validate_disjoint(specs: &[(&str, &RenderSpec)]) -> Result<()> {
for (index, (left, left_spec)) in specs.iter().enumerate() {
for (right, right_spec) in &specs[index + 1..] {
if left_spec.path == right_spec.path {
return Err(NewgitError::RenderPathConflict {
left: (*left).to_owned(),
right: (*right).to_owned(),
path: left_spec.path.clone(),
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{RenderSpec, Replacement, apply, reverse, validate_disjoint};
use crate::error::NewgitError;
use crate::exports::RenderContext;
fn ports() -> BTreeMap<String, u16> {
BTreeMap::from([("api".to_owned(), 54400), ("db".to_owned(), 54500)])
}
fn spec(replace: Vec<Replacement>) -> RenderSpec {
RenderSpec {
path: "supabase/config.toml".into(),
replace,
}
}
fn replacement(find: &str, with: &str) -> Replacement {
Replacement {
find: find.to_owned(),
with: with.to_owned(),
count: 1,
}
}
#[test]
fn substitutes_into_committed_content() {
let ports = ports();
let context = RenderContext {
branch_slug: "feature-a",
ports: Some(&ports),
..RenderContext::default()
};
let committed = "[api]\nport = 54321\n[db]\nport = 54322\n";
let spec = spec(vec![
replacement("port = 54321", "port = {{ports.api}}"),
replacement("port = 54322", "port = {{ports.db}}"),
]);
let (rendered, applied) = apply("supabase", &spec, committed, &context).expect("renders");
assert_eq!(rendered, "[api]\nport = 54400\n[db]\nport = 54500\n");
assert_eq!(applied.len(), 2);
}
#[test]
fn rendering_is_idempotent_because_it_reads_committed_content() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "port = 54321\n";
let spec = spec(vec![replacement("port = 54321", "port = {{ports.api}}")]);
let (once, _) = apply("supabase", &spec, committed, &context).expect("renders");
let (twice, _) = apply("supabase", &spec, committed, &context).expect("renders");
assert_eq!(once, twice);
}
#[test]
fn a_find_that_matches_twice_is_refused() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "port = 54321\nport = 54321\n";
let spec = spec(vec![replacement("port = 54321", "port = {{ports.api}}")]);
assert!(matches!(
apply("supabase", &spec, committed, &context),
Err(NewgitError::RenderMatchCount {
expected: 1,
found: 2,
..
})
));
}
#[test]
fn a_find_that_stopped_matching_is_refused() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "port = 55555\n";
let spec = spec(vec![replacement("port = 54321", "port = {{ports.api}}")]);
assert!(matches!(
apply("supabase", &spec, committed, &context),
Err(NewgitError::RenderMatchCount { found: 0, .. })
));
}
#[test]
fn a_declared_count_permits_exactly_that_many() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "- \"3000:3000\"\n- \"3000:3000\"\n";
let spec = spec(vec![Replacement {
find: "\"3000:3000\"".to_owned(),
with: "\"{{ports.api}}:3000\"".to_owned(),
count: 2,
}]);
let (rendered, _) = apply("app", &spec, committed, &context).expect("renders");
assert_eq!(rendered, "- \"54400:3000\"\n- \"54400:3000\"\n");
}
#[test]
fn a_declared_count_still_fails_when_the_file_gains_one() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "x\nx\nx\n";
let spec = spec(vec![Replacement {
find: "x".to_owned(),
with: "{{ports.api}}".to_owned(),
count: 2,
}]);
assert!(matches!(
apply("app", &spec, committed, &context),
Err(NewgitError::RenderMatchCount {
expected: 2,
found: 3,
..
})
));
}
#[test]
fn multiline_find_disambiguates_identical_defaults() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "[api]\nport = 54321\n\n[studio]\nport = 54321\n";
let spec = spec(vec![replacement(
"[api]\nport = 54321",
"[api]\nport = {{ports.api}}",
)]);
let (rendered, _) = apply("supabase", &spec, committed, &context).expect("renders");
assert_eq!(rendered, "[api]\nport = 54400\n\n[studio]\nport = 54321\n");
}
#[test]
fn a_find_that_equals_an_earlier_rules_output_is_not_a_match_target() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "port = 54321\nport = 54400\n";
let spec = spec(vec![
replacement("port = 54321", "port = {{ports.api}}"),
replacement("port = 54400", "port = {{ports.db}}"),
]);
let (rendered, _) = apply("supabase", &spec, committed, &context).expect("renders");
assert_eq!(rendered, "port = 54400\nport = 54500\n");
}
#[test]
fn declaration_order_does_not_change_the_render() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "port = 54321\nport = 54400\n";
let forwards = spec(vec![
replacement("port = 54321", "port = {{ports.api}}"),
replacement("port = 54400", "port = {{ports.db}}"),
]);
let backwards = spec(vec![
replacement("port = 54400", "port = {{ports.db}}"),
replacement("port = 54321", "port = {{ports.api}}"),
]);
let (one, _) = apply("supabase", &forwards, committed, &context).expect("renders");
let (two, _) = apply("supabase", &backwards, committed, &context).expect("renders");
assert_eq!(one, two);
}
#[test]
fn two_finds_claiming_overlapping_text_are_refused() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "port = 54321\n";
let spec = spec(vec![
replacement("port = 54321", "port = {{ports.api}}"),
replacement("= 54321", "= {{ports.db}}"),
]);
assert!(matches!(
apply("supabase", &spec, committed, &context),
Err(NewgitError::RenderOverlappingFinds { .. })
));
}
#[test]
fn round_trips_through_reverse() {
let ports = ports();
let context = RenderContext {
branch_slug: "feature-a",
ports: Some(&ports),
..RenderContext::default()
};
let committed = "SUPABASE_URL=http://127.0.0.1:54321\nAPI_KEY=local\n";
let spec = spec(vec![replacement(
"SUPABASE_URL=http://127.0.0.1:54321",
"SUPABASE_URL=http://127.0.0.1:{{ports.api}}",
)]);
let (rendered, applied) = apply("supabase", &spec, committed, &context).expect("renders");
assert_eq!(reverse(&rendered, &applied), committed);
}
#[test]
fn reverse_keeps_edits_made_beside_the_rendered_value() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "SUPABASE_URL=http://127.0.0.1:54321\n";
let spec = spec(vec![replacement(
"SUPABASE_URL=http://127.0.0.1:54321",
"SUPABASE_URL=http://127.0.0.1:{{ports.api}}",
)]);
let (rendered, applied) = apply("supabase", &spec, committed, &context).expect("renders");
let edited = format!("{rendered}STRIPE_KEY=sk_test_123\n");
assert_eq!(
reverse(&edited, &applied),
"SUPABASE_URL=http://127.0.0.1:54321\nSTRIPE_KEY=sk_test_123\n"
);
}
#[test]
fn a_value_that_cannot_be_reversed_unambiguously_is_refused() {
let ports = ports();
let context = RenderContext {
ports: Some(&ports),
..RenderContext::default()
};
let committed = "port = 54321\nother = 54400\n";
let spec = spec(vec![replacement("54321", "{{ports.api}}")]);
assert!(matches!(
apply("supabase", &spec, committed, &context),
Err(NewgitError::RenderNotInvertible { .. })
));
}
#[test]
fn an_unresolved_placeholder_is_refused() {
let context = RenderContext::default();
let spec = spec(vec![replacement("port = 54321", "port = {{ports.api}}")]);
assert!(matches!(
apply("supabase", &spec, "port = 54321\n", &context),
Err(NewgitError::RenderUnresolved { .. })
));
}
#[test]
fn two_resources_rendering_one_path_is_refused() {
let left = spec(vec![]);
let right = spec(vec![]);
assert!(matches!(
validate_disjoint(&[("supabase", &left), ("app", &right)]),
Err(NewgitError::RenderPathConflict { .. })
));
}
}