1use crate::diff::{DiffOp, ResourceDiff};
25use regex_lite::Regex;
26use std::collections::{BTreeMap, BTreeSet};
27use std::sync::OnceLock;
28
29fn ref_regex() -> &'static Regex {
35 static RE: OnceLock<Regex> = OnceLock::new();
36 RE.get_or_init(|| {
37 Regex::new(r"\{\{\s*content_blocks\.\$\{\s*([^\s}|]+)\s*\}(?:\s*\|[^}]*)?\s*\}\}")
38 .expect("static regex")
39 })
40}
41
42pub fn extract_block_references(body: &str) -> Vec<String> {
47 ref_regex()
48 .captures_iter(body)
49 .map(|cap| cap[1].to_string())
50 .collect()
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Cycle {
58 pub path: Vec<String>,
59}
60
61impl std::fmt::Display for Cycle {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(f, "{}", self.path.join(" → "))
64 }
65}
66
67pub fn topo_sort<'a>(
75 nodes: &'a [String],
76 edges: &'a BTreeMap<String, Vec<String>>,
77) -> Result<Vec<String>, Cycle> {
78 let in_set: BTreeSet<&'a str> = nodes.iter().map(String::as_str).collect();
79 let mut visited: BTreeSet<&'a str> = BTreeSet::new();
80 let mut on_stack: Vec<&'a str> = Vec::new();
81 let mut on_stack_set: BTreeSet<&'a str> = BTreeSet::new();
82 let mut out: Vec<&'a str> = Vec::with_capacity(nodes.len());
83
84 for n in nodes {
85 let n = n.as_str();
86 if !visited.contains(n) {
87 visit(
88 n,
89 edges,
90 &in_set,
91 &mut visited,
92 &mut on_stack,
93 &mut on_stack_set,
94 &mut out,
95 )?;
96 }
97 }
98 Ok(out.into_iter().map(str::to_owned).collect())
99}
100
101fn visit<'a>(
102 node: &'a str,
103 edges: &'a BTreeMap<String, Vec<String>>,
104 in_set: &BTreeSet<&'a str>,
105 visited: &mut BTreeSet<&'a str>,
106 on_stack: &mut Vec<&'a str>,
107 on_stack_set: &mut BTreeSet<&'a str>,
108 out: &mut Vec<&'a str>,
109) -> Result<(), Cycle> {
110 if on_stack_set.contains(node) {
111 let start = on_stack
114 .iter()
115 .position(|n| *n == node)
116 .expect("on_stack and on_stack_set must stay in sync");
117 let mut path: Vec<String> = on_stack[start..].iter().map(|s| (*s).to_owned()).collect();
118 path.push(node.to_owned());
119 return Err(Cycle { path });
120 }
121 if visited.contains(node) {
122 return Ok(());
123 }
124 on_stack.push(node);
125 on_stack_set.insert(node);
126
127 if let Some(targets) = edges.get(node) {
128 for t in targets {
129 let t = t.as_str();
130 if in_set.contains(t) {
131 visit(t, edges, in_set, visited, on_stack, on_stack_set, out)?;
132 }
133 }
134 }
135
136 on_stack.pop();
137 on_stack_set.remove(node);
138 visited.insert(node);
139 out.push(node);
140 Ok(())
141}
142
143pub fn reorder_content_block_diffs_by_dependency(
154 diffs: Vec<ResourceDiff>,
155) -> Result<Vec<ResourceDiff>, Cycle> {
156 let mut others: Vec<ResourceDiff> = Vec::new();
157 let mut inactionable: Vec<ResourceDiff> = Vec::new();
158 let mut actionable_names: Vec<String> = Vec::new();
159 let mut by_name: BTreeMap<String, ResourceDiff> = BTreeMap::new();
160 let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
161
162 for d in diffs {
163 match d {
164 ResourceDiff::ContentBlock(cb) => {
165 let body: Option<&str> = if cb.orphan {
166 None
167 } else {
168 match &cb.op {
169 DiffOp::Added(b) => Some(b.content.as_str()),
170 DiffOp::Modified { to, .. } => Some(to.content.as_str()),
171 _ => None,
172 }
173 };
174 match body {
175 Some(b) => {
176 let name = cb.name.clone();
177 let mut refs: Vec<String> = extract_block_references(b)
181 .into_iter()
182 .filter(|t| *t != name)
183 .collect();
184 refs.sort();
185 refs.dedup();
186 if !refs.is_empty() {
187 edges.insert(name.clone(), refs);
188 }
189 actionable_names.push(name.clone());
190 by_name.insert(name, ResourceDiff::ContentBlock(cb));
191 }
192 None => inactionable.push(ResourceDiff::ContentBlock(cb)),
193 }
194 }
195 other => others.push(other),
196 }
197 }
198
199 let sorted_names = topo_sort(&actionable_names, &edges)?;
200
201 let mut out = Vec::with_capacity(others.len() + sorted_names.len() + inactionable.len());
202 out.extend(others);
207 for n in &sorted_names {
208 out.push(
209 by_name
210 .remove(n)
211 .expect("topo_sort returns names from input set"),
212 );
213 }
214 out.extend(inactionable);
215 Ok(out)
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::diff::content_block::ContentBlockDiff;
222 use crate::resource::{ContentBlock, ContentBlockState};
223
224 fn cb(name: &str, body: &str) -> ContentBlock {
225 ContentBlock {
226 name: name.into(),
227 description: None,
228 content: body.into(),
229 tags: vec![],
230 state: ContentBlockState::Active,
231 }
232 }
233
234 fn added(name: &str, body: &str) -> ResourceDiff {
235 ResourceDiff::ContentBlock(ContentBlockDiff {
236 name: name.into(),
237 op: DiffOp::Added(cb(name, body)),
238 text_diff: None,
239 orphan: false,
240 })
241 }
242
243 fn unchanged(name: &str) -> ResourceDiff {
244 ResourceDiff::ContentBlock(ContentBlockDiff {
245 name: name.into(),
246 op: DiffOp::Unchanged,
247 text_diff: None,
248 orphan: false,
249 })
250 }
251
252 fn order(diffs: &[ResourceDiff]) -> Vec<&str> {
253 diffs
254 .iter()
255 .filter_map(|d| match d {
256 ResourceDiff::ContentBlock(cb) => Some(cb.name.as_str()),
257 _ => None,
258 })
259 .collect()
260 }
261
262 #[test]
263 fn extracts_a_single_reference() {
264 let body = "Hello {{content_blocks.${other_block} | id: 'cb1'}}!";
265 assert_eq!(extract_block_references(body), vec!["other_block"]);
266 }
267
268 #[test]
269 fn extracts_references_with_whitespace_variations() {
270 let body = "
271 {{content_blocks.${a}}}
272 {{ content_blocks.${ b } }}
273 {{content_blocks.${c} | id: 'x' }}
274 {{ content_blocks.${d} | id: 'y'}}
275 ";
276 assert_eq!(extract_block_references(body), vec!["a", "b", "c", "d"]);
277 }
278
279 #[test]
280 fn extracts_multiple_references_in_one_body() {
281 let body = "head {{content_blocks.${one}}} mid {{content_blocks.${two}}} tail";
282 assert_eq!(extract_block_references(body), vec!["one", "two"]);
283 }
284
285 #[test]
286 fn ignores_unrelated_liquid() {
287 let body = "{{ user.${first_name} }} {{ campaign.${id} }}";
288 assert!(extract_block_references(body).is_empty());
289 }
290
291 #[test]
292 fn topo_sort_emits_targets_before_referrers() {
293 let nodes = vec!["a".to_string(), "b".to_string()];
295 let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
296 edges.insert("a".into(), vec!["b".into()]);
297 let out = topo_sort(&nodes, &edges).unwrap();
298 assert_eq!(out, vec!["b".to_string(), "a".to_string()]);
299 }
300
301 #[test]
302 fn topo_sort_drops_edges_to_unknown_targets() {
303 let nodes = vec!["a".to_string()];
305 let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
306 edges.insert("a".into(), vec!["b".into()]);
307 let out = topo_sort(&nodes, &edges).unwrap();
308 assert_eq!(out, vec!["a".to_string()]);
309 }
310
311 #[test]
312 fn topo_sort_detects_cycle_and_names_it() {
313 let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
314 let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
315 edges.insert("a".into(), vec!["b".into()]);
316 edges.insert("b".into(), vec!["c".into()]);
317 edges.insert("c".into(), vec!["a".into()]);
318 let err = topo_sort(&nodes, &edges).unwrap_err();
319 assert_eq!(err.path.first(), err.path.last());
321 let s: BTreeSet<&str> = err.path.iter().map(String::as_str).collect();
322 assert!(s.contains("a") && s.contains("b") && s.contains("c"));
323 }
324
325 #[test]
326 fn reorder_puts_dependency_target_before_referrer() {
327 let diffs = vec![
330 added("a_referrer", "see {{content_blocks.${b_target}}}"),
331 added("b_target", "leaf"),
332 ];
333 let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
334 assert_eq!(order(&out), vec!["b_target", "a_referrer"]);
335 }
336
337 #[test]
338 fn reorder_keeps_independent_blocks_in_input_order() {
339 let diffs = vec![
340 added("alpha", "no refs"),
341 added("bravo", "no refs"),
342 added("charlie", "no refs"),
343 ];
344 let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
345 assert_eq!(order(&out), vec!["alpha", "bravo", "charlie"]);
346 }
347
348 #[test]
349 fn reorder_treats_reference_to_unchanged_block_as_leaf() {
350 let diffs = vec![
354 added("referrer", "see {{content_blocks.${already_there}}}"),
355 unchanged("already_there"),
356 ];
357 let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
358 let names = order(&out);
359 assert_eq!(names, vec!["referrer", "already_there"]);
361 }
362
363 #[test]
364 fn reorder_reports_cycle_with_block_names() {
365 let diffs = vec![
366 added("a", "{{content_blocks.${b}}}"),
367 added("b", "{{content_blocks.${a}}}"),
368 ];
369 let err = reorder_content_block_diffs_by_dependency(diffs).unwrap_err();
370 let s: BTreeSet<&str> = err.path.iter().map(String::as_str).collect();
371 assert!(s.contains("a") && s.contains("b"));
372 }
373
374 #[test]
375 fn reorder_drops_self_reference_silently() {
376 let diffs = vec![added("loner", "{{content_blocks.${loner}}}")];
380 let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
381 assert_eq!(order(&out), vec!["loner"]);
382 }
383
384 #[test]
385 fn reorder_handles_diamond_correctly() {
386 let diffs = vec![
393 added("a", "{{content_blocks.${b}}} and {{content_blocks.${c}}}"),
394 added("b", "{{content_blocks.${d}}}"),
395 added("c", "{{content_blocks.${d}}}"),
396 added("d", "leaf"),
397 ];
398 let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
399 let names = order(&out);
400 let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
401 assert!(pos("d") < pos("b"));
402 assert!(pos("d") < pos("c"));
403 assert!(pos("b") < pos("a"));
404 assert!(pos("c") < pos("a"));
405 }
406}