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::{Disabled, 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 // Patches carry an already-evaluated boolean; they overwrite
262 // any expression the target declared (later activation
263 // re-evaluates nothing).
264 target.disabled = Disabled::Flag(disabled);
265 }
266 if let Some(inject) = patch.inject.as_ref() {
267 target.inject = inject.clone();
268 }
269 if !patch.extra.is_empty() {
270 let keys: Vec<&str> = patch.extra.keys().map(String::as_str).collect();
271 warn(&format!(
272 "patch: skipping unsupported override key(s) [{}] on entry {id:?}",
273 keys.join(", ")
274 ));
275 }
276 }
277 data
278}
279
280/// Compose patch layers into the effective entry list over an empty root.
281///
282/// **All layers flatten into a single [`apply_entry_patches`] call** — the
283/// same single call a boot makes, never one call per layer. The single-pass
284/// id index never sees rows a plain `config` replacement introduced inside a
285/// group, so a later layer targeting such a row warns and misses; composing
286/// layer-by-layer would rebuild the index between layers and produce a tree
287/// boot never mounts. Composers must keep this shape.
288///
289/// # Example
290///
291/// ```
292/// use cordis_include::{compose_layers, EntryOptions, PatchOptions};
293///
294/// let bundle = vec![PatchOptions {
295/// insert: Some(vec![EntryOptions::new("adapter-http").with_id("http")]),
296/// ..Default::default()
297/// }];
298/// let user = vec![PatchOptions {
299/// id: Some("http".into()),
300/// disabled: Some(true),
301/// ..Default::default()
302/// }];
303/// let entries = compose_layers(&[bundle, user], |_| {});
304/// assert_eq!(entries.len(), 1);
305/// assert!(entries[0].disabled.is_disabled());
306/// ```
307pub fn compose_layers(layers: &[Vec<PatchOptions>], warn: impl FnMut(&str)) -> Vec<EntryOptions> {
308 let flattened: Vec<PatchOptions> = layers.iter().flatten().cloned().collect();
309 apply_entry_patches(&[], &flattened, warn)
310}
311
312/// One labeled patch layer, for provenance-aware composition and dumps.
313#[derive(Debug, Clone, Copy)]
314pub struct DumpLayer<'a> {
315 /// Source label shown in dump comments and warning attributions
316 /// (a file basename or path).
317 pub label: &'a str,
318 /// The layer's patches, in application order.
319 pub patches: &'a [PatchOptions],
320}
321
322/// Where one composed row came from: its origin layer and every later layer
323/// that changed it.
324#[derive(Debug, Clone, PartialEq, Eq, Default)]
325pub struct Provenance {
326 /// The label of the layer that contributed the row.
327 pub origin: String,
328 /// Labels of the layers that patched it, in application order.
329 pub patched_by: Vec<String>,
330}
331
332/// Compose layers exactly as [`compose_layers`] does (single flatten over
333/// `base`) while tracking, per row, which layer contributed it and which
334/// layers changed it.
335///
336/// Provenance uses upstream's prefix-snapshot diff: snapshot *k* applies
337/// layers 1..*k* over the same base, and a row is compared positionally
338/// against the previous snapshot — the patch algorithm only rewrites rows in
339/// place or appends, so a top-level index identifies one row across
340/// snapshots, and a layer whose addition changed the row (config
341/// replacement, disable, group insert) is listed as having patched it.
342/// Appended rows carry the appending layer as their origin.
343///
344/// Skipped-patch warnings are attributed to layers the same way: earlier
345/// layers' patches see an identical preceding state in every snapshot that
346/// includes them, so each snapshot's warning list extends the previous one
347/// and the new tail belongs to the added layer. `warn` receives each tail
348/// line prefixed with `[label]`.
349///
350/// Patches are never mutated (application clones into the result), so the
351/// same layer slices are safely reused across snapshots.
352pub fn compose_with_provenance(
353 base: &[EntryOptions],
354 base_label: &str,
355 layers: &[DumpLayer<'_>],
356 mut warn: impl FnMut(&str),
357) -> (Vec<EntryOptions>, Vec<Provenance>) {
358 let mut provenance = vec![
359 Provenance {
360 origin: base_label.to_owned(),
361 patched_by: Vec::new(),
362 };
363 base.len()
364 ];
365 let mut previous = base.to_vec();
366 let mut previous_warnings: Vec<String> = Vec::new();
367 for (count, layer) in layers.iter().enumerate() {
368 let flattened: Vec<PatchOptions> = layers[..=count]
369 .iter()
370 .flat_map(|layer| layer.patches.iter().cloned())
371 .collect();
372 let mut warnings: Vec<String> = Vec::new();
373 let snapshot = apply_entry_patches(base, &flattened, |line| warnings.push(line.to_owned()));
374 for line in warnings.iter().skip(previous_warnings.len()) {
375 warn(&format!("[{}] {}", layer.label, line));
376 }
377 for (position, entry) in snapshot.iter().enumerate() {
378 if position >= previous.len() {
379 provenance.push(Provenance {
380 origin: layer.label.to_owned(),
381 patched_by: Vec::new(),
382 });
383 } else if entry != &previous[position] {
384 provenance[position].patched_by.push(layer.label.to_owned());
385 }
386 }
387 previous = snapshot;
388 previous_warnings = warnings;
389 }
390 (previous, provenance)
391}
392
393/// Render composed rows as one loadable YAML document, grouped under a
394/// `# == origin[, patched by …]` comment per contiguous run of rows with the
395/// same source. `${{ env.NAME }}` templates print verbatim, unevaluated.
396///
397/// Entry fields serialize in the crate's stable order
398/// (`id`, `name`, `disabled`, `inject`, `group`, `config`); config leaves
399/// are delegated to the serde YAML emitter.
400pub fn render_dump(composed: &[EntryOptions], provenance: &[Provenance]) -> Result<String> {
401 let mut sections: Vec<String> = Vec::new();
402 let mut group: Vec<&EntryOptions> = Vec::new();
403 let mut current: Option<String> = None;
404 for (entry, record) in composed.iter().zip(provenance) {
405 let label = if record.patched_by.is_empty() {
406 record.origin.clone()
407 } else {
408 format!(
409 "{}, patched by {}",
410 record.origin,
411 record.patched_by.join(", ")
412 )
413 };
414 if current.as_deref() != Some(label.as_str()) {
415 flush_group(&mut sections, &group, current.take())?;
416 current = Some(label);
417 group.clear();
418 }
419 group.push(entry);
420 }
421 flush_group(&mut sections, &group, current)?;
422 Ok(sections.join("\n") + "\n")
423}
424
425/// Serialize one contiguous group under its label comment.
426fn flush_group(
427 sections: &mut Vec<String>,
428 group: &[&EntryOptions],
429 label: Option<String>,
430) -> Result<()> {
431 if group.is_empty() {
432 return Ok(());
433 }
434 let label = label.expect("a non-empty group always has a label");
435 let rows: Vec<EntryOptions> = group.iter().map(|entry| (*entry).clone()).collect();
436 let text = crate::yaml::emit_entry_list(&rows);
437 sections.push(format!("# == {label}\n{}", text.trim_end()));
438 Ok(())
439}
440
441/// Compose layers over `base` and render the dump in one step — the offline
442/// twin of [`compose_with_provenance`] plus [`render_dump`]. See those for
443/// the single-flatten, provenance, and warning-attribution contracts.
444///
445/// # Example
446///
447/// ```
448/// use cordis_include::{render_config_dump, DumpLayer, EntryOptions, PatchOptions};
449///
450/// let base = [EntryOptions::new("./noop").with_id("shared")];
451/// let overlay = [PatchOptions {
452/// id: Some("shared".into()),
453/// disabled: Some(true),
454/// ..Default::default()
455/// }];
456/// let layers = [DumpLayer {
457/// label: "overlay.yml",
458/// patches: &overlay,
459/// }];
460/// let dump = render_config_dump(&base, "base.yml", &layers, |_| {}).unwrap();
461/// assert!(dump.contains("# == base.yml, patched by overlay.yml"), "{dump}");
462/// ```
463pub fn render_config_dump(
464 base: &[EntryOptions],
465 base_label: &str,
466 layers: &[DumpLayer<'_>],
467 warn: impl FnMut(&str),
468) -> Result<String> {
469 let (composed, provenance) = compose_with_provenance(base, base_label, layers, warn);
470 render_dump(&composed, &provenance)
471}
472
473/// Load an optional patch-list file: a top-level YAML array of patch rows.
474/// A missing file means "no layer" (`Ok(None)`); any other read failure, a
475/// parse failure, a non-array document, or a non-mapping entry is a hard
476/// error — a present patch file that cannot apply is a misconfiguration and
477/// must fail loud, never be silently skipped.
478pub fn load_optional_patches(path: impl AsRef<Path>) -> Result<Option<Vec<PatchOptions>>> {
479 let path = path.as_ref();
480 let content = match fs::read_to_string(path) {
481 Ok(content) => content,
482 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
483 Err(error) => {
484 return Err(IncludeError::Message {
485 message: format!("failed to read patches {}: {error}", path.display()),
486 });
487 }
488 };
489 Ok(Some(parse_patch_list(path, &content, "patches")?))
490}
491
492/// Load a required overlay patch list — a bundle's `cordis.patch.yml` or a
493/// `--patch` overlay. Same file format as [`load_optional_patches`], but a
494/// missing file is a hard error: the caller *named* this file, so its
495/// absence is a misconfiguration, not "no overlay".
496pub fn load_overlay_patches(path: impl AsRef<Path>) -> Result<Vec<PatchOptions>> {
497 let path = path.as_ref();
498 let content = fs::read_to_string(path).map_err(|error| IncludeError::Message {
499 message: format!("failed to read overlay {}: {error}", path.display()),
500 })?;
501 parse_patch_list(path, &content, "overlay")
502}
503
504/// Parse one patch list: a bare top-level YAML array of [`PatchOptions`]
505/// rows, each a mapping.
506fn parse_patch_list(path: &Path, content: &str, label: &str) -> Result<Vec<PatchOptions>> {
507 let node = crate::yaml::parse_node(content).map_err(|error| IncludeError::Message {
508 message: format!("failed to parse {label} {}: {error}", path.display()),
509 })?;
510 let Some(rows) = node.as_array() else {
511 return Err(IncludeError::Message {
512 message: format!(
513 "{label} {} must be a top-level YAML array of loader patch entries",
514 path.display()
515 ),
516 });
517 };
518 let mut patches = Vec::with_capacity(rows.len());
519 for (index, row) in rows.iter().enumerate() {
520 if row.as_object().is_none() {
521 return Err(IncludeError::Message {
522 message: format!(
523 "{label} entry {} in {} must be a mapping (a loader patch entry)",
524 index + 1,
525 path.display()
526 ),
527 });
528 }
529 let patch =
530 crate::yaml::patch_from_node(row.clone()).map_err(|error| IncludeError::Message {
531 message: format!(
532 "failed to parse {label} entry {} in {}: {error}",
533 index + 1,
534 path.display()
535 ),
536 })?;
537 patches.push(patch);
538 }
539 Ok(patches)
540}