// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Which layer of a configuration a source belongs to.
///
/// The tiers are here rather than as a number the caller assigns because the
/// order almost everybody wants — defaults, then files, then the environment,
/// then whatever the command line said — is a fact about deployment, not about
/// this repository, and naming it means a caller who wants the usual thing does
/// not write it out.
pub(all) enum Tier {
/// Values the program carries with it, used when nothing overrides them.
Default
/// A file the deployment shipped, or the one named on the command line.
File
/// The process environment.
Env
/// Whatever the caller declares last: flags, a test fixture, an override.
Override
} derive(Eq, Debug)
///|
/// What to do when two layers name the same value and disagree.
pub(all) enum Clash {
/// The stronger layer wins, silently. What everybody does.
Last
/// The weaker layer wins. For a caller layering a patch *under* a document.
First
/// Two layers naming the same value is an error. For a configuration where
/// an accidental override is a bug.
Refuse
} derive(Eq, Debug)
///|
/// How to merge: which tier is stronger, and what a disagreement means.
///
/// `wins` runs from the weakest tier to the strongest, and a tier left out of it
/// sorts below every tier in it, in the order the sources were given. Putting the
/// tiers in a different order is how a caller reverses the direction of the
/// whole stack — an overlay read after the base file, say — without touching any
/// parser.
pub(all) struct Knobs {
/// Tiers from weakest to strongest: the last one wins.
wins : Array[Tier]
/// What a disagreement means.
clash : Clash
} derive(Eq, Debug)
///|
/// Defaults below files below the environment below explicit overrides, with
/// the stronger layer winning.
pub let layered : Knobs = { wins: [Default, File, Env, Override], clash: Last, }
///|
/// One layer of a configuration: a tree, the tier it belongs to, and the name
/// that goes into error messages.
pub(all) struct Source {
/// Which layer this is.
tier : Tier
/// The name the caller wants to see in an error: a file name, `env`.
name : String
/// The tree this layer contributes.
node : Node
} derive(Eq, Debug)
///|
/// A source whose name is taken from the tree it carries.
pub fn Source::new(tier : Tier, name : String, node : Node) -> Source {
{ tier, name, node, }
}
///|
/// The rank of a tier under `knobs`, lowest first.
fn rank(tier : Tier, knobs : Knobs) -> Int32 {
let mut i = 0
while i < knobs.wins.length() {
if knobs.wins[i] == tier {
return i
}
i += 1
}
-1
}
///|
/// Merge two trees. `over` is the stronger one; the policy decides what happens
/// where they disagree.
///
/// Mappings merge member by member, so a later file that sets one key under
/// `db` keeps the rest of the earlier file's `db`. Lists replace rather than
/// concatenate: a list in a configuration is nearly always one decision —
/// which hosts, which plugins — and appending the two decisions together gives
/// a value nobody wrote.
pub fn Node::merge(
self : Node,
over : Node,
clash~ : Clash = Last,
path~ : String = "",
) -> Node raise Refused {
let base = self
match (base.value, over.value) {
(Map(left), Map(right)) => {
let out = left.copy()
for entry in right.entries {
let key = entry.0
let child = if path == "" { key } else { path + "." + key }
match out.get(key) {
Some(before) =>
out.set(key, before.merge(entry.1, clash~, path=child))
None => out.set(key, entry.1.copy())
}
}
{ value: Map(out), source: over.source, at: over.at, }
}
(_, _) =>
if base.value == over.value {
base
} else {
match clash {
Last => over
First => base
Refuse =>
raise Clash(
path~,
first=spot(base.source, base.at),
second=spot(over.source, over.at),
)
}
}
}
}
///|
/// Merge a whole stack, weakest first, in the order `knobs` asks for.
///
/// Sources of the same tier keep the order they were given, so `Source` order is
/// the tie-break and a caller never has to number anything.
pub fn merge(sources : Array[Source], knobs? : Knobs) -> Node raise Refused {
let knobs = knobs.unwrap_or(layered)
let ranked = []
let mut index = 0
for source in sources {
ranked.push((rank(source.tier, knobs), index, source))
index += 1
}
// A stable insertion sort: the stack is a handful of layers, and the tie-break
// has to be the caller's order rather than whatever the sort happens to do.
let mut i = 1
while i < ranked.length() {
let item = ranked[i]
let mut j = i
while j > 0 && ranked[j - 1].0 > item.0 {
ranked[j] = ranked[j - 1]
j -= 1
}
ranked[j] = item
i += 1
}
let empty = { value: Map(Table::new()), source: "", at: start, }
let mut acc = empty
for entry in ranked {
acc = acc.merge(entry.2.node, clash=knobs.clash)
}
acc
}
///|
pub extend Tier with Eq::{equal, not_equal}
///|
pub extend Tier with Debug::{to_repr}
///|
pub extend Clash with Eq::{equal, not_equal}
///|
pub extend Clash with Debug::{to_repr}
///|
pub extend Knobs with Eq::{equal, not_equal}
///|
pub extend Knobs with Debug::{to_repr}
///|
pub extend Source with Eq::{equal, not_equal}
///|
pub extend Source with Debug::{to_repr}