use crate::error::{IncludeError, Result};
use crate::node::Node;
use crate::options::{EntryOptions, GROUP_NAME};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PatchOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub insert: Option<Vec<EntryOptions>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inject: Option<Vec<String>>,
#[serde(flatten)]
pub extra: IndexMap<String, Node>,
}
fn is_group(entry: &EntryOptions) -> bool {
entry.name == GROUP_NAME || !entry.group.is_empty()
}
type EntryPath = Vec<usize>;
fn index_entry(entry: &EntryOptions, path: &[usize], index: &mut HashMap<String, EntryPath>) {
if let Some(id) = entry.id.as_deref().filter(|id| !id.is_empty()) {
index.insert(id.to_owned(), path.to_vec());
}
if is_group(entry) {
for (position, child) in entry.group.iter().enumerate() {
let mut child_path = path.to_vec();
child_path.push(position);
index_entry(child, &child_path, index);
}
}
}
fn resolve<'a>(data: &'a [EntryOptions], path: &[usize]) -> &'a EntryOptions {
let mut entry = &data[path[0]];
for &position in &path[1..] {
entry = &entry.group[position];
}
entry
}
fn resolve_mut<'a>(data: &'a mut [EntryOptions], path: &[usize]) -> &'a mut EntryOptions {
let mut entry = &mut data[path[0]];
for &position in &path[1..] {
entry = &mut entry.group[position];
}
entry
}
fn warn_unindexed(entries: &[EntryOptions], warn: &mut impl FnMut(&str)) {
for entry in entries {
if entry.id.as_deref().is_none_or(str::is_empty) {
warn(
"patch insert: entry has no id; later layers cannot patch it and it restarts on every recomposition",
);
}
warn_unindexed(&entry.group, warn);
}
}
pub fn apply_entry_patches(
data: &[EntryOptions],
patches: &[PatchOptions],
mut warn: impl FnMut(&str),
) -> Vec<EntryOptions> {
let mut data = data.to_vec();
if patches.is_empty() {
return data;
}
let mut index: HashMap<String, EntryPath> = HashMap::new();
for (position, entry) in data.iter().enumerate() {
index_entry(entry, &[position], &mut index);
}
for patch in patches {
if let Some(insert) = patch.insert.as_ref() {
warn_unindexed(insert, &mut warn);
match patch.id.as_deref().filter(|id| !id.is_empty()) {
Some(id) => {
let Some(path) = index.get(id) else {
warn(&format!("patch insert: entry {id:?} not found"));
continue;
};
let path = path.clone();
let target = resolve(&data, &path);
if !is_group(target) {
warn(&format!("patch insert: entry {id:?} is not a group"));
continue;
}
let start = target.group.len();
resolve_mut(&mut data, &path)
.group
.extend(insert.iter().cloned());
for (offset, entry) in insert.iter().enumerate() {
let mut child_path = path.clone();
child_path.push(start + offset);
index_entry(entry, &child_path, &mut index);
}
}
None => {
let start = data.len();
data.extend(insert.iter().cloned());
for (offset, entry) in insert.iter().enumerate() {
index_entry(entry, &[start + offset], &mut index);
}
}
}
continue;
}
let Some(id) = patch.id.as_deref().filter(|id| !id.is_empty()) else {
warn("patch: id is required for non-insert patches");
continue;
};
let Some(path) = index.get(id) else {
warn(&format!("patch: entry {id:?} not found"));
continue;
};
let path = path.clone();
let target_name = resolve(&data, &path).name.clone();
if let Some(name) = patch.name.as_deref().filter(|name| !name.is_empty()) {
if name != target_name {
warn(&format!(
"patch: name mismatch for {id:?} (expected {target_name:?}, got {name:?}), skipping"
));
continue;
}
}
let target = resolve_mut(&mut data, &path);
if let Some(config) = patch.config.as_ref() {
target.config = Some(config.clone());
}
if let Some(disabled) = patch.disabled {
target.disabled = disabled;
}
if let Some(inject) = patch.inject.as_ref() {
target.inject = inject.clone();
}
if !patch.extra.is_empty() {
let keys: Vec<&str> = patch.extra.keys().map(String::as_str).collect();
warn(&format!(
"patch: skipping unsupported override key(s) [{}] on entry {id:?}",
keys.join(", ")
));
}
}
data
}
pub fn compose_layers(layers: &[Vec<PatchOptions>], warn: impl FnMut(&str)) -> Vec<EntryOptions> {
let flattened: Vec<PatchOptions> = layers.iter().flatten().cloned().collect();
apply_entry_patches(&[], &flattened, warn)
}
#[derive(Debug, Clone, Copy)]
pub struct DumpLayer<'a> {
pub label: &'a str,
pub patches: &'a [PatchOptions],
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Provenance {
pub origin: String,
pub patched_by: Vec<String>,
}
pub fn compose_with_provenance(
base: &[EntryOptions],
base_label: &str,
layers: &[DumpLayer<'_>],
mut warn: impl FnMut(&str),
) -> (Vec<EntryOptions>, Vec<Provenance>) {
let mut provenance = vec![
Provenance {
origin: base_label.to_owned(),
patched_by: Vec::new(),
};
base.len()
];
let mut previous = base.to_vec();
let mut previous_warnings: Vec<String> = Vec::new();
for (count, layer) in layers.iter().enumerate() {
let flattened: Vec<PatchOptions> = layers[..=count]
.iter()
.flat_map(|layer| layer.patches.iter().cloned())
.collect();
let mut warnings: Vec<String> = Vec::new();
let snapshot = apply_entry_patches(base, &flattened, |line| warnings.push(line.to_owned()));
for line in warnings.iter().skip(previous_warnings.len()) {
warn(&format!("[{}] {}", layer.label, line));
}
for (position, entry) in snapshot.iter().enumerate() {
if position >= previous.len() {
provenance.push(Provenance {
origin: layer.label.to_owned(),
patched_by: Vec::new(),
});
} else if entry != &previous[position] {
provenance[position].patched_by.push(layer.label.to_owned());
}
}
previous = snapshot;
previous_warnings = warnings;
}
(previous, provenance)
}
pub fn render_dump(composed: &[EntryOptions], provenance: &[Provenance]) -> Result<String> {
let mut sections: Vec<String> = Vec::new();
let mut group: Vec<&EntryOptions> = Vec::new();
let mut current: Option<String> = None;
for (entry, record) in composed.iter().zip(provenance) {
let label = if record.patched_by.is_empty() {
record.origin.clone()
} else {
format!(
"{}, patched by {}",
record.origin,
record.patched_by.join(", ")
)
};
if current.as_deref() != Some(label.as_str()) {
flush_group(&mut sections, &group, current.take())?;
current = Some(label);
group.clear();
}
group.push(entry);
}
flush_group(&mut sections, &group, current)?;
Ok(sections.join("\n") + "\n")
}
fn flush_group(
sections: &mut Vec<String>,
group: &[&EntryOptions],
label: Option<String>,
) -> Result<()> {
if group.is_empty() {
return Ok(());
}
let label = label.expect("a non-empty group always has a label");
let rows: Vec<EntryOptions> = group.iter().map(|entry| (*entry).clone()).collect();
let text = crate::yaml::emit_entry_list(&rows);
sections.push(format!("# == {label}\n{}", text.trim_end()));
Ok(())
}
pub fn render_config_dump(
base: &[EntryOptions],
base_label: &str,
layers: &[DumpLayer<'_>],
warn: impl FnMut(&str),
) -> Result<String> {
let (composed, provenance) = compose_with_provenance(base, base_label, layers, warn);
render_dump(&composed, &provenance)
}
pub fn load_optional_patches(path: impl AsRef<Path>) -> Result<Option<Vec<PatchOptions>>> {
let path = path.as_ref();
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(IncludeError::Message {
message: format!("failed to read patches {}: {error}", path.display()),
});
}
};
Ok(Some(parse_patch_list(path, &content, "patches")?))
}
pub fn load_overlay_patches(path: impl AsRef<Path>) -> Result<Vec<PatchOptions>> {
let path = path.as_ref();
let content = fs::read_to_string(path).map_err(|error| IncludeError::Message {
message: format!("failed to read overlay {}: {error}", path.display()),
})?;
parse_patch_list(path, &content, "overlay")
}
fn parse_patch_list(path: &Path, content: &str, label: &str) -> Result<Vec<PatchOptions>> {
let node = crate::yaml::parse_node(content).map_err(|error| IncludeError::Message {
message: format!("failed to parse {label} {}: {error}", path.display()),
})?;
let Some(rows) = node.as_array() else {
return Err(IncludeError::Message {
message: format!(
"{label} {} must be a top-level YAML array of loader patch entries",
path.display()
),
});
};
let mut patches = Vec::with_capacity(rows.len());
for (index, row) in rows.iter().enumerate() {
if row.as_object().is_none() {
return Err(IncludeError::Message {
message: format!(
"{label} entry {} in {} must be a mapping (a loader patch entry)",
index + 1,
path.display()
),
});
}
let patch =
crate::yaml::patch_from_node(row.clone()).map_err(|error| IncludeError::Message {
message: format!(
"failed to parse {label} entry {} in {}: {error}",
index + 1,
path.display()
),
})?;
patches.push(patch);
}
Ok(patches)
}