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::{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 rows: Vec<EntryOptions> = group.iter().map(|entry| (*entry).clone()).collect();
433    let text = crate::yaml::emit_entry_list(&rows);
434    sections.push(format!("# == {label}\n{}", text.trim_end()));
435    Ok(())
436}
437
438/// Compose layers over `base` and render the dump in one step — the offline
439/// twin of [`compose_with_provenance`] plus [`render_dump`]. See those for
440/// the single-flatten, provenance, and warning-attribution contracts.
441///
442/// # Example
443///
444/// ```
445/// use cordis_include::{render_config_dump, DumpLayer, EntryOptions, PatchOptions};
446///
447/// let base = [EntryOptions::new("./noop").with_id("shared")];
448/// let overlay = [PatchOptions {
449///     id: Some("shared".into()),
450///     disabled: Some(true),
451///     ..Default::default()
452/// }];
453/// let layers = [DumpLayer {
454///     label: "overlay.yml",
455///     patches: &overlay,
456/// }];
457/// let dump = render_config_dump(&base, "base.yml", &layers, |_| {}).unwrap();
458/// assert!(dump.contains("# == base.yml, patched by overlay.yml"), "{dump}");
459/// ```
460pub fn render_config_dump(
461    base: &[EntryOptions],
462    base_label: &str,
463    layers: &[DumpLayer<'_>],
464    warn: impl FnMut(&str),
465) -> Result<String> {
466    let (composed, provenance) = compose_with_provenance(base, base_label, layers, warn);
467    render_dump(&composed, &provenance)
468}
469
470/// Load an optional patch-list file: a top-level YAML array of patch rows.
471/// A missing file means "no layer" (`Ok(None)`); any other read failure, a
472/// parse failure, a non-array document, or a non-mapping entry is a hard
473/// error — a present patch file that cannot apply is a misconfiguration and
474/// must fail loud, never be silently skipped.
475pub fn load_optional_patches(path: impl AsRef<Path>) -> Result<Option<Vec<PatchOptions>>> {
476    let path = path.as_ref();
477    let content = match fs::read_to_string(path) {
478        Ok(content) => content,
479        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
480        Err(error) => {
481            return Err(IncludeError::Message {
482                message: format!("failed to read patches {}: {error}", path.display()),
483            });
484        }
485    };
486    Ok(Some(parse_patch_list(path, &content, "patches")?))
487}
488
489/// Load a required overlay patch list — a bundle's `cordis.patch.yml` or a
490/// `--patch` overlay. Same file format as [`load_optional_patches`], but a
491/// missing file is a hard error: the caller *named* this file, so its
492/// absence is a misconfiguration, not "no overlay".
493pub fn load_overlay_patches(path: impl AsRef<Path>) -> Result<Vec<PatchOptions>> {
494    let path = path.as_ref();
495    let content = fs::read_to_string(path).map_err(|error| IncludeError::Message {
496        message: format!("failed to read overlay {}: {error}", path.display()),
497    })?;
498    parse_patch_list(path, &content, "overlay")
499}
500
501/// Parse one patch list: a bare top-level YAML array of [`PatchOptions`]
502/// rows, each a mapping.
503fn parse_patch_list(path: &Path, content: &str, label: &str) -> Result<Vec<PatchOptions>> {
504    let node = crate::yaml::parse_node(content).map_err(|error| IncludeError::Message {
505        message: format!("failed to parse {label} {}: {error}", path.display()),
506    })?;
507    let Some(rows) = node.as_array() else {
508        return Err(IncludeError::Message {
509            message: format!(
510                "{label} {} must be a top-level YAML array of loader patch entries",
511                path.display()
512            ),
513        });
514    };
515    let mut patches = Vec::with_capacity(rows.len());
516    for (index, row) in rows.iter().enumerate() {
517        if row.as_object().is_none() {
518            return Err(IncludeError::Message {
519                message: format!(
520                    "{label} entry {} in {} must be a mapping (a loader patch entry)",
521                    index + 1,
522                    path.display()
523                ),
524            });
525        }
526        let patch =
527            crate::yaml::patch_from_node(row.clone()).map_err(|error| IncludeError::Message {
528                message: format!(
529                    "failed to parse {label} entry {} in {}: {error}",
530                    index + 1,
531                    path.display()
532                ),
533            })?;
534        patches.push(patch);
535    }
536    Ok(patches)
537}