Skip to main content

nu_protocol/config/
mod.rs

1//! Module containing the internal representation of user configuration
2
3use crate::config::reedline::{KeyIdentity, name_of};
4use crate::{self as nu_protocol, Filesize};
5use crate::{ConfigWarning, FromValue};
6use helper::*;
7use prelude::*;
8use std::collections::{BTreeSet, HashMap};
9
10pub use ansi_coloring::UseAnsiColoring;
11pub use clip::ClipConfig;
12pub use completions::{
13    CompletionAlgorithm, CompletionConfig, CompletionSort, ExternalCompleterConfig,
14};
15pub use datetime_format::DatetimeFormatConfig;
16pub use defaults::default_color_config;
17pub use display_errors::DisplayErrors;
18pub use duration_max_unit::DurationMaxUnit;
19pub use filesize::FilesizeConfig;
20pub use helper::extract_value;
21pub use hinter::HinterConfig;
22pub use history::{HistoryConfig, HistoryFileFormat, HistoryPath};
23pub use hooks::Hooks;
24pub use ls::LsConfig;
25pub use output::{BannerKind, ErrorStyle};
26pub use plugin_gc::{PluginGcConfig, PluginGcConfigs};
27pub use reedline::{CursorShapeConfig, EditBindings, NuCursorShape, ParsedKeybinding, ParsedMenu};
28pub use rm::RmConfig;
29pub use shell_integration::ShellIntegrationConfig;
30pub use table::{FooterMode, TableConfig, TableIndent, TableIndexMode, TableMode, TrimStrategy};
31
32mod ansi_coloring;
33mod clip;
34mod completions;
35mod datetime_format;
36mod defaults;
37mod display_errors;
38mod duration_max_unit;
39mod error;
40mod filesize;
41mod helper;
42mod hinter;
43mod history;
44mod hooks;
45mod ls;
46mod output;
47mod plugin_gc;
48mod prelude;
49mod reedline;
50mod rm;
51mod shell_integration;
52mod table;
53
54#[derive(Clone, Debug, IntoValue, Serialize, Deserialize)]
55pub struct Config {
56    pub filesize: FilesizeConfig,
57    pub table: TableConfig,
58    pub ls: LsConfig,
59    pub clip: ClipConfig,
60    pub color_config: HashMap<String, Value>,
61    pub footer_mode: FooterMode,
62    pub float_precision: i64,
63    pub recursion_limit: i64,
64    pub use_ansi_coloring: UseAnsiColoring,
65    pub completions: CompletionConfig,
66    pub edit_mode: EditBindings,
67    pub show_hints: bool,
68    pub hinter: HinterConfig,
69    pub history: HistoryConfig,
70    pub keybindings: Vec<ParsedKeybinding>,
71    pub abbreviations: HashMap<String, String>,
72    pub menus: Vec<ParsedMenu>,
73    pub hooks: Hooks,
74    pub rm: RmConfig,
75    pub shell_integration: ShellIntegrationConfig,
76    pub buffer_editor: Value,
77    pub show_banner: BannerKind,
78    pub bracketed_paste: bool,
79    pub render_right_prompt_on_last_line: bool,
80    pub explore: HashMap<String, Value>,
81    pub cursor_shape: CursorShapeConfig,
82    pub datetime_format: DatetimeFormatConfig,
83    pub error_style: ErrorStyle,
84    pub error_lines: i64,
85    pub display_errors: DisplayErrors,
86    pub use_kitty_protocol: bool,
87    pub highlight_resolved_externals: bool,
88    pub auto_cd_implicit: bool,
89    pub duration_max_unit: DurationMaxUnit,
90    /// Maximum estimated memory size of the interactive last-result payload (`$ans.last`).
91    ///
92    /// Measured with [`Value::memory_size`]. Default is `0` (no `.last` payload; opt-in).
93    /// Oversized results are truncated to fit this budget. The variable name itself is a code
94    /// constant (`LAST_RESULT_VAR_NAME`), not a config option. With a positive budget, `$ans`
95    /// is `{ last, exit_code, duration, command }`. With `0`, `$ans` still has `exit_code`,
96    /// `duration`, and `command` but omits `last` entirely.
97    pub max_last_result_size: Filesize,
98    /// Configuration for plugins.
99    ///
100    /// Users can provide configuration for a plugin through this entry.  The entry name must
101    /// match the registered plugin name so `plugin add nu_plugin_example` will be able to place
102    /// its configuration under a `nu_plugin_example` column.
103    pub plugins: HashMap<String, Value>,
104    /// Configuration for plugin garbage collection.
105    pub plugin_gc: PluginGcConfigs,
106}
107
108impl Default for Config {
109    fn default() -> Config {
110        Config {
111            show_banner: BannerKind::default(),
112
113            table: TableConfig::default(),
114            rm: RmConfig::default(),
115            ls: LsConfig::default(),
116
117            datetime_format: DatetimeFormatConfig::default(),
118
119            explore: defaults::default_explore(),
120
121            history: HistoryConfig::default(),
122
123            completions: CompletionConfig::default(),
124
125            recursion_limit: 50,
126
127            filesize: FilesizeConfig::default(),
128
129            cursor_shape: CursorShapeConfig::default(),
130
131            clip: ClipConfig::default(),
132
133            color_config: defaults::default_color_config(),
134            footer_mode: FooterMode::RowCount(25),
135            float_precision: 2,
136            buffer_editor: Value::nothing(Span::unknown()),
137            use_ansi_coloring: UseAnsiColoring::default(),
138            bracketed_paste: true,
139            edit_mode: EditBindings::default(),
140            show_hints: true,
141            hinter: HinterConfig::default(),
142
143            shell_integration: ShellIntegrationConfig::default(),
144
145            render_right_prompt_on_last_line: false,
146
147            hooks: Hooks::new(),
148
149            menus: defaults::default_menus(),
150
151            keybindings: defaults::default_keybindings(),
152            abbreviations: HashMap::new(),
153
154            error_style: ErrorStyle::default(),
155            error_lines: 1,
156            display_errors: DisplayErrors::default(),
157
158            use_kitty_protocol: false,
159            highlight_resolved_externals: false,
160
161            auto_cd_implicit: false,
162            duration_max_unit: DurationMaxUnit::default(),
163
164            // Opt-in for `.last` payload: 0 drops last, keeps exit_code/duration/command.
165            max_last_result_size: Filesize::ZERO,
166
167            plugins: HashMap::new(),
168            plugin_gc: PluginGcConfigs::default(),
169        }
170    }
171}
172
173impl UpdateFromValue for Config {
174    fn update<'a>(
175        &mut self,
176        value: &'a Value,
177        path: &mut ConfigPath<'a>,
178        errors: &mut ConfigErrors,
179    ) {
180        let Value::Record { val: record, .. } = value else {
181            errors.type_mismatch(path, Type::record(), value);
182            return;
183        };
184
185        for (col, val) in record.iter() {
186            let current_path = &mut path.push(col);
187
188            match col.as_str() {
189                "ls" => self.ls.update(val, current_path, errors),
190                "rm" => self.rm.update(val, current_path, errors),
191                "history" => self.history.update(val, current_path, errors),
192                "completions" => self.completions.update(val, current_path, errors),
193                "cursor_shape" => self.cursor_shape.update(val, current_path, errors),
194                "table" => self.table.update(val, current_path, errors),
195                "filesize" => self.filesize.update(val, current_path, errors),
196                "explore" => self.explore.update(val, current_path, errors),
197                "color_config" => self.color_config.update(val, current_path, errors),
198                "clip" => self.clip.update(val, current_path, errors),
199                "footer_mode" => self.footer_mode.update(val, current_path, errors),
200                "float_precision" => self.float_precision.update(val, current_path, errors),
201                "use_ansi_coloring" => self.use_ansi_coloring.update(val, current_path, errors),
202                "edit_mode" => self.edit_mode.update(val, current_path, errors),
203                "show_hints" => self.show_hints.update(val, current_path, errors),
204                "hinter" => self.hinter.update(val, current_path, errors),
205                "shell_integration" => self.shell_integration.update(val, current_path, errors),
206                "show_banner" => self.show_banner.update(val, current_path, errors),
207                "display_errors" => self.display_errors.update(val, current_path, errors),
208                "render_right_prompt_on_last_line" => {
209                    self.render_right_prompt_on_last_line
210                        .update(val, current_path, errors)
211                }
212                "bracketed_paste" => self.bracketed_paste.update(val, current_path, errors),
213                "use_kitty_protocol" => self.use_kitty_protocol.update(val, current_path, errors),
214                "highlight_resolved_externals" => {
215                    self.highlight_resolved_externals
216                        .update(val, current_path, errors)
217                }
218                "auto_cd_implicit" => self.auto_cd_implicit.update(val, current_path, errors),
219                "duration_max_unit" => self.duration_max_unit.update(val, current_path, errors),
220                "plugins" => self.plugins.update(val, current_path, errors),
221                "plugin_gc" => self.plugin_gc.update(val, current_path, errors),
222                "abbreviations" => self.abbreviations.update(val, current_path, errors),
223                "hooks" => self.hooks.update(val, current_path, errors),
224                "datetime_format" => self.datetime_format.update(val, current_path, errors),
225                "error_style" => self.error_style.update(val, current_path, errors),
226
227                "buffer_editor" => match val {
228                    Value::Nothing { .. } | Value::String { .. } => {
229                        self.buffer_editor = val.clone();
230                    }
231                    Value::List { vals: values, .. }
232                        if values
233                            .iter()
234                            .all(|list_element| matches!(list_element, Value::String { .. })) =>
235                    {
236                        self.buffer_editor = val.clone();
237                    }
238                    _ => errors.type_mismatch(
239                        current_path,
240                        Type::custom("string, list<string>, or nothing"),
241                        val,
242                    ),
243                },
244
245                "max_last_result_size" => {
246                    self.max_last_result_size.update(val, current_path, errors)
247                }
248
249                "menus" => match Vec::<ParsedMenu>::from_value(val.clone()) {
250                    Ok(menus) => {
251                        for menu in menus {
252                            let target_name = menu.name.to_expanded_string("", self);
253
254                            let found_index = self.menus.iter().position(|existing_menu| {
255                                existing_menu.name.to_expanded_string("", self) == target_name
256                            });
257
258                            if let Some(index) = found_index {
259                                self.menus[index] = menu;
260                            } else {
261                                self.menus.push(menu);
262                            }
263                        }
264                    }
265                    Err(error) => errors.error(error.into()),
266                },
267
268                "keybindings" => match Vec::<ParsedKeybinding>::from_value(val.clone()) {
269                    Ok(keybindings) => self.merge_keybindings(keybindings, val.span(), errors),
270                    Err(error) => errors.error(error.into()),
271                },
272
273                "error_lines" => match val.as_int() {
274                    Ok(integer) if integer >= 0 => self.error_lines = integer,
275                    Ok(_) => {
276                        errors.invalid_value(current_path, "an int greater than or equal to 0", val)
277                    }
278                    Err(_) => errors.type_mismatch(current_path, Type::Int, val),
279                },
280
281                "recursion_limit" => match val.as_int() {
282                    Ok(integer) if integer > 1 => self.recursion_limit = integer,
283                    Ok(_) => errors.invalid_value(current_path, "an int greater than 1", val),
284                    Err(_) => errors.type_mismatch(current_path, Type::Int, val),
285                },
286
287                _ => errors.unknown_option(current_path, val),
288            }
289        }
290    }
291}
292
293impl UpdateFromValue for Filesize {
294    fn update(&mut self, value: &Value, path: &mut ConfigPath, errors: &mut ConfigErrors) {
295        match value.as_filesize() {
296            Ok(size) if !size.is_negative() => *self = size,
297            Ok(_) => errors.invalid_value(path, "a non-negative filesize", value),
298            Err(_) => errors.type_mismatch(path, Type::Filesize, value),
299        }
300    }
301}
302
303impl Config {
304    /// Returns the configured last-result size budget in bytes (`0` disables `.last` only).
305    pub fn max_last_result_size_bytes(&self) -> usize {
306        self.max_last_result_size.get().max(0) as usize
307    }
308
309    pub fn update_from_value(
310        &mut self,
311        old: &Config,
312        value: &Value,
313    ) -> Result<Option<ShellWarning>, ShellError> {
314        self.update_from_value_with_options(old, value, false)
315    }
316
317    /// Like [`Config::update_from_value`], but allows callers to indicate that runtime-locked
318    /// options should refuse to change.
319    ///
320    /// `history_locked_after_startup` should be set to `true` once the REPL has finished
321    /// initializing reedline's history backend. After that point, changing any of the
322    /// startup-only history fields (`path`, `max_size`, `file_format`, `isolation`) has no
323    /// effect on the live history, so we reject the assignment with a clear error instead of
324    /// silently ignoring it.
325    pub fn update_from_value_with_options(
326        &mut self,
327        old: &Config,
328        value: &Value,
329        history_locked_after_startup: bool,
330    ) -> Result<Option<ShellWarning>, ShellError> {
331        // Current behaviour is that config errors are displayed, but do not prevent the rest
332        // of the config from being updated (fields with errors are skipped/not updated).
333        // Errors are simply collected one-by-one and wrapped into a ShellError variant at the end.
334        let mut errors =
335            ConfigErrors::new(old).with_history_locked_after_startup(history_locked_after_startup);
336        let mut path = ConfigPath::new();
337
338        self.update(value, &mut path, &mut errors);
339
340        errors.check()
341    }
342
343    fn merge_keybindings(
344        &mut self,
345        incoming: Vec<ParsedKeybinding>,
346        span: Span,
347        errors: &mut ConfigErrors,
348    ) {
349        if incoming.is_empty() {
350            self.keybindings.clear();
351            return;
352        }
353
354        let mut shared_names = BTreeSet::new();
355        let identities = incoming.into_iter().map(|kb| {
356            let name = name_of(&kb);
357            let id = KeyIdentity::of(&kb);
358            (kb, name, id)
359        });
360
361        // Snapshot of existing identities, kept in lockstep with the list.
362        // `claimed` marks entries already spoken for by this assignment, so a
363        // second incoming binding with the same name starts a new entry
364        // instead of re-keying its sibling.
365        struct Existing {
366            name: Option<String>,
367            id: KeyIdentity,
368            claimed: bool,
369        }
370        let mut ex_kbs: Vec<Existing> = self
371            .keybindings
372            .iter()
373            .map(|ex| Existing {
374                name: name_of(ex),
375                id: KeyIdentity::of(ex),
376                claimed: false,
377            })
378            .collect();
379
380        for (kb, name, id) in identities {
381            // The same binding (name and key): replace, claimed or not, so a
382            // re-sourced config stays idempotent and event updates land.
383            if let Some(i) = ex_kbs.iter().position(|ex| ex.name == name && ex.id == id) {
384                ex_kbs[i].claimed = true;
385                self.keybindings[i] = kb;
386                continue;
387            }
388
389            // A named binding with a new key re-keys the unclaimed entry of
390            // that name in place, keeping its position in the list.
391            if name.is_some()
392                && let Some(i) = ex_kbs.iter().position(|ex| ex.name == name && !ex.claimed)
393            {
394                ex_kbs[i].id = id;
395                ex_kbs[i].claimed = true;
396                self.keybindings[i] = kb;
397                continue;
398            }
399
400            // A new binding. If its name is already taken (necessarily by a
401            // claimed entry), both stay active; say so once.
402            if let Some(name) = &name
403                && ex_kbs.iter().any(|ex| ex.name.as_ref() == Some(name))
404            {
405                shared_names.insert(name.clone());
406            }
407            ex_kbs.push(Existing {
408                name,
409                id,
410                claimed: true,
411            });
412            self.keybindings.push(kb);
413        }
414        if !shared_names.is_empty() {
415            errors.warn(ConfigWarning::SharedKeybindingName {
416                names: shared_names.into_iter().collect::<Vec<_>>().join(", "),
417                span,
418            });
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    /// A record-valued config field is a full-record replace on assignment (e.g.
428    /// `$env.config.keybindings = [...]`), but `update_from_value` must still merge
429    /// named defaults into place rather than silently dropping ones the caller
430    /// didn't mention.
431    #[test]
432    fn reassigning_a_named_list_field_keeps_unmentioned_defaults() {
433        let old = Config::default();
434        let mut new = old.clone();
435
436        let mut extra_menu = old.menus[0].clone();
437        extra_menu.name = Value::test_string("added_menu");
438        let mut extra_keybinding = old.keybindings[0].clone();
439        extra_keybinding.name = Some(Value::test_string("added_binding"));
440
441        let value = Value::test_record(record! {
442            "menus" => Value::test_list(vec![extra_menu.into_value(Span::test_data())]),
443            "keybindings" => Value::test_list(vec![extra_keybinding.into_value(Span::test_data())]),
444        });
445        new.update_from_value(&old, &value)
446            .expect("update should succeed");
447
448        for default_menu in &old.menus {
449            let name = default_menu.name.to_expanded_string("", &old);
450            assert!(
451                new.menus
452                    .iter()
453                    .any(|m| m.name.to_expanded_string("", &new) == name),
454                "default menu {name:?} was lost after reassigning `menus`"
455            );
456        }
457        for default_keybinding in &old.keybindings {
458            let Some(name) = default_keybinding
459                .name
460                .as_ref()
461                .map(|n| n.to_expanded_string("", &old))
462            else {
463                continue;
464            };
465            assert!(
466                new.keybindings.iter().any(|k| k
467                    .name
468                    .as_ref()
469                    .is_some_and(|n| n.to_expanded_string("", &new) == name)),
470                "default keybinding {name:?} was lost after reassigning `keybindings`"
471            );
472        }
473    }
474
475    /// Guards the unnamed case: with no `name` to merge on, every reassignment
476    /// used to append another copy.
477    #[test]
478    fn reassigning_an_unnamed_keybinding_does_not_duplicate_it() {
479        let old = Config::default();
480        let mut new = old.clone();
481
482        let mut unnamed = old.keybindings[0].clone();
483        unnamed.name = None;
484        unnamed.modifier = Value::test_string("alt");
485        unnamed.keycode = Value::test_string("char_j");
486        new.keybindings.push(unnamed);
487
488        let expected = new.keybindings.len();
489
490        // Feed the list back through `update_from_value` the way re-sourcing a
491        // config (or any `$env.config.keybindings = ...`) does.
492        for _ in 0..2 {
493            let value = Value::test_record(record! {
494                "keybindings" => Value::test_list(
495                    new.keybindings
496                        .iter()
497                        .map(|keybinding| keybinding.clone().into_value(Span::test_data()))
498                        .collect(),
499                ),
500            });
501            new.update_from_value(&old, &value)
502                .expect("update should succeed");
503        }
504
505        assert_eq!(
506            new.keybindings.len(),
507            expected,
508            "reassigning `keybindings` duplicated the unnamed binding"
509        );
510    }
511
512    // --- merge semantics: replace on same name+key, append+warn on shared name ---
513
514    fn keybinding(
515        name: Option<&str>,
516        modifier: &str,
517        keycode: &str,
518        mode: Value,
519    ) -> ParsedKeybinding {
520        ParsedKeybinding {
521            name: name.map(Value::test_string),
522            modifier: Value::test_string(modifier),
523            keycode: Value::test_string(keycode),
524            event: Value::test_nothing(),
525            mode,
526        }
527    }
528
529    /// Run one `$env.config.keybindings = [...]` assignment; returns the warning.
530    fn assign(
531        config: &mut Config,
532        old: &Config,
533        keybindings: Vec<ParsedKeybinding>,
534    ) -> Option<ShellWarning> {
535        let value = Value::test_record(record! {
536            "keybindings" => Value::test_list(
537                keybindings
538                    .into_iter()
539                    .map(|kb| kb.into_value(Span::test_data()))
540                    .collect(),
541            ),
542        });
543        config
544            .update_from_value(old, &value)
545            .expect("update should succeed")
546    }
547
548    fn count_named(config: &Config, name: &str) -> usize {
549        config
550            .keybindings
551            .iter()
552            .filter(|kb| {
553                kb.name
554                    .as_ref()
555                    .is_some_and(|n| n.to_expanded_string("", config) == name)
556            })
557            .count()
558    }
559
560    /// The atuin regression (nushell/nushell#18848): two bindings sharing a name
561    /// on different keys must both survive, with one warning.
562    #[test]
563    fn a_shared_name_on_different_keys_keeps_both_bindings_and_warns() {
564        let old = Config::default();
565        let mut new = old.clone();
566
567        let warning = assign(
568            &mut new,
569            &old,
570            vec![
571                keybinding(
572                    Some("atuin"),
573                    "control",
574                    "char_r",
575                    Value::test_string("emacs"),
576                ),
577                keybinding(Some("atuin"), "none", "up", Value::test_string("emacs")),
578            ],
579        );
580
581        assert_eq!(
582            count_named(&new, "atuin"),
583            2,
584            "one of the bindings was dropped"
585        );
586        assert!(warning.is_some(), "sharing a name should warn");
587    }
588
589    /// Re-sourcing the exact same binding is idempotent and silent.
590    #[test]
591    fn reassigning_the_same_binding_replaces_it_without_warning() {
592        let old = Config::default();
593        let mut new = old.clone();
594
595        let atuin = || {
596            keybinding(
597                Some("atuin"),
598                "control",
599                "char_r",
600                Value::test_string("emacs"),
601            )
602        };
603        assign(&mut new, &old, vec![atuin()]);
604        let len = new.keybindings.len();
605
606        let warning = assign(&mut new, &old, vec![atuin()]);
607        assert_eq!(
608            new.keybindings.len(),
609            len,
610            "re-sourcing duplicated the binding"
611        );
612        assert!(
613            warning.is_none(),
614            "an identical re-assignment must not warn"
615        );
616    }
617
618    /// Same name and key with a new event is the update case: replaced in place.
619    #[test]
620    fn a_new_event_on_the_same_key_replaces_the_binding() {
621        let old = Config::default();
622        let mut new = old.clone();
623
624        assign(
625            &mut new,
626            &old,
627            vec![keybinding(
628                Some("atuin"),
629                "control",
630                "char_r",
631                Value::test_string("emacs"),
632            )],
633        );
634        let len = new.keybindings.len();
635
636        let mut updated = keybinding(
637            Some("atuin"),
638            "control",
639            "char_r",
640            Value::test_string("emacs"),
641        );
642        updated.event = Value::test_string("marker");
643        assign(&mut new, &old, vec![updated]);
644
645        assert_eq!(new.keybindings.len(), len);
646        let event = new
647            .keybindings
648            .iter()
649            .rev()
650            .find(|kb| {
651                kb.name
652                    .as_ref()
653                    .is_some_and(|n| n.to_expanded_string("", &new) == "atuin")
654            })
655            .map(|kb| kb.event.clone());
656        assert_eq!(
657            event,
658            Some(Value::test_string("marker")),
659            "event was not updated"
660        );
661    }
662
663    /// `emacs` and `[emacs]` spell the same key, so the second assignment replaces.
664    #[test]
665    fn a_bare_mode_and_its_singleton_list_merge_into_one_binding() {
666        let old = Config::default();
667        let mut new = old.clone();
668
669        assign(
670            &mut new,
671            &old,
672            vec![keybinding(
673                Some("atuin"),
674                "control",
675                "char_r",
676                Value::test_string("emacs"),
677            )],
678        );
679        let warning = assign(
680            &mut new,
681            &old,
682            vec![keybinding(
683                Some("atuin"),
684                "control",
685                "char_r",
686                Value::test_list(vec![Value::test_string("emacs")]),
687            )],
688        );
689
690        assert_eq!(
691            count_named(&new, "atuin"),
692            1,
693            "the mode spellings did not merge"
694        );
695        assert!(warning.is_none());
696    }
697
698    /// The same new binding twice in one assignment collapses to one entry
699    /// (guards the identity snapshot staying in sync with the list).
700    #[test]
701    fn the_same_binding_twice_in_one_assignment_is_stored_once() {
702        let old = Config::default();
703        let mut new = old.clone();
704
705        let atuin = || {
706            keybinding(
707                Some("atuin"),
708                "control",
709                "char_r",
710                Value::test_string("emacs"),
711            )
712        };
713        assign(&mut new, &old, vec![atuin(), atuin()]);
714
715        assert_eq!(count_named(&new, "atuin"), 1, "the duplicate was appended");
716    }
717
718    /// Re-keying by name: assigning a named binding with a new key replaces the
719    /// existing binding of that name in place, keeping its list position
720    /// (`$env.config.keybindings.0.keycode = ...` depends on this).
721    #[test]
722    fn a_named_binding_with_a_new_key_replaces_in_place() {
723        let old = Config::default();
724        let mut new = old.clone();
725
726        assign(
727            &mut new,
728            &old,
729            vec![keybinding(
730                Some("atuin"),
731                "control",
732                "char_r",
733                Value::test_string("emacs"),
734            )],
735        );
736        let len = new.keybindings.len();
737        let index = new
738            .keybindings
739            .iter()
740            .position(|kb| {
741                kb.name
742                    .as_ref()
743                    .is_some_and(|n| n.to_expanded_string("", &new) == "atuin")
744            })
745            .expect("binding was added");
746
747        let warning = assign(
748            &mut new,
749            &old,
750            vec![keybinding(
751                Some("atuin"),
752                "none",
753                "up",
754                Value::test_string("emacs"),
755            )],
756        );
757
758        assert_eq!(new.keybindings.len(), len, "re-keying must not append");
759        assert_eq!(
760            new.keybindings[index].keycode,
761            Value::test_string("up"),
762            "the binding was not re-keyed in place"
763        );
764        assert!(warning.is_none(), "re-keying a lone name must not warn");
765    }
766
767    /// A changed mode set is a re-key too, not a sibling binding.
768    #[test]
769    fn a_named_binding_with_a_changed_mode_replaces_instead_of_appending() {
770        let old = Config::default();
771        let mut new = old.clone();
772
773        assign(
774            &mut new,
775            &old,
776            vec![keybinding(
777                Some("atuin"),
778                "control",
779                "char_r",
780                Value::test_string("emacs"),
781            )],
782        );
783        let warning = assign(
784            &mut new,
785            &old,
786            vec![keybinding(
787                Some("atuin"),
788                "control",
789                "char_r",
790                Value::test_list(vec![
791                    Value::test_string("vi_normal"),
792                    Value::test_string("vi_insert"),
793                ]),
794            )],
795        );
796
797        assert_eq!(count_named(&new, "atuin"), 1, "the mode change appended");
798        assert!(warning.is_none());
799    }
800
801    /// Assigning an empty list is the reset escape hatch.
802    #[test]
803    fn assigning_an_empty_list_clears_the_keybindings() {
804        let old = Config::default();
805        let mut new = old.clone();
806
807        assign(&mut new, &old, vec![]);
808        assert!(new.keybindings.is_empty(), "`= []` should reset the list");
809    }
810}