Skip to main content

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}