Skip to main content

keymap_config/
lib.rs

1//! # keymap-config
2//!
3//! Builds a [`Keymap`] from a TOML `[keys]` table, resolving action names with a
4//! caller-supplied function and reporting problems that should not silently
5//! pass.
6//!
7//! ```
8//! #[derive(Clone, Debug, PartialEq)]
9//! enum Action { Quit, Save }
10//!
11//! let toml = r#"
12//! [keys]
13//! "ctrl+q" = "quit"
14//! "ctrl+s" = "save"
15//! "#;
16//!
17//! let out = keymap_config::from_str(toml, |name| match name {
18//!     "quit" => Some(Action::Quit),
19//!     "save" => Some(Action::Save),
20//!     _ => None,
21//! })
22//! .unwrap();
23//!
24//! assert!(out.warnings.is_empty());
25//! ```
26//!
27//! ## Named layers
28//!
29//! Bindings live in *layers*. The bare top-level `[keys]` table is the
30//! [`GLOBAL_LAYER`] layer; additional `[layers.<name>]` tables each build a layer
31//! of that name, holding chord→action entries directly:
32//!
33//! ```toml
34//! [keys]               # the implicit "global" layer
35//! "ctrl+q" = "quit"
36//!
37//! [layers.panel]       # a caller-named layer
38//! "ctrl+s" = "split"
39//! ```
40//!
41//! [`from_str`] returns these as [`BuildOutput::layers`], a name→[`Keymap`] map
42//! always containing `"global"`. Which layers are active, and in what order, is
43//! **not** this crate's concern: the caller picks them per event and resolves
44//! with [`keymap_core::resolve_layered`] (first layer to bind a chord wins, misses
45//! fall through). The same chord in two layers is therefore an *override*, not a
46//! conflict — this crate only ever reports conflicts *within* a single layer.
47//! Layer names are opaque labels; the library attaches meaning to none of them.
48//!
49//! ## What is an error versus a warning
50//!
51//! Structural problems — malformed TOML, or a key string that does not parse —
52//! are [`BuildError`]: there is no usable map to return. Survivable problems —
53//! two keys resolving to the same chord, or an action name the resolver does not
54//! recognize — are collected into [`BuildOutput::warnings`] so the rest of the
55//! bindings still work and the user can be told what to fix.
56//!
57//! ## Legacy-terminal survivability (opt-in, separate from warnings)
58//!
59//! "This binding won't survive a legacy terminal" (e.g. a `cmd+…` chord a legacy
60//! terminal cannot deliver, or `ctrl+i` which is indistinguishable from `tab`) is
61//! deliberately *not* a [`Warning`]: it depends on the deployment terminal, not
62//! the config's correctness, and folding it into [`BuildOutput::warnings`] would
63//! make a perfectly good config report warnings. It is exposed instead as the
64//! opt-in [`keymap_core::legacy_lints`], which you call on a built keymap when
65//! you want it: `keymap_core::legacy_lints(out.global())` (or on any one layer).
66//! Callers gating on `warnings.is_empty()` are unaffected.
67//!
68//! ## Multi-key sequences
69//!
70//! Alongside the single-chord `[keys]` table, an `[[sequences]]` array of tables
71//! binds *sequences* of chords (leader trees, `ctrl+x ctrl+s`) into a
72//! [`SequenceKeymap`](keymap_seq::SequenceKeymap), returned as
73//! [`BuildOutput::sequences`]:
74//!
75//! ```toml
76//! [keys]
77//! "ctrl+q" = "quit"
78//!
79//! [[sequences]]
80//! keys = ["ctrl+x", "ctrl+s"]
81//! action = "save"
82//! ```
83//!
84//! Each element of `keys` reuses the single-chord grammar, so no new key syntax
85//! is introduced. Two sequence bindings that share a prefix cannot coexist
86//! without a timeout (see `keymap-seq`); such a pair is reported as
87//! [`Warning::PrefixShadow`] and the later one is dropped. A single-key binding
88//! that shadows a sequence (e.g. `"j"` in `[keys]` versus a sequence starting
89//! with `j`) is reported as the advisory [`Warning::SequenceShadow`] — both
90//! bindings are kept (they live in separate maps), so it only flags that the
91//! caller composing the two maps must resolve the overlap with a timeout.
92//!
93//! ## Runnable examples
94//!
95//! Under [`examples/`](https://github.com/S-Nakamur-a/keymap-rs/tree/main/crates/keymap-config/examples):
96//! `cargo run -p keymap-config --example load_config` walks the TOML-to-keymap
97//! pipeline end to end, and `--example rebind` shows the runtime-rebind flow
98//! on top of `keymap_term::decode`.
99
100use std::collections::{BTreeMap, HashMap};
101
102use keymap_core::{KeyInput, Keymap, ParseKeyInputError};
103use keymap_seq::{SeqBindError, SequenceKeymap};
104use serde::Deserialize;
105
106/// The name of the layer built from the top-level `[keys]` table.
107///
108/// It is the one name this crate assigns: the bare `[keys]` table (and an
109/// explicit `[layers.global]`, if present) build the layer under this key, which
110/// is **always present** in [`BuildOutput::layers`] even when empty. Every other
111/// layer name is opaque to the library — it is just the label the config author
112/// chose; the library attaches no meaning to it and never decides when a layer is
113/// active. That selection stays caller-side (see [`keymap_core::resolve_layered`]).
114pub const GLOBAL_LAYER: &str = "global";
115
116/// The result of a successful build: the named layers plus any non-fatal warnings.
117#[derive(Debug, Clone)]
118#[non_exhaustive]
119pub struct BuildOutput<A> {
120    /// The assembled layers, keyed by name. The top-level `[keys]` table builds
121    /// the [`GLOBAL_LAYER`] layer (always present, even when empty); each
122    /// `[layers.<name>]` table builds the layer of that name. Within any one
123    /// layer, conflicting chords resolve to the last binding. The same chord
124    /// bound in two *different* layers is not a conflict — it is the override the
125    /// caller composes with [`keymap_core::resolve_layered`], so this crate never
126    /// reports across layer boundaries.
127    ///
128    /// Always contains the `"global"` key, so `layers.len()` counts the global
129    /// layer even when the config bound nothing in it.
130    pub layers: BTreeMap<String, Keymap<A>>,
131    /// The assembled sequence map, built from the top-level `[[sequences]]`
132    /// tables. Sequences are **not** layered (they belong to the global config);
133    /// empty when the config has no sequences.
134    pub sequences: SequenceKeymap<A>,
135    /// Problems that did not prevent building (conflicts, unknown actions,
136    /// prefix shadows), in the order they were first seen.
137    pub warnings: Vec<Warning>,
138    /// Chords declared as tombstones (`= false`) in the config, grouped by layer
139    /// name. A tombstone records an *intent to remove* a chord from a base
140    /// keymap; the chord is not present in `layers` (it has no action), but is
141    /// carried here so [`merge`] can apply the removal to a base
142    /// [`BuildOutput`].
143    ///
144    /// Empty when the config has no `= false` declarations.
145    pub unbinds: BTreeMap<String, Vec<KeyInput>>,
146}
147
148impl<A> BuildOutput<A> {
149    /// The [`GLOBAL_LAYER`] layer (built from the top-level `[keys]` table).
150    ///
151    /// This is the convenience accessor for the common case of a single,
152    /// unlayered keymap: `out.global()` is exactly `&out.layers[GLOBAL_LAYER]`.
153    /// A config with no `[keys]` table yields an empty global layer, not an
154    /// absent one.
155    ///
156    /// # Panics
157    ///
158    /// Never in practice: [`from_str`] always inserts the global layer (empty if
159    /// the config had no `[keys]` table), so the lookup cannot miss.
160    #[must_use]
161    pub fn global(&self) -> &Keymap<A> {
162        self.layers
163            .get(GLOBAL_LAYER)
164            .expect("the global layer is always inserted")
165    }
166}
167
168/// A non-fatal problem found while building a [`Keymap`].
169#[derive(Debug, Clone, PartialEq, Eq)]
170#[non_exhaustive]
171pub enum Warning {
172    /// Two or more keys resolved to the same chord *within one layer*; the last
173    /// one wins. A named layer's conflict uses this same variant and carries no
174    /// layer name, so two layers each conflicting on the same chord produce
175    /// warnings indistinguishable on their own (the chord identifies the clash,
176    /// not the layer). The same chord across *different* layers is an override,
177    /// not a conflict, and is never reported.
178    Conflict {
179        /// The shared chord, in canonical form (e.g. `"ctrl+a"`).
180        chord: String,
181        /// The competing action names, in file order.
182        contenders: Vec<String>,
183        /// The action name that won (the last binding for the chord).
184        winner: String,
185    },
186    /// An action name in the config was not recognized by the resolver; the
187    /// binding was skipped.
188    UnknownAction {
189        /// The key string as written in the config. For a sequence, the
190        /// canonical chords joined by spaces.
191        key: String,
192        /// The unrecognized action name.
193        action: String,
194    },
195    /// Two sequence bindings share a prefix: the shorter (`prefix`) is a proper
196    /// prefix of the longer (`shadowed`), so they cannot coexist without a
197    /// timeout to tell them apart. The later-in-file binding was dropped to keep
198    /// the sequence table prefix-free.
199    PrefixShadow {
200        /// The shorter sequence, one canonical chord per element.
201        prefix: Vec<String>,
202        /// The action bound to the shorter sequence.
203        prefix_action: String,
204        /// The longer sequence that shares the prefix, one chord per element.
205        shadowed: Vec<String>,
206        /// The action bound to the longer sequence.
207        shadowed_action: String,
208    },
209    /// A `[[sequences]]` entry had an empty `keys` array; it was skipped.
210    EmptySequence {
211        /// The action name the empty sequence would have been bound to.
212        action: String,
213    },
214    /// A single-chord `[keys]` binding collides with the first key of a
215    /// `[[sequences]]` entry (e.g. `"j"` in `[keys]` and a sequence `["j", "k"]`).
216    /// Pressing that chord is then ambiguous — fire the single action now, or wait
217    /// to see if the sequence continues? — and can only be disambiguated by a
218    /// caller-side timeout (the vim `jj` case; see `keymap-seq`).
219    ///
220    /// Unlike [`PrefixShadow`](Warning::PrefixShadow), **nothing is dropped**: the
221    /// chord stays in its layer (the global one — this is checked only against the
222    /// global layer) and the sequence stays in [`BuildOutput::sequences`] (they are
223    /// separate maps). This is purely an
224    /// advisory that the caller composing the two maps must resolve the overlap.
225    SequenceShadow {
226        /// The single chord, in canonical form (e.g. `"j"`).
227        chord: String,
228        /// The action bound to the single chord.
229        chord_action: String,
230        /// One sequence the chord shadows — the lexicographically-first when it
231        /// shadows several — one canonical chord per element (its first equals
232        /// `chord`).
233        sequence: Vec<String>,
234        /// The action bound to that sequence.
235        sequence_action: String,
236    },
237}
238
239/// A category tag for a [`Warning`], without the field data.
240///
241/// Useful for filtering or routing warnings without pattern-matching on the full
242/// variant: `warning.kind() == WarningKind::Conflict` tests the category in one
243/// comparison, even when you do not need the chord or contender details.
244///
245/// This is `#[non_exhaustive]` — new [`Warning`] variants will add corresponding
246/// `WarningKind` variants in a non-breaking additive release.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
248#[non_exhaustive]
249pub enum WarningKind {
250    /// See [`Warning::Conflict`].
251    Conflict,
252    /// See [`Warning::UnknownAction`].
253    UnknownAction,
254    /// See [`Warning::PrefixShadow`].
255    PrefixShadow,
256    /// See [`Warning::EmptySequence`].
257    EmptySequence,
258    /// See [`Warning::SequenceShadow`].
259    SequenceShadow,
260}
261
262impl Warning {
263    /// Returns the category of this warning without the field data.
264    ///
265    /// Useful for filtering: `w.kind() == WarningKind::Conflict` tests the
266    /// category in a single comparison, even when the field details are not needed.
267    #[must_use]
268    pub fn kind(&self) -> WarningKind {
269        match self {
270            Warning::Conflict { .. } => WarningKind::Conflict,
271            Warning::UnknownAction { .. } => WarningKind::UnknownAction,
272            Warning::PrefixShadow { .. } => WarningKind::PrefixShadow,
273            Warning::EmptySequence { .. } => WarningKind::EmptySequence,
274            Warning::SequenceShadow { .. } => WarningKind::SequenceShadow,
275            // Non-exhaustive: future variants covered at compile time.
276        }
277    }
278}
279
280impl core::fmt::Display for Warning {
281    /// Formats the warning as a single human-readable line.
282    ///
283    /// **Display only — do not parse this output.** The format is not stable and
284    /// may change between minor versions; it is intended for logging and user
285    /// messages, not machine consumption.
286    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
287        match self {
288            Warning::Conflict { chord, winner, .. } => {
289                write!(f, "conflict on {chord:?}: {winner:?} wins")
290            }
291            Warning::UnknownAction { key, action } => {
292                write!(f, "unknown action {action:?} for key {key:?}")
293            }
294            Warning::PrefixShadow {
295                prefix,
296                shadowed,
297                prefix_action,
298                ..
299            } => {
300                write!(
301                    f,
302                    "prefix shadow: {:?} ({prefix_action:?}) shadows {:?}; later binding dropped",
303                    prefix.join(" "),
304                    shadowed.join(" ")
305                )
306            }
307            Warning::EmptySequence { action } => {
308                write!(f, "empty sequence for action {action:?}")
309            }
310            Warning::SequenceShadow {
311                chord,
312                sequence,
313                chord_action,
314                ..
315            } => {
316                write!(
317                    f,
318                    "sequence shadow: chord {chord:?} ({chord_action:?}) shadows sequence {:?}",
319                    sequence.join(" ")
320                )
321            }
322        }
323    }
324}
325
326/// A fatal problem that prevents building a [`Keymap`].
327#[derive(Debug)]
328#[non_exhaustive]
329pub enum BuildError {
330    /// The input was not valid TOML.
331    Toml(toml::de::Error),
332    /// A key string in the `[keys]` table could not be parsed.
333    KeyParse {
334        /// The offending key string as written.
335        key: String,
336        /// The underlying parse error.
337        source: ParseKeyInputError,
338    },
339    /// A chord in a `[keys]` or `[layers.<name>]` table was assigned `= true`.
340    ///
341    /// Only `= false` is a valid tombstone (unbind declaration); `= true` is
342    /// rejected as an explicit error to prevent accidental partial unbinds. In
343    /// 0.1.0 both `= true` and `= false` caused `BuildError::Toml` (type
344    /// mismatch); 0.1.1 accepts `= false` but keeps `= true` fatal.
345    InvalidTombstone {
346        /// The offending chord string as written.
347        key: String,
348    },
349}
350
351impl core::fmt::Display for BuildError {
352    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
353        match self {
354            BuildError::Toml(_) => f.write_str("invalid TOML"),
355            BuildError::KeyParse { key, .. } => write!(f, "invalid key string {key:?}"),
356            BuildError::InvalidTombstone { key } => {
357                write!(
358                    f,
359                    "invalid tombstone for {key:?}: use `= false` to unbind, not `= true`"
360                )
361            }
362        }
363    }
364}
365
366impl std::error::Error for BuildError {
367    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
368        match self {
369            BuildError::Toml(e) => Some(e),
370            BuildError::KeyParse { source, .. } => Some(source),
371            BuildError::InvalidTombstone { .. } => None,
372        }
373    }
374}
375
376impl From<toml::de::Error> for BuildError {
377    fn from(e: toml::de::Error) -> Self {
378        BuildError::Toml(e)
379    }
380}
381
382// `deny_unknown_fields` makes a misspelled or unsupported key a `BuildError::Toml`
383// rather than a silent no-op — chosen pre-1.0 because adding it *later* would
384// reject configs that used to parse. It also keeps the door open for additive
385// fields (e.g. a future per-`[[sequences]]` `layer = "..."` tag) without their
386// typos failing silently.
387#[derive(Deserialize)]
388#[serde(deny_unknown_fields)]
389struct RawConfig {
390    #[serde(default)]
391    keys: BTreeMap<String, RawValue>,
392    #[serde(default)]
393    layers: BTreeMap<String, BTreeMap<String, RawValue>>,
394    #[serde(default)]
395    sequences: Vec<RawSequence>,
396}
397
398/// A chord's value in TOML: either an action name (string) or a tombstone
399/// (`false` = unbind, `true` = error).
400///
401/// In 0.1.0 any non-string chord value was a `BuildError::Toml`. In 0.1.1
402/// `= false` is newly accepted as a tombstone (unbind). `= true` remains a
403/// `BuildError::InvalidTombstone` — an explicit error on `true` prevents
404/// accidental partial unbinds from a copy-paste of `= true` (i.e. whatever
405/// the caller meant was not "bind this chord to nothing").
406#[derive(Deserialize)]
407#[serde(untagged)]
408enum RawValue {
409    /// A chord binding: `"chord" = "action_name"`.
410    Action(String),
411    /// A chord tombstone: `"chord" = false` (unbind) or `"chord" = true` (error).
412    Bool(bool),
413}
414
415#[derive(Deserialize)]
416#[serde(deny_unknown_fields)]
417struct RawSequence {
418    keys: Vec<String>,
419    action: String,
420}
421
422/// The built sequence map paired with its sequence→action-name dictionary (the
423/// names let a cross-shadow be reported against the single-key table).
424type SequenceBuild<A> = (SequenceKeymap<A>, HashMap<Vec<KeyInput>, String>);
425
426/// Return type of [`build_layer`]: the keymap, the winning action-name-per-chord
427/// dictionary (for cross-shadow detection in the global layer), and the list of
428/// tombstone chords (`= false`).
429type LayerBuild<A> = (Keymap<A>, HashMap<KeyInput, String>, Vec<KeyInput>);
430
431/// Builds a [`Keymap`] from a TOML string.
432///
433/// The `[keys]` table maps key strings (see `keymap_core::KeyInput`'s `FromStr`)
434/// to action names; `resolve` turns each action name into the caller's action
435/// type `A`, returning `None` for names it does not recognize.
436///
437/// # Errors
438///
439/// Returns [`BuildError`] if the input is not valid TOML, or if any key string
440/// fails to parse. Unknown action names and chord conflicts are not errors —
441/// they are returned in [`BuildOutput::warnings`].
442pub fn from_str<A, F>(toml_str: &str, mut resolve: F) -> Result<BuildOutput<A>, BuildError>
443where
444    F: FnMut(&str) -> Option<A>,
445{
446    let RawConfig {
447        keys,
448        mut layers,
449        sequences: raw_sequences,
450    } = toml::from_str(toml_str)?;
451
452    let mut warnings = Vec::new();
453    let mut built: BTreeMap<String, Keymap<A>> = BTreeMap::new();
454    let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
455
456    // The global layer is the top-level `[keys]` table plus an explicit
457    // `[layers.global]` if the author also wrote one. The two are concatenated —
458    // `[keys]` first, then `[layers.global]` — and fed through the same per-chord
459    // pipeline, so a chord present in both is reported as an ordinary in-layer
460    // `Conflict` (and, being last, the `[layers.global]` entry wins) rather than
461    // silently dropped. The global layer is always inserted, even when empty.
462    let explicit_global = layers.remove(GLOBAL_LAYER).unwrap_or_default();
463    let global_entries = keys.into_iter().chain(explicit_global);
464    let (global_keymap, global_names, global_unbinds) =
465        build_layer(global_entries, &mut resolve, &mut warnings)?;
466    built.insert(GLOBAL_LAYER.to_string(), global_keymap);
467    if !global_unbinds.is_empty() {
468        unbinds.insert(GLOBAL_LAYER.to_string(), global_unbinds);
469    }
470
471    // Every other named layer, in `BTreeMap` name order so warnings are
472    // deterministic. Each layer's conflicts are detected within itself; layer
473    // boundaries are never crossed (the caller owns the active chain).
474    for (name, raw_keys) in layers {
475        // Only the global layer feeds the cross-shadow check below, so a
476        // non-global layer's name dictionary is computed but not needed here.
477        let (keymap, _names, layer_unbinds) = build_layer(raw_keys, &mut resolve, &mut warnings)?;
478        built.insert(name.clone(), keymap);
479        if !layer_unbinds.is_empty() {
480            unbinds.insert(name, layer_unbinds);
481        }
482    }
483
484    // Sequences are global-only, so the cross-shadow check (single chord versus
485    // the first key of a sequence) runs against the global layer alone.
486    let (sequences, seq_names) = build_sequences(raw_sequences, &mut resolve, &mut warnings)?;
487    detect_cross_shadows(&global_names, &seq_names, &mut warnings);
488
489    Ok(BuildOutput {
490        layers: built,
491        sequences,
492        warnings,
493        unbinds,
494    })
495}
496
497/// Builds one layer's [`Keymap`] from its `(raw key string, raw value)`
498/// entries, appending survivable problems to `warnings`.
499///
500/// Entries are grouped by the chord they normalize to (so different spellings of
501/// the same chord — `ctrl+a` and `control+a` — collide), visited in first-seen
502/// order. Within a group the last resolvable binding wins and a [`Warning::Conflict`]
503/// is reported; an unresolvable action name is a [`Warning::UnknownAction`] and
504/// that single entry is skipped. A `= false` tombstone is collected into the
505/// returned unbind list without adding a binding; a `= true` tombstone is a fatal
506/// [`BuildError::InvalidTombstone`].
507///
508/// Returns `(keymap, names, unbinds)`:
509/// - `keymap`: the resolved bindings.
510/// - `names`: the winning action name per chord (used by the cross-shadow check
511///   in the global layer; other callers discard it).
512/// - `unbinds`: chords declared with `= false`, in parse order.
513fn build_layer<A, I, F>(
514    entries: I,
515    resolve: &mut F,
516    warnings: &mut Vec<Warning>,
517) -> Result<LayerBuild<A>, BuildError>
518where
519    I: IntoIterator<Item = (String, RawValue)>,
520    F: FnMut(&str) -> Option<A>,
521{
522    // Parse every key first (parse failures are fatal), grouping entries by the
523    // chord they normalize to, in first-seen order.
524    let mut order: Vec<KeyInput> = Vec::new();
525    let mut groups: HashMap<KeyInput, Vec<(String, String)>> = HashMap::new();
526    let mut tombstone_order: Vec<KeyInput> = Vec::new();
527    let mut tombstone_set: std::collections::HashSet<KeyInput> = std::collections::HashSet::new();
528
529    for (raw_key, raw_value) in entries {
530        let chord = raw_key
531            .parse::<KeyInput>()
532            .map_err(|source| BuildError::KeyParse {
533                key: raw_key.clone(),
534                source,
535            })?;
536
537        match raw_value {
538            RawValue::Bool(false) => {
539                // Tombstone: record in unbinds (deduplicated, first-seen order).
540                if tombstone_set.insert(chord) {
541                    tombstone_order.push(chord);
542                }
543            }
544            RawValue::Bool(true) => {
545                return Err(BuildError::InvalidTombstone { key: raw_key });
546            }
547            RawValue::Action(action_name) => {
548                let entry = groups.entry(chord).or_default();
549                if entry.is_empty() {
550                    order.push(chord);
551                }
552                entry.push((raw_key, action_name));
553            }
554        }
555    }
556
557    let mut keymap = Keymap::new();
558    let mut names: HashMap<KeyInput, String> = HashMap::new();
559    for chord in order {
560        let Some(entries) = groups.remove(&chord) else {
561            continue;
562        };
563
564        let mut resolved: Vec<(String, A)> = Vec::new();
565        for (raw_key, action_name) in entries {
566            match resolve(&action_name) {
567                Some(action) => resolved.push((action_name, action)),
568                None => warnings.push(Warning::UnknownAction {
569                    key: raw_key,
570                    action: action_name,
571                }),
572            }
573        }
574
575        if resolved.len() > 1 {
576            let contenders: Vec<String> = resolved.iter().map(|(name, _)| name.clone()).collect();
577            if let Some(winner) = contenders.last().cloned() {
578                warnings.push(Warning::Conflict {
579                    chord: chord.to_string(),
580                    contenders,
581                    winner,
582                });
583            }
584        }
585
586        // Last binding wins.
587        if let Some((name, action)) = resolved.pop() {
588            keymap.bind(chord, action);
589            names.insert(chord, name);
590        }
591    }
592
593    Ok((keymap, names, tombstone_order))
594}
595
596/// Serializes a [`Keymap`] and [`SequenceKeymap`] back into a TOML config string
597/// — the inverse of [`from_str`].
598///
599/// `name_of` is the mirror of [`from_str`]'s `resolve`: it turns each bound
600/// action `A` back into the name written in the config, returning `None` for an
601/// action that has no config name. A binding whose action returns `None` is
602/// **omitted** from the output (the write-side dual of how [`from_str`] reports
603/// an unresolvable name as [`Warning::UnknownAction`]); pass a total `name_of`
604/// for a faithful export.
605///
606/// The result is **deterministic**: the `[keys]` table is emitted in canonical
607/// chord order and the `[[sequences]]` array in joined-chord order, so the same
608/// maps always produce byte-identical output (suitable for writing to disk and
609/// diffing). I/O is the caller's — like [`from_str`], this crate only deals in
610/// `&str`/`String`.
611///
612/// The guarantee is a **semantic round-trip**, not byte identity:
613/// `from_str(&to_toml(km, seq, name_of), resolve)` rebuilds keymaps equivalent to
614/// `km`/`seq` when `name_of` and `resolve` are inverses. The reverse direction is
615/// lossy by design — normalization (`shift+a` → `a`) is non-invertible, and
616/// comments, ordering, and original spelling are not preserved.
617///
618/// Action names and chords are emitted by constructing a [`toml::Value`] (never
619/// by string concatenation), so the `toml` serializer handles all escaping; a
620/// name containing quotes or TOML metacharacters cannot break out of its string
621/// to inject extra bindings on the round-trip.
622///
623/// # Trust boundary
624///
625/// `to_toml` applies **no** policy: it emits whatever is bound, reserved/escape
626/// keys included. A caller that writes this output somewhere another process
627/// (notably a PTY-internal process) can also write **must** enforce reserved-key
628/// rejection at its own load/bind boundary — otherwise an attacker who can write
629/// that file can rebind the escape key. See `docs/STATUS.md`.
630///
631/// # Panics
632///
633/// Never in practice: it serializes a TOML value built only from string keys and
634/// values, which the `toml` serializer cannot fail on.
635pub fn to_toml<A, F>(keymap: &Keymap<A>, sequences: &SequenceKeymap<A>, mut name_of: F) -> String
636where
637    F: FnMut(&A) -> Option<&str>,
638{
639    let mut root = toml::Table::new();
640
641    let keys = keymap_to_table(keymap, &mut name_of);
642    if !keys.is_empty() {
643        root.insert("keys".to_string(), toml::Value::Table(keys));
644    }
645    insert_sequences(&mut root, sequences, &mut name_of);
646
647    // Serializing a table of string-only values is infallible.
648    toml::to_string(&root).expect("string-only TOML value always serializes")
649}
650
651/// Serializes a named-layer set and the global [`SequenceKeymap`] back into a
652/// TOML config string — the inverse of [`from_str`] for a multi-layer config.
653///
654/// The [`GLOBAL_LAYER`] layer is emitted as the top-level `[keys]` table and every
655/// other layer as `[layers.<name>]`, with `[[sequences]]` carrying the (global)
656/// sequence map. Like [`to_toml`] the output is **deterministic** (`BTreeMap`/sorted
657/// layer, chord, and sequence order) and applies **no** policy — the same trust-boundary
658/// caveat applies (see [`to_toml`]). Pass [`BuildOutput::layers`] straight in to
659/// round-trip a parsed config; an empty layer emits nothing, and a global-only set
660/// produces byte-identical output to [`to_toml`] on that one layer.
661///
662/// To also emit tombstone entries (`"chord" = false`) for unbind declarations, use
663/// [`to_toml_layered_with_unbinds`] instead.
664///
665/// # Panics
666///
667/// Never in practice, for the same reason as [`to_toml`].
668pub fn to_toml_layered<A, F>(
669    layers: &BTreeMap<String, Keymap<A>>,
670    sequences: &SequenceKeymap<A>,
671    mut name_of: F,
672) -> String
673where
674    F: FnMut(&A) -> Option<&str>,
675{
676    to_toml_layered_impl(layers, sequences, &BTreeMap::new(), &mut name_of)
677}
678
679/// Serializes a named-layer set, the global [`SequenceKeymap`], and any unbind
680/// declarations (`"chord" = false`) back into a TOML config string.
681///
682/// This is the tombstone-aware additive version of [`to_toml_layered`]: when
683/// `unbinds` is non-empty, each chord in `unbinds[layer]` is emitted as
684/// `"chord" = false` in the appropriate layer table. When `unbinds` is empty the
685/// output is **byte-identical** to [`to_toml_layered`] — callers that never use
686/// tombstones can use either function interchangeably.
687///
688/// Like all emit functions the output is deterministic and injection-safe: all
689/// values are constructed as [`toml::Value`] (never by string concatenation), so
690/// a chord whose display form contains TOML metacharacters cannot break out of its
691/// string.
692///
693/// # Panics
694///
695/// Never in practice, for the same reason as [`to_toml`].
696pub fn to_toml_layered_with_unbinds<A, F>(
697    layers: &BTreeMap<String, Keymap<A>>,
698    sequences: &SequenceKeymap<A>,
699    unbinds: &BTreeMap<String, Vec<KeyInput>>,
700    mut name_of: F,
701) -> String
702where
703    F: FnMut(&A) -> Option<&str>,
704{
705    to_toml_layered_impl(layers, sequences, unbinds, &mut name_of)
706}
707
708/// Shared implementation for [`to_toml_layered`] and [`to_toml_layered_with_unbinds`].
709fn to_toml_layered_impl<A, F>(
710    layers: &BTreeMap<String, Keymap<A>>,
711    sequences: &SequenceKeymap<A>,
712    unbinds: &BTreeMap<String, Vec<KeyInput>>,
713    name_of: &mut F,
714) -> String
715where
716    F: FnMut(&A) -> Option<&str>,
717{
718    let mut root = toml::Table::new();
719    let mut named = toml::Table::new();
720
721    // Collect all layer names from both `layers` and `unbinds` so that a layer
722    // with only tombstones (no remaining bindings) is still emitted.
723    let mut all_layer_names: std::collections::BTreeSet<&str> =
724        layers.keys().map(String::as_str).collect();
725    for name in unbinds.keys() {
726        all_layer_names.insert(name.as_str());
727    }
728
729    for name in all_layer_names {
730        let mut table = if let Some(keymap) = layers.get(name) {
731            keymap_to_table(keymap, name_of)
732        } else {
733            toml::Table::new()
734        };
735
736        // Append tombstone entries (`= false`) for this layer, using toml::Value
737        // to avoid any string injection.
738        if let Some(layer_unbinds) = unbinds.get(name) {
739            for chord in layer_unbinds {
740                table.insert(chord.to_string(), toml::Value::Boolean(false));
741            }
742        }
743
744        if table.is_empty() {
745            continue;
746        }
747        if name == GLOBAL_LAYER {
748            root.insert("keys".to_string(), toml::Value::Table(table));
749        } else {
750            named.insert(name.to_string(), toml::Value::Table(table));
751        }
752    }
753
754    insert_sequences(&mut root, sequences, name_of);
755    if !named.is_empty() {
756        root.insert("layers".to_string(), toml::Value::Table(named));
757    }
758
759    // Serializing a table of string-only values is infallible.
760    toml::to_string(&root).expect("string-only TOML value always serializes")
761}
762
763/// The result of a [`merge`] operation: the merged output plus advisory notes.
764///
765/// Notes are kept **separate from [`BuildOutput::warnings`]** so that callers
766/// gating on `output.warnings.is_empty()` (e.g. `deny_warnings`) are not
767/// triggered by legitimate override operations. A note is information the caller
768/// may want to surface or log; it is never an error.
769#[derive(Debug)]
770#[non_exhaustive]
771pub struct Merged<A> {
772    /// The merged build output. Its `warnings` field carries the union of
773    /// `base.warnings` and `overlay.warnings` from the inputs; notes about the
774    /// merge operation itself live in [`notes`](Self::notes).
775    pub output: BuildOutput<A>,
776    /// Advisory notes about the merge: overrides, unbinds, and dropped sequences.
777    /// Never mixed into [`output.warnings`](BuildOutput::warnings).
778    pub notes: Vec<MergeNote>,
779}
780
781/// An advisory note emitted by [`merge`].
782///
783/// All variants are display/audit information only; none indicate an error or
784/// require action from the caller.
785///
786/// This is `#[non_exhaustive]` — future additive merge operations may add
787/// further note kinds without a breaking change.
788#[derive(Debug, Clone, PartialEq, Eq)]
789#[non_exhaustive]
790pub enum MergeNote {
791    /// The overlay bound `chord` in `layer`, overriding an existing binding in
792    /// the base. `chord` is in canonical form (e.g. `"ctrl+s"`).
793    Overrode {
794        /// The layer name in which the override occurred.
795        layer: String,
796        /// The chord that was overridden, in canonical form.
797        chord: String,
798    },
799    /// The overlay declared `chord` in `layer` as a tombstone (`= false`), and
800    /// the chord was removed from the base layer. `chord` is in canonical form.
801    Unbound {
802        /// The layer name from which the chord was removed.
803        layer: String,
804        /// The removed chord, in canonical form.
805        chord: String,
806    },
807    /// The overlay's `unbinds` contained a tombstone for `chord` in `layer`,
808    /// but the base had no binding for that chord — the tombstone was a no-op.
809    UnbindMiss {
810        /// The layer name.
811        layer: String,
812        /// The chord that was not found in the base, in canonical form.
813        chord: String,
814    },
815    /// A sequence from the base was dropped because it shared a prefix with a
816    /// sequence in the overlay (the overlay's sequence takes priority). The
817    /// dropped sequence's chords are one canonical string per key.
818    DroppedSequence {
819        /// The chords of the dropped base sequence, one canonical string per key.
820        sequence: Vec<String>,
821    },
822}
823
824/// Merges a `base` (defaults) [`BuildOutput`] with an `overlay` (user config)
825/// [`BuildOutput`], returning the combined result with advisory notes.
826///
827/// ## Merge semantics
828///
829/// - **Layers**: the layer sets are unioned. For each layer name present in
830///   both `base` and `overlay`, bindings are merged chord-by-chord: the overlay
831///   chord wins silently (it is the user's override). A [`MergeNote::Overrode`]
832///   is emitted for each overridden chord.
833/// - **Tombstones** (`overlay.unbinds`): for each chord listed as an unbind in
834///   the overlay, the chord is removed from the corresponding base layer (if
835///   present). A [`MergeNote::Unbound`] note is emitted for successful removals;
836///   [`MergeNote::UnbindMiss`] when the chord was absent from the base.
837/// - **Sequences**: the overlay's sequences are folded into the base's. An
838///   exact-match sequence in the overlay silently replaces the base's. A sequence
839///   in the overlay that is a prefix of (or is prefixed by) a sequence in the
840///   base causes the base sequence to be dropped; a [`MergeNote::DroppedSequence`]
841///   note is emitted.
842/// - **Warnings**: the merged `output.warnings` is the concatenation of
843///   `base.warnings` and `overlay.warnings`. Merge notes go to `notes`, never
844///   to `output.warnings`.
845///
846/// Override operations are intentionally silent in `output.warnings` so that a
847/// caller gating on `output.warnings.is_empty()` (deny-warnings mode) is not
848/// triggered by the user legitimately overriding a default binding.
849///
850/// ## Bound: `A: Clone`
851///
852/// `merge` requires `A: Clone` because it copies actions from the overlay
853/// into the merged map. [`SequenceKeymap::bindings`] yields shared references
854/// only (there is no by-value iterator), so cloning is the only way to move
855/// sequence actions across maps without unsafe code. This bound is on `merge`
856/// alone and is not propagated to any core type.
857#[must_use]
858pub fn merge<A: Clone>(mut base: BuildOutput<A>, overlay: BuildOutput<A>) -> Merged<A> {
859    let mut notes: Vec<MergeNote> = Vec::new();
860
861    // Merge warnings: base first, then overlay.
862    base.warnings.extend(overlay.warnings);
863
864    // ── Tombstones ────────────────────────────────────────────────────────────
865    // Apply overlay.unbinds to base.layers before merging bindings, so an
866    // overlay that both unbinds and re-binds a chord gets a clean slate.
867    for (layer_name, chords) in &overlay.unbinds {
868        for chord in chords {
869            let removed = base
870                .layers
871                .get_mut(layer_name)
872                .and_then(|layer| layer.unbind(chord));
873            if removed.is_some() {
874                notes.push(MergeNote::Unbound {
875                    layer: layer_name.clone(),
876                    chord: chord.to_string(),
877                });
878            } else {
879                notes.push(MergeNote::UnbindMiss {
880                    layer: layer_name.clone(),
881                    chord: chord.to_string(),
882                });
883            }
884        }
885    }
886
887    // ── Layer bindings ────────────────────────────────────────────────────────
888    // Move actions from the overlay layers into the base. `Keymap` exposes no
889    // by-value iterator, so we use `iter()` to collect chord keys, then `unbind`
890    // to take ownership of each action (moving, not cloning, for layer data).
891    for (layer_name, overlay_keymap) in overlay.layers {
892        let base_layer = base.layers.entry(layer_name.clone()).or_default();
893
894        // Collect chords first (immutable borrow), then drain by value.
895        let keys: Vec<KeyInput> = overlay_keymap.iter().map(|(k, _)| *k).collect();
896        let mut owned_overlay = overlay_keymap;
897        for chord in keys {
898            if base_layer.contains(&chord) {
899                notes.push(MergeNote::Overrode {
900                    layer: layer_name.clone(),
901                    chord: chord.to_string(),
902                });
903            }
904            if let Some(action) = owned_overlay.unbind(&chord) {
905                base_layer.bind(chord, action);
906            }
907        }
908    }
909
910    // ── Sequences ─────────────────────────────────────────────────────────────
911    // Fold overlay sequences into the base. `SequenceKeymap::bindings` yields
912    // `(Vec<KeyInput>, &A)` — shared references only; `A: Clone` is required
913    // to copy actions across maps (documented in the function's bound).
914    //
915    // Strategy: collect all overlay (path, action) pairs first (cloning), then
916    // bind each into the base. `SequenceKeymap::bind` returns `Err(PrefixShadow)`
917    // when the new entry would conflict with an existing one in the *base*. In
918    // that case, drop the conflicting base sequence and retry.
919    let overlay_seqs: Vec<(Vec<KeyInput>, A)> = overlay
920        .sequences
921        .bindings()
922        .map(|(path, action)| (path.clone(), action.clone()))
923        .collect();
924
925    for (path, action) in overlay_seqs {
926        use keymap_seq::SeqBindError;
927        // Retry loop: on each `PrefixShadow` we drop the conflicting base
928        // sequence and retry until the bind succeeds (or a non-shadow error).
929        while let Err(SeqBindError::PrefixShadow { sequence, conflict }) =
930            base.sequences.bind(path.iter().copied(), action.clone())
931        {
932            // The conflicting path in the base must be dropped.
933            let victim = if sequence == path { conflict } else { sequence };
934            notes.push(MergeNote::DroppedSequence {
935                sequence: render_sequence(&victim),
936            });
937            // Remove the victim from the base so the retry can succeed.
938            // `SequenceKeymap` has no `unbind`; rebuild by collecting
939            // all remaining bindings and starting fresh.
940            let remaining: Vec<(Vec<KeyInput>, A)> = base
941                .sequences
942                .bindings()
943                .filter(|(p, _)| *p != victim.as_slice())
944                .map(|(p, a)| (p.clone(), a.clone()))
945                .collect();
946            base.sequences = SequenceKeymap::new();
947            for (p, a) in remaining {
948                // These were already valid (prefix-free among themselves),
949                // so bind should not fail. Silently drop on error (should
950                // not occur).
951                let _ = base.sequences.bind(p.iter().copied(), a);
952            }
953        }
954    }
955
956    Merged {
957        output: base,
958        notes,
959    }
960}
961
962/// Renders one keymap as a `chord -> action name` [`toml::Table`]. A `toml` table
963/// is a sorted map, so chord order is canonical with no extra sorting; bindings
964/// whose action has no config name (`name_of` returns `None`) are omitted.
965fn keymap_to_table<A, F>(keymap: &Keymap<A>, name_of: &mut F) -> toml::Table
966where
967    F: FnMut(&A) -> Option<&str>,
968{
969    let mut table = toml::Table::new();
970    for (chord, action) in keymap.iter() {
971        if let Some(name) = name_of(action) {
972            table.insert(chord.to_string(), toml::Value::String(name.to_string()));
973        }
974    }
975    table
976}
977
978/// Inserts a `[[sequences]]` array into `root` when the map has any named
979/// sequences, sorted by the joined canonical chords (the same ordering key
980/// cross-shadow detection uses) for deterministic output.
981fn insert_sequences<A, F>(root: &mut toml::Table, sequences: &SequenceKeymap<A>, name_of: &mut F)
982where
983    F: FnMut(&A) -> Option<&str>,
984{
985    let mut seqs: Vec<(Vec<String>, String)> = sequences
986        .bindings()
987        .filter_map(|(path, action)| {
988            name_of(action).map(|name| (render_sequence(&path), name.to_string()))
989        })
990        .collect();
991    seqs.sort_by_key(|(chords, _)| chords.join(" "));
992
993    if seqs.is_empty() {
994        return;
995    }
996    let array = seqs
997        .into_iter()
998        .map(|(chords, name)| {
999            let mut entry = toml::Table::new();
1000            entry.insert(
1001                "keys".to_string(),
1002                toml::Value::Array(chords.into_iter().map(toml::Value::String).collect()),
1003            );
1004            entry.insert("action".to_string(), toml::Value::String(name));
1005            toml::Value::Table(entry)
1006        })
1007        .collect();
1008    root.insert("sequences".to_string(), toml::Value::Array(array));
1009}
1010
1011/// Flags single-chord bindings that collide with the first key of a sequence.
1012///
1013/// The two maps are built independently and composed by the caller, so a chord
1014/// `j` and a sequence starting with `j` are not a build-time conflict in either
1015/// table alone — but together they make the press of `j` ambiguous. One advisory
1016/// [`Warning::SequenceShadow`] is emitted per offending chord (naming the
1017/// lexicographically-first sequence it shadows), chords visited in canonical-string
1018/// order for a deterministic warning sequence.
1019fn detect_cross_shadows(
1020    single_names: &HashMap<KeyInput, String>,
1021    seq_names: &HashMap<Vec<KeyInput>, String>,
1022    warnings: &mut Vec<Warning>,
1023) {
1024    let mut singles: Vec<(&KeyInput, &String)> = single_names.iter().collect();
1025    singles.sort_by_key(|(chord, _)| chord.to_string());
1026
1027    for (chord, chord_action) in singles {
1028        let mut shadowed: Vec<(&Vec<KeyInput>, &String)> = seq_names
1029            .iter()
1030            .filter(|(seq, _)| seq.first() == Some(chord))
1031            .collect();
1032        shadowed.sort_by_key(|(seq, _)| render_sequence(seq).join(" "));
1033
1034        if let Some((sequence, sequence_action)) = shadowed.first() {
1035            warnings.push(Warning::SequenceShadow {
1036                chord: chord.to_string(),
1037                chord_action: chord_action.clone(),
1038                sequence: render_sequence(sequence),
1039                sequence_action: (*sequence_action).clone(),
1040            });
1041        }
1042    }
1043}
1044
1045/// Builds the [`SequenceKeymap`] from the `[[sequences]]` tables, appending any
1046/// survivable problems to `warnings`.
1047///
1048/// Detection of prefix conflicts lives entirely in `keymap-seq`: this function
1049/// just calls [`SequenceKeymap::bind`] in file order and translates its result
1050/// into warnings, keeping only a sequence→action-name dictionary (names are this
1051/// crate's vocabulary, not `keymap-seq`'s) so it can name both sides of a clash.
1052///
1053/// Two write policies show through here, intentionally asymmetric:
1054/// - An exact re-binding of the same sequence is **last-wins** (overwrites) and
1055///   reported as a [`Warning::Conflict`], matching the single-chord `[keys]`
1056///   table.
1057/// - A *prefix* clash keeps the **earlier** binding and drops the later one
1058///   (reported as [`Warning::PrefixShadow`]); the established prefix path wins.
1059///
1060/// Returns the built map together with the sequence→action-name dictionary it
1061/// accrues, so the caller can detect cross-shadows against the single-key table
1062/// without re-deriving names.
1063fn build_sequences<A, F>(
1064    raw_sequences: Vec<RawSequence>,
1065    resolve: &mut F,
1066    warnings: &mut Vec<Warning>,
1067) -> Result<SequenceBuild<A>, BuildError>
1068where
1069    F: FnMut(&str) -> Option<A>,
1070{
1071    let mut sequences = SequenceKeymap::new();
1072    // Action *names* keyed by the sequence they bind, so a clash can name both
1073    // sides. This is naming data, not conflict detection — the trie owns that.
1074    let mut names: HashMap<Vec<KeyInput>, String> = HashMap::new();
1075
1076    for raw_seq in raw_sequences {
1077        let mut keys = Vec::with_capacity(raw_seq.keys.len());
1078        for raw_key in &raw_seq.keys {
1079            let chord = raw_key
1080                .parse::<KeyInput>()
1081                .map_err(|source| BuildError::KeyParse {
1082                    key: raw_key.clone(),
1083                    source,
1084                })?;
1085            keys.push(chord);
1086        }
1087
1088        let Some(action) = resolve(&raw_seq.action) else {
1089            warnings.push(Warning::UnknownAction {
1090                key: render_sequence(&keys).join(" "),
1091                action: raw_seq.action,
1092            });
1093            continue;
1094        };
1095
1096        match sequences.bind(keys.iter().copied(), action) {
1097            Ok(None) => {
1098                names.insert(keys, raw_seq.action);
1099            }
1100            Ok(Some(_)) => {
1101                // Exact re-binding: last wins, reported like a repeated chord.
1102                let previous = names.insert(keys.clone(), raw_seq.action.clone());
1103                warnings.push(Warning::Conflict {
1104                    chord: render_sequence(&keys).join(" "),
1105                    contenders: vec![previous.unwrap_or_default(), raw_seq.action.clone()],
1106                    winner: raw_seq.action,
1107                });
1108            }
1109            Err(SeqBindError::Empty) => {
1110                warnings.push(Warning::EmptySequence {
1111                    action: raw_seq.action,
1112                });
1113            }
1114            Err(SeqBindError::PrefixShadow { sequence, conflict }) => {
1115                let conflict_action = names.get(&conflict).cloned().unwrap_or_default();
1116                // The shorter sequence is the prefix that wins; the longer is shadowed.
1117                let (prefix, prefix_action, shadowed, shadowed_action) =
1118                    if sequence.len() <= conflict.len() {
1119                        (sequence, raw_seq.action, conflict, conflict_action)
1120                    } else {
1121                        (conflict, conflict_action, sequence, raw_seq.action)
1122                    };
1123                warnings.push(Warning::PrefixShadow {
1124                    prefix: render_sequence(&prefix),
1125                    prefix_action,
1126                    shadowed: render_sequence(&shadowed),
1127                    shadowed_action,
1128                });
1129            }
1130            // `SeqBindError` is non-exhaustive: a future rejection reason we do
1131            // not yet model drops the binding rather than aborting the build.
1132            Err(_) => {}
1133        }
1134    }
1135
1136    Ok((sequences, names))
1137}
1138
1139/// Renders a sequence as one canonical chord string per key.
1140fn render_sequence(keys: &[KeyInput]) -> Vec<String> {
1141    keys.iter().map(ToString::to_string).collect()
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147    use keymap_core::{Key, Modifiers};
1148
1149    #[derive(Debug, Clone, PartialEq)]
1150    enum Action {
1151        Quit,
1152        Save,
1153        Split,
1154        Top,
1155    }
1156
1157    fn resolver(name: &str) -> Option<Action> {
1158        match name {
1159            "quit" => Some(Action::Quit),
1160            "save" => Some(Action::Save),
1161            "split" => Some(Action::Split),
1162            "top" => Some(Action::Top),
1163            _ => None,
1164        }
1165    }
1166
1167    use keymap_seq::Match;
1168
1169    fn seq(keys: &[(char, Modifiers)]) -> Vec<KeyInput> {
1170        keys.iter()
1171            .map(|&(c, m)| KeyInput::new(Key::Char(c), m))
1172            .collect()
1173    }
1174
1175    #[test]
1176    fn builds_bindings_and_resolves_actions() {
1177        let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+s\" = \"save\"\n";
1178        let out = from_str(toml, resolver).unwrap();
1179        assert!(out.warnings.is_empty());
1180        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1181        assert_eq!(out.global().get(&q), Some(&Action::Quit));
1182    }
1183
1184    #[test]
1185    fn bare_keys_build_the_global_layer_which_is_always_present() {
1186        // Even an empty config has a (empty) global layer, so `global()` never
1187        // panics and callers can rely on `layers["global"]` existing.
1188        let empty: BuildOutput<Action> = from_str("", resolver).unwrap();
1189        assert_eq!(empty.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1190        assert!(empty.global().is_empty());
1191
1192        let out = from_str("[keys]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap();
1193        assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1194    }
1195
1196    #[test]
1197    fn named_layers_are_parsed_under_their_names() {
1198        let toml = "\
1199[keys]\n\"ctrl+q\" = \"quit\"\n\
1200[layers.panel]\n\"ctrl+s\" = \"split\"\n";
1201        let out = from_str(toml, resolver).unwrap();
1202        assert!(out.warnings.is_empty());
1203        // global and panel both present, named exactly.
1204        assert_eq!(
1205            out.layers.keys().map(String::as_str).collect::<Vec<_>>(),
1206            vec!["global", "panel"]
1207        );
1208        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1209        let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
1210        assert_eq!(out.global().get(&q), Some(&Action::Quit));
1211        assert_eq!(out.layers["panel"].get(&s), Some(&Action::Split));
1212        // A chord lives only in the layer that bound it.
1213        assert_eq!(out.global().get(&s), None);
1214        assert_eq!(out.layers["panel"].get(&q), None);
1215    }
1216
1217    #[test]
1218    fn a_layer_with_no_keys_section_still_gets_an_empty_global() {
1219        let toml = "[layers.panel]\n\"ctrl+s\" = \"split\"\n";
1220        let out = from_str(toml, resolver).unwrap();
1221        assert!(out.global().is_empty());
1222        assert!(!out.layers["panel"].is_empty());
1223    }
1224
1225    #[test]
1226    fn same_chord_in_two_layers_is_an_override_not_a_conflict() {
1227        // The crux of the layered design: `ctrl+s` bound in both global and panel
1228        // is exactly the override `resolve_layered` exists for. The library never
1229        // knows which layer is active, so it must not report across the boundary.
1230        let toml = "\
1231[keys]\n\"ctrl+s\" = \"save\"\n\
1232[layers.panel]\n\"ctrl+s\" = \"split\"\n";
1233        let out = from_str(toml, resolver).unwrap();
1234        assert!(
1235            out.warnings.is_empty(),
1236            "cross-layer override must not warn: {:?}",
1237            out.warnings
1238        );
1239        let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
1240        assert_eq!(out.global().get(&s), Some(&Action::Save));
1241        assert_eq!(out.layers["panel"].get(&s), Some(&Action::Split));
1242    }
1243
1244    #[test]
1245    fn explicit_global_layer_merges_into_the_top_level_keys() {
1246        let toml = "\
1247[keys]\n\"ctrl+q\" = \"quit\"\n\
1248[layers.global]\n\"ctrl+s\" = \"save\"\n";
1249        let out = from_str(toml, resolver).unwrap();
1250        assert!(out.warnings.is_empty());
1251        // No separate "global" layer is created beyond the merged one.
1252        assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1253        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1254        let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
1255        assert_eq!(out.global().get(&q), Some(&Action::Quit));
1256        assert_eq!(out.global().get(&s), Some(&Action::Save));
1257    }
1258
1259    #[test]
1260    fn keys_and_explicit_global_colliding_on_a_chord_conflict_last_wins() {
1261        // The same chord in `[keys]` and `[layers.global]` is a within-layer
1262        // conflict (they are the same layer): reported, and the `[layers.global]`
1263        // entry — fed after `[keys]` — wins.
1264        let toml = "\
1265[keys]\n\"ctrl+q\" = \"quit\"\n\
1266[layers.global]\n\"ctrl+q\" = \"save\"\n";
1267        let out = from_str(toml, resolver).unwrap();
1268        assert_eq!(
1269            out.warnings,
1270            vec![Warning::Conflict {
1271                chord: "ctrl+q".to_string(),
1272                contenders: vec!["quit".to_string(), "save".to_string()],
1273                winner: "save".to_string(),
1274            }]
1275        );
1276        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1277        assert_eq!(out.global().get(&q), Some(&Action::Save));
1278    }
1279
1280    #[test]
1281    fn conflict_within_a_named_layer_is_reported() {
1282        // `ctrl+a` and `control+a` are the same chord spelled two ways; within one
1283        // layer that is a conflict, exactly as it is in the global layer.
1284        let toml = "\
1285[layers.panel]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n";
1286        let out = from_str(toml, resolver).unwrap();
1287        assert_eq!(
1288            out.warnings,
1289            vec![Warning::Conflict {
1290                chord: "ctrl+a".to_string(),
1291                contenders: vec!["save".to_string(), "quit".to_string()],
1292                winner: "quit".to_string(),
1293            }]
1294        );
1295    }
1296
1297    #[test]
1298    fn cross_shadow_is_checked_against_global_only() {
1299        // A chord `j` lives in `panel`, while the (global) sequence `j k` lives in
1300        // the global config. Because sequences are global and cross-shadow is a
1301        // global-layer-only check, the panel chord is *not* flagged.
1302        let toml = "\
1303[layers.panel]\n\"j\" = \"top\"\n\
1304[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
1305        let out = from_str(toml, resolver).unwrap();
1306        assert!(
1307            out.warnings.is_empty(),
1308            "a non-global chord must not cross-shadow a global sequence: {:?}",
1309            out.warnings
1310        );
1311    }
1312
1313    #[test]
1314    fn unknown_action_in_a_named_layer_is_a_warning_carrying_no_layer_name() {
1315        // A named layer goes through the same pipeline as global, so an unknown
1316        // action there is a `UnknownAction` warning (binding skipped, not fatal).
1317        // By design the warning names the chord, not the layer.
1318        let toml = "[layers.panel]\n\"ctrl+z\" = \"undo\"\n";
1319        let out = from_str(toml, resolver).unwrap();
1320        assert_eq!(
1321            out.warnings,
1322            vec![Warning::UnknownAction {
1323                key: "ctrl+z".to_string(),
1324                action: "undo".to_string(),
1325            }]
1326        );
1327        assert!(out.layers["panel"].is_empty());
1328    }
1329
1330    #[test]
1331    fn malformed_key_in_a_named_layer_is_a_fatal_error() {
1332        // Parse failures are fatal in any layer, not just `[keys]`.
1333        let toml = "[layers.panel]\n\"ctrl+nope\" = \"quit\"\n";
1334        let err = from_str(toml, resolver).unwrap_err();
1335        assert!(matches!(err, BuildError::KeyParse { .. }));
1336    }
1337
1338    #[test]
1339    fn sequences_do_not_create_extra_layers() {
1340        // A global-only config with sequences must still yield exactly the global
1341        // layer — sequences live beside the layers, not as one.
1342        let toml = "\
1343[keys]\n\"ctrl+q\" = \"quit\"\n\
1344[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n";
1345        let out = from_str(toml, resolver).unwrap();
1346        assert!(out.warnings.is_empty());
1347        assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
1348        assert!(!out.sequences.is_empty());
1349    }
1350
1351    #[test]
1352    fn unknown_actions_across_layers_warn_global_first_then_name_order() {
1353        // Warning order is deterministic: global first, then named layers in
1354        // `BTreeMap` (lexicographic) name order.
1355        let toml = "\
1356[keys]\n\"a\" = \"nope_global\"\n\
1357[layers.zeta]\n\"b\" = \"nope_zeta\"\n\
1358[layers.alpha]\n\"c\" = \"nope_alpha\"\n";
1359        let out = from_str(toml, resolver).unwrap();
1360        let unknown_actions: Vec<&str> = out
1361            .warnings
1362            .iter()
1363            .filter_map(|w| match w {
1364                Warning::UnknownAction { action, .. } => Some(action.as_str()),
1365                _ => None,
1366            })
1367            .collect();
1368        assert_eq!(
1369            unknown_actions,
1370            vec!["nope_global", "nope_alpha", "nope_zeta"]
1371        );
1372    }
1373
1374    #[test]
1375    fn unknown_top_level_field_is_a_fatal_error() {
1376        // `deny_unknown_fields`: a misspelled table is rejected, not ignored.
1377        let err = from_str("[kesy]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap_err();
1378        assert!(matches!(err, BuildError::Toml(_)));
1379    }
1380
1381    #[test]
1382    fn unknown_sequence_field_is_a_fatal_error() {
1383        // Guards the future `layer = "..."` extension: an unsupported sequence
1384        // field fails loudly today rather than being silently dropped.
1385        let toml = "[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\nlayer = \"panel\"\n";
1386        let err = from_str(toml, resolver).unwrap_err();
1387        assert!(matches!(err, BuildError::Toml(_)));
1388    }
1389
1390    #[test]
1391    fn unknown_action_is_a_warning_not_a_failure() {
1392        let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+z\" = \"undo\"\n";
1393        let out = from_str(toml, resolver).unwrap();
1394        // The good binding still works.
1395        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1396        assert_eq!(out.global().get(&q), Some(&Action::Quit));
1397        assert_eq!(
1398            out.warnings,
1399            vec![Warning::UnknownAction {
1400                key: "ctrl+z".to_string(),
1401                action: "undo".to_string(),
1402            }]
1403        );
1404    }
1405
1406    #[test]
1407    fn distinct_spellings_of_same_chord_conflict() {
1408        // Different spellings of the same chord: "ctrl+a" and the alias
1409        // "control+a". (Note "ctrl+A" would be a *different* chord, since the
1410        // glyph case is significant by design.)
1411        let toml = "[keys]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n";
1412        let out = from_str(toml, resolver).unwrap();
1413        let a = KeyInput::new(Key::Char('a'), Modifiers::CTRL);
1414        // One winner is bound for the shared chord.
1415        assert!(out.global().get(&a).is_some());
1416        let conflicts: Vec<_> = out
1417            .warnings
1418            .iter()
1419            .filter(|w| matches!(w, Warning::Conflict { .. }))
1420            .collect();
1421        assert_eq!(conflicts.len(), 1);
1422    }
1423
1424    #[test]
1425    fn legacy_lints_are_opt_in_and_separate_from_warnings() {
1426        // `cmd+s` is a perfectly valid binding — the config is clean — but it
1427        // won't reach a legacy terminal. That is NOT a build warning; it surfaces
1428        // only when the caller opts in by calling `legacy_lints`.
1429        let toml = "[keys]\n\"cmd+s\" = \"save\"\n";
1430        let out = from_str(toml, resolver).unwrap();
1431        assert!(out.warnings.is_empty());
1432        assert_eq!(
1433            keymap_core::legacy_lints(out.global()),
1434            vec![keymap_core::LegacyLint::Unrepresentable {
1435                chord: "super+s".to_string(),
1436            }]
1437        );
1438    }
1439
1440    #[test]
1441    fn malformed_key_is_a_fatal_error() {
1442        let toml = "[keys]\n\"ctrl+nope\" = \"quit\"\n";
1443        let err = from_str(toml, resolver).unwrap_err();
1444        assert!(matches!(err, BuildError::KeyParse { .. }));
1445    }
1446
1447    #[test]
1448    fn malformed_toml_is_a_fatal_error() {
1449        let err = from_str("this is not toml", resolver).unwrap_err();
1450        assert!(matches!(err, BuildError::Toml(_)));
1451    }
1452
1453    #[test]
1454    fn empty_config_builds_empty_map() {
1455        let out: BuildOutput<Action> = from_str("", resolver).unwrap();
1456        assert!(out.global().is_empty());
1457        assert!(out.sequences.is_empty());
1458        assert!(out.warnings.is_empty());
1459    }
1460
1461    #[test]
1462    fn builds_sequence_bindings() {
1463        let toml = "\
1464[keys]\n\"ctrl+q\" = \"quit\"\n\
1465[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n\
1466[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+c\"]\naction = \"quit\"\n";
1467        let out = from_str(toml, resolver).unwrap();
1468        assert!(out.warnings.is_empty());
1469        // Single chord still lands in the single-key map.
1470        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
1471        assert_eq!(out.global().get(&q), Some(&Action::Quit));
1472        // The sequence resolves; a partial buffer is a prefix.
1473        let save = seq(&[('x', Modifiers::CTRL), ('s', Modifiers::CTRL)]);
1474        assert_eq!(out.sequences.lookup(&save), Match::Exact(&Action::Save));
1475        assert_eq!(out.sequences.lookup(&save[..1]), Match::Prefix);
1476    }
1477
1478    #[test]
1479    fn sequence_unknown_action_is_a_warning() {
1480        let toml = "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+z\"]\naction = \"undo\"\n";
1481        let out = from_str(toml, resolver).unwrap();
1482        assert!(out.sequences.is_empty());
1483        assert_eq!(
1484            out.warnings,
1485            vec![Warning::UnknownAction {
1486                key: "ctrl+x ctrl+z".to_string(),
1487                action: "undo".to_string(),
1488            }]
1489        );
1490    }
1491
1492    #[test]
1493    fn sequence_prefix_shadow_is_a_warning_and_drops_the_later() {
1494        // `g` (top) then `g g` (split): the shorter shadows the longer.
1495        let toml = "\
1496[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\n\
1497[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"split\"\n";
1498        let out = from_str(toml, resolver).unwrap();
1499        // The earlier (shorter) binding survives; the later is dropped.
1500        assert_eq!(
1501            out.sequences.lookup(&seq(&[('g', Modifiers::NONE)])),
1502            Match::Exact(&Action::Top)
1503        );
1504        assert_eq!(
1505            out.warnings,
1506            vec![Warning::PrefixShadow {
1507                prefix: vec!["g".to_string()],
1508                prefix_action: "top".to_string(),
1509                shadowed: vec!["g".to_string(), "g".to_string()],
1510                shadowed_action: "split".to_string(),
1511            }]
1512        );
1513    }
1514
1515    #[test]
1516    fn sequence_prefix_shadow_reverse_order_drops_the_later_short_one() {
1517        // Longer bound first, then the shorter prefix arrives: the shorter is the
1518        // later binding and is dropped, but it is still labelled the `prefix`.
1519        let toml = "\
1520[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"split\"\n\
1521[[sequences]]\nkeys = [\"g\"]\naction = \"top\"\n";
1522        let out = from_str(toml, resolver).unwrap();
1523        // The earlier (longer) binding survives.
1524        assert_eq!(
1525            out.sequences
1526                .lookup(&seq(&[('g', Modifiers::NONE), ('g', Modifiers::NONE)])),
1527            Match::Exact(&Action::Split)
1528        );
1529        assert_eq!(
1530            out.warnings,
1531            vec![Warning::PrefixShadow {
1532                prefix: vec!["g".to_string()],
1533                prefix_action: "top".to_string(),
1534                shadowed: vec!["g".to_string(), "g".to_string()],
1535                shadowed_action: "split".to_string(),
1536            }]
1537        );
1538    }
1539
1540    #[test]
1541    fn same_sequence_three_times_reports_pairwise_conflicts() {
1542        let toml = "\
1543[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"save\"\n\
1544[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"split\"\n\
1545[[sequences]]\nkeys = [\"ctrl+x\"]\naction = \"quit\"\n";
1546        let out = from_str(toml, resolver).unwrap();
1547        // Last one wins.
1548        assert_eq!(
1549            out.sequences.lookup(&seq(&[('x', Modifiers::CTRL)])),
1550            Match::Exact(&Action::Quit)
1551        );
1552        // Two pairwise conflicts, each naming the running winner vs the newcomer.
1553        assert_eq!(
1554            out.warnings,
1555            vec![
1556                Warning::Conflict {
1557                    chord: "ctrl+x".to_string(),
1558                    contenders: vec!["save".to_string(), "split".to_string()],
1559                    winner: "split".to_string(),
1560                },
1561                Warning::Conflict {
1562                    chord: "ctrl+x".to_string(),
1563                    contenders: vec!["split".to_string(), "quit".to_string()],
1564                    winner: "quit".to_string(),
1565                },
1566            ]
1567        );
1568    }
1569
1570    #[test]
1571    fn empty_sequence_is_a_warning() {
1572        let toml = "[[sequences]]\nkeys = []\naction = \"save\"\n";
1573        let out = from_str(toml, resolver).unwrap();
1574        assert!(out.sequences.is_empty());
1575        assert_eq!(
1576            out.warnings,
1577            vec![Warning::EmptySequence {
1578                action: "save".to_string(),
1579            }]
1580        );
1581    }
1582
1583    #[test]
1584    fn duplicate_sequence_conflicts_and_last_wins() {
1585        let toml = "\
1586[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n\
1587[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"split\"\n";
1588        let out = from_str(toml, resolver).unwrap();
1589        let s = seq(&[('x', Modifiers::CTRL), ('s', Modifiers::CTRL)]);
1590        assert_eq!(out.sequences.lookup(&s), Match::Exact(&Action::Split));
1591        assert_eq!(
1592            out.warnings,
1593            vec![Warning::Conflict {
1594                chord: "ctrl+x ctrl+s".to_string(),
1595                contenders: vec!["save".to_string(), "split".to_string()],
1596                winner: "split".to_string(),
1597            }]
1598        );
1599    }
1600
1601    #[test]
1602    fn malformed_sequence_key_is_a_fatal_error() {
1603        let toml = "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+nope\"]\naction = \"save\"\n";
1604        let err = from_str(toml, resolver).unwrap_err();
1605        assert!(matches!(err, BuildError::KeyParse { .. }));
1606    }
1607
1608    #[test]
1609    fn single_chord_shadowing_a_sequence_is_an_advisory_warning() {
1610        // `j` (down) and a sequence `j j` (the vim `jj` case): pressing `j` is
1611        // ambiguous without a timeout. Both bindings are kept — only flagged.
1612        let toml = "\
1613[keys]\n\"j\" = \"top\"\n\
1614[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
1615        let out = from_str(toml, resolver).unwrap();
1616        // Nothing dropped: the chord resolves and the sequence resolves.
1617        assert_eq!(
1618            out.global()
1619                .get(&KeyInput::new(Key::Char('j'), Modifiers::NONE)),
1620            Some(&Action::Top)
1621        );
1622        assert_eq!(
1623            out.sequences
1624                .lookup(&seq(&[('j', Modifiers::NONE), ('k', Modifiers::NONE)])),
1625            Match::Exact(&Action::Save)
1626        );
1627        assert_eq!(
1628            out.warnings,
1629            vec![Warning::SequenceShadow {
1630                chord: "j".to_string(),
1631                chord_action: "top".to_string(),
1632                sequence: vec!["j".to_string(), "k".to_string()],
1633                sequence_action: "save".to_string(),
1634            }]
1635        );
1636    }
1637
1638    #[test]
1639    fn length_one_sequence_equal_to_a_single_chord_shadows() {
1640        // A degenerate overlap: the single chord and a length-1 sequence are the
1641        // exact same key, bound in both maps.
1642        let toml = "\
1643[keys]\n\"q\" = \"quit\"\n\
1644[[sequences]]\nkeys = [\"q\"]\naction = \"save\"\n";
1645        let out = from_str(toml, resolver).unwrap();
1646        assert_eq!(
1647            out.warnings,
1648            vec![Warning::SequenceShadow {
1649                chord: "q".to_string(),
1650                chord_action: "quit".to_string(),
1651                sequence: vec!["q".to_string()],
1652                sequence_action: "save".to_string(),
1653            }]
1654        );
1655    }
1656
1657    #[test]
1658    fn disjoint_first_keys_do_not_shadow() {
1659        // `j` single, `g g` sequence: different first keys, no overlap.
1660        let toml = "\
1661[keys]\n\"j\" = \"top\"\n\
1662[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"save\"\n";
1663        let out = from_str(toml, resolver).unwrap();
1664        assert!(out.warnings.is_empty());
1665    }
1666
1667    #[test]
1668    fn one_chord_shadowing_several_sequences_reports_the_lex_first() {
1669        // `j` starts both `j k` and `j a`; one advisory names the lex-first (`j a`).
1670        let toml = "\
1671[keys]\n\"j\" = \"top\"\n\
1672[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n\
1673[[sequences]]\nkeys = [\"j\", \"a\"]\naction = \"quit\"\n";
1674        let out = from_str(toml, resolver).unwrap();
1675        assert_eq!(
1676            out.warnings,
1677            vec![Warning::SequenceShadow {
1678                chord: "j".to_string(),
1679                chord_action: "top".to_string(),
1680                sequence: vec!["j".to_string(), "a".to_string()],
1681                sequence_action: "quit".to_string(),
1682            }]
1683        );
1684    }
1685
1686    #[test]
1687    fn multiple_shadowing_chords_emit_in_canonical_chord_order() {
1688        // `z` is declared before `j`, but warnings come out chord-sorted (`j`
1689        // before `z`) — pins the sort, which the HashMap source order would not
1690        // otherwise guarantee.
1691        let toml = "\
1692[keys]\n\"z\" = \"top\"\n\"j\" = \"quit\"\n\
1693[[sequences]]\nkeys = [\"z\", \"x\"]\naction = \"save\"\n\
1694[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"split\"\n";
1695        let out = from_str(toml, resolver).unwrap();
1696        assert_eq!(
1697            out.warnings,
1698            vec![
1699                Warning::SequenceShadow {
1700                    chord: "j".to_string(),
1701                    chord_action: "quit".to_string(),
1702                    sequence: vec!["j".to_string(), "k".to_string()],
1703                    sequence_action: "split".to_string(),
1704                },
1705                Warning::SequenceShadow {
1706                    chord: "z".to_string(),
1707                    chord_action: "top".to_string(),
1708                    sequence: vec!["z".to_string(), "x".to_string()],
1709                    sequence_action: "save".to_string(),
1710                },
1711            ]
1712        );
1713    }
1714
1715    #[test]
1716    fn unknown_single_chord_does_not_shadow() {
1717        // `j` resolves to nothing, so it never enters the keymap — it cannot
1718        // shadow. Only the UnknownAction warning fires, no SequenceShadow.
1719        let toml = "\
1720[keys]\n\"j\" = \"nonexistent\"\n\
1721[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"save\"\n";
1722        let out = from_str(toml, resolver).unwrap();
1723        assert_eq!(
1724            out.warnings,
1725            vec![Warning::UnknownAction {
1726                key: "j".to_string(),
1727                action: "nonexistent".to_string(),
1728            }]
1729        );
1730    }
1731
1732    #[test]
1733    fn cross_shadow_coexists_with_conflict_and_comes_last() {
1734        // A single-key Conflict and a SequenceShadow in one config: detection
1735        // appends after the key/sequence passes, so the shadow is emitted last.
1736        let toml = "\
1737[keys]\n\"ctrl+a\" = \"quit\"\n\"control+a\" = \"save\"\n\"j\" = \"top\"\n\
1738[[sequences]]\nkeys = [\"j\", \"k\"]\naction = \"split\"\n";
1739        let out = from_str(toml, resolver).unwrap();
1740        assert_eq!(
1741            out.warnings,
1742            vec![
1743                // The `[keys]` table is a BTreeMap, so "control+a" (save) sorts
1744                // before "ctrl+a" (quit); save is the earlier contender, quit wins.
1745                Warning::Conflict {
1746                    chord: "ctrl+a".to_string(),
1747                    contenders: vec!["save".to_string(), "quit".to_string()],
1748                    winner: "quit".to_string(),
1749                },
1750                Warning::SequenceShadow {
1751                    chord: "j".to_string(),
1752                    chord_action: "top".to_string(),
1753                    sequence: vec!["j".to_string(), "k".to_string()],
1754                    sequence_action: "split".to_string(),
1755                },
1756            ]
1757        );
1758    }
1759
1760    #[test]
1761    fn chord_matching_a_non_first_sequence_key_does_not_shadow() {
1762        // `j` is only the *second* key of `g j`; the predicate is `first() ==`,
1763        // not "contains", so this must not warn.
1764        let toml = "\
1765[keys]\n\"j\" = \"top\"\n\
1766[[sequences]]\nkeys = [\"g\", \"j\"]\naction = \"save\"\n";
1767        let out = from_str(toml, resolver).unwrap();
1768        assert!(out.warnings.is_empty());
1769    }
1770
1771    // --- to_toml round-trip ---
1772    //
1773    // These use `String` as the action type, so `name_of`/`resolve` are exact
1774    // inverses (`|a| Some(a)` / `|n| Some(n.to_owned())`) and any round-trip
1775    // failure is the serializer's, not the resolver's.
1776
1777    fn km_pairs(km: &Keymap<String>) -> Vec<(KeyInput, String)> {
1778        let mut v: Vec<_> = km.iter().map(|(k, a)| (*k, a.clone())).collect();
1779        v.sort_by_key(|(k, _)| k.to_string());
1780        v
1781    }
1782
1783    fn seq_pairs(s: &SequenceKeymap<String>) -> Vec<(Vec<KeyInput>, String)> {
1784        let mut v: Vec<_> = s.bindings().map(|(p, a)| (p, a.clone())).collect();
1785        v.sort_by_key(|(p, _)| render_sequence(p).join(" "));
1786        v
1787    }
1788
1789    /// `to_toml` then `from_str` reproduces the same bindings.
1790    fn assert_round_trips(km: &Keymap<String>, seq: &SequenceKeymap<String>) {
1791        let toml = to_toml(km, seq, |a: &String| Some(a.as_str()));
1792        let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1793        assert!(
1794            out.warnings.is_empty(),
1795            "round-trip warned: {:?}",
1796            out.warnings
1797        );
1798        assert_eq!(km_pairs(km), km_pairs(out.global()));
1799        assert_eq!(seq_pairs(seq), seq_pairs(&out.sequences));
1800    }
1801
1802    fn norm(key: Key, mods: Modifiers) -> KeyInput {
1803        KeyInput::normalized(key, mods)
1804    }
1805
1806    #[test]
1807    fn to_toml_round_trips_keys_and_sequences() {
1808        let mut km = Keymap::new();
1809        km.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1810        km.bind(norm(Key::Char('s'), Modifiers::CTRL), "save".to_owned());
1811        // Normalization fixed point: bare shift+letter folds to the letter, so it
1812        // emits as "a" and round-trips to the same normalized chord.
1813        km.bind(norm(Key::Char('a'), Modifiers::SHIFT), "all".to_owned());
1814        // Multi-modifier keeps shift: "ctrl+shift+a" survives as itself.
1815        km.bind(
1816            norm(Key::Char('a'), Modifiers::CTRL | Modifiers::SHIFT),
1817            "alt_all".to_owned(),
1818        );
1819
1820        let mut seq = SequenceKeymap::new();
1821        seq.bind(
1822            [
1823                norm(Key::Char('x'), Modifiers::CTRL),
1824                norm(Key::Char('s'), Modifiers::CTRL),
1825            ],
1826            "seq_save".to_owned(),
1827        )
1828        .unwrap();
1829        seq.bind(
1830            [
1831                norm(Key::Char('g'), Modifiers::NONE),
1832                norm(Key::Char('g'), Modifiers::NONE),
1833            ],
1834            "top".to_owned(),
1835        )
1836        .unwrap();
1837
1838        assert_round_trips(&km, &seq);
1839    }
1840
1841    #[test]
1842    fn to_toml_is_deterministic_and_canonically_ordered() {
1843        let mut km = Keymap::new();
1844        km.bind(norm(Key::Char('z'), Modifiers::CTRL), "z".to_owned());
1845        km.bind(norm(Key::Char('a'), Modifiers::CTRL), "a".to_owned());
1846        let seq = SequenceKeymap::new();
1847
1848        let first = to_toml(&km, &seq, |a: &String| Some(a.as_str()));
1849        let second = to_toml(&km, &seq, |a: &String| Some(a.as_str()));
1850        assert_eq!(first, second, "output must be deterministic");
1851        // Canonical chord order: "ctrl+a" before "ctrl+z" regardless of bind order.
1852        let a_at = first.find("ctrl+a").unwrap();
1853        let z_at = first.find("ctrl+z").unwrap();
1854        assert!(
1855            a_at < z_at,
1856            "keys must be emitted in canonical order:\n{first}"
1857        );
1858    }
1859
1860    #[test]
1861    fn to_toml_omits_unnameable_bindings() {
1862        let mut km = Keymap::new();
1863        km.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1864        km.bind(norm(Key::Char('x'), Modifiers::CTRL), "secret".to_owned());
1865        let seq = SequenceKeymap::new();
1866
1867        // `name_of` declines to name "secret", so that binding is dropped.
1868        let toml = to_toml(&km, &seq, |a: &String| {
1869            (a != "secret").then_some(a.as_str())
1870        });
1871        let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1872        assert_eq!(
1873            out.global().get(&norm(Key::Char('q'), Modifiers::CTRL)),
1874            Some(&"quit".to_owned())
1875        );
1876        assert_eq!(
1877            out.global().get(&norm(Key::Char('x'), Modifiers::CTRL)),
1878            None
1879        );
1880    }
1881
1882    #[test]
1883    fn to_toml_empty_maps_emit_empty_string() {
1884        let km: Keymap<String> = Keymap::new();
1885        let seq: SequenceKeymap<String> = SequenceKeymap::new();
1886        assert_eq!(to_toml(&km, &seq, |a: &String| Some(a.as_str())), "");
1887    }
1888
1889    #[test]
1890    fn to_toml_round_trips_adversarial_names_and_chords() {
1891        // Action names with TOML metacharacters must not break out of their
1892        // string and inject bindings; emitting via `toml::Value` escapes them.
1893        let mut km = Keymap::new();
1894        km.bind(
1895            norm(Key::Char('a'), Modifiers::NONE),
1896            "quit\"; [injected]\nx = \"oops".to_owned(),
1897        );
1898        // Chords whose glyph collides with grammar/TOML punctuation.
1899        km.bind(norm(Key::Char('+'), Modifiers::NONE), "plus".to_owned());
1900        km.bind(norm(Key::Char(' '), Modifiers::NONE), "space".to_owned());
1901        km.bind(norm(Key::Char('"'), Modifiers::NONE), "quote".to_owned());
1902        km.bind(norm(Key::Char('あ'), Modifiers::NONE), "hira".to_owned());
1903        km.bind(norm(Key::Char('F'), Modifiers::NONE), "cap_f".to_owned());
1904
1905        // Sequence starts with an unbound key (`z`) so it does not cross-shadow
1906        // the single-key ` `/`+` bindings; ` ` and `+` still appear as non-first
1907        // elements to exercise array-element round-tripping of odd glyphs.
1908        let mut seq = SequenceKeymap::new();
1909        seq.bind(
1910            [
1911                norm(Key::Char('z'), Modifiers::NONE),
1912                norm(Key::Char(' '), Modifiers::NONE),
1913                norm(Key::Char('+'), Modifiers::NONE),
1914            ],
1915            "z_space_plus".to_owned(),
1916        )
1917        .unwrap();
1918
1919        assert_round_trips(&km, &seq);
1920    }
1921
1922    #[test]
1923    fn shadow_matching_is_on_the_parsed_chord_not_the_label() {
1924        // Positive: `ctrl+x` single shadows `[ctrl+x, ctrl+s]`.
1925        let toml = "\
1926[keys]\n\"ctrl+x\" = \"top\"\n\
1927[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save\"\n";
1928        let out = from_str(toml, resolver).unwrap();
1929        assert_eq!(
1930            out.warnings,
1931            vec![Warning::SequenceShadow {
1932                chord: "ctrl+x".to_string(),
1933                chord_action: "top".to_string(),
1934                sequence: vec!["ctrl+x".to_string(), "ctrl+s".to_string()],
1935                sequence_action: "save".to_string(),
1936            }]
1937        );
1938
1939        // Negative: a different modifier set is a different KeyInput, no shadow.
1940        let toml = "\
1941[keys]\n\"ctrl+x\" = \"top\"\n\
1942[[sequences]]\nkeys = [\"ctrl+shift+x\", \"ctrl+s\"]\naction = \"save\"\n";
1943        let out = from_str(toml, resolver).unwrap();
1944        assert!(out.warnings.is_empty());
1945    }
1946
1947    #[test]
1948    fn to_toml_layered_round_trips_named_layers() {
1949        let mut global = Keymap::new();
1950        global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1951        let mut panel = Keymap::new();
1952        panel.bind(norm(Key::Char('s'), Modifiers::CTRL), "split".to_owned());
1953        // The same chord in two layers is preserved independently on round-trip.
1954        panel.bind(
1955            norm(Key::Char('q'), Modifiers::CTRL),
1956            "panel_quit".to_owned(),
1957        );
1958
1959        let mut layers = BTreeMap::new();
1960        layers.insert(GLOBAL_LAYER.to_string(), global);
1961        layers.insert("panel".to_string(), panel);
1962
1963        let mut seq = SequenceKeymap::new();
1964        seq.bind(
1965            [
1966                norm(Key::Char('x'), Modifiers::CTRL),
1967                norm(Key::Char('s'), Modifiers::CTRL),
1968            ],
1969            "seq_save".to_owned(),
1970        )
1971        .unwrap();
1972
1973        let toml = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
1974        let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
1975        assert!(
1976            out.warnings.is_empty(),
1977            "round-trip warned: {:?}",
1978            out.warnings
1979        );
1980        assert_eq!(km_pairs(&layers["global"]), km_pairs(out.global()));
1981        assert_eq!(km_pairs(&layers["panel"]), km_pairs(&out.layers["panel"]));
1982        assert_eq!(seq_pairs(&seq), seq_pairs(&out.sequences));
1983    }
1984
1985    #[test]
1986    fn to_toml_layered_matches_to_toml_for_a_global_only_set() {
1987        // A set with only the global layer must emit byte-identical output to
1988        // `to_toml` on that one keymap, so the two emitters cannot drift.
1989        let mut global = Keymap::new();
1990        global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
1991        global.bind(norm(Key::Char('s'), Modifiers::CTRL), "save".to_owned());
1992        let mut seq = SequenceKeymap::new();
1993        seq.bind(
1994            [
1995                norm(Key::Char('g'), Modifiers::NONE),
1996                norm(Key::Char('g'), Modifiers::NONE),
1997            ],
1998            "top".to_owned(),
1999        )
2000        .unwrap();
2001
2002        let mut layers = BTreeMap::new();
2003        layers.insert(GLOBAL_LAYER.to_string(), global.clone());
2004
2005        let plain = to_toml(&global, &seq, |a: &String| Some(a.as_str()));
2006        let layered = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
2007        assert_eq!(plain, layered);
2008        // And an empty global-only set emits the empty string, like `to_toml`.
2009        let empty_layers: BTreeMap<String, Keymap<String>> = BTreeMap::new();
2010        assert_eq!(
2011            to_toml_layered(&empty_layers, &SequenceKeymap::new(), |a: &String| Some(
2012                a.as_str()
2013            )),
2014            ""
2015        );
2016    }
2017
2018    #[test]
2019    fn to_toml_layered_drops_empty_layers() {
2020        // An empty layer emits no table, so it does not survive a round-trip.
2021        // This is the deliberate asymmetry: `from_str` keeps a declared-but-empty
2022        // layer in the map, but `to_toml_layered` writes only layers with bindings.
2023        let mut global = Keymap::new();
2024        global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
2025        let mut layers = BTreeMap::new();
2026        layers.insert(GLOBAL_LAYER.to_string(), global);
2027        layers.insert("panel".to_string(), Keymap::<String>::new());
2028
2029        let toml = to_toml_layered(&layers, &SequenceKeymap::new(), |a: &String| {
2030            Some(a.as_str())
2031        });
2032        assert!(
2033            !toml.contains("panel"),
2034            "empty layer must not be emitted:\n{toml}"
2035        );
2036        let out = from_str(&toml, |name: &str| Some(name.to_owned())).unwrap();
2037        assert_eq!(out.layers.keys().collect::<Vec<_>>(), vec![GLOBAL_LAYER]);
2038    }
2039
2040    // ─── chord tombstone (= false) tests ───────────────────────────────────────
2041
2042    /// In 0.1.0 `"chord" = false` produced a `BuildError::Toml`. This test
2043    /// pins that it is now `Allowed` (returns a `BuildOutput`, not an error)
2044    /// and the chord lands in `unbinds`, not in the keymap.
2045    #[test]
2046    fn tombstone_false_was_build_error_in_0_1_0_now_accepted() {
2047        // The key fact: this used to error; it now succeeds.
2048        let toml = "[keys]\n\"ctrl+q\" = false\n";
2049        let out = from_str(toml, resolver).unwrap();
2050        // The chord is NOT in the keymap.
2051        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
2052        assert_eq!(out.global().get(&q), None);
2053        // The chord IS in unbinds.
2054        let global_unbinds = out
2055            .unbinds
2056            .get(GLOBAL_LAYER)
2057            .expect("global unbinds present");
2058        assert!(global_unbinds.contains(&q));
2059        // No warnings: tombstones are silent.
2060        assert!(out.warnings.is_empty());
2061    }
2062
2063    /// `"chord" = true` remains a `BuildError::InvalidTombstone`.
2064    #[test]
2065    fn tombstone_true_is_still_an_error() {
2066        let toml = "[keys]\n\"ctrl+q\" = true\n";
2067        let err = from_str(toml, resolver).unwrap_err();
2068        assert!(
2069            matches!(err, BuildError::InvalidTombstone { .. }),
2070            "expected InvalidTombstone, got {err:?}"
2071        );
2072    }
2073
2074    #[test]
2075    fn tombstone_in_named_layer_lands_in_unbinds_not_keymap() {
2076        let toml = "[layers.panel]\n\"ctrl+s\" = false\n";
2077        let out = from_str(toml, resolver).unwrap();
2078        let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
2079        assert!(out.layers["panel"].is_empty());
2080        let panel_unbinds = out.unbinds.get("panel").expect("panel unbinds present");
2081        assert!(panel_unbinds.contains(&s));
2082    }
2083
2084    #[test]
2085    fn tombstone_coexists_with_bindings_in_same_layer() {
2086        // A layer can have both a binding and a tombstone.
2087        let toml = "[keys]\n\"ctrl+q\" = \"quit\"\n\"ctrl+s\" = false\n";
2088        let out = from_str(toml, resolver).unwrap();
2089        let q = KeyInput::new(Key::Char('q'), Modifiers::CTRL);
2090        let s = KeyInput::new(Key::Char('s'), Modifiers::CTRL);
2091        assert_eq!(out.global().get(&q), Some(&Action::Quit));
2092        assert_eq!(out.global().get(&s), None);
2093        let global_unbinds = out.unbinds.get(GLOBAL_LAYER).unwrap();
2094        assert!(global_unbinds.contains(&s));
2095    }
2096
2097    #[test]
2098    fn empty_config_has_empty_unbinds() {
2099        // Configs with no tombstones have an empty unbinds map.
2100        let out: BuildOutput<Action> =
2101            from_str("[keys]\n\"ctrl+q\" = \"quit\"\n", resolver).unwrap();
2102        assert!(out.unbinds.is_empty());
2103    }
2104
2105    // ─── to_toml_layered tombstone round-trip tests ─────────────────────────
2106
2107    /// When `unbinds` is empty, `to_toml_layered_with_unbinds` is byte-identical
2108    /// to `to_toml_layered` — this pins that the additive version does not change
2109    /// the output for callers that never use tombstones.
2110    #[test]
2111    fn to_toml_layered_with_empty_unbinds_matches_0_1_0_behavior() {
2112        let mut global = Keymap::new();
2113        global.bind(norm(Key::Char('q'), Modifiers::CTRL), "quit".to_owned());
2114        let mut layers = BTreeMap::new();
2115        layers.insert(GLOBAL_LAYER.to_string(), global.clone());
2116        let seq = SequenceKeymap::new();
2117        let empty_unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
2118
2119        let plain = to_toml_layered(&layers, &seq, |a: &String| Some(a.as_str()));
2120        let with_empty =
2121            to_toml_layered_with_unbinds(&layers, &seq, &empty_unbinds, |a: &String| {
2122                Some(a.as_str())
2123            });
2124        assert_eq!(
2125            plain, with_empty,
2126            "empty unbinds must produce byte-identical output to to_toml_layered"
2127        );
2128    }
2129
2130    #[test]
2131    fn to_toml_layered_emits_tombstones_and_round_trips() {
2132        // A tombstone in the global layer round-trips through to_toml_layered →
2133        // from_str and appears again in unbinds.
2134        let global: Keymap<String> = Keymap::new(); // nothing bound
2135        let mut layers = BTreeMap::new();
2136        layers.insert(GLOBAL_LAYER.to_string(), global);
2137
2138        let ctrl_s = norm(Key::Char('s'), Modifiers::CTRL);
2139        let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
2140        unbinds.insert(GLOBAL_LAYER.to_string(), vec![ctrl_s]);
2141
2142        let toml_str = to_toml_layered_with_unbinds(
2143            &layers,
2144            &SequenceKeymap::new(),
2145            &unbinds,
2146            |a: &String| Some(a.as_str()),
2147        );
2148        // The tombstone must appear in the output.
2149        assert!(
2150            toml_str.contains("false"),
2151            "tombstone must be emitted as `= false`:\n{toml_str}"
2152        );
2153        // Round-trip: parsing the emitted TOML recovers the unbind.
2154        let out = from_str(&toml_str, |name: &str| Some(name.to_owned())).unwrap();
2155        let global_unbinds = out
2156            .unbinds
2157            .get(GLOBAL_LAYER)
2158            .expect("global unbinds present");
2159        assert!(global_unbinds.contains(&ctrl_s));
2160        assert!(out.warnings.is_empty());
2161    }
2162
2163    #[test]
2164    fn to_toml_layered_tombstone_injection_safe() {
2165        // A chord whose string representation contains TOML metacharacters must
2166        // not be able to inject extra entries. The tombstone is emitted via
2167        // `toml::Value::Boolean(false)` — the same injection-safe path as strings.
2168        // This test uses a space chord (which emits as `" "`) to exercise quoting.
2169        let space = norm(Key::Char(' '), Modifiers::NONE);
2170        let layers: BTreeMap<String, Keymap<String>> = BTreeMap::new();
2171        let mut unbinds: BTreeMap<String, Vec<KeyInput>> = BTreeMap::new();
2172        unbinds.insert(GLOBAL_LAYER.to_string(), vec![space]);
2173
2174        let toml_str = to_toml_layered_with_unbinds(
2175            &layers,
2176            &SequenceKeymap::new(),
2177            &unbinds,
2178            |a: &String| Some(a.as_str()),
2179        );
2180        // Must round-trip cleanly (no injection = no parse error).
2181        let out = from_str(&toml_str, |name: &str| Some(name.to_owned())).unwrap();
2182        let global_unbinds = out
2183            .unbinds
2184            .get(GLOBAL_LAYER)
2185            .expect("global unbinds present");
2186        assert!(global_unbinds.contains(&space));
2187    }
2188
2189    // ─── merge tests ─────────────────────────────────────────────────────────
2190
2191    fn make_output(toml: &str) -> BuildOutput<String> {
2192        from_str(toml, |name: &str| Some(name.to_owned())).unwrap()
2193    }
2194
2195    #[test]
2196    fn merge_overlay_chord_wins_silently_with_override_note() {
2197        let base = make_output("[keys]\n\"ctrl+s\" = \"save_base\"\n");
2198        let overlay = make_output("[keys]\n\"ctrl+s\" = \"save_overlay\"\n");
2199        let merged = merge(base, overlay);
2200
2201        let s = norm(Key::Char('s'), Modifiers::CTRL);
2202        assert_eq!(
2203            merged.output.global().get(&s),
2204            Some(&"save_overlay".to_owned()),
2205            "overlay wins"
2206        );
2207        assert!(
2208            merged.output.warnings.is_empty(),
2209            "override must not produce a Warning"
2210        );
2211        assert!(
2212            merged.notes.iter().any(|n| matches!(n,
2213                MergeNote::Overrode { layer, chord }
2214                if layer == GLOBAL_LAYER && chord == "ctrl+s"
2215            )),
2216            "Overrode note must be emitted: {:?}",
2217            merged.notes
2218        );
2219    }
2220
2221    #[test]
2222    fn merge_layer_union_adds_layers_from_overlay() {
2223        let base = make_output("[keys]\n\"ctrl+q\" = \"quit\"\n");
2224        let overlay = make_output("[layers.panel]\n\"ctrl+s\" = \"split\"\n");
2225        let merged = merge(base, overlay);
2226
2227        assert!(
2228            merged.output.layers.contains_key("panel"),
2229            "panel layer added"
2230        );
2231        let s = norm(Key::Char('s'), Modifiers::CTRL);
2232        assert_eq!(
2233            merged.output.layers["panel"].get(&s),
2234            Some(&"split".to_owned())
2235        );
2236        assert!(
2237            merged.notes.is_empty(),
2238            "no notes for pure add: {:?}",
2239            merged.notes
2240        );
2241    }
2242
2243    #[test]
2244    fn merge_sequence_exact_overlay_wins() {
2245        let base =
2246            make_output("[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save_base\"\n");
2247        let overlay = make_output(
2248            "[[sequences]]\nkeys = [\"ctrl+x\", \"ctrl+s\"]\naction = \"save_overlay\"\n",
2249        );
2250        let merged = merge(base, overlay);
2251
2252        let xs = vec![
2253            norm(Key::Char('x'), Modifiers::CTRL),
2254            norm(Key::Char('s'), Modifiers::CTRL),
2255        ];
2256        assert_eq!(
2257            merged.output.sequences.lookup(&xs),
2258            keymap_seq::Match::Exact(&"save_overlay".to_owned()),
2259            "overlay sequence wins"
2260        );
2261        assert!(merged.output.warnings.is_empty());
2262    }
2263
2264    #[test]
2265    fn merge_sequence_prefix_clash_drops_base_with_note() {
2266        // Base has `g g`; overlay has `g`. The overlay's shorter sequence is a
2267        // prefix of the base's longer one. The base sequence is dropped.
2268        let base = make_output("[[sequences]]\nkeys = [\"g\", \"g\"]\naction = \"top_base\"\n");
2269        let overlay = make_output("[[sequences]]\nkeys = [\"g\"]\naction = \"top_overlay\"\n");
2270        let merged = merge(base, overlay);
2271
2272        let g = vec![norm(Key::Char('g'), Modifiers::NONE)];
2273        assert_eq!(
2274            merged.output.sequences.lookup(&g),
2275            keymap_seq::Match::Exact(&"top_overlay".to_owned()),
2276            "overlay wins"
2277        );
2278        assert!(
2279            merged
2280                .notes
2281                .iter()
2282                .any(|n| matches!(n, MergeNote::DroppedSequence { .. })),
2283            "DroppedSequence note must be emitted: {:?}",
2284            merged.notes
2285        );
2286    }
2287
2288    #[test]
2289    fn merge_unbind_removes_from_base_with_unbound_note() {
2290        let base = make_output("[keys]\n\"ctrl+s\" = \"save\"\n");
2291        let overlay = make_output("[keys]\n\"ctrl+s\" = false\n");
2292        let merged = merge(base, overlay);
2293
2294        let s = norm(Key::Char('s'), Modifiers::CTRL);
2295        assert_eq!(
2296            merged.output.global().get(&s),
2297            None,
2298            "chord removed from base"
2299        );
2300        assert!(
2301            merged.notes.iter().any(|n| matches!(n,
2302                MergeNote::Unbound { layer, chord }
2303                if layer == GLOBAL_LAYER && chord == "ctrl+s"
2304            )),
2305            "Unbound note must be emitted: {:?}",
2306            merged.notes
2307        );
2308        assert!(merged.output.warnings.is_empty());
2309    }
2310
2311    #[test]
2312    fn merge_unbind_of_absent_chord_produces_miss_note() {
2313        let base = make_output("[keys]\n\"ctrl+q\" = \"quit\"\n");
2314        let overlay = make_output("[keys]\n\"ctrl+s\" = false\n");
2315        let merged = merge(base, overlay);
2316
2317        assert!(
2318            merged.notes.iter().any(|n| matches!(n,
2319                MergeNote::UnbindMiss { chord, .. }
2320                if chord == "ctrl+s"
2321            )),
2322            "UnbindMiss note expected: {:?}",
2323            merged.notes
2324        );
2325        // The present binding is untouched.
2326        let q = norm(Key::Char('q'), Modifiers::CTRL);
2327        assert_eq!(merged.output.global().get(&q), Some(&"quit".to_owned()));
2328    }
2329
2330    #[test]
2331    fn merge_warnings_are_concatenated_not_mixed_into_notes() {
2332        // Both base and overlay have warnings (unknown action names); they must
2333        // appear in output.warnings, not in notes. Use `resolver` which only
2334        // knows "quit"/"save"/"split"/"top" so "nope_*" is an UnknownAction.
2335        let base = from_str("[keys]\n\"ctrl+z\" = \"nope_base\"\n", resolver).unwrap();
2336        let overlay = from_str("[keys]\n\"ctrl+y\" = \"nope_overlay\"\n", resolver).unwrap();
2337        assert_eq!(base.warnings.len(), 1);
2338        assert_eq!(overlay.warnings.len(), 1);
2339        let merged = merge(base, overlay);
2340
2341        assert_eq!(merged.output.warnings.len(), 2, "both warnings carried");
2342        assert!(merged.notes.is_empty());
2343    }
2344
2345    // ─── WarningKind + Display for Warning (S5) ──────────────────────────────
2346
2347    /// Every `Warning` variant maps to the corresponding `WarningKind` variant with
2348    /// no gaps — the table below pins the 1-to-1 correspondence.
2349    #[test]
2350    fn warning_kind_covers_all_variants() {
2351        let conflict = Warning::Conflict {
2352            chord: "ctrl+a".to_string(),
2353            contenders: vec!["quit".to_string(), "save".to_string()],
2354            winner: "save".to_string(),
2355        };
2356        assert_eq!(conflict.kind(), WarningKind::Conflict);
2357
2358        let unknown = Warning::UnknownAction {
2359            key: "ctrl+z".to_string(),
2360            action: "undo".to_string(),
2361        };
2362        assert_eq!(unknown.kind(), WarningKind::UnknownAction);
2363
2364        let prefix = Warning::PrefixShadow {
2365            prefix: vec!["g".to_string()],
2366            prefix_action: "top".to_string(),
2367            shadowed: vec!["g".to_string(), "g".to_string()],
2368            shadowed_action: "top2".to_string(),
2369        };
2370        assert_eq!(prefix.kind(), WarningKind::PrefixShadow);
2371
2372        let empty = Warning::EmptySequence {
2373            action: "quit".to_string(),
2374        };
2375        assert_eq!(empty.kind(), WarningKind::EmptySequence);
2376
2377        let shadow = Warning::SequenceShadow {
2378            chord: "j".to_string(),
2379            chord_action: "down".to_string(),
2380            sequence: vec!["j".to_string(), "k".to_string()],
2381            sequence_action: "top".to_string(),
2382        };
2383        assert_eq!(shadow.kind(), WarningKind::SequenceShadow);
2384    }
2385
2386    /// Display output is non-empty, human-readable, and contains the key field
2387    /// for each variant. Does not assert exact format (format is not stable), but
2388    /// checks the load-bearing content the user would need to read.
2389    #[test]
2390    fn warning_display_is_human_readable() {
2391        let conflict = Warning::Conflict {
2392            chord: "ctrl+a".to_string(),
2393            contenders: vec!["quit".to_string(), "save".to_string()],
2394            winner: "save".to_string(),
2395        };
2396        let s = conflict.to_string();
2397        assert!(
2398            s.contains("ctrl+a"),
2399            "conflict display must mention the chord: {s}"
2400        );
2401        assert!(
2402            s.contains("save"),
2403            "conflict display must mention the winner: {s}"
2404        );
2405
2406        let unknown = Warning::UnknownAction {
2407            key: "ctrl+z".to_string(),
2408            action: "undo".to_string(),
2409        };
2410        let s = unknown.to_string();
2411        assert!(
2412            s.contains("undo"),
2413            "unknown-action display must mention the action: {s}"
2414        );
2415
2416        let prefix = Warning::PrefixShadow {
2417            prefix: vec!["g".to_string()],
2418            prefix_action: "top".to_string(),
2419            shadowed: vec!["g".to_string(), "g".to_string()],
2420            shadowed_action: "top2".to_string(),
2421        };
2422        let s = prefix.to_string();
2423        assert!(
2424            !s.is_empty(),
2425            "prefix-shadow display must not be empty: {s}"
2426        );
2427
2428        let empty_seq = Warning::EmptySequence {
2429            action: "quit".to_string(),
2430        };
2431        let s = empty_seq.to_string();
2432        assert!(
2433            s.contains("quit"),
2434            "empty-sequence display must mention the action: {s}"
2435        );
2436
2437        let shadow = Warning::SequenceShadow {
2438            chord: "j".to_string(),
2439            chord_action: "down".to_string(),
2440            sequence: vec!["j".to_string(), "k".to_string()],
2441            sequence_action: "top".to_string(),
2442        };
2443        let s = shadow.to_string();
2444        assert!(
2445            s.contains('j'),
2446            "sequence-shadow display must mention the chord: {s}"
2447        );
2448    }
2449}