cordis_include/patch.rs
1//! Patch algebra for entry lists: the composition mechanism behind bundles
2//! and profiles.
3//!
4//! A *patch list* is a bare top-level YAML array of [`PatchOptions`] rows.
5//! Each row either inserts entries (`insert`) or overrides an existing entry
6//! by `id`. [`apply_entry_patches`] is THE patch semantics — the one routine
7//! every consumer (mounting, recomposition, offline config dumps) funnels
8//! through, so a dump can never drift from what boots.
9//!
10//! Two contracts are load-bearing:
11//!
12//! - **Detachment.** Inputs are never modified and the result shares nothing
13//! with them — even with no patches the returned list is a fresh copy.
14//! Recomposition must always restart from the original patch data; feeding
15//! a materialized composition back in would bake earlier patches into the
16//! base and make a removed or changed patch impossible to revert.
17//! - **Single flatten.** Layer lists ([`compose_layers`],
18//! [`compose_with_provenance`]) are flattened into ONE
19//! [`apply_entry_patches`] call — the same single call a boot makes, not
20//! one call per layer. The single-pass id index is built once (base rows
21//! plus inserted rows as the same pass adds them) and never sees rows a
22//! plain `config` replacement introduced inside a group; a per-layer
23//! composition would rebuild the index between layers and let later layers
24//! patch rows boot never mounts.
25//!
26//! Patch-file IO follows the fail-loud contract: a *named* overlay
27//! ([`load_overlay_patches`]) must exist — its absence is a
28//! misconfiguration — while an *optional* user layer
29//! ([`load_optional_patches`]) treats a missing file as "no layer". A
30//! present-but-broken file (unreadable, unparsable, not a top-level array,
31//! an entry that is not a mapping) always fails loud: a patch file that
32//! cannot apply must never be silently skipped.
33
34use crate::error::{IncludeError, Result};
35use crate::node::Node;
36use crate::options::{EntryOptions, GROUP_NAME};
37use indexmap::IndexMap;
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40use std::fs;
41use std::path::Path;
42
43/// One patch row: insert entries, or override an existing entry by id.
44///
45/// Every field is optional; rows are struct-typed where upstream's JS patches
46/// could carry any field. Unknown keys are kept in [`PatchOptions::extra`]
47/// (round-trip and diagnostics) and warn-skipped at application time.
48#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
49pub struct PatchOptions {
50 /// Target entry id. With `insert` it names the group to insert into;
51 /// without `insert` it selects the entry to override.
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub id: Option<String>,
54 /// Entries to insert: appended to the target group's children when `id`
55 /// names a group, to the top level otherwise. `Some(vec![])` still takes
56 /// the insert branch (upstream truthiness); only `None` means "not an
57 /// insert".
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub insert: Option<Vec<EntryOptions>>,
60 /// Guard on the target: when present and non-empty it must equal the
61 /// target's `name` or the patch warns and skips. It is never written
62 /// back — a guard, not an override.
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub name: Option<String>,
65 /// Whole replacement of the target's `config` (a replacement, not a
66 /// merge).
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub config: Option<Node>,
69 /// Replacement of the target's `disabled` flag.
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub disabled: Option<bool>,
72 /// Replacement of the target's `inject` list.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub inject: Option<Vec<String>>,
75 /// Unknown keys, preserved in file order. Applying a patch warns and
76 /// skips them: upstream JS patches could override any field, but the
77 /// Rust entry has no slot for them (`group` is the children list, not
78 /// upstream's marker; `intercept`/`isolate` are not ported).
79 #[serde(flatten)]
80 pub extra: IndexMap<String, Node>,
81}
82
83/// Whether an entry is a group.
84///
85/// Upstream marks groups with an explicit `group: true` flag; this port
86/// infers them structurally: the built-in group plugin by name, or any entry
87/// with children. The name arm keeps inserts working on a declared group
88/// that momentarily has no rows (upstream's `target.config = []` branch).
89fn is_group(entry: &EntryOptions) -> bool {
90 entry.name == GROUP_NAME || !entry.group.is_empty()
91}
92
93/// Child positions leading from the root list to one entry.
94type EntryPath = Vec<usize>;
95
96/// Index one entry (and, for groups, its subtree) at `path`.
97fn index_entry(entry: &EntryOptions, path: &[usize], index: &mut HashMap<String, EntryPath>) {
98 if let Some(id) = entry.id.as_deref().filter(|id| !id.is_empty()) {
99 index.insert(id.to_owned(), path.to_vec());
100 }
101 if is_group(entry) {
102 for (position, child) in entry.group.iter().enumerate() {
103 let mut child_path = path.to_vec();
104 child_path.push(position);
105 index_entry(child, &child_path, index);
106 }
107 }
108}
109
110/// Borrow the entry a path addresses. Paths are built from the live
111/// structure and never outlive the mutations that created them.
112fn resolve<'a>(data: &'a [EntryOptions], path: &[usize]) -> &'a EntryOptions {
113 let mut entry = &data[path[0]];
114 for &position in &path[1..] {
115 entry = &entry.group[position];
116 }
117 entry
118}
119
120/// Mutably borrow the entry a path addresses.
121fn resolve_mut<'a>(data: &'a mut [EntryOptions], path: &[usize]) -> &'a mut EntryOptions {
122 let mut entry = &mut data[path[0]];
123 for &position in &path[1..] {
124 entry = &mut entry.group[position];
125 }
126 entry
127}
128
129/// Warn about inserted rows without ids: they cannot be matched by later
130/// layers and are deleted and recreated (fiber restart) on every
131/// recomposition.
132fn warn_unindexed(entries: &[EntryOptions], warn: &mut impl FnMut(&str)) {
133 for entry in entries {
134 if entry.id.as_deref().is_none_or(str::is_empty) {
135 warn(
136 "patch insert: entry has no id; later layers cannot patch it and it restarts on every recomposition",
137 );
138 }
139 warn_unindexed(&entry.group, warn);
140 }
141}
142
143/// Apply patch rows to an entry list — THE patch semantics of this crate,
144/// shared by mounting, recomposition, and offline config tooling so a dump
145/// can never drift from what boots.
146///
147/// Semantics (a direct port of upstream `applyEntryPatches`):
148///
149/// - The result is fully detached from both inputs, even when `patches` is
150/// empty. Recomposition must always restart from the original patch data.
151/// - The id index is built once from `data` (recursing into groups) and
152/// extended only by `insert` rows as they are added, so a later patch in
153/// the same list can target a row an earlier patch inserted — and rows
154/// that appear any other way (inside a replaced `config`, say) stay
155/// invisible to it.
156/// - `insert` with `id` appends to that group's children (the target must
157/// be a group); `insert` without `id` appends to the top level.
158/// - Non-insert patches require `id`. `name`, when present and non-empty,
159/// must equal the target's name. `config`, `disabled`, and `inject`
160/// replace the target's fields wholesale.
161/// - A patch that matches nothing — missing id, unknown target, name
162/// mismatch, non-group insert target — warns through `warn` and is
163/// skipped, never an error: one overlay shared across surfaces does not
164/// have to match every tree.
165/// - Override keys with no Rust slot (`group`, `intercept`, `isolate`,
166/// anything unknown) warn and are skipped.
167///
168/// # Example
169///
170/// ```
171/// use cordis_include::{apply_entry_patches, EntryOptions, Node, PatchOptions};
172///
173/// let base = vec![EntryOptions::new("adapter-http").with_id("http")];
174/// let patches = vec![PatchOptions {
175/// id: Some("http".into()),
176/// config: Some(Node::from_iter([(
177/// "port".to_string(),
178/// Node::Int(8080),
179/// )])),
180/// ..Default::default()
181/// }];
182/// let composed = apply_entry_patches(&base, &patches, |_| {});
183/// assert_eq!(composed[0].config.as_ref().unwrap()["port"], Node::Int(8080));
184/// // The input stays untouched: recomposition restarts from the originals.
185/// assert!(base[0].config.is_none());
186/// ```
187pub fn apply_entry_patches(
188 data: &[EntryOptions],
189 patches: &[PatchOptions],
190 mut warn: impl FnMut(&str),
191) -> Vec<EntryOptions> {
192 let mut data = data.to_vec();
193 if patches.is_empty() {
194 return data;
195 }
196 let mut index: HashMap<String, EntryPath> = HashMap::new();
197 for (position, entry) in data.iter().enumerate() {
198 index_entry(entry, &[position], &mut index);
199 }
200 for patch in patches {
201 // `Some(..)` takes the insert branch even for an empty list —
202 // upstream's truthiness check on the insert field.
203 if let Some(insert) = patch.insert.as_ref() {
204 warn_unindexed(insert, &mut warn);
205 match patch.id.as_deref().filter(|id| !id.is_empty()) {
206 Some(id) => {
207 let Some(path) = index.get(id) else {
208 warn(&format!("patch insert: entry {id:?} not found"));
209 continue;
210 };
211 let path = path.clone();
212 let target = resolve(&data, &path);
213 if !is_group(target) {
214 warn(&format!("patch insert: entry {id:?} is not a group"));
215 continue;
216 }
217 let start = target.group.len();
218 resolve_mut(&mut data, &path)
219 .group
220 .extend(insert.iter().cloned());
221 for (offset, entry) in insert.iter().enumerate() {
222 let mut child_path = path.clone();
223 child_path.push(start + offset);
224 index_entry(entry, &child_path, &mut index);
225 }
226 }
227 None => {
228 let start = data.len();
229 data.extend(insert.iter().cloned());
230 for (offset, entry) in insert.iter().enumerate() {
231 index_entry(entry, &[start + offset], &mut index);
232 }
233 }
234 }
235 continue;
236 }
237
238 let Some(id) = patch.id.as_deref().filter(|id| !id.is_empty()) else {
239 warn("patch: id is required for non-insert patches");
240 continue;
241 };
242 let Some(path) = index.get(id) else {
243 warn(&format!("patch: entry {id:?} not found"));
244 continue;
245 };
246 let path = path.clone();
247 let target_name = resolve(&data, &path).name.clone();
248 if let Some(name) = patch.name.as_deref().filter(|name| !name.is_empty()) {
249 if name != target_name {
250 warn(&format!(
251 "patch: name mismatch for {id:?} (expected {target_name:?}, got {name:?}), skipping"
252 ));
253 continue;
254 }
255 }
256 let target = resolve_mut(&mut data, &path);
257 if let Some(config) = patch.config.as_ref() {
258 target.config = Some(config.clone());
259 }
260 if let Some(disabled) = patch.disabled {
261 target.disabled = disabled;
262 }
263 if let Some(inject) = patch.inject.as_ref() {
264 target.inject = inject.clone();
265 }
266 if !patch.extra.is_empty() {
267 let keys: Vec<&str> = patch.extra.keys().map(String::as_str).collect();
268 warn(&format!(
269 "patch: skipping unsupported override key(s) [{}] on entry {id:?}",
270 keys.join(", ")
271 ));
272 }
273 }
274 data
275}
276
277/// Compose patch layers into the effective entry list over an empty root.
278///
279/// **All layers flatten into a single [`apply_entry_patches`] call** — the
280/// same single call a boot makes, never one call per layer. The single-pass
281/// id index never sees rows a plain `config` replacement introduced inside a
282/// group, so a later layer targeting such a row warns and misses; composing
283/// layer-by-layer would rebuild the index between layers and produce a tree
284/// boot never mounts. Composers must keep this shape.
285///
286/// # Example
287///
288/// ```
289/// use cordis_include::{compose_layers, EntryOptions, PatchOptions};
290///
291/// let bundle = vec![PatchOptions {
292/// insert: Some(vec![EntryOptions::new("adapter-http").with_id("http")]),
293/// ..Default::default()
294/// }];
295/// let user = vec![PatchOptions {
296/// id: Some("http".into()),
297/// disabled: Some(true),
298/// ..Default::default()
299/// }];
300/// let entries = compose_layers(&[bundle, user], |_| {});
301/// assert_eq!(entries.len(), 1);
302/// assert!(entries[0].disabled);
303/// ```
304pub fn compose_layers(layers: &[Vec<PatchOptions>], warn: impl FnMut(&str)) -> Vec<EntryOptions> {
305 let flattened: Vec<PatchOptions> = layers.iter().flatten().cloned().collect();
306 apply_entry_patches(&[], &flattened, warn)
307}
308
309/// One labeled patch layer, for provenance-aware composition and dumps.
310#[derive(Debug, Clone, Copy)]
311pub struct DumpLayer<'a> {
312 /// Source label shown in dump comments and warning attributions
313 /// (a file basename or path).
314 pub label: &'a str,
315 /// The layer's patches, in application order.
316 pub patches: &'a [PatchOptions],
317}
318
319/// Where one composed row came from: its origin layer and every later layer
320/// that changed it.
321#[derive(Debug, Clone, PartialEq, Eq, Default)]
322pub struct Provenance {
323 /// The label of the layer that contributed the row.
324 pub origin: String,
325 /// Labels of the layers that patched it, in application order.
326 pub patched_by: Vec<String>,
327}
328
329/// Compose layers exactly as [`compose_layers`] does (single flatten over
330/// `base`) while tracking, per row, which layer contributed it and which
331/// layers changed it.
332///
333/// Provenance uses upstream's prefix-snapshot diff: snapshot *k* applies
334/// layers 1..*k* over the same base, and a row is compared positionally
335/// against the previous snapshot — the patch algorithm only rewrites rows in
336/// place or appends, so a top-level index identifies one row across
337/// snapshots, and a layer whose addition changed the row (config
338/// replacement, disable, group insert) is listed as having patched it.
339/// Appended rows carry the appending layer as their origin.
340///
341/// Skipped-patch warnings are attributed to layers the same way: earlier
342/// layers' patches see an identical preceding state in every snapshot that
343/// includes them, so each snapshot's warning list extends the previous one
344/// and the new tail belongs to the added layer. `warn` receives each tail
345/// line prefixed with `[label]`.
346///
347/// Patches are never mutated (application clones into the result), so the
348/// same layer slices are safely reused across snapshots.
349pub fn compose_with_provenance(
350 base: &[EntryOptions],
351 base_label: &str,
352 layers: &[DumpLayer<'_>],
353 mut warn: impl FnMut(&str),
354) -> (Vec<EntryOptions>, Vec<Provenance>) {
355 let mut provenance = vec![
356 Provenance {
357 origin: base_label.to_owned(),
358 patched_by: Vec::new(),
359 };
360 base.len()
361 ];
362 let mut previous = base.to_vec();
363 let mut previous_warnings: Vec<String> = Vec::new();
364 for (count, layer) in layers.iter().enumerate() {
365 let flattened: Vec<PatchOptions> = layers[..=count]
366 .iter()
367 .flat_map(|layer| layer.patches.iter().cloned())
368 .collect();
369 let mut warnings: Vec<String> = Vec::new();
370 let snapshot = apply_entry_patches(base, &flattened, |line| warnings.push(line.to_owned()));
371 for line in warnings.iter().skip(previous_warnings.len()) {
372 warn(&format!("[{}] {}", layer.label, line));
373 }
374 for (position, entry) in snapshot.iter().enumerate() {
375 if position >= previous.len() {
376 provenance.push(Provenance {
377 origin: layer.label.to_owned(),
378 patched_by: Vec::new(),
379 });
380 } else if entry != &previous[position] {
381 provenance[position].patched_by.push(layer.label.to_owned());
382 }
383 }
384 previous = snapshot;
385 previous_warnings = warnings;
386 }
387 (previous, provenance)
388}
389
390/// Render composed rows as one loadable YAML document, grouped under a
391/// `# == origin[, patched by …]` comment per contiguous run of rows with the
392/// same source. `${{ env.NAME }}` templates print verbatim, unevaluated.
393///
394/// Entry fields serialize in the crate's stable order
395/// (`id`, `name`, `disabled`, `inject`, `group`, `config`); config leaves
396/// are delegated to the serde YAML emitter.
397pub fn render_dump(composed: &[EntryOptions], provenance: &[Provenance]) -> Result<String> {
398 let mut sections: Vec<String> = Vec::new();
399 let mut group: Vec<&EntryOptions> = Vec::new();
400 let mut current: Option<String> = None;
401 for (entry, record) in composed.iter().zip(provenance) {
402 let label = if record.patched_by.is_empty() {
403 record.origin.clone()
404 } else {
405 format!(
406 "{}, patched by {}",
407 record.origin,
408 record.patched_by.join(", ")
409 )
410 };
411 if current.as_deref() != Some(label.as_str()) {
412 flush_group(&mut sections, &group, current.take())?;
413 current = Some(label);
414 group.clear();
415 }
416 group.push(entry);
417 }
418 flush_group(&mut sections, &group, current)?;
419 Ok(sections.join("\n") + "\n")
420}
421
422/// Serialize one contiguous group under its label comment.
423fn flush_group(
424 sections: &mut Vec<String>,
425 group: &[&EntryOptions],
426 label: Option<String>,
427) -> Result<()> {
428 if group.is_empty() {
429 return Ok(());
430 }
431 let label = label.expect("a non-empty group always has a label");
432 let text = serde_yaml_ng::to_string(&group).map_err(|error| IncludeError::Parse {
433 format: "yaml",
434 source: Box::new(error),
435 })?;
436 sections.push(format!("# == {label}\n{}", text.trim_end()));
437 Ok(())
438}
439
440/// Compose layers over `base` and render the dump in one step — the offline
441/// twin of [`compose_with_provenance`] plus [`render_dump`]. See those for
442/// the single-flatten, provenance, and warning-attribution contracts.
443///
444/// # Example
445///
446/// ```
447/// use cordis_include::{render_config_dump, DumpLayer, EntryOptions, PatchOptions};
448///
449/// let base = [EntryOptions::new("./noop").with_id("shared")];
450/// let overlay = [PatchOptions {
451/// id: Some("shared".into()),
452/// disabled: Some(true),
453/// ..Default::default()
454/// }];
455/// let layers = [DumpLayer {
456/// label: "overlay.yml",
457/// patches: &overlay,
458/// }];
459/// let dump = render_config_dump(&base, "base.yml", &layers, |_| {}).unwrap();
460/// assert!(dump.contains("# == base.yml, patched by overlay.yml"), "{dump}");
461/// ```
462pub fn render_config_dump(
463 base: &[EntryOptions],
464 base_label: &str,
465 layers: &[DumpLayer<'_>],
466 warn: impl FnMut(&str),
467) -> Result<String> {
468 let (composed, provenance) = compose_with_provenance(base, base_label, layers, warn);
469 render_dump(&composed, &provenance)
470}
471
472/// Load an optional patch-list file: a top-level YAML array of patch rows.
473/// A missing file means "no layer" (`Ok(None)`); any other read failure, a
474/// parse failure, a non-array document, or a non-mapping entry is a hard
475/// error — a present patch file that cannot apply is a misconfiguration and
476/// must fail loud, never be silently skipped.
477pub fn load_optional_patches(path: impl AsRef<Path>) -> Result<Option<Vec<PatchOptions>>> {
478 let path = path.as_ref();
479 let content = match fs::read_to_string(path) {
480 Ok(content) => content,
481 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
482 Err(error) => {
483 return Err(IncludeError::Message {
484 message: format!("failed to read patches {}: {error}", path.display()),
485 });
486 }
487 };
488 Ok(Some(parse_patch_list(path, &content, "patches")?))
489}
490
491/// Load a required overlay patch list — a bundle's `cordis.patch.yml` or a
492/// `--patch` overlay. Same file format as [`load_optional_patches`], but a
493/// missing file is a hard error: the caller *named* this file, so its
494/// absence is a misconfiguration, not "no overlay".
495pub fn load_overlay_patches(path: impl AsRef<Path>) -> Result<Vec<PatchOptions>> {
496 let path = path.as_ref();
497 let content = fs::read_to_string(path).map_err(|error| IncludeError::Message {
498 message: format!("failed to read overlay {}: {error}", path.display()),
499 })?;
500 parse_patch_list(path, &content, "overlay")
501}
502
503/// Parse one patch list: a bare top-level YAML array of [`PatchOptions`]
504/// rows, each a mapping.
505fn parse_patch_list(path: &Path, content: &str, label: &str) -> Result<Vec<PatchOptions>> {
506 let parsed = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(content).map_err(|error| {
507 IncludeError::Message {
508 message: format!("failed to parse {label} {}: {error}", path.display()),
509 }
510 })?;
511 let Some(rows) = parsed.as_sequence() else {
512 return Err(IncludeError::Message {
513 message: format!(
514 "{label} {} must be a top-level YAML array of loader patch entries",
515 path.display()
516 ),
517 });
518 };
519 let mut patches = Vec::with_capacity(rows.len());
520 for (index, row) in rows.iter().enumerate() {
521 if !row.is_mapping() {
522 return Err(IncludeError::Message {
523 message: format!(
524 "{label} entry {} in {} must be a mapping (a loader patch entry)",
525 index + 1,
526 path.display()
527 ),
528 });
529 }
530 let patch: PatchOptions =
531 serde_yaml_ng::from_value(row.clone()).map_err(|error| IncludeError::Message {
532 message: format!(
533 "failed to parse {label} entry {} in {}: {error}",
534 index + 1,
535 path.display()
536 ),
537 })?;
538 patches.push(patch);
539 }
540 Ok(patches)
541}